CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
0.3.0 修复版: - 脚注 id 属性注入 XSS 防护(行内引用 + 脚注区) - 全局插件注入移至 afterCreate(searchReplace 等依赖 textarea 的插件真正生效)+ use() 同名防重 - 行内渲染缓存附加 refs 指纹,防跨文档引用链接串数据 - 白名单裸标签 <u>/</u> 透传,underline(Ctrl+U)预览可见 - getStatus()/渲染 env 使用实例 locale;replaceAll 替换文本按字面处理 - replaceAllRegex 保留 $1 捕获组语义 0.3.1 性能版: - 高亮规则按语言缓存(registerLanguage 覆盖失效),bench +33% - 统计增量计算:字数/词数/行数差异区间,击键零全量扫描 - outline 树构建 O(n²) → 迭代栈 O(n) - 拖放图片 >500KB 拦截、scrollToLine 真实行高、undo/redo 光标恢复 - selectionChange/cursorMove 与浮动工具栏解耦、unregisterShortcut 大小写归一 - toast 接入 7 种 ANIMATIONS、实例主题订阅随 destroy 断开(dispose) 0.4.0 工程版: - Playwright 真实浏览器冒烟测试(e2e/,22 项断言)+ CI e2e job - sideEffects: false 便于 tree-shaking - MeEditor.destroy() 全面复位(全局钩子清理 + 内部注入钩子重建) - 实例 locale 统一作用于全部 UI 文案(状态栏/工具栏/右键菜单/大纲) 测试 782 → 826,全绿;typecheck/lint/build 通过
131 lines
5.6 KiB
JavaScript
131 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* MetonaEditor 浏览器冒烟测试(Playwright + 本地静态服务)
|
||
* 前置: npm run build(需 dist/metona-editor.js 存在)
|
||
* 用法: npm run test:e2e
|
||
*/
|
||
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { chromium } = require('playwright');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const MIME = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'text/javascript',
|
||
'.css': 'text/css',
|
||
'.svg': 'image/svg+xml',
|
||
'.png': 'image/png',
|
||
'.mjs': 'text/javascript',
|
||
'.cjs': 'text/javascript',
|
||
};
|
||
|
||
const server = http.createServer((req, res) => {
|
||
let p = decodeURIComponent((req.url || '/').split('?')[0]);
|
||
if (p === '/') p = '/e2e/host.html';
|
||
const file = path.resolve(ROOT, '.' + p);
|
||
if (!file.startsWith(ROOT) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||
res.writeHead(404); res.end('404 Not Found'); return;
|
||
}
|
||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'text/plain' });
|
||
fs.createReadStream(file).pipe(res);
|
||
});
|
||
|
||
let passed = 0;
|
||
const check = (name, cond) => {
|
||
if (!cond) throw new Error(`FAIL: ${name}`);
|
||
passed++;
|
||
console.log(` ✓ ${name}`);
|
||
};
|
||
|
||
(async () => {
|
||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||
const port = server.address().port;
|
||
console.log(`MetonaEditor E2E · http://127.0.0.1:${port}/`);
|
||
|
||
const browser = await chromium.launch();
|
||
const page = await browser.newPage();
|
||
const errors = [];
|
||
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
||
page.on('pageerror', (e) => errors.push(String(e)));
|
||
|
||
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' });
|
||
|
||
// 1. 编辑器渲染
|
||
await page.waitForSelector('.me-wrapper', { timeout: 10000 });
|
||
check('编辑器 DOM 渲染', await page.locator('.me-wrapper').count() === 1);
|
||
check('textarea 存在', await page.locator('.me-textarea').count() === 1);
|
||
check('初始 value 写入', (await page.locator('.me-textarea').inputValue()).includes('# E2E Title'));
|
||
|
||
// 2. 预览渲染
|
||
const h1 = await page.locator('.me-preview h1').textContent();
|
||
check('预览渲染 h1', h1 === 'E2E Title');
|
||
check('预览渲染粗体', (await page.locator('.me-preview strong').count()) === 1);
|
||
check('预览渲染下划线 <u>', (await page.locator('.me-preview u').count()) === 1);
|
||
check('预览渲染表格', (await page.locator('.me-preview table').count()) === 1);
|
||
|
||
// 3. 语法高亮
|
||
check('代码块高亮 keyword', (await page.locator('.me-preview .me-hl-keyword').count()) > 0);
|
||
check('代码块高亮 comment', (await page.locator('.me-preview .me-hl-comment').count()) > 0);
|
||
|
||
// 4. 输入 → 预览实时更新
|
||
await page.locator('.me-textarea').fill('# 输入测试\n\nhello **world**');
|
||
await page.waitForTimeout(100);
|
||
check('输入后预览更新 h1', (await page.locator('.me-preview h1').textContent()) === '输入测试');
|
||
check('输入后预览更新 strong', (await page.locator('.me-preview strong').textContent()) === 'world');
|
||
|
||
// 5. 工具栏命令(bold 包裹选区)
|
||
await page.locator('.me-textarea').fill('select me');
|
||
await page.locator('.me-textarea').evaluate((el) => { el.focus(); el.setSelectionRange(0, 9); });
|
||
await page.locator('.me-btn[data-action="bold"]').click();
|
||
check('工具栏 bold 命令', (await page.locator('.me-textarea').inputValue()) === '**select me**');
|
||
|
||
// 6. 模式切换
|
||
await page.locator('.me-btn[data-mode="preview"]').click();
|
||
check('切换到 preview 模式', await page.locator('.me-body.me-mode-preview').count() === 1);
|
||
await page.locator('.me-btn[data-mode="edit"]').click();
|
||
check('切换到 edit 模式', await page.locator('.me-body.me-mode-edit').count() === 1);
|
||
await page.locator('.me-btn[data-mode="split"]').click();
|
||
check('切换到 split 模式', await page.locator('.me-body.me-mode-split').count() === 1);
|
||
|
||
// 7. 主题切换
|
||
await page.evaluate(() => window.__ed.setTheme('dark'));
|
||
check('实例主题 dark', (await page.locator('.me-wrapper').getAttribute('data-md-theme')) === 'dark');
|
||
|
||
// 8. 搜索插件(Ctrl+F)
|
||
await page.locator('.me-textarea').press('Control+f');
|
||
check('Ctrl+F 打开搜索面板', (await page.locator('.me-search').count()) === 1);
|
||
await page.locator('.me-search-find').fill('select');
|
||
await page.locator('.me-search-next').click();
|
||
check('搜索面板查找选中', (await page.locator('.me-textarea').evaluate((el) => el.value.substring(el.selectionStart, el.selectionEnd))) === 'select');
|
||
await page.keyboard.press('Escape');
|
||
|
||
// 9. 统计状态栏
|
||
check('状态栏字数统计', (await page.locator('.me-statusbar').textContent()).includes('字符'));
|
||
|
||
// 10. 撤销(bold 后撤销回到初始内容)
|
||
await page.locator('.me-textarea').press('Control+z');
|
||
await page.waitForTimeout(50);
|
||
check('Ctrl+Z 撤销', (await page.locator('.me-textarea').inputValue()).includes('E2E Title'));
|
||
|
||
// 11. 实例 API 直调
|
||
const stats = await page.evaluate(() => window.__ed.getStats());
|
||
check('getStats 返回结构', typeof stats.characters === 'number' && typeof stats.words === 'number');
|
||
const status = await page.evaluate(() => window.__ed.getStatus());
|
||
check('getStatus 返回结构', status.mode === 'split' && status.theme === 'dark' && status.locale === 'zh-CN');
|
||
|
||
await browser.close();
|
||
server.close();
|
||
|
||
if (errors.length) {
|
||
console.error('\n页面错误:', errors);
|
||
process.exit(1);
|
||
}
|
||
console.log(`\n全部通过: ${passed} 项断言 · 无页面错误`);
|
||
})().catch((e) => {
|
||
console.error('\n' + (e && e.message ? e.message : e));
|
||
try { server.close(); } catch (_) {}
|
||
process.exit(1);
|
||
});
|