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