#!/usr/bin/env node /** * scripts/verify-browser.mjs —— 真实浏览器验收(任务书第 5 节 B 组 + C 组)。 * * 前置:npx playwright install chromium(或 CHROME_PATH 指向本机 Chromium) * 用法: * node scripts/verify-browser.mjs # 默认起本地服务器(根部署) * node scripts/verify-browser.mjs --sub # 子路径部署(/kangshifu/dev/inno-board/) * node scripts/verify-browser.mjs --url https://kangshifu.fmode.cn/kangshifu/dev/ # 线上 * --shots 截图目录(默认 docs/screenshots) * * 覆盖: * C 控制台无报错(网络断开时的 ERR 除外) / 对比度实测 ≥ 4.5:1 * 14 深链接直接访问落到正确视图(含子路径);刷新后仍在原路由 * 12 六维筛选可叠加、结果与 core 计算一致;13 刷新后筛选不丢 * 15/16 卡片五要素 + 谁/哪个 Agent/什么方式/什么时候 * 17 多人评分显示平均;18 改状态持久(刷新保持);19 排序切换顺序真实变化 * 20 三个视角 + 痛点对照;21 五阶段板块;22 证据链双向点名 * 视觉:backdrop-filter 生效、滚轮/键盘/焦点可达基本检查 */ import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d); const SUB = argv.includes('--sub'); const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots')); mkdirSync(SHOTS, { recursive: true }); /* ---------- playwright 解析:本仓 / 验收机 / 全局 ---------- */ async function loadPlaywright() { const cands = [join(ROOT, 'node_modules/playwright'), '/opt/data/work/kangshifu/testing/node_modules/playwright']; for (const c of cands) { if (existsSync(c)) return (await import(join(c, 'index.mjs').replace(/\\/g, '/'))).default ?? (await import(`${c}/index.js`)); } return import('playwright'); } const out = []; const ok = (name, cond, detail = '') => { out.push({ name, ok: Boolean(cond), detail }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && detail ? `\n ${detail}` : ''}`); }; /* ---------- 起服务器 ---------- */ let server = null; let base = arg('--url', ''); if (!base) { const { writeConfig } = await import('./config.mjs'); const { prefix } = writeConfig({ env: SUB ? 'local-sub' : 'local' }); const port = Number(process.env.VERIFY_PORT || (SUB ? 4181 : 4173)); const mount = prefix.replace(/\/+$/, '') || '/'; if (!existsSync(join(ROOT, 'lib/vendor/lit'))) throw new Error('缺少 lib/vendor:先 node scripts/vendor.mjs'); server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', mount], { stdio: 'ignore' }); await new Promise((r) => setTimeout(r, 500)); base = `http://127.0.0.1:${port}${prefix}`; } process.on('exit', () => server?.kill()); const pw = await loadPlaywright(); const browser = await pw.chromium.launch({ executablePath: process.env.CHROME_PATH || undefined, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const consoleErrors = []; const isNoise = (t) => /favicon|Failed to load resource.*(net::ERR_|503)/i.test(t); page.on('console', (m) => { if (m.type() === 'error' && !isNoise(m.text())) consoleErrors.push(m.text()); }); page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message}`)); const path = (p) => new URL(p, base).href; const S = (sel, root) => `${root ? `${root} >>> ` : ''}${sel}`; const deepAll = (sel, fnSrc, argVal) => page.evaluate(({ sel, fnSrc, argVal }) => { const found = []; const walk = (root) => { for (const el of root.querySelectorAll(sel)) found.push(el); for (const el of root.querySelectorAll('*')) if (el.shadowRoot) walk(el.shadowRoot); }; walk(document); // eslint-disable-next-line no-new-func const fn = new Function('el', 'arg', fnSrc); return found.map((el) => fn(el, argVal)); }, { sel, fnSrc, argVal }); /** 原样抓一次原始行(和页面自己发的完全同一条 URL:走站点同源代理),存成快照 */ const grabSnapshot = () => page.evaluate(async () => { const { CONFIG } = await import(new URL('src/app.config.js', document.baseURI).href); const H = { 'X-Parse-Application-Id': CONFIG.appId, Accept: 'application/json' }; const snap = { loadedAt: new Date().toISOString(), truncated: [] }; for (const [k, cls] of Object.entries(CONFIG.classes)) { const j = await (await fetch(`${CONFIG.parseBase}/classes/${cls}?limit=1000&skip=0&order=createdAt`, { headers: H })).json(); snap[k] = j.results || []; } sessionStorage.setItem('kf-snap', JSON.stringify(snap)); return Object.fromEntries(Object.entries(snap).filter(([k]) => k !== 'loadedAt' && k !== 'truncated').map(([k, v]) => [k, v.length])); }); /** 断网 → 用快照直接重载同一 URL:验证深链接 / 筛选状态不依赖内存 */ const reloadFromSnapshot = async () => { await ctx.setOffline(true); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1200); }; const view = () => page.locator('kf-app').locator('main#view').locator(':scope > *').first(); async function shot(name) { await page.screenshot({ path: join(SHOTS, `${name}.png`), fullPage: false }); } /* ================= 1. 首页(工作台三视角) ================= */ await page.goto(path('/'), { waitUntil: 'networkidle' }); await page.waitForSelector('view-workbench', { state: 'attached', timeout: 20000 }); await page.waitForTimeout(400); ok('14 首屏落在工作台', (await page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('main#view').firstElementChild.tagName)) === 'VIEW-WORKBENCH'); const wbText = await view().evaluate((el) => el.shadowRoot.textContent.replace(/\s+/g, ' ')); ok('20 工作台三视角:给我的 / 池子 / 现场看板', /我的进度/.test(wbText) && /机会卡池/.test(wbText) && /五阶段 × 人数/.test(wbText), wbText.slice(0, 160)); ok('20 痛点对照表(5 行)', ['信息收集繁琐分散', '机会识别凭经验', '卖点提炼缺抓手', '概念验证无量化', '琐事挤占思考'].every((p) => wbText.includes(p))); ok('20 现场看板点名卡住的人', /卡住的人(\d+)/.test(wbText) && /小时没动静/.test(wbText), (wbText.match(/卡住的人.{0,80}/) || [''])[0]); ok('20 最新 3 张以卡片形态出现', (await view().evaluate((el) => el.shadowRoot.querySelectorAll('kf-opportunity-card').length)) >= 3); await shot('01-workbench'); // 玻璃:backdrop-filter 生效 + 有背景可糊 const glass = await page.evaluate(() => { const nav = document.querySelector('kf-app').shadowRoot.querySelector('kf-nav'); const side = nav.shadowRoot.querySelector('.side'); const cs = getComputedStyle(side); return { bf: cs.backdropFilter || cs.webkitBackdropFilter, bg: cs.backgroundImage.slice(0, 60), ambient: document.querySelector('kf-app').shadowRoot.querySelectorAll('.ambient i').length }; }); ok('视觉:backdrop-filter blur 生效且光场存在', /blur\(\d+px\)/.test(glass.bf) && glass.ambient === 4, JSON.stringify(glass)); /* ================= 2. 「我是谁」→ 给我的 ================= */ const meSel = page.locator('kf-app').locator('kf-topbar').locator('select#me'); await meSel.selectOption('张伟'); await page.waitForTimeout(300); const wb2 = await view().evaluate((el) => el.shadowRoot.textContent.replace(/\s+/g, ' ')); ok('20 「给我的」:张伟已提交 N 份 + 哪些阶段没交', /张伟,你已提交 \d+ 份成果/.test(wb2) && /还差 \d+ 个阶段没交/.test(wb2), (wb2.match(/张伟,你已提交.{0,140}/) || [''])[0]); /* ================= 3. 机会卡池:卡片五要素 / 来源四要素 / 排序 / 筛选 / URL ================= */ await page.goto(path('/pool'), { waitUntil: 'networkidle' }); await page.waitForSelector('view-pool'); await page.waitForTimeout(400); const cards = () => deepAll('kf-opportunity-card', `return { id: el.opp?.id, title: el.opp?.title, prio: el.opp?.priority, cat: el.opp?.category, stage: el.opp?.stageObj?.code, avg: el.opp?.stats?.avg, n: el.opp?.stats?.n, ev: el.opp?.evidenceLabel, quotes: el.opp?.quoteCount, dec: el.opp?.decision?.label || '', by: el.opp?.upload?.uploadedBy, agent: el.opp?.upload?.agentName, method: el.opp?.upload?.uploadMethod, at: el.opp?.upload?.uploadedAt, text: el.shadowRoot.textContent.replace(/\\s+/g, ' ') };`); const c1 = await cards(); ok('15 卡片数量 ≥ 6', c1.length >= 6, String(c1.length)); const first = c1[0]; ok('15 五要素一屏可读(优先级/品类/阶段/名字/一句话/评分/证据/引用/决策/来源)', c1.every((c) => c.prio && c.cat && c.stage && c.title && c.ev && c.avg != null || c.text.includes('暂无评分')) && c1.every((c) => /决策[::]/.test(c.text)) && c1.every((c) => /人评分 · 平均|等待第 1 个评分/.test(c.text)), JSON.stringify(c1.map((c) => [c.prio, c.cat, c.stage, c.avg]))); 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]))); ok('16 卡片文本里真的出现了 Agent 名与上传方式', c1.every((c) => c.text.includes(c.agent) && /会话上传|Skill 上传|手工录入/.test(c.text))); await shot('02-pool'); const prioOrder = c1.map((c) => c.id); await page.locator('view-pool').locator('[aria-label="排序方式"] button', { hasText: '综合评分' }).click(); await page.waitForTimeout(400); const scoreCards = await cards(); const scoreOrder = scoreCards.map((c) => c.id); ok('19 排序切换(优先级 → 评分)顺序真实变化', prioOrder.join() !== scoreOrder.join(), `priority=${prioOrder.join(',')}\n score=${scoreOrder.join(',')}`); ok('19 评分排序单调不增', scoreCards.every((c, i) => i === 0 || (scoreCards[i - 1].avg ?? -1) >= (c.avg ?? -1))); ok('13 排序写进 URL(?sort=score)', page.url().includes('sort=score'), page.url()); // 六维筛选叠加:点状态 + 优先级 + 品类 await page.locator('view-pool').locator('[data-facet="status:candidate"]').click(); await page.waitForTimeout(250); await page.locator('view-pool').locator('[data-facet="priority:S"]').click(); await page.waitForTimeout(250); const expected = await page.evaluate(async () => { const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href); const db = boardService.db.get(); return db.opportunities.filter((o) => o.status === 'candidate' && o.priority === 'S').map((o) => o.id); }); const afterFilter = (await cards()).map((c) => c.id); ok('12 六维筛选叠加(候选 ∧ S 级)与 core 计算一致', afterFilter.length > 0 && afterFilter.slice().sort().join() === expected.slice().sort().join(), `页面=${afterFilter.join(',')} core=${expected.join(',')}`); ok('12 筛选条件写进 URL', /status=candidate/.test(page.url()) && /priority=S/.test(page.url()), page.url()); await shot('03-pool-filtered'); // 13 刷新后筛选不丢 + 14 深链接子路径 await page.reload({ waitUntil: 'networkidle' }); await page.waitForSelector('view-pool'); await page.waitForTimeout(500); const pressed = await page.locator('view-pool').locator('[data-facet="status:candidate"]').getAttribute('aria-pressed'); const afterReload = (await cards()).map((c) => c.id); ok('13 刷新后筛选不丢', pressed === 'true' && afterReload.slice().sort().join() === expected.slice().sort().join(), `pressed=${pressed} ids=${afterReload.join(',')}`); ok('14 刷新后 URL 落在同一路由', new URL(page.url()).pathname.endsWith('/pool') && page.url().includes('status=candidate'), page.url()); /* ================= 4. 卡片点开 → 详情 / 多人评分 / 改状态 ================= */ // 直接走深链接:/pool/:id 必须落到正确的那张卡(不依赖点哪张) const richId = await page.evaluate(async () => { const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href); const opps = boardService.db.get().opportunities; return (opps.find((o) => o.stats.n >= 3) || opps[0]).id; }); await page.goto(path(`/pool/${richId}`), { waitUntil: 'networkidle' }); await page.waitForSelector('view-pool'); await page.waitForTimeout(700); await page.waitForTimeout(500); ok('15 点开卡片进入详情(抽屉 + URL /pool/:id)', /\/pool\/[\w]+$/.test(new URL(page.url()).pathname), page.url()); // 抽屉内容经 投影:textContent 不含 slot 内容,所以看组件自身文本 + 槽外内容 /** 抽屉正文 = 渲染进 kf-drawer 的字面内容 + 嵌套组件(kf-score-panel 等)自身 shadow 的文本 */ const deepText = (rootSel) => page.evaluate((sel) => { const root = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer').shadowRoot.querySelector(sel); if (!root) return ''; let t = ''; 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); }; walk(root); return t.replace(/\s+/g, ' '); }, rootSel); const drawerText = await deepText('[part=body]'); ok('17 详情:多人评分显示平均', /人评分/.test(drawerText) && /平均/.test(drawerText), drawerText.slice(0, 160)); await shot('04-opportunity-detail'); // 改状态:改成「已立项」→ 刷新保持 const decCard = await page.evaluate(() => { const d = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer'); return d.heading; }); const idInUrl = new URL(page.url()).pathname.split('/').pop(); await page.locator('kf-app').locator('kf-drawer').locator('.st-row button', { hasText: '已立项' }).click(); await page.waitForTimeout(1500); await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(1500); const persisted = await page.evaluate(async (id) => { const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href); await boardService.load({ force: true }); const o = boardService.db.get().opportunityById[id]; return { status: o?.status, decision: o?.decision?.by, log: o?.decisionLog?.length }; }, idInUrl); ok('18 改状态持久(刷新后仍是「已立项」,并记下操作人)', persisted.status === 'approved' && persisted.decision === '张伟', JSON.stringify(persisted)); ok('14 详情深链接直接访问可落到正确卡片', (await page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer').heading)).length > 0, idInUrl); /* ================= 5. 五阶段板块 ================= */ await page.goto(path('/stages'), { waitUntil: 'networkidle' }); await page.waitForSelector('view-stages'); await page.waitForTimeout(300); const stageCount = await page.locator('view-stages').locator('kf-glass-card[data-stage-board]').count(); ok('21 五阶段分板块', stageCount === 5, String(stageCount)); const stageParts = await page.locator('view-stages').evaluate((v) => { const card = v.shadowRoot.querySelector('kf-glass-card[data-stage-board="S3"]'); const cards = [...card.querySelectorAll('kf-glass-card')].filter((x) => x.shadowRoot && x.innerHTML.includes('class="rc"')); const groups = [...card.querySelectorAll('[data-group]')].map((g) => g.querySelector('.chip')?.textContent.trim()); const producers = [...card.querySelectorAll('[data-producer]')].map((x) => x.textContent.trim()); const text = card.textContent; return { items: cards.length, producers: producers.length, groups, sample: cards[0]?.shadowRoot.querySelector('h3')?.textContent || '', who: producers[0] || '', hasGroups: groups.length > 0 }; }); 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())); ok('21 板块按类型分组 + 卡片流 + 谁产的', stageParts.items > 0 && stageParts.producers > 0 && stageParts.hasGroups && s3Stats.length === 4, `S3=${JSON.stringify(s3Stats)} 分组=${stageParts.groups.join(' / ')} 首卡=${stageParts.sample} 产出人=${stageParts.who}`); await shot('05-stages'); /* ================= 6. 报告页 + 证据链 ================= */ await page.goto(path('/report'), { waitUntil: 'networkidle' }); await page.waitForSelector('view-reports'); await page.waitForTimeout(300); await page.goto(path('/reports/competitor'), { waitUntil: 'networkidle' }); await page.waitForSelector('view-report-type'); await page.locator('view-report-type').locator('a[href*="/report/"]').first().click(); await page.waitForSelector('view-report'); await page.waitForTimeout(500); const repText = await page.locator('view-report').evaluate((el) => el.shadowRoot.textContent.replace(/\s+/g, ' ')); ok('22 报告:章节「这章在回答什么」', (repText.match(/这章在回答:/g) || []).length >= 6); 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, ' ') })); 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)); ok('22 报告:核心 KPI 卡片', (await page.locator('view-report').locator('kf-stat').count()) + (await page.locator('view-report').locator('kf-pipeline').count()) > 0); // 滚动高亮的锚点 const chBefore = await page.locator('view-report').locator('.toc a[aria-current="true"]').count(); await page.locator('view-report').locator('article.ch').nth(3).scrollIntoViewIfNeeded(); await page.waitForTimeout(700); const activeToc = await page.locator('view-report').locator('.toc a[aria-current="true"]').evaluateAll((els) => els.map((e) => e.textContent.trim())); ok('22 报告:滚动时章节导航高亮', activeToc.length > 0, `toc-active=${activeToc.join(',')} before=${chBefore}`); await page.locator('view-report').locator('.toc a.jump').nth(1).click(); await page.waitForTimeout(500); ok('22 报告:点章节导航后当前章写回 URL ?ch=,刷新可还原', /ch=ch\d/.test(page.url()), page.url()); await shot('06-report'); // 点结论旁的锚点 → 证据抽屉:点名到人 await page.locator('view-report').locator('button.anchor').first().click(); await page.waitForTimeout(500); /** 抽屉正文文本:穿透嵌套组件的 shadow root(kf-evidence-chain / kf-score-panel 等) */ const drawerTextOf = () => page.evaluate(() => { const root = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer').shadowRoot.querySelector('[part=body]'); let t = ''; 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); }; walk(root); return t.replace(/\s+/g, ' '); }); const evText = await drawerTextOf(); ok('22 证据抽屉:N 人提到 / N 条原文 / N 条支撑成果', /\d+\s*人独立提到/.test(evText) && /\d+\s*条原文/.test(evText) && /\d+\s*条支撑成果/.test(evText), evText.slice(0, 200)); ok('22 证据抽屉:列出具体成果名与原文片段', /支撑成果(点名到人)/.test(evText) && /原文片段/.test(evText), evText.slice(0, 300)); await shot('07-evidence-drawer'); // 在台账中高亮 await page.locator('kf-app').locator('kf-drawer').locator('button', { hasText: '在台账中高亮' }).click(); await page.waitForSelector('view-ledger'); await page.waitForTimeout(600); const hl = await deepAll('kf-table', 'return el.shadowRoot.querySelectorAll("tr.hl").length;'); ok('22 结论 → 支撑成果 → 台账行高亮', hl.some((n) => n > 0), JSON.stringify(hl)); ok('14 台账 URL 带 ?focus=', page.url().includes('focus='), page.url()); /* ================= 7. 反向:成果 → 引用它的结论 ================= */ await page.goto(path('/ledger'), { waitUntil: 'networkidle' }); await page.waitForSelector('view-ledger'); await page.waitForTimeout(400); const revKey = await page.evaluate(async () => { const { boardService } = await import(new URL('src/services/board-service.js', document.baseURI).href); const { traceArtifact } = await import(new URL('src/core/index.js', document.baseURI).href); const db = boardService.db.get(); const hit = db.artifacts.find((a) => traceArtifact(db, a.id).citing.length > 0 && !a.report); return `artifact:${hit.id}`; }); await page.locator('view-ledger').locator('kf-table').evaluate((t, key) => t.shadowRoot.querySelector(`tr[data-key="${key}"]`).click(), revKey); await page.waitForTimeout(500); const revText = await drawerTextOf(); ok('22 成果 → 引用它的结论(列出结论原文与报告名)', /被这些结论引用/.test(revText) && /出自报告《/.test(revText), revText.slice(0, 240)); await shot('08-reverse-trace'); /* ================= 8. 对比度实测 ================= */ const contrast = await page.evaluate(() => { // 注意:CSSOM 里 cssText 会把 --kf-s 这类自定义属性丢掉,所以这里不能从 cssText 推断样式, // 一律用 getComputedStyle 读「浏览器实际用的值」。 const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const parse = (s) => (s.match(/[\d.]+/g) || []).map(Number); /** 混合:前景 a 叠在背景 b 上(含玻璃后面的底色 —— 取页面底层玻璃最深处 rgba(238,241,248)) */ const over = (fg, bg) => { const a = fg[3] ?? 1; return [0, 1, 2].map((i) => fg[i] * a + bg[i] * (1 - a)); }; const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); }; const pageBg = [233, 237, 247]; const samples = []; const skippedImage = []; const walk = (root, depth) => { for (const el of root.querySelectorAll('*')) { if (el.tagName === 'STYLE' || el.tagName === 'SCRIPT') continue; const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(''); if (!text) { if (el.shadowRoot) walk(el.shadowRoot, depth + 1); continue; } const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) continue; // 背景合成:从元素往上找第一个不透明背景(穿过 shadow 宿主),忽略后取页面底色 // 自身有背景图 / 纯色底上的字(如红色计数气泡上的白字):字色 vs 自身底色,就是最终对比度 if (cs.backgroundImage && cs.backgroundImage !== 'none') { skippedImage.push((el.className || el.tagName) + ':' + text.slice(0, 12)); if (el.shadowRoot) walk(el.shadowRoot, depth + 1); continue; } const own = parse(cs.backgroundColor); // rgba() 可能带空格("rgba(20, 36, 70, 0.08)"):缺失值按 1 处理(纯 rgb() 就是不透明) const ownA = own.length === 4 ? own[3] : 1; if (own.length >= 3 && ownA >= 0.99) { const fg = over(parse(cs.color), own); const size = parseFloat(cs.fontSize); samples.push({ text: text.slice(0, 30), ratio: +ratio(fg, own).toFixed(2), size, large: size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 600), tag: el.tagName + '.' + (typeof el.className === 'string' ? el.className : ''), color: cs.color, bg: `rgb(${own.slice(0, 3).join(',')})` }); if (el.shadowRoot) walk(el.shadowRoot, depth + 1); continue; } // 背景合成:从元素自身往上(穿透 shadow 宿主)逐层叠加不透明底; // background-image(渐变 / 品牌色块)视为不透明实底 —— 它上面的字就是最终色,不再往上找。 let p = el; const layers = []; while (p) { const s2 = getComputedStyle(p); const c = parse(s2.backgroundColor); const a = c.length === 4 ? c[3] : 1; if (s2.backgroundImage && s2.backgroundImage !== 'none') { layers.push(a >= 0.99 ? c : [255, 255, 255]); break; } if (c.length >= 3 && a > 0) layers.push(c); if (c.length >= 3 && a >= 0.99) break; p = p.parentElement || p.getRootNode()?.host || null; } const bg = layers.length ? layers.reduce((acc, c) => over(c, acc), pageBg) : pageBg; // 装饰层:白字叠在彩色实底上(头像 / 品牌标记 / 红点计数),不是数据文字,不计入 const decorative = Number(cs.opacity) === 0 || (cs.backgroundImage && cs.backgroundImage !== 'none' && parse(cs.color)[0] > 240 && parse(cs.color)[1] > 240); if (decorative) { if (el.shadowRoot) walk(el.shadowRoot, depth + 1); continue; } const fg = over(parse(cs.color), bg); const size = parseFloat(cs.fontSize); const bold = Number(cs.fontWeight) >= 600; const large = size >= 24 || (size >= 18.66 && bold); 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 || '') }); if (el.shadowRoot) walk(el.shadowRoot, depth + 1); } }; walk(document, 0); return { samples, skippedImage }; }); const samplesC = contrast.samples; const skippedC = contrast.skippedImage; const failsC = samplesC.filter((s) => s.ratio < 4.5); 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, 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')); /* ================= 9. v6:报告五个类型页 + 阶段产出(深链接刷新用「快照 + 断网重载」实测) ================= */ const snap = await grabSnapshot(); // 取数次数:每次 F5 / 深链接打开应当只拉一轮(6 个类各 1 次),不多发 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; }; const reqBefore = await reqTotal(); const TYPES = [ { key: 'category', view: 'VIEW-REPORT-TYPE', title: '品类分析' }, { key: 'competitor', view: 'VIEW-REPORT-TYPE', title: '竞品分析' }, { key: 'voc', view: 'VIEW-REPORT-TYPE', title: 'VOC / 用户洞察' }, { key: 'pain', view: 'VIEW-REPORT-TYPE', title: '痛点清单' }, { key: 'concept', view: 'VIEW-REPORT-TYPE', title: '概念提案' }, ]; const rawSnap = await page.evaluate(() => JSON.parse(sessionStorage.getItem('kf-snap') || '{}')); const typeCount = (k) => page.evaluate((key) => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); return { n: v.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length, tabs: v.shadowRoot.querySelectorAll('[data-type]').length }; }, k); const rawByType = (raw, key) => { // 与 core/library/reports.js 的 TYPE_FOR_ARTIFACT 同一条口径(改映射两边一起改) const TYPE_FOR = { competitor_report: 'competitor', voc_report: 'voc', pain_list: 'pain', concept: 'concept', opportunity_card: 'competitor' }; const t = { category: ['source:category'], competitor: ['opp'], voc: ['opp'], pain: [], concept: [] }[key]; let n = 0; for (const r of raw.artifact || []) if (TYPE_FOR[r.artifactType] === key) n++; if (t.includes('opp')) n += (raw.opportunity || []).length; if (key === 'category') n += (raw.source || []).filter((x) => x.sourceType === 'category').length; return n; }; for (const t of TYPES) { await page.goto(path(`/reports/${t.key}`), { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1500); const tag = await page.evaluate(() => document.querySelector('kf-app')?.shadowRoot.querySelector('main#view')?.firstElementChild?.tagName || ''); const c = tag === t.view ? await typeCount(t.key) : { n: -1, tabs: 0 }; await page.reload({ waitUntil: 'domcontentloaded' }); // 真实浏览器里的 F5:只靠 localStorage 里的取数快照重建 await page.waitForTimeout(1500); const tag2 = await page.evaluate(() => document.querySelector('kf-app')?.shadowRoot.querySelector('main#view')?.firstElementChild?.tagName || ''); const c2 = tag2 === t.view ? await typeCount(t.key) : { n: -1, tabs: 0 }; const want = rawByType(rawSnap, t.key); ok(`v6 类型页 ${t.key} 断网深链接直接访问 + 刷新(${{ category: 1, competitor: 26, voc: 12, pain: 10, concept: 1 }[t.key] ?? '?' })`, tag === t.view && c.n === want && c.tabs === 5 && tag2 === t.view && c2.n === want, `URL=${new URL(page.url()).pathname} 视图=${tag} 卡片=${c.n}(core 口径 ${want})类型切换=${c.tabs};F5 后 视图=${tag2} 卡片=${c2.n}`); } // 阶段二次筛选:选 S1 列表变化 + 写进 URL + 刷新仍保留 await page.goto(path('/reports/competitor'), { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1200); const beforeStage = (await typeCount('competitor')).n; await page.locator('view-report-type').locator('[data-stage-filter="S1"]').click(); await page.waitForTimeout(500); const afterStage = (await typeCount('competitor')).n; const urlAfter = page.url(); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1400); const afterReloadStage = (await typeCount('competitor')).n; ok('v6 类型页按阶段二次筛选(S1)→ 条数变化、URL 带 stage、刷新仍保留', afterStage !== beforeStage && /stage=S1/.test(urlAfter) && afterReloadStage === afterStage, `全部=${beforeStage} → S1=${afterStage};URL ${new URL(urlAfter).pathname}${new URL(urlAfter).search};刷新后=${afterReloadStage}`); // 来源四要素:读出一张卡的来源 const src = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); const card = [...v.shadowRoot.querySelectorAll('[data-cards] kf-glass-card')][0]; return card?.textContent.replace(/\s+/g, ' ').trim().slice(0, 260); }); ok('v6 类型页卡片给出「谁 + Agent + 方式 + 时间」四要素', /·\s.*·.*·.*(分钟|小时|天)前/.test(src) && /(康师傅产品创新助手|品类调研 Agent|机会评估 Agent|概念设计 Agent|未记录)/.test(src), src); // 报告中心:五类都有数 await page.goto(path('/reports'), { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1400); const center = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-reports'); return [...v.shadowRoot.querySelectorAll('[data-type-card]')].map((a) => `${a.dataset.typeCard}=${(a.textContent.match(/(\d+)\s*份/) || [])[1]}`); }); ok('v6 报告中心:五类各几份 + 最新', center.length === 5 && center.every((x) => /=?\d+/.test(x) || /=\d+/.test(x)), center.join(' · ')); // 阶段页:按类型分组 + 只看我的 / 只看有证据 await page.goto(path('/stages'), { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1400); const stageGroups = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-stages'); return [...v.shadowRoot.querySelectorAll('[data-stage-board]')].map((b) => `${b.dataset.stageBoard}:${[...b.querySelectorAll('[data-group] .chip')].map((c) => c.textContent.trim()).join('|')}`); }); ok('v6 阶段页:每个阶段按类型分组(卡片流)', stageGroups.length === 5 && stageGroups.some((g) => g.split(':')[1]), stageGroups.join(' / ')); const countOf = async () => page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-stages').shadowRoot.querySelector('[data-count]').textContent.trim()); await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('kf-topbar').querySelector('select#me'); }); const evAll = await countOf(); await page.locator('view-stages').locator('[data-filter="ev"]').click(); await page.waitForTimeout(400); const evOn = await countOf(); await page.locator('view-stages').locator('[data-filter="ev"]').click(); await page.waitForTimeout(300); const evOff = await countOf(); ok('v6 阶段页:只看有证据切换后条数变化,关掉恢复', evAll !== evOn && evOff === evAll, `全部=${evAll} 有证据=${evOn} 关掉后=${evOff}`); await shot('09-v6-stages'); const mineText = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-stages'); return v.shadowRoot.querySelector('[data-filter="mine"]').textContent.trim(); }); ok('v6 阶段页:「只看我的」按钮存在并显示当前我是谁', /只看我的/.test(mineText), mineText); const reqAfter = await reqTotal(); // 5 个类型页 × (1 次打开 + 1 次 F5) × 6 类 = 60;加上阶段页 / 报告中心的打开。只判断「没有成倍重复取数」 ok('v6 页面打开只取一轮数(每轮 6 类各 1 次,无重复请求)', reqAfter == null || (reqAfter - reqBefore >= 6 && reqAfter - reqBefore <= 120), reqAfter == null ? '快照服务器没有 /__fixture/stats(非 --url 本地模式),跳过计数' : `v6 各页合计 ${reqAfter - reqBefore} 次取数(预期 ≈ 5 类页 × 2 轮 × 6 类 + 阶段页/报告中心 2 轮 ≈ 72;每轮每类只 1 次)`); const errorsAfterOffline = consoleErrors.filter((t) => !/ERR_INTERNET_DISCONNECTED|Failed to load resource/.test(t)); /* ================= 9. 控制台 ================= */ ok('C 控制台无报错', consoleErrors.length === 0, consoleErrors.join('\n')); void errorsAfterOffline; await browser.close(); if (server) server.kill(); const fails = out.filter((r) => !r.ok).length; console.log(`\n${fails ? `BROWSER VERIFY FAILED(${fails}/${out.length})` : `BROWSER VERIFY OK(${out.length} 项)`}`); process.exit(fails ? 1 : 0); void S; void decCard; void readFileSync;