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