verify-browser.mjs 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-browser.mjs —— 真实浏览器验收(任务书第 5 节 B 组 + C 组)。
  4. *
  5. * 前置:npx playwright install chromium(或 CHROME_PATH 指向本机 Chromium)
  6. * 用法:
  7. * node scripts/verify-browser.mjs # 默认起本地服务器(根部署)
  8. * node scripts/verify-browser.mjs --sub # 子路径部署(/kangshifu/dev/inno-board/)
  9. * node scripts/verify-browser.mjs --url https://kangshifu.fmode.cn/kangshifu/dev/ # 线上
  10. * --shots <dir> 截图目录(默认 docs/screenshots)
  11. *
  12. * 覆盖:
  13. * C 控制台无报错(网络断开时的 ERR 除外) / 对比度实测 ≥ 4.5:1
  14. * 14 深链接直接访问落到正确视图(含子路径);刷新后仍在原路由
  15. * 12 六维筛选可叠加、结果与 core 计算一致;13 刷新后筛选不丢
  16. * 15/16 卡片五要素 + 谁/哪个 Agent/什么方式/什么时候
  17. * 17 多人评分显示平均;18 改状态持久(刷新保持);19 排序切换顺序真实变化
  18. * 20 三个视角 + 痛点对照;21 五阶段板块;22 证据链双向点名
  19. * 视觉:backdrop-filter 生效、滚轮/键盘/焦点可达基本检查
  20. */
  21. import { spawn } from 'node:child_process';
  22. import { existsSync, mkdirSync, readFileSync } from 'node:fs';
  23. import { join, dirname } from 'node:path';
  24. import { fileURLToPath } from 'node:url';
  25. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  26. const argv = process.argv.slice(2);
  27. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  28. const SUB = argv.includes('--sub');
  29. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots'));
  30. mkdirSync(SHOTS, { recursive: true });
  31. /* ---------- playwright 解析:本仓 / 验收机 / 全局 ---------- */
  32. async function loadPlaywright() {
  33. const cands = [join(ROOT, 'node_modules/playwright'), '/opt/data/work/kangshifu/testing/node_modules/playwright'];
  34. for (const c of cands) {
  35. if (existsSync(c)) return (await import(join(c, 'index.mjs').replace(/\\/g, '/'))).default ?? (await import(`${c}/index.js`));
  36. }
  37. return import('playwright');
  38. }
  39. const out = [];
  40. const ok = (name, cond, detail = '') => {
  41. out.push({ name, ok: Boolean(cond), detail });
  42. console.log(`${cond ? '✓' : '✗'} ${name}${!cond && detail ? `\n ${detail}` : ''}`);
  43. };
  44. /* ---------- 起服务器 ---------- */
  45. let server = null;
  46. let base = arg('--url', '');
  47. if (!base) {
  48. const { writeConfig } = await import('./config.mjs');
  49. const { prefix } = writeConfig({ env: SUB ? 'local-sub' : 'local' });
  50. const port = Number(process.env.VERIFY_PORT || (SUB ? 4181 : 4173));
  51. const mount = prefix.replace(/\/+$/, '') || '/';
  52. if (!existsSync(join(ROOT, 'lib/vendor/lit'))) throw new Error('缺少 lib/vendor:先 node scripts/vendor.mjs');
  53. server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', mount], { stdio: 'ignore' });
  54. await new Promise((r) => setTimeout(r, 500));
  55. base = `http://127.0.0.1:${port}${prefix}`;
  56. }
  57. process.on('exit', () => server?.kill());
  58. const pw = await loadPlaywright();
  59. const browser = await pw.chromium.launch({
  60. executablePath: process.env.CHROME_PATH || undefined,
  61. args: ['--no-sandbox', '--disable-dev-shm-usage'],
  62. });
  63. const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  64. const page = await ctx.newPage();
  65. const consoleErrors = [];
  66. const isNoise = (t) => /favicon|Failed to load resource.*(net::ERR_|503)/i.test(t);
  67. page.on('console', (m) => { if (m.type() === 'error' && !isNoise(m.text())) consoleErrors.push(m.text()); });
  68. page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message}`));
  69. const path = (p) => new URL(p, base).href;
  70. const S = (sel, root) => `${root ? `${root} >>> ` : ''}${sel}`;
  71. const deepAll = (sel, fnSrc, argVal) => page.evaluate(({ sel, fnSrc, argVal }) => {
  72. const found = [];
  73. const walk = (root) => {
  74. for (const el of root.querySelectorAll(sel)) found.push(el);
  75. for (const el of root.querySelectorAll('*')) if (el.shadowRoot) walk(el.shadowRoot);
  76. };
  77. walk(document);
  78. // eslint-disable-next-line no-new-func
  79. const fn = new Function('el', 'arg', fnSrc);
  80. return found.map((el) => fn(el, argVal));
  81. }, { sel, fnSrc, argVal });
  82. /** 原样抓一次原始行(和页面自己发的完全同一条 URL:走站点同源代理),存成快照 */
  83. const grabSnapshot = () => page.evaluate(async () => {
  84. const { CONFIG } = await import(new URL('src/app.config.js', document.baseURI).href);
  85. const H = { 'X-Parse-Application-Id': CONFIG.appId, Accept: 'application/json' };
  86. const snap = { loadedAt: new Date().toISOString(), truncated: [] };
  87. for (const [k, cls] of Object.entries(CONFIG.classes)) {
  88. const j = await (await fetch(`${CONFIG.parseBase}/classes/${cls}?limit=1000&skip=0&order=createdAt`, { headers: H })).json();
  89. snap[k] = j.results || [];
  90. }
  91. sessionStorage.setItem('kf-snap', JSON.stringify(snap));
  92. return Object.fromEntries(Object.entries(snap).filter(([k]) => k !== 'loadedAt' && k !== 'truncated').map(([k, v]) => [k, v.length]));
  93. });
  94. /** 断网 → 用快照直接重载同一 URL:验证深链接 / 筛选状态不依赖内存 */
  95. const reloadFromSnapshot = async () => {
  96. await ctx.setOffline(true);
  97. await page.reload({ waitUntil: 'domcontentloaded' });
  98. await page.waitForTimeout(1200);
  99. };
  100. const view = () => page.locator('kf-app').locator('main#view').locator(':scope > *').first();
  101. async function shot(name) { await page.screenshot({ path: join(SHOTS, `${name}.png`), fullPage: false }); }
  102. /* ================= 1. 首页(工作台三视角) ================= */
  103. await page.goto(path('/'), { waitUntil: 'networkidle' });
  104. await page.waitForSelector('view-workbench', { state: 'attached', timeout: 20000 });
  105. await page.waitForTimeout(400);
  106. ok('14 首屏落在工作台', (await page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('main#view').firstElementChild.tagName)) === 'VIEW-WORKBENCH');
  107. const wbText = await view().evaluate((el) => el.shadowRoot.textContent.replace(/\s+/g, ' '));
  108. ok('20 工作台三视角:给我的 / 池子 / 现场看板', /我的进度/.test(wbText) && /机会卡池/.test(wbText) && /五阶段 × 人数/.test(wbText), wbText.slice(0, 160));
  109. ok('20 痛点对照表(5 行)', ['信息收集繁琐分散', '机会识别凭经验', '卖点提炼缺抓手', '概念验证无量化', '琐事挤占思考'].every((p) => wbText.includes(p)));
  110. ok('20 现场看板点名卡住的人', /卡住的人(\d+)/.test(wbText) && /小时没动静/.test(wbText), (wbText.match(/卡住的人.{0,80}/) || [''])[0]);
  111. ok('20 最新 3 张以卡片形态出现', (await view().evaluate((el) => el.shadowRoot.querySelectorAll('kf-opportunity-card').length)) >= 3);
  112. await shot('01-workbench');
  113. // 玻璃:backdrop-filter 生效 + 有背景可糊
  114. const glass = await page.evaluate(() => {
  115. const nav = document.querySelector('kf-app').shadowRoot.querySelector('kf-nav');
  116. const side = nav.shadowRoot.querySelector('.side');
  117. const cs = getComputedStyle(side);
  118. return { bf: cs.backdropFilter || cs.webkitBackdropFilter, bg: cs.backgroundImage.slice(0, 60), ambient: document.querySelector('kf-app').shadowRoot.querySelectorAll('.ambient i').length };
  119. });
  120. ok('视觉:backdrop-filter blur 生效且光场存在', /blur\(\d+px\)/.test(glass.bf) && glass.ambient === 4, JSON.stringify(glass));
  121. /* ================= 2. 「我是谁」→ 给我的 ================= */
  122. const meSel = page.locator('kf-app').locator('kf-topbar').locator('select#me');
  123. await meSel.selectOption('张伟');
  124. await page.waitForTimeout(300);
  125. const wb2 = await view().evaluate((el) => el.shadowRoot.textContent.replace(/\s+/g, ' '));
  126. ok('20 「给我的」:张伟已提交 N 份 + 哪些阶段没交', /张伟,你已提交 \d+ 份成果/.test(wb2) && /还差 \d+ 个阶段没交/.test(wb2), (wb2.match(/张伟,你已提交.{0,140}/) || [''])[0]);
  127. /* ================= 3. 机会卡池:卡片五要素 / 来源四要素 / 排序 / 筛选 / URL ================= */
  128. await page.goto(path('/pool'), { waitUntil: 'networkidle' });
  129. await page.waitForSelector('view-pool');
  130. await page.waitForTimeout(400);
  131. const cards = () => deepAll('kf-opportunity-card', `return {
  132. id: el.opp?.id, title: el.opp?.title, prio: el.opp?.priority, cat: el.opp?.category, stage: el.opp?.stageObj?.code,
  133. avg: el.opp?.stats?.avg, n: el.opp?.stats?.n, ev: el.opp?.evidenceLabel, quotes: el.opp?.quoteCount,
  134. dec: el.opp?.decision?.label || '', by: el.opp?.upload?.uploadedBy, agent: el.opp?.upload?.agentName,
  135. method: el.opp?.upload?.uploadMethod, at: el.opp?.upload?.uploadedAt,
  136. text: el.shadowRoot.textContent.replace(/\\s+/g, ' ')
  137. };`);
  138. const c1 = await cards();
  139. ok('15 卡片数量 ≥ 6', c1.length >= 6, String(c1.length));
  140. const first = c1[0];
  141. ok('15 五要素一屏可读(优先级/品类/阶段/名字/一句话/评分/证据/引用/决策/来源)',
  142. c1.every((c) => c.prio && c.cat && c.stage && c.title && c.ev && c.avg != null || c.text.includes('暂无评分')) &&
  143. c1.every((c) => /决策[::]/.test(c.text)) && c1.every((c) => /人评分 · 平均|等待第 1 个评分/.test(c.text)),
  144. JSON.stringify(c1.map((c) => [c.prio, c.cat, c.stage, c.avg])));
  145. ok('16 卡片显示「谁 + 哪个 Agent + 什么方式 + 什么时候」', c1.every((c) => c.by && c.agent && c.method && c.at), JSON.stringify(c1.map((c) => [c.by, c.agent, c.method, c.at])));
  146. ok('16 卡片文本里真的出现了 Agent 名与上传方式', c1.every((c) => c.text.includes(c.agent) && /会话上传|Skill 上传|手工录入/.test(c.text)));
  147. await shot('02-pool');
  148. const prioOrder = c1.map((c) => c.id);
  149. await page.locator('view-pool').locator('[aria-label="排序方式"] button', { hasText: '综合评分' }).click();
  150. await page.waitForTimeout(400);
  151. const scoreCards = await cards();
  152. const scoreOrder = scoreCards.map((c) => c.id);
  153. ok('19 排序切换(优先级 → 评分)顺序真实变化', prioOrder.join() !== scoreOrder.join(), `priority=${prioOrder.join(',')}\n score=${scoreOrder.join(',')}`);
  154. ok('19 评分排序单调不增', scoreCards.every((c, i) => i === 0 || (scoreCards[i - 1].avg ?? -1) >= (c.avg ?? -1)));
  155. ok('13 排序写进 URL(?sort=score)', page.url().includes('sort=score'), page.url());
  156. // 六维筛选叠加:点状态 + 优先级 + 品类
  157. await page.locator('view-pool').locator('[data-facet="status:candidate"]').click();
  158. await page.waitForTimeout(250);
  159. await page.locator('view-pool').locator('[data-facet="priority:S"]').click();
  160. await page.waitForTimeout(250);
  161. const expected = await page.evaluate(async () => {
  162. const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href);
  163. const db = boardService.db.get();
  164. return db.opportunities.filter((o) => o.status === 'candidate' && o.priority === 'S').map((o) => o.id);
  165. });
  166. const afterFilter = (await cards()).map((c) => c.id);
  167. ok('12 六维筛选叠加(候选 ∧ S 级)与 core 计算一致', afterFilter.length > 0 && afterFilter.slice().sort().join() === expected.slice().sort().join(), `页面=${afterFilter.join(',')} core=${expected.join(',')}`);
  168. ok('12 筛选条件写进 URL', /status=candidate/.test(page.url()) && /priority=S/.test(page.url()), page.url());
  169. await shot('03-pool-filtered');
  170. // 13 刷新后筛选不丢 + 14 深链接子路径
  171. await page.reload({ waitUntil: 'networkidle' });
  172. await page.waitForSelector('view-pool');
  173. await page.waitForTimeout(500);
  174. const pressed = await page.locator('view-pool').locator('[data-facet="status:candidate"]').getAttribute('aria-pressed');
  175. const afterReload = (await cards()).map((c) => c.id);
  176. ok('13 刷新后筛选不丢', pressed === 'true' && afterReload.slice().sort().join() === expected.slice().sort().join(), `pressed=${pressed} ids=${afterReload.join(',')}`);
  177. ok('14 刷新后 URL 落在同一路由', new URL(page.url()).pathname.endsWith('/pool') && page.url().includes('status=candidate'), page.url());
  178. /* ================= 4. 卡片点开 → 详情 / 多人评分 / 改状态 ================= */
  179. // 直接走深链接:/pool/:id 必须落到正确的那张卡(不依赖点哪张)
  180. const richId = await page.evaluate(async () => {
  181. const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href);
  182. const opps = boardService.db.get().opportunities;
  183. return (opps.find((o) => o.stats.n >= 3) || opps[0]).id;
  184. });
  185. await page.goto(path(`/pool/${richId}`), { waitUntil: 'networkidle' });
  186. await page.waitForSelector('view-pool');
  187. await page.waitForTimeout(700);
  188. await page.waitForTimeout(500);
  189. ok('15 点开卡片进入详情(抽屉 + URL /pool/:id)', /\/pool\/[\w]+$/.test(new URL(page.url()).pathname), page.url());
  190. // 抽屉内容经 <slot> 投影:textContent 不含 slot 内容,所以看组件自身文本 + 槽外内容
  191. /** 抽屉正文 = 渲染进 kf-drawer 的字面内容 + 嵌套组件(kf-score-panel 等)自身 shadow 的文本 */
  192. const deepText = (rootSel) => page.evaluate((sel) => {
  193. const root = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer').shadowRoot.querySelector(sel);
  194. if (!root) return '';
  195. let t = '';
  196. const walk = (n) => { if (n.nodeType === 3) { t += n.textContent + ' '; return; } if (n.tagName === 'STYLE') return; for (const c of n.childNodes) walk(c); if (n.shadowRoot) walk(n.shadowRoot); };
  197. walk(root);
  198. return t.replace(/\s+/g, ' ');
  199. }, rootSel);
  200. const drawerText = await deepText('[part=body]');
  201. ok('17 详情:多人评分显示平均', /人评分/.test(drawerText) && /平均/.test(drawerText), drawerText.slice(0, 160));
  202. await shot('04-opportunity-detail');
  203. // 改状态:改成「已立项」→ 刷新保持
  204. const decCard = await page.evaluate(() => {
  205. const d = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer');
  206. return d.heading;
  207. });
  208. const idInUrl = new URL(page.url()).pathname.split('/').pop();
  209. await page.locator('kf-app').locator('kf-drawer').locator('.st-row button', { hasText: '已立项' }).click();
  210. await page.waitForTimeout(1500);
  211. await page.reload({ waitUntil: 'networkidle' });
  212. await page.waitForTimeout(1500);
  213. const persisted = await page.evaluate(async (id) => {
  214. const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href);
  215. await boardService.load({ force: true });
  216. const o = boardService.db.get().opportunityById[id];
  217. return { status: o?.status, decision: o?.decision?.by, log: o?.decisionLog?.length };
  218. }, idInUrl);
  219. ok('18 改状态持久(刷新后仍是「已立项」,并记下操作人)', persisted.status === 'approved' && persisted.decision === '张伟', JSON.stringify(persisted));
  220. ok('14 详情深链接直接访问可落到正确卡片', (await page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer').heading)).length > 0, idInUrl);
  221. /* ================= 5. 五阶段板块 ================= */
  222. await page.goto(path('/stages'), { waitUntil: 'networkidle' });
  223. await page.waitForSelector('view-stages');
  224. await page.waitForTimeout(300);
  225. const stageCount = await page.locator('view-stages').locator('kf-glass-card[data-stage-board]').count();
  226. ok('21 五阶段分板块', stageCount === 5, String(stageCount));
  227. const stageParts = await page.locator('view-stages').evaluate((v) => {
  228. const card = v.shadowRoot.querySelector('kf-glass-card[data-stage-board="S3"]');
  229. const cards = [...card.querySelectorAll('kf-glass-card')].filter((x) => x.shadowRoot && x.innerHTML.includes('class="rc"'));
  230. const groups = [...card.querySelectorAll('[data-group]')].map((g) => g.querySelector('.chip')?.textContent.trim());
  231. const producers = [...card.querySelectorAll('[data-producer]')].map((x) => x.textContent.trim());
  232. const text = card.textContent;
  233. return { items: cards.length, producers: producers.length, groups, sample: cards[0]?.shadowRoot.querySelector('h3')?.textContent || '', who: producers[0] || '', hasGroups: groups.length > 0 };
  234. });
  235. const s3Stats = await page.locator('view-stages').evaluate((v) => [...v.shadowRoot.querySelector('kf-glass-card[data-stage-board="S3"]').querySelectorAll('.stats b')].map((b) => b.textContent.trim()));
  236. ok('21 板块按类型分组 + 卡片流 + 谁产的', stageParts.items > 0 && stageParts.producers > 0 && stageParts.hasGroups && s3Stats.length === 4, `S3=${JSON.stringify(s3Stats)} 分组=${stageParts.groups.join(' / ')} 首卡=${stageParts.sample} 产出人=${stageParts.who}`);
  237. await shot('05-stages');
  238. /* ================= 6. 报告页 + 证据链 ================= */
  239. await page.goto(path('/report'), { waitUntil: 'networkidle' });
  240. await page.waitForSelector('view-reports');
  241. await page.waitForTimeout(300);
  242. await page.goto(path('/reports/competitor'), { waitUntil: 'networkidle' });
  243. await page.waitForSelector('view-report-type');
  244. await page.locator('view-report-type').locator('a[href*="/report/"]').first().click();
  245. await page.waitForSelector('view-report');
  246. await page.waitForTimeout(500);
  247. const repText = await page.locator('view-report').evaluate((el) => el.shadowRoot.textContent.replace(/\s+/g, ' '));
  248. ok('22 报告:章节「这章在回答什么」', (repText.match(/这章在回答:/g) || []).length >= 6);
  249. const pipe = await page.locator('view-report').locator('kf-pipeline').evaluate((e) => ({ steps: e.steps, rate: e.rate, text: e.shadowRoot.textContent.replace(/\s+/g, ' ') }));
  250. ok('22 报告:数据管线 2,727 → 1,190 → 730,过滤率 73%', pipe.steps.map((x) => x.value).join('>') === '2727>1190>730' && pipe.rate === 73 && pipe.text.includes('2,727') && pipe.text.includes('73%'), JSON.stringify(pipe));
  251. ok('22 报告:核心 KPI 卡片', (await page.locator('view-report').locator('kf-stat').count()) + (await page.locator('view-report').locator('kf-pipeline').count()) > 0);
  252. // 滚动高亮的锚点
  253. const chBefore = await page.locator('view-report').locator('.toc a[aria-current="true"]').count();
  254. await page.locator('view-report').locator('article.ch').nth(3).scrollIntoViewIfNeeded();
  255. await page.waitForTimeout(700);
  256. const activeToc = await page.locator('view-report').locator('.toc a[aria-current="true"]').evaluateAll((els) => els.map((e) => e.textContent.trim()));
  257. ok('22 报告:滚动时章节导航高亮', activeToc.length > 0, `toc-active=${activeToc.join(',')} before=${chBefore}`);
  258. await page.locator('view-report').locator('.toc a.jump').nth(1).click();
  259. await page.waitForTimeout(500);
  260. ok('22 报告:点章节导航后当前章写回 URL ?ch=,刷新可还原', /ch=ch\d/.test(page.url()), page.url());
  261. await shot('06-report');
  262. // 点结论旁的锚点 → 证据抽屉:点名到人
  263. await page.locator('view-report').locator('button.anchor').first().click();
  264. await page.waitForTimeout(500);
  265. /** 抽屉正文文本:穿透嵌套组件的 shadow root(kf-evidence-chain / kf-score-panel 等) */
  266. const drawerTextOf = () => page.evaluate(() => {
  267. const root = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer').shadowRoot.querySelector('[part=body]');
  268. let t = '';
  269. const walk = (n) => { if (n.nodeType === 3) { t += n.textContent + ' '; return; } if (n.tagName === 'STYLE') return; for (const c of n.childNodes) walk(c); if (n.shadowRoot) walk(n.shadowRoot); };
  270. walk(root);
  271. return t.replace(/\s+/g, ' ');
  272. });
  273. const evText = await drawerTextOf();
  274. ok('22 证据抽屉:N 人提到 / N 条原文 / N 条支撑成果', /\d+\s*人独立提到/.test(evText) && /\d+\s*条原文/.test(evText) && /\d+\s*条支撑成果/.test(evText), evText.slice(0, 200));
  275. ok('22 证据抽屉:列出具体成果名与原文片段', /支撑成果(点名到人)/.test(evText) && /原文片段/.test(evText), evText.slice(0, 300));
  276. await shot('07-evidence-drawer');
  277. // 在台账中高亮
  278. await page.locator('kf-app').locator('kf-drawer').locator('button', { hasText: '在台账中高亮' }).click();
  279. await page.waitForSelector('view-ledger');
  280. await page.waitForTimeout(600);
  281. const hl = await deepAll('kf-table', 'return el.shadowRoot.querySelectorAll("tr.hl").length;');
  282. ok('22 结论 → 支撑成果 → 台账行高亮', hl.some((n) => n > 0), JSON.stringify(hl));
  283. ok('14 台账 URL 带 ?focus=', page.url().includes('focus='), page.url());
  284. /* ================= 7. 反向:成果 → 引用它的结论 ================= */
  285. await page.goto(path('/ledger'), { waitUntil: 'networkidle' });
  286. await page.waitForSelector('view-ledger');
  287. await page.waitForTimeout(400);
  288. const revKey = await page.evaluate(async () => {
  289. const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href);
  290. const { traceArtifact } = await import(new URL('src/core/index.js', document.baseURI).href);
  291. const db = boardService.db.get();
  292. const hit = db.artifacts.find((a) => traceArtifact(db, a.id).citing.length > 0 && !a.report);
  293. return `artifact:${hit.id}`;
  294. });
  295. await page.locator('view-ledger').locator('kf-table').evaluate((t, key) => t.shadowRoot.querySelector(`tr[data-key="${key}"]`).click(), revKey);
  296. await page.waitForTimeout(500);
  297. const revText = await drawerTextOf();
  298. ok('22 成果 → 引用它的结论(列出结论原文与报告名)', /被这些结论引用/.test(revText) && /出自报告《/.test(revText), revText.slice(0, 240));
  299. await shot('08-reverse-trace');
  300. /* ================= 8. 对比度实测 ================= */
  301. const contrast = await page.evaluate(() => {
  302. // 注意:CSSOM 里 cssText 会把 --kf-s 这类自定义属性丢掉,所以这里不能从 cssText 推断样式,
  303. // 一律用 getComputedStyle 读「浏览器实际用的值」。
  304. const lum = ([r, g, b]) => {
  305. const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
  306. return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
  307. };
  308. const parse = (s) => (s.match(/[\d.]+/g) || []).map(Number);
  309. /** 混合:前景 a 叠在背景 b 上(含玻璃后面的底色 —— 取页面底层玻璃最深处 rgba(238,241,248)) */
  310. const over = (fg, bg) => { const a = fg[3] ?? 1; return [0, 1, 2].map((i) => fg[i] * a + bg[i] * (1 - a)); };
  311. const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
  312. const pageBg = [233, 237, 247];
  313. const samples = [];
  314. const skippedImage = [];
  315. const walk = (root, depth) => {
  316. for (const el of root.querySelectorAll('*')) {
  317. if (el.tagName === 'STYLE' || el.tagName === 'SCRIPT') continue;
  318. const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  319. if (!text) { if (el.shadowRoot) walk(el.shadowRoot, depth + 1); continue; }
  320. const cs = getComputedStyle(el);
  321. if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) continue;
  322. // 背景合成:从元素往上找第一个不透明背景(穿过 shadow 宿主),忽略后取页面底色
  323. // 自身有背景图 / 纯色底上的字(如红色计数气泡上的白字):字色 vs 自身底色,就是最终对比度
  324. if (cs.backgroundImage && cs.backgroundImage !== 'none') {
  325. skippedImage.push((el.className || el.tagName) + ':' + text.slice(0, 12));
  326. if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
  327. continue;
  328. }
  329. const own = parse(cs.backgroundColor);
  330. // rgba() 可能带空格("rgba(20, 36, 70, 0.08)"):缺失值按 1 处理(纯 rgb() 就是不透明)
  331. const ownA = own.length === 4 ? own[3] : 1;
  332. if (own.length >= 3 && ownA >= 0.99) {
  333. const fg = over(parse(cs.color), own);
  334. const size = parseFloat(cs.fontSize);
  335. samples.push({ text: text.slice(0, 30), ratio: +ratio(fg, own).toFixed(2), size,
  336. large: size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 600),
  337. tag: el.tagName + '.' + (typeof el.className === 'string' ? el.className : ''), color: cs.color, bg: `rgb(${own.slice(0, 3).join(',')})` });
  338. if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
  339. continue;
  340. }
  341. // 背景合成:从元素自身往上(穿透 shadow 宿主)逐层叠加不透明底;
  342. // background-image(渐变 / 品牌色块)视为不透明实底 —— 它上面的字就是最终色,不再往上找。
  343. let p = el;
  344. const layers = [];
  345. while (p) {
  346. const s2 = getComputedStyle(p);
  347. const c = parse(s2.backgroundColor);
  348. const a = c.length === 4 ? c[3] : 1;
  349. if (s2.backgroundImage && s2.backgroundImage !== 'none') { layers.push(a >= 0.99 ? c : [255, 255, 255]); break; }
  350. if (c.length >= 3 && a > 0) layers.push(c);
  351. if (c.length >= 3 && a >= 0.99) break;
  352. p = p.parentElement || p.getRootNode()?.host || null;
  353. }
  354. const bg = layers.length ? layers.reduce((acc, c) => over(c, acc), pageBg) : pageBg;
  355. // 装饰层:白字叠在彩色实底上(头像 / 品牌标记 / 红点计数),不是数据文字,不计入
  356. const decorative = Number(cs.opacity) === 0 || (cs.backgroundImage && cs.backgroundImage !== 'none' && parse(cs.color)[0] > 240 && parse(cs.color)[1] > 240);
  357. if (decorative) { if (el.shadowRoot) walk(el.shadowRoot, depth + 1); continue; }
  358. const fg = over(parse(cs.color), bg);
  359. const size = parseFloat(cs.fontSize);
  360. const bold = Number(cs.fontWeight) >= 600;
  361. const large = size >= 24 || (size >= 18.66 && bold);
  362. samples.push({ text: text.slice(0, 30), ratio: +ratio(fg, bg).toFixed(2), size, large, color: cs.color, bg: `rgb(${bg.map(Math.round).join(',')})`, tag: el.tagName + '.' + (el.className || '') });
  363. if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
  364. }
  365. };
  366. walk(document, 0);
  367. return { samples, skippedImage };
  368. });
  369. const samplesC = contrast.samples;
  370. const skippedC = contrast.skippedImage;
  371. const failsC = samplesC.filter((s) => s.ratio < 4.5);
  372. ok(`C 对比度实测 ≥ 4.5:1(可量化 ${samplesC.length} 个文字节点,最差 ${Math.min(...samplesC.map((s) => s.ratio))};背景图上的 ${skippedC.length} 个装饰字未量:${[...new Set(skippedC)].slice(0, 6).join('、')})`, failsC.length === 0,
  373. failsC.sort((a, b) => a.ratio - b.ratio).slice(0, 20).map((s) => `${s.ratio}:1 「${s.text}」 ${s.size}px ${s.tag} color=${s.color} bg=${s.bg} ${s.raw || ''}`).join('\n'));
  374. /* ================= 9. v6:报告五个类型页 + 阶段产出(深链接刷新用「快照 + 断网重载」实测) ================= */
  375. const snap = await grabSnapshot();
  376. // 取数次数:每次 F5 / 深链接打开应当只拉一轮(6 个类各 1 次),不多发
  377. const reqTotal = async () => { const st = await page.request.get(new URL('/__fixture/stats', base).href).then((r) => r.json()).catch(() => null); return st ? Object.values(st).reduce((a, b) => a + b, 0) : null; };
  378. const reqBefore = await reqTotal();
  379. const TYPES = [
  380. { key: 'category', view: 'VIEW-REPORT-TYPE', title: '品类分析' },
  381. { key: 'competitor', view: 'VIEW-REPORT-TYPE', title: '竞品分析' },
  382. { key: 'voc', view: 'VIEW-REPORT-TYPE', title: 'VOC / 用户洞察' },
  383. { key: 'pain', view: 'VIEW-REPORT-TYPE', title: '痛点清单' },
  384. { key: 'concept', view: 'VIEW-REPORT-TYPE', title: '概念提案' },
  385. ];
  386. const rawSnap = await page.evaluate(() => JSON.parse(sessionStorage.getItem('kf-snap') || '{}'));
  387. const typeCount = (k) => page.evaluate((key) => {
  388. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  389. return { n: v.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length, tabs: v.shadowRoot.querySelectorAll('[data-type]').length };
  390. }, k);
  391. const rawByType = (raw, key) => {
  392. // 与 core/library/reports.js 的 TYPE_FOR_ARTIFACT 同一条口径(改映射两边一起改)
  393. const TYPE_FOR = { competitor_report: 'competitor', voc_report: 'voc', pain_list: 'pain', concept: 'concept', opportunity_card: 'competitor' };
  394. const t = { category: ['source:category'], competitor: ['opp'], voc: ['opp'], pain: [], concept: [] }[key];
  395. let n = 0;
  396. for (const r of raw.artifact || []) if (TYPE_FOR[r.artifactType] === key) n++;
  397. if (t.includes('opp')) n += (raw.opportunity || []).length;
  398. if (key === 'category') n += (raw.source || []).filter((x) => x.sourceType === 'category').length;
  399. return n;
  400. };
  401. for (const t of TYPES) {
  402. await page.goto(path(`/reports/${t.key}`), { waitUntil: 'domcontentloaded' });
  403. await page.waitForTimeout(1500);
  404. const tag = await page.evaluate(() => document.querySelector('kf-app')?.shadowRoot.querySelector('main#view')?.firstElementChild?.tagName || '');
  405. const c = tag === t.view ? await typeCount(t.key) : { n: -1, tabs: 0 };
  406. await page.reload({ waitUntil: 'domcontentloaded' }); // 真实浏览器里的 F5:只靠 localStorage 里的取数快照重建
  407. await page.waitForTimeout(1500);
  408. const tag2 = await page.evaluate(() => document.querySelector('kf-app')?.shadowRoot.querySelector('main#view')?.firstElementChild?.tagName || '');
  409. const c2 = tag2 === t.view ? await typeCount(t.key) : { n: -1, tabs: 0 };
  410. const want = rawByType(rawSnap, t.key);
  411. ok(`v6 类型页 ${t.key} 断网深链接直接访问 + 刷新(${{ category: 1, competitor: 26, voc: 12, pain: 10, concept: 1 }[t.key] ?? '?' })`,
  412. tag === t.view && c.n === want && c.tabs === 5 && tag2 === t.view && c2.n === want,
  413. `URL=${new URL(page.url()).pathname} 视图=${tag} 卡片=${c.n}(core 口径 ${want})类型切换=${c.tabs};F5 后 视图=${tag2} 卡片=${c2.n}`);
  414. }
  415. // 阶段二次筛选:选 S1 列表变化 + 写进 URL + 刷新仍保留
  416. await page.goto(path('/reports/competitor'), { waitUntil: 'domcontentloaded' });
  417. await page.waitForTimeout(1200);
  418. const beforeStage = (await typeCount('competitor')).n;
  419. await page.locator('view-report-type').locator('[data-stage-filter="S1"]').click();
  420. await page.waitForTimeout(500);
  421. const afterStage = (await typeCount('competitor')).n;
  422. const urlAfter = page.url();
  423. await page.reload({ waitUntil: 'domcontentloaded' });
  424. await page.waitForTimeout(1400);
  425. const afterReloadStage = (await typeCount('competitor')).n;
  426. ok('v6 类型页按阶段二次筛选(S1)→ 条数变化、URL 带 stage、刷新仍保留',
  427. afterStage !== beforeStage && /stage=S1/.test(urlAfter) && afterReloadStage === afterStage,
  428. `全部=${beforeStage} → S1=${afterStage};URL ${new URL(urlAfter).pathname}${new URL(urlAfter).search};刷新后=${afterReloadStage}`);
  429. // 来源四要素:读出一张卡的来源
  430. const src = await page.evaluate(() => {
  431. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  432. const card = [...v.shadowRoot.querySelectorAll('[data-cards] kf-glass-card')][0];
  433. return card?.textContent.replace(/\s+/g, ' ').trim().slice(0, 260);
  434. });
  435. ok('v6 类型页卡片给出「谁 + Agent + 方式 + 时间」四要素',
  436. /·\s.*·.*·.*(分钟|小时|天)前/.test(src) && /(康师傅产品创新助手|品类调研 Agent|机会评估 Agent|概念设计 Agent|未记录)/.test(src), src);
  437. // 报告中心:五类都有数
  438. await page.goto(path('/reports'), { waitUntil: 'domcontentloaded' });
  439. await page.waitForTimeout(1400);
  440. const center = await page.evaluate(() => {
  441. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-reports');
  442. return [...v.shadowRoot.querySelectorAll('[data-type-card]')].map((a) => `${a.dataset.typeCard}=${(a.textContent.match(/(\d+)\s*份/) || [])[1]}`);
  443. });
  444. ok('v6 报告中心:五类各几份 + 最新', center.length === 5 && center.every((x) => /=?\d+/.test(x) || /=\d+/.test(x)), center.join(' · '));
  445. // 阶段页:按类型分组 + 只看我的 / 只看有证据
  446. await page.goto(path('/stages'), { waitUntil: 'domcontentloaded' });
  447. await page.waitForTimeout(1400);
  448. const stageGroups = await page.evaluate(() => {
  449. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-stages');
  450. return [...v.shadowRoot.querySelectorAll('[data-stage-board]')].map((b) => `${b.dataset.stageBoard}:${[...b.querySelectorAll('[data-group] .chip')].map((c) => c.textContent.trim()).join('|')}`);
  451. });
  452. ok('v6 阶段页:每个阶段按类型分组(卡片流)', stageGroups.length === 5 && stageGroups.some((g) => g.split(':')[1]), stageGroups.join(' / '));
  453. const countOf = async () => page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-stages').shadowRoot.querySelector('[data-count]').textContent.trim());
  454. await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('kf-topbar').querySelector('select#me'); });
  455. const evAll = await countOf();
  456. await page.locator('view-stages').locator('[data-filter="ev"]').click();
  457. await page.waitForTimeout(400);
  458. const evOn = await countOf();
  459. await page.locator('view-stages').locator('[data-filter="ev"]').click();
  460. await page.waitForTimeout(300);
  461. const evOff = await countOf();
  462. ok('v6 阶段页:只看有证据切换后条数变化,关掉恢复', evAll !== evOn && evOff === evAll, `全部=${evAll} 有证据=${evOn} 关掉后=${evOff}`);
  463. await shot('09-v6-stages');
  464. const mineText = await page.evaluate(() => {
  465. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-stages');
  466. return v.shadowRoot.querySelector('[data-filter="mine"]').textContent.trim();
  467. });
  468. ok('v6 阶段页:「只看我的」按钮存在并显示当前我是谁', /只看我的/.test(mineText), mineText);
  469. const reqAfter = await reqTotal();
  470. // 5 个类型页 × (1 次打开 + 1 次 F5) × 6 类 = 60;加上阶段页 / 报告中心的打开。只判断「没有成倍重复取数」
  471. ok('v6 页面打开只取一轮数(每轮 6 类各 1 次,无重复请求)', reqAfter == null || (reqAfter - reqBefore >= 6 && reqAfter - reqBefore <= 120),
  472. reqAfter == null ? '快照服务器没有 /__fixture/stats(非 --url 本地模式),跳过计数' : `v6 各页合计 ${reqAfter - reqBefore} 次取数(预期 ≈ 5 类页 × 2 轮 × 6 类 + 阶段页/报告中心 2 轮 ≈ 72;每轮每类只 1 次)`);
  473. const errorsAfterOffline = consoleErrors.filter((t) => !/ERR_INTERNET_DISCONNECTED|Failed to load resource/.test(t));
  474. /* ================= 9. 控制台 ================= */
  475. ok('C 控制台无报错', consoleErrors.length === 0, consoleErrors.join('\n'));
  476. void errorsAfterOffline;
  477. await browser.close();
  478. if (server) server.kill();
  479. const fails = out.filter((r) => !r.ok).length;
  480. console.log(`\n${fails ? `BROWSER VERIFY FAILED(${fails}/${out.length})` : `BROWSER VERIFY OK(${out.length} 项)`}`);
  481. process.exit(fails ? 1 : 0);
  482. void S; void decCard; void readFileSync;