smoke-imhub.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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. * 🔴 再等占位/PENDING 态退场(骨架屏、`--`、`加载中` 也算 innerText>20)。
  79. * 移动端 projects/workbench 数据来自 MockApi 的模拟延迟(320ms),
  80. * 不等的话会抓到标题+骨架就断言,误报「主区内容过少/疑似白屏」。
  81. */
  82. await page
  83. .waitForFunction(
  84. () => !/加载中|PENDING|正在加载|载入中/i.test(document.body.innerText),
  85. null,
  86. { timeout: 10_000 },
  87. )
  88. .catch(() => {}); // 有意的长加载态页面不阻塞
  89. await page.waitForTimeout(300); // 给 @for 列表一帧落地
  90. }
  91. /** 无横向溢出 + 无哑按钮 + 无裸露原生控件 */
  92. async function checkLayout(page) {
  93. const r = await page.evaluate(() => {
  94. const de = document.documentElement;
  95. const namelessButtons = [...document.querySelectorAll('button')].filter(
  96. (b) =>
  97. b.getBoundingClientRect().width > 0 &&
  98. !b.textContent.trim() &&
  99. // 可访问名来源:aria-label / aria-labelledby / title 文本(WAI-ARIA 1.2)
  100. !b.getAttribute('aria-label') &&
  101. !b.getAttribute('aria-labelledby') &&
  102. !b.getAttribute('title'),
  103. );
  104. return {
  105. docWidth: de.scrollWidth,
  106. winWidth: window.innerWidth,
  107. namelessButtons: namelessButtons.length,
  108. namelessSample: namelessButtons
  109. .slice(0, 3)
  110. .map((b) => (b.outerHTML || '').replace(/\s+/g, ' ').slice(0, 200)),
  111. };
  112. });
  113. assert.ok(
  114. r.docWidth <= r.winWidth + 1,
  115. `横向溢出:doc=${r.docWidth} > win=${r.winWidth}`,
  116. );
  117. assert.equal(
  118. r.namelessButtons,
  119. 0,
  120. `${r.namelessButtons} 个按钮无可访问名:${r.namelessSample.join(' | ')}`,
  121. );
  122. }
  123. /** 主区有可见文本 */
  124. async function checkRendered(page) {
  125. const len = await page.evaluate(() => {
  126. const main = document.querySelector('#main') ?? document.body;
  127. return (main.textContent ?? '').trim().length;
  128. });
  129. assert.ok(len > 10, `主区内容过少(${len} 字符),疑似白屏`);
  130. }
  131. // ---------- 主流程 ----------
  132. let serverCtx = null;
  133. let base = process.env.SMOKE_URL ?? '';
  134. if (!base) {
  135. if (!existsSync(distDir)) {
  136. console.error(`❌ 未找到构建产物 ${distDir}\n 先执行 npm run build`);
  137. process.exit(2);
  138. }
  139. serverCtx = await serve(distDir);
  140. base = `http://127.0.0.1:${serverCtx.port}`;
  141. process.stdout.write(`静态服务器: ${base}\n\n`);
  142. }
  143. await mkdir(outDir, { recursive: true });
  144. const launch = { headless: true };
  145. if (process.env.SMOKE_CHROME) launch.executablePath = process.env.SMOKE_CHROME;
  146. else if (process.platform === 'win32') {
  147. const cache = resolve(process.env.LOCALAPPDATA ?? '', 'ms-playwright');
  148. if (existsSync(cache)) {
  149. const installed = (await readdir(cache)).filter((x) => /^chromium-\d+$/.test(x)).sort().reverse();
  150. if (installed.length) {
  151. launch.executablePath = resolve(cache, installed[0], 'chrome-win64', 'chrome.exe');
  152. }
  153. }
  154. }
  155. const browser = await chromium.launch(launch);
  156. const desktopCtx = await browser.newContext({
  157. viewport: { width: 1440, height: 900 },
  158. reducedMotion: 'reduce',
  159. });
  160. const mobileCtx = await browser.newContext({
  161. ...devices['iPhone 13'],
  162. viewport: { width: 390, height: 844 },
  163. reducedMotion: 'reduce',
  164. });
  165. const pc = await desktopCtx.newPage();
  166. const phone = await mobileCtx.newPage();
  167. for (const [page, tag] of [[pc, 'desktop'], [phone, 'mobile']]) {
  168. page.on('pageerror', (e) => pageErrors.push(`${tag}: ${e.message}`));
  169. page.on('requestfailed', (r) =>
  170. failedRequests.push(`${tag}: ${r.url()} ${r.failure()?.errorText ?? ''}`),
  171. );
  172. }
  173. /**
  174. * 先把端固定 + 注入登录态。
  175. *
  176. * 🔴 2026-10-17 起恢复了正式鉴权(`AUTH_REVIEW_BYPASS = false`),
  177. * 未登录访问端内路由会被守卫重定向到 `/login`。
  178. * 冒烟用的是演示数据构建,故直接写入一份「已登录」的 localStorage 状态
  179. * 绕过登录页 —— 本脚本测的是**路由与 UI 结构**,不是登录流程
  180. * (登录流程见 `parse-api.spec.ts` 的 R1/R4/R5 分支)。
  181. *
  182. * 键名与 `SessionService` 保持一致:
  183. * `fmode_session_token` / `fmode_me`
  184. */
  185. async function fixMode(page, mode) {
  186. await page.goto(base + '/');
  187. await page.evaluate(
  188. ([m, token, me]) => {
  189. localStorage.setItem('fmode_ui_mode_override', m);
  190. localStorage.setItem('fmode_session_token', token);
  191. localStorage.setItem('fmode_me', JSON.stringify(me));
  192. },
  193. [
  194. mode,
  195. 'smoke-token',
  196. { id: 'smoke-user', name: '冒烟用户', mobile: '13800000000', companyId: 'E4KpGvTEto' },
  197. ],
  198. );
  199. }
  200. // ---------- 桌面端 ----------
  201. const DESKTOP_ROUTES = [
  202. 'chat',
  203. 'contacts',
  204. 'projects',
  205. 'events',
  206. 'workbench',
  207. 'tasks',
  208. 'deliverables',
  209. 'mine',
  210. ];
  211. await fixMode(pc, 'desktop');
  212. for (const route of DESKTOP_ROUTES) {
  213. await test(`desktop 路由 /desktop/${route}`, async () => {
  214. await go(pc, base, `/desktop/${route}`);
  215. await checkRendered(pc);
  216. await checkLayout(pc);
  217. });
  218. }
  219. await test('desktop 根路径重定向到会话页(AC-17.1)', async () => {
  220. await go(pc, base, '/desktop');
  221. assert.match(pc.url(), /\/desktop\/chat$/);
  222. });
  223. await test('desktop 会话页结构完整(AC-04.2 链路骨架)', async () => {
  224. await go(pc, base, '/desktop/chat');
  225. /*
  226. * 🔴 本脚本用的是**假 token**(`smoke-token`)—— 只够绕过路由守卫,
  227. * 拿不到任何真实矩阵数据,所以**不会有会话行**,也就点不进具体会话。
  228. *
  229. * 因此这里只断言「页面骨架渲染正确」,不验证发送:
  230. * · 左栏容器/会话区域存在
  231. * · 右侧空态或会话区存在(二者其一)
  232. *
  233. * 真正的发送语义由单测覆盖:chat.service.spec 的 sendText 系列
  234. * + 多会话隔离用例(带线程根、按会话过滤)。
  235. */
  236. /*
  237. * 🔴 2026-10-30 起左栏(会话树)从页面提升到 `DesktopShell` 外壳组件
  238. * —— 一级路由切换时左栏要保持不变,不能挂在某个页面里。
  239. * 断言随之从页面内 `.list-col` 改为外壳的 `app-session-tree`。
  240. */
  241. const sidebar = await pc.locator('app-session-tree, .list-col').count();
  242. assert.ok(sidebar > 0, '左栏会话列表区域应存在');
  243. const mainArea = await pc.locator('.main-col').count();
  244. assert.ok(mainArea > 0, '右侧会话区应存在');
  245. });
  246. await test('desktop 触达区:可见按钮均 >= 24px 高(控件级基线)', async () => {
  247. await go(pc, base, '/desktop/mine');
  248. const tooSmall = await pc.evaluate(() =>
  249. [...document.querySelectorAll('button')]
  250. .filter((b) => {
  251. const r = b.getBoundingClientRect();
  252. return r.width > 0 && r.height > 0 && r.height < 24;
  253. })
  254. .map((b) => (b.textContent ?? '').trim().slice(0, 20)),
  255. );
  256. assert.equal(tooSmall.length, 0, `过小按钮:${tooSmall.join(' / ')}`);
  257. });
  258. await test('desktop 旧路径 /messages 重定向(FR-16 AC-16.2)', async () => {
  259. await go(pc, base, '/messages');
  260. assert.match(pc.url(), /\/desktop\/chat/);
  261. });
  262. await test('desktop 旧路径 /call/:id 落详情页(FR-11 禁建通话路由)', async () => {
  263. await go(pc, base, '/call/whatever');
  264. assert.match(pc.url(), /\/desktop\/agent\/whatever/);
  265. });
  266. // ---------- 移动端 ----------
  267. const MOBILE_ROUTES = [
  268. 'home',
  269. 'messages',
  270. 'contacts',
  271. 'projects',
  272. 'events',
  273. 'workbench',
  274. 'tasks',
  275. 'deliverables',
  276. 'mine',
  277. ];
  278. await fixMode(phone, 'mobile');
  279. for (const route of MOBILE_ROUTES) {
  280. await test(`mobile 路由 /mobile/${route}`, async () => {
  281. await go(phone, base, `/mobile/${route}`);
  282. await checkRendered(phone);
  283. await checkLayout(phone);
  284. });
  285. }
  286. await test('mobile 根路径重定向到首页(AC-17.1)', async () => {
  287. await go(phone, base, '/mobile');
  288. assert.match(phone.url(), /\/mobile\/home$/);
  289. });
  290. await test('mobile 底栏 5 个 Tab(AC-01.3)', async () => {
  291. await go(phone, base, '/mobile/home');
  292. // 底座 ui-m-tabbar 的实际结构:`.ui-m-tab` 为每个 Tab
  293. const tabs = await phone.locator('ui-m-tabbar .ui-m-tab').count();
  294. assert.equal(tabs, 5, `底栏 Tab 数应为 5,实际 ${tabs}`);
  295. });
  296. await test('mobile 旧路径 /workbench 重定向(FR-16)', async () => {
  297. await go(phone, base, '/workbench');
  298. assert.match(phone.url(), /\/mobile\/workbench/);
  299. });
  300. await test('mobile 搜索框字号 >= 16px(AC-19.3 iOS 不缩放)', async () => {
  301. await go(phone, base, '/mobile/contacts');
  302. const bad = await phone.evaluate(() => {
  303. const inputs = [...document.querySelectorAll('input[type="search"], input[type="text"], textarea')];
  304. return inputs
  305. .filter((el) => parseFloat(getComputedStyle(el).fontSize) < 16)
  306. .map((el) => getComputedStyle(el).fontSize);
  307. });
  308. assert.equal(bad.length, 0, `字号过小的输入框:${bad.join(', ')}`);
  309. });
  310. // ---------- 收尾 ----------
  311. await test('全程无未捕获页面错误', async () => {
  312. assert.equal(pageErrors.length, 0, `页面错误:\n${pageErrors.slice(0, 5).join('\n')}`);
  313. });
  314. await test('全程无失败的关键请求(排除 SPA 导航中止的 chunk 与字体)', async () => {
  315. // 说明:SPA 路由切换时,上一个页面正在下载的 lazy chunk 会被浏览器 abort
  316. // (net::ERR_ABORTED);字体文件同理 —— 属正常噪声,非缺陷。
  317. const real = failedRequests.filter(
  318. (u) => (u.includes('127.0.0.1') || u.includes('localhost')) && !u.includes('ERR_ABORTED'),
  319. );
  320. assert.equal(real.length, 0, `真实失败请求:\n${real.slice(0, 5).join('\n')}`);
  321. });
  322. await writeFile(
  323. resolve(outDir, 'summary.json'),
  324. JSON.stringify(
  325. {
  326. base,
  327. total: results.length,
  328. passed: results.filter((r) => r.passed).length,
  329. failed: results.filter((r) => !r.passed).length,
  330. results,
  331. pageErrors,
  332. failedRequests,
  333. },
  334. null,
  335. 2,
  336. ),
  337. );
  338. await browser.close();
  339. serverCtx?.server.close();
  340. const failed = results.filter((r) => !r.passed);
  341. process.stdout.write(
  342. `\nTOTAL: ${results.length - failed.length} PASS / ${failed.length} FAIL\n` +
  343. `结果:test-results/smoke/summary.json\n`,
  344. );
  345. process.exit(failed.length === 0 ? 0 : 1);