#!/usr/bin/env node /** * 双端全路由冒烟断言(PRD §2.3 成功标准 ③ / SPEC NFR-06) * * 用法: * npm run build && npm run smoke * # 或指定已运行的地址: SMOKE_URL=http://localhost:4200 npm run smoke * * 行为: * 1. 起一个静态服务器托管 `dist/fmode-app-v2/browser`(或复用 SMOKE_URL) * 2. 用桌面(1440×900)与移动(390×844)两个上下文跑全路由断言 * 3. 断言项:路由可达 / 无未捕获页面错误 / 无横向溢出 / 关键交互可用 * 4. 结果落 `test-results/smoke/`(截图 + 汇总 JSON) * * 🔴 数据源说明:冒烟在 **演示数据模式** 下跑(`useMockData: true` 构建), * 以便在无真实后端凭据时也能验证 UI 结构与路由。 * 真实数据链路另由 `npm run smoke:live`(需登录态)覆盖。 */ import { chromium, devices } from 'playwright'; import { mkdir, writeFile, readFile, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { createServer } from 'node:http'; import { extname, join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import assert from 'node:assert/strict'; const here = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(here, '..'); const distDir = resolve(projectRoot, 'dist/fmode-app-v2/browser'); const outDir = resolve(projectRoot, 'test-results/smoke'); const results = []; const pageErrors = []; const failedRequests = []; // ---------- 静态服务器 ---------- const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.ico': 'image/x-icon', '.png': 'image/png', }; function serve(dir) { const server = createServer(async (req, res) => { try { const url = decodeURIComponent((req.url ?? '/').split('?')[0]); let file = join(dir, url); if (!existsSync(file) || url === '/') file = join(dir, 'index.html'); const body = await readFile(file); res.writeHead(200, { 'Content-Type': MIME[extname(file)] ?? 'application/octet-stream' }); res.end(body); } catch { res.writeHead(404).end('not found'); } }); return new Promise((ok) => server.listen(0, () => ok({ server, port: server.address().port }))); } // ---------- 断言辅助 ---------- async function test(name, fn) { try { await fn(); results.push({ name, passed: true }); process.stdout.write(`PASS ${name}\n`); } catch (e) { results.push({ name, passed: false, error: e.message }); process.stdout.write(`FAIL ${name}: ${e.message}\n`); } } /** 进入路由并等待渲染 */ async function go(page, base, path) { await page.goto(base + path, { waitUntil: 'domcontentloaded' }); // 等 lazy chunk 渲染出内容 await page.waitForFunction(() => document.body.innerText.trim().length > 20, null, { timeout: 15_000, }); } /** 无横向溢出 + 无哑按钮 + 无裸露原生控件 */ async function checkLayout(page) { const r = await page.evaluate(() => { const de = document.documentElement; const namelessButtons = [...document.querySelectorAll('button')].filter( (b) => b.getBoundingClientRect().width > 0 && !b.textContent.trim() && !b.getAttribute('aria-label'), ).length; return { docWidth: de.scrollWidth, winWidth: window.innerWidth, namelessButtons, }; }); assert.ok( r.docWidth <= r.winWidth + 1, `横向溢出:doc=${r.docWidth} > win=${r.winWidth}`, ); assert.equal(r.namelessButtons, 0, `${r.namelessButtons} 个按钮无可访问名`); } /** 主区有可见文本 */ async function checkRendered(page) { const len = await page.evaluate(() => { const main = document.querySelector('#main') ?? document.body; return (main.textContent ?? '').trim().length; }); assert.ok(len > 10, `主区内容过少(${len} 字符),疑似白屏`); } // ---------- 主流程 ---------- let serverCtx = null; let base = process.env.SMOKE_URL ?? ''; if (!base) { if (!existsSync(distDir)) { console.error(`❌ 未找到构建产物 ${distDir}\n 先执行 npm run build`); process.exit(2); } serverCtx = await serve(distDir); base = `http://127.0.0.1:${serverCtx.port}`; process.stdout.write(`静态服务器: ${base}\n\n`); } await mkdir(outDir, { recursive: true }); const launch = { headless: true }; if (process.env.SMOKE_CHROME) launch.executablePath = process.env.SMOKE_CHROME; else if (process.platform === 'win32') { const cache = resolve(process.env.LOCALAPPDATA ?? '', 'ms-playwright'); if (existsSync(cache)) { const installed = (await readdir(cache)).filter((x) => /^chromium-\d+$/.test(x)).sort().reverse(); if (installed.length) { launch.executablePath = resolve(cache, installed[0], 'chrome-win64', 'chrome.exe'); } } } const browser = await chromium.launch(launch); const desktopCtx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce', }); const mobileCtx = await browser.newContext({ ...devices['iPhone 13'], viewport: { width: 390, height: 844 }, reducedMotion: 'reduce', }); const pc = await desktopCtx.newPage(); const phone = await mobileCtx.newPage(); for (const [page, tag] of [[pc, 'desktop'], [phone, 'mobile']]) { page.on('pageerror', (e) => pageErrors.push(`${tag}: ${e.message}`)); page.on('requestfailed', (r) => failedRequests.push(`${tag}: ${r.url()} ${r.failure()?.errorText ?? ''}`), ); } /** * 先把端固定 + 注入登录态。 * * 🔴 2026-10-17 起恢复了正式鉴权(`AUTH_REVIEW_BYPASS = false`), * 未登录访问端内路由会被守卫重定向到 `/login`。 * 冒烟用的是演示数据构建,故直接写入一份「已登录」的 localStorage 状态 * 绕过登录页 —— 本脚本测的是**路由与 UI 结构**,不是登录流程 * (登录流程见 `parse-api.spec.ts` 的 R1/R4/R5 分支)。 * * 键名与 `SessionService` 保持一致: * `fmode_session_token` / `fmode_me` */ async function fixMode(page, mode) { await page.goto(base + '/'); await page.evaluate( ([m, token, me]) => { localStorage.setItem('fmode_ui_mode_override', m); localStorage.setItem('fmode_session_token', token); localStorage.setItem('fmode_me', JSON.stringify(me)); }, [ mode, 'smoke-token', { id: 'smoke-user', name: '冒烟用户', mobile: '13800000000', companyId: 'E4KpGvTEto' }, ], ); } // ---------- 桌面端 ---------- const DESKTOP_ROUTES = [ 'chat', 'contacts', 'projects', 'events', 'workbench', 'tasks', 'deliverables', 'mine', ]; await fixMode(pc, 'desktop'); for (const route of DESKTOP_ROUTES) { await test(`desktop 路由 /desktop/${route}`, async () => { await go(pc, base, `/desktop/${route}`); await checkRendered(pc); await checkLayout(pc); }); } await test('desktop 根路径重定向到会话页(AC-17.1)', async () => { await go(pc, base, '/desktop'); assert.match(pc.url(), /\/desktop\/chat$/); }); await test('desktop 会话页发送消息 → 气泡追加且输入框清空(AC-04.2)', async () => { await go(pc, base, '/desktop/chat'); // 选中第一个会话 const conv = pc.locator('.conv-row').first(); await conv.waitFor({ timeout: 10_000 }); await conv.click(); const input = pc.locator('app-composer textarea').first(); await input.waitFor({ timeout: 10_000 }); const before = await pc.locator('app-message-bubble').count(); await input.fill('冒烟测试消息'); await input.press('Enter'); await pc.waitForFunction( (n) => document.querySelectorAll('app-message-bubble').length > n, before, { timeout: 10_000 }, ); assert.equal(await input.inputValue(), '', '发送后输入框应清空'); }); await test('desktop 触达区:可见按钮均 >= 24px 高(控件级基线)', async () => { await go(pc, base, '/desktop/mine'); const tooSmall = await pc.evaluate(() => [...document.querySelectorAll('button')] .filter((b) => { const r = b.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 24; }) .map((b) => (b.textContent ?? '').trim().slice(0, 20)), ); assert.equal(tooSmall.length, 0, `过小按钮:${tooSmall.join(' / ')}`); }); await test('desktop 旧路径 /messages 重定向(FR-16 AC-16.2)', async () => { await go(pc, base, '/messages'); assert.match(pc.url(), /\/desktop\/chat/); }); await test('desktop 旧路径 /call/:id 落详情页(FR-11 禁建通话路由)', async () => { await go(pc, base, '/call/whatever'); assert.match(pc.url(), /\/desktop\/agent\/whatever/); }); // ---------- 移动端 ---------- const MOBILE_ROUTES = [ 'home', 'messages', 'contacts', 'projects', 'events', 'workbench', 'tasks', 'deliverables', 'mine', ]; await fixMode(phone, 'mobile'); for (const route of MOBILE_ROUTES) { await test(`mobile 路由 /mobile/${route}`, async () => { await go(phone, base, `/mobile/${route}`); await checkRendered(phone); await checkLayout(phone); }); } await test('mobile 根路径重定向到首页(AC-17.1)', async () => { await go(phone, base, '/mobile'); assert.match(phone.url(), /\/mobile\/home$/); }); await test('mobile 底栏 5 个 Tab(AC-01.3)', async () => { await go(phone, base, '/mobile/home'); // 底座 ui-m-tabbar 的实际结构:`.ui-m-tab` 为每个 Tab const tabs = await phone.locator('ui-m-tabbar .ui-m-tab').count(); assert.equal(tabs, 5, `底栏 Tab 数应为 5,实际 ${tabs}`); }); await test('mobile 旧路径 /workbench 重定向(FR-16)', async () => { await go(phone, base, '/workbench'); assert.match(phone.url(), /\/mobile\/workbench/); }); await test('mobile 搜索框字号 >= 16px(AC-19.3 iOS 不缩放)', async () => { await go(phone, base, '/mobile/contacts'); const bad = await phone.evaluate(() => { const inputs = [...document.querySelectorAll('input[type="search"], input[type="text"], textarea')]; return inputs .filter((el) => parseFloat(getComputedStyle(el).fontSize) < 16) .map((el) => getComputedStyle(el).fontSize); }); assert.equal(bad.length, 0, `字号过小的输入框:${bad.join(', ')}`); }); // ---------- 收尾 ---------- await test('全程无未捕获页面错误', async () => { assert.equal(pageErrors.length, 0, `页面错误:\n${pageErrors.slice(0, 5).join('\n')}`); }); await test('全程无失败的关键请求(排除 SPA 导航中止的 chunk 与字体)', async () => { // 说明:SPA 路由切换时,上一个页面正在下载的 lazy chunk 会被浏览器 abort // (net::ERR_ABORTED);字体文件同理 —— 属正常噪声,非缺陷。 const real = failedRequests.filter( (u) => (u.includes('127.0.0.1') || u.includes('localhost')) && !u.includes('ERR_ABORTED'), ); assert.equal(real.length, 0, `真实失败请求:\n${real.slice(0, 5).join('\n')}`); }); await writeFile( resolve(outDir, 'summary.json'), JSON.stringify( { base, total: results.length, passed: results.filter((r) => r.passed).length, failed: results.filter((r) => !r.passed).length, results, pageErrors, failedRequests, }, null, 2, ), ); await browser.close(); serverCtx?.server.close(); const failed = results.filter((r) => !r.passed); process.stdout.write( `\nTOTAL: ${results.length - failed.length} PASS / ${failed.length} FAIL\n` + `结果:test-results/smoke/summary.json\n`, ); process.exit(failed.length === 0 ? 0 : 1);