smoke-imhub.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. #!/usr/bin/env node
  2. /**
  3. * 双端全路由冒烟断言(PRD §2.3 成功标准 ③ / SPEC NFR-06)
  4. *
  5. * 用法:
  6. * npm run build && npm run smoke
  7. * # 或指定已运行的地址: SMOKE_URL=http://localhost:4200 npm run smoke
  8. *
  9. * 行为:
  10. * 1. 起一个静态服务器托管 `dist/fmode-app-v2/browser`(或复用 SMOKE_URL)
  11. * 2. 用桌面(1440×900)与移动(390×844)两个上下文跑全路由断言
  12. * 3. 断言项:路由可达 / 无未捕获页面错误 / 无横向溢出 / 关键交互可用
  13. * 4. 结果落 `test-results/smoke/`(截图 + 汇总 JSON)
  14. *
  15. * 🔴 数据源说明:冒烟在 **演示数据模式** 下跑(`useMockData: true` 构建),
  16. * 以便在无真实后端凭据时也能验证 UI 结构与路由。
  17. * 真实数据链路另由 `npm run smoke:live`(需登录态)覆盖。
  18. */
  19. import { chromium, devices } from 'playwright';
  20. import { mkdir, writeFile, readFile, readdir } from 'node:fs/promises';
  21. import { existsSync } from 'node:fs';
  22. import { createServer } from 'node:http';
  23. import { extname, join, resolve, dirname } from 'node:path';
  24. import { fileURLToPath } from 'node:url';
  25. import assert from 'node:assert/strict';
  26. const here = dirname(fileURLToPath(import.meta.url));
  27. const projectRoot = resolve(here, '..');
  28. const distDir = resolve(projectRoot, 'dist/fmode-app-v2/browser');
  29. const outDir = resolve(projectRoot, 'test-results/smoke');
  30. const results = [];
  31. const pageErrors = [];
  32. const failedRequests = [];
  33. // ---------- 静态服务器 ----------
  34. const MIME = {
  35. '.html': 'text/html',
  36. '.js': 'text/javascript',
  37. '.css': 'text/css',
  38. '.json': 'application/json',
  39. '.svg': 'image/svg+xml',
  40. '.woff2': 'font/woff2',
  41. '.ico': 'image/x-icon',
  42. '.png': 'image/png',
  43. };
  44. function serve(dir) {
  45. const server = createServer(async (req, res) => {
  46. try {
  47. const url = decodeURIComponent((req.url ?? '/').split('?')[0]);
  48. let file = join(dir, url);
  49. if (!existsSync(file) || url === '/') file = join(dir, 'index.html');
  50. const body = await readFile(file);
  51. res.writeHead(200, { 'Content-Type': MIME[extname(file)] ?? 'application/octet-stream' });
  52. res.end(body);
  53. } catch {
  54. res.writeHead(404).end('not found');
  55. }
  56. });
  57. return new Promise((ok) => server.listen(0, () => ok({ server, port: server.address().port })));
  58. }
  59. // ---------- 断言辅助 ----------
  60. async function test(name, fn) {
  61. try {
  62. await fn();
  63. results.push({ name, passed: true });
  64. process.stdout.write(`PASS ${name}\n`);
  65. } catch (e) {
  66. results.push({ name, passed: false, error: e.message });
  67. process.stdout.write(`FAIL ${name}: ${e.message}\n`);
  68. }
  69. }
  70. /** 进入路由并等待渲染 */
  71. async function go(page, base, path) {
  72. await page.goto(base + path, { waitUntil: 'domcontentloaded' });
  73. // 等 lazy chunk 渲染出内容
  74. await page.waitForFunction(() => document.body.innerText.trim().length > 20, null, {
  75. timeout: 15_000,
  76. });
  77. }
  78. /** 无横向溢出 + 无哑按钮 + 无裸露原生控件 */
  79. async function checkLayout(page) {
  80. const r = await page.evaluate(() => {
  81. const de = document.documentElement;
  82. const namelessButtons = [...document.querySelectorAll('button')].filter(
  83. (b) => b.getBoundingClientRect().width > 0 && !b.textContent.trim() && !b.getAttribute('aria-label'),
  84. ).length;
  85. return {
  86. docWidth: de.scrollWidth,
  87. winWidth: window.innerWidth,
  88. namelessButtons,
  89. };
  90. });
  91. assert.ok(
  92. r.docWidth <= r.winWidth + 1,
  93. `横向溢出:doc=${r.docWidth} > win=${r.winWidth}`,
  94. );
  95. assert.equal(r.namelessButtons, 0, `${r.namelessButtons} 个按钮无可访问名`);
  96. }
  97. /** 主区有可见文本 */
  98. async function checkRendered(page) {
  99. const len = await page.evaluate(() => {
  100. const main = document.querySelector('#main') ?? document.body;
  101. return (main.textContent ?? '').trim().length;
  102. });
  103. assert.ok(len > 10, `主区内容过少(${len} 字符),疑似白屏`);
  104. }
  105. // ---------- 主流程 ----------
  106. let serverCtx = null;
  107. let base = process.env.SMOKE_URL ?? '';
  108. if (!base) {
  109. if (!existsSync(distDir)) {
  110. console.error(`❌ 未找到构建产物 ${distDir}\n 先执行 npm run build`);
  111. process.exit(2);
  112. }
  113. serverCtx = await serve(distDir);
  114. base = `http://127.0.0.1:${serverCtx.port}`;
  115. process.stdout.write(`静态服务器: ${base}\n\n`);
  116. }
  117. await mkdir(outDir, { recursive: true });
  118. const launch = { headless: true };
  119. if (process.env.SMOKE_CHROME) launch.executablePath = process.env.SMOKE_CHROME;
  120. else if (process.platform === 'win32') {
  121. const cache = resolve(process.env.LOCALAPPDATA ?? '', 'ms-playwright');
  122. if (existsSync(cache)) {
  123. const installed = (await readdir(cache)).filter((x) => /^chromium-\d+$/.test(x)).sort().reverse();
  124. if (installed.length) {
  125. launch.executablePath = resolve(cache, installed[0], 'chrome-win64', 'chrome.exe');
  126. }
  127. }
  128. }
  129. const browser = await chromium.launch(launch);
  130. const desktopCtx = await browser.newContext({
  131. viewport: { width: 1440, height: 900 },
  132. reducedMotion: 'reduce',
  133. });
  134. const mobileCtx = await browser.newContext({
  135. ...devices['iPhone 13'],
  136. viewport: { width: 390, height: 844 },
  137. reducedMotion: 'reduce',
  138. });
  139. const pc = await desktopCtx.newPage();
  140. const phone = await mobileCtx.newPage();
  141. for (const [page, tag] of [[pc, 'desktop'], [phone, 'mobile']]) {
  142. page.on('pageerror', (e) => pageErrors.push(`${tag}: ${e.message}`));
  143. page.on('requestfailed', (r) =>
  144. failedRequests.push(`${tag}: ${r.url()} ${r.failure()?.errorText ?? ''}`),
  145. );
  146. }
  147. /**
  148. * 先把端固定 + 注入登录态。
  149. *
  150. * 🔴 2026-10-17 起恢复了正式鉴权(`AUTH_REVIEW_BYPASS = false`),
  151. * 未登录访问端内路由会被守卫重定向到 `/login`。
  152. * 冒烟用的是演示数据构建,故直接写入一份「已登录」的 localStorage 状态
  153. * 绕过登录页 —— 本脚本测的是**路由与 UI 结构**,不是登录流程
  154. * (登录流程见 `parse-api.spec.ts` 的 R1/R4/R5 分支)。
  155. *
  156. * 键名与 `SessionService` 保持一致:
  157. * `fmode_session_token` / `fmode_me`
  158. */
  159. async function fixMode(page, mode) {
  160. await page.goto(base + '/');
  161. await page.evaluate(
  162. ([m, token, me]) => {
  163. localStorage.setItem('fmode_ui_mode_override', m);
  164. localStorage.setItem('fmode_session_token', token);
  165. localStorage.setItem('fmode_me', JSON.stringify(me));
  166. },
  167. [
  168. mode,
  169. 'smoke-token',
  170. { id: 'smoke-user', name: '冒烟用户', mobile: '13800000000', companyId: 'E4KpGvTEto' },
  171. ],
  172. );
  173. }
  174. // ---------- 桌面端 ----------
  175. const DESKTOP_ROUTES = [
  176. 'chat',
  177. 'contacts',
  178. 'projects',
  179. 'events',
  180. 'workbench',
  181. 'tasks',
  182. 'deliverables',
  183. 'mine',
  184. ];
  185. await fixMode(pc, 'desktop');
  186. for (const route of DESKTOP_ROUTES) {
  187. await test(`desktop 路由 /desktop/${route}`, async () => {
  188. await go(pc, base, `/desktop/${route}`);
  189. await checkRendered(pc);
  190. await checkLayout(pc);
  191. });
  192. }
  193. await test('desktop 根路径重定向到会话页(AC-17.1)', async () => {
  194. await go(pc, base, '/desktop');
  195. assert.match(pc.url(), /\/desktop\/chat$/);
  196. });
  197. await test('desktop 会话页发送消息 → 气泡追加且输入框清空(AC-04.2)', async () => {
  198. await go(pc, base, '/desktop/chat');
  199. // 选中第一个会话
  200. const conv = pc.locator('.conv-row').first();
  201. await conv.waitFor({ timeout: 10_000 });
  202. await conv.click();
  203. const input = pc.locator('app-composer textarea').first();
  204. await input.waitFor({ timeout: 10_000 });
  205. const before = await pc.locator('app-message-bubble').count();
  206. await input.fill('冒烟测试消息');
  207. await input.press('Enter');
  208. await pc.waitForFunction(
  209. (n) => document.querySelectorAll('app-message-bubble').length > n,
  210. before,
  211. { timeout: 10_000 },
  212. );
  213. assert.equal(await input.inputValue(), '', '发送后输入框应清空');
  214. });
  215. await test('desktop 触达区:可见按钮均 >= 24px 高(控件级基线)', async () => {
  216. await go(pc, base, '/desktop/mine');
  217. const tooSmall = await pc.evaluate(() =>
  218. [...document.querySelectorAll('button')]
  219. .filter((b) => {
  220. const r = b.getBoundingClientRect();
  221. return r.width > 0 && r.height > 0 && r.height < 24;
  222. })
  223. .map((b) => (b.textContent ?? '').trim().slice(0, 20)),
  224. );
  225. assert.equal(tooSmall.length, 0, `过小按钮:${tooSmall.join(' / ')}`);
  226. });
  227. await test('desktop 旧路径 /messages 重定向(FR-16 AC-16.2)', async () => {
  228. await go(pc, base, '/messages');
  229. assert.match(pc.url(), /\/desktop\/chat/);
  230. });
  231. await test('desktop 旧路径 /call/:id 落详情页(FR-11 禁建通话路由)', async () => {
  232. await go(pc, base, '/call/whatever');
  233. assert.match(pc.url(), /\/desktop\/agent\/whatever/);
  234. });
  235. // ---------- 移动端 ----------
  236. const MOBILE_ROUTES = [
  237. 'home',
  238. 'messages',
  239. 'contacts',
  240. 'projects',
  241. 'events',
  242. 'workbench',
  243. 'tasks',
  244. 'deliverables',
  245. 'mine',
  246. ];
  247. await fixMode(phone, 'mobile');
  248. for (const route of MOBILE_ROUTES) {
  249. await test(`mobile 路由 /mobile/${route}`, async () => {
  250. await go(phone, base, `/mobile/${route}`);
  251. await checkRendered(phone);
  252. await checkLayout(phone);
  253. });
  254. }
  255. await test('mobile 根路径重定向到首页(AC-17.1)', async () => {
  256. await go(phone, base, '/mobile');
  257. assert.match(phone.url(), /\/mobile\/home$/);
  258. });
  259. await test('mobile 底栏 5 个 Tab(AC-01.3)', async () => {
  260. await go(phone, base, '/mobile/home');
  261. // 底座 ui-m-tabbar 的实际结构:`.ui-m-tab` 为每个 Tab
  262. const tabs = await phone.locator('ui-m-tabbar .ui-m-tab').count();
  263. assert.equal(tabs, 5, `底栏 Tab 数应为 5,实际 ${tabs}`);
  264. });
  265. await test('mobile 旧路径 /workbench 重定向(FR-16)', async () => {
  266. await go(phone, base, '/workbench');
  267. assert.match(phone.url(), /\/mobile\/workbench/);
  268. });
  269. await test('mobile 搜索框字号 >= 16px(AC-19.3 iOS 不缩放)', async () => {
  270. await go(phone, base, '/mobile/contacts');
  271. const bad = await phone.evaluate(() => {
  272. const inputs = [...document.querySelectorAll('input[type="search"], input[type="text"], textarea')];
  273. return inputs
  274. .filter((el) => parseFloat(getComputedStyle(el).fontSize) < 16)
  275. .map((el) => getComputedStyle(el).fontSize);
  276. });
  277. assert.equal(bad.length, 0, `字号过小的输入框:${bad.join(', ')}`);
  278. });
  279. // ---------- 收尾 ----------
  280. await test('全程无未捕获页面错误', async () => {
  281. assert.equal(pageErrors.length, 0, `页面错误:\n${pageErrors.slice(0, 5).join('\n')}`);
  282. });
  283. await test('全程无失败的关键请求(排除 SPA 导航中止的 chunk 与字体)', async () => {
  284. // 说明:SPA 路由切换时,上一个页面正在下载的 lazy chunk 会被浏览器 abort
  285. // (net::ERR_ABORTED);字体文件同理 —— 属正常噪声,非缺陷。
  286. const real = failedRequests.filter(
  287. (u) => (u.includes('127.0.0.1') || u.includes('localhost')) && !u.includes('ERR_ABORTED'),
  288. );
  289. assert.equal(real.length, 0, `真实失败请求:\n${real.slice(0, 5).join('\n')}`);
  290. });
  291. await writeFile(
  292. resolve(outDir, 'summary.json'),
  293. JSON.stringify(
  294. {
  295. base,
  296. total: results.length,
  297. passed: results.filter((r) => r.passed).length,
  298. failed: results.filter((r) => !r.passed).length,
  299. results,
  300. pageErrors,
  301. failedRequests,
  302. },
  303. null,
  304. 2,
  305. ),
  306. );
  307. await browser.close();
  308. serverCtx?.server.close();
  309. const failed = results.filter((r) => !r.passed);
  310. process.stdout.write(
  311. `\nTOTAL: ${results.length - failed.length} PASS / ${failed.length} FAIL\n` +
  312. `结果:test-results/smoke/summary.json\n`,
  313. );
  314. process.exit(failed.length === 0 ? 0 : 1);