Files
MetonaEditor/e2e/smoke.cjs
T
thzxx d6a618af5d feat: v0.4.3 — 浮动工具栏定位引擎重做 + 程序化编辑统一刷新管线
- 浮动工具栏 mirror 镜像测量:隐藏镜像层复制 textarea 排版样式,选区锚点插零宽字符读像素坐标,CJK/软换行/跨行选区误差全部消除

- 实测尺寸替代魔法数字;滚动 rAF 节流重定位;滚出视口自动隐藏;a11y:role=toolbar + aria-label、visibility 移出 Tab 序列、键盘可达

- 修复死开关/监听器泄漏/销毁后 focus 崩溃/blur 焦点抖动误隐藏

- _afterProgrammaticEdit 统一 8 条程序化路径的渲染/行号/字数/大纲/事件刷新;右键菜单 label 转义、copyAsHTML Firefox 降级、paste 失败提示、Esc 退出全屏

- 测试 871 → 895,e2e 22 → 28 项断言(含真实浏览器浮动工具栏布局验证)
2026-08-20 20:43:01 +08:00

176 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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');
// 12. 浮动工具栏(真实浏览器布局验证:mirror 镜像测量定位)
// 放在依赖旧值/历史栈的用例之后执行(本节自行填充测试内容)
await page.locator('.me-textarea').fill('# 标题\n\n第二行中文文本内容\n\n第三行 more text');
await page.waitForTimeout(50);
await page.locator('.me-textarea').evaluate((el) => {
el.focus();
const start = el.value.indexOf('第二行');
el.setSelectionRange(start, start + 6);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForSelector('.me-float-toolbar.me-visible', { timeout: 3000 });
check('浮动工具栏选区后显示', true);
const pos = await page.evaluate(() => {
const bar = document.querySelector('.me-float-toolbar');
const ta = document.querySelector('.me-textarea');
const bb = bar.getBoundingClientRect();
const tb = ta.getBoundingClientRect();
return { barL: bb.left, barR: bb.right, barT: bb.top, barB: bb.bottom, taL: tb.left, taR: tb.right, taT: tb.top, taB: tb.bottom };
});
check('浮动工具栏水平定位在编辑区内', pos.barR > pos.taL && pos.barL < pos.taR);
check('浮动工具栏垂直定位在选区行附近', pos.barT >= pos.taT - 50 && pos.barB <= pos.taB + 50);
// 等待显示过渡动画结束后再点击(避免 actionability 等待期间竞态)
await page.waitForTimeout(300);
await page.locator('.me-float-toolbar .me-btn').first().click();
check('浮动工具栏 bold 命令', (await page.locator('.me-textarea').inputValue()).includes('**第二行中文文**'));
check('命令执行后浮动工具栏隐藏', (await page.locator('.me-float-toolbar.me-visible').count()) === 0);
// 12.5 长行折行选区(软换行场景下工具栏不跑出可视区)
await page.locator('.me-textarea').fill('# T\n\n' + '很长的中文内容用于触发自动折行的测试文本'.repeat(20));
await page.waitForTimeout(50);
await page.locator('.me-textarea').evaluate((el) => {
el.focus();
el.setSelectionRange(el.value.length - 20, el.value.length);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForTimeout(100);
check('长行折行选区工具栏保持可视区内', (await page.evaluate(() => {
const bar = document.querySelector('.me-float-toolbar');
const ta = document.querySelector('.me-textarea');
if (!bar || !bar.classList.contains('me-visible')) return false;
const bb = bar.getBoundingClientRect();
const tb = ta.getBoundingClientRect();
return bb.top >= tb.top - 50 && bb.bottom <= tb.bottom + 50;
})));
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);
});