- 浮动工具栏 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 项断言(含真实浏览器浮动工具栏布局验证)
176 lines
8.1 KiB
JavaScript
176 lines
8.1 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');
|
||
|
||
// 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);
|
||
});
|