feat: v0.4.0 — 安全修复×6 + 增量性能×3 + Playwright E2E + 实例级 i18n
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 通过
This commit is contained in:
2026-08-09 12:31:14 +08:00
parent 64aedb9546
commit cb0caff4cf
24 changed files with 1122 additions and 129 deletions
+130
View File
@@ -0,0 +1,130 @@
#!/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);
});