| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- #!/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 <dir> 截图目录(默认 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());
- // 抽屉内容经 <slot> 投影: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;
|