#!/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'); 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); });