#!/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('预览渲染下划线 ', (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); });