/* verify.js —— v4 验收:真实 Chromium(headless Chrome)+ puppeteer-core,对本地看板逐条断言 §5 的 20 项。 * * 准备: * cd /opt/data/work/kangshifu/impl && python3 -m http.server 8765 & * NODE_PATH=/tmp/e2e/node_modules node docs/verify.js [baseUrl] * 产出:控制台逐条 PASS/FAIL 与证据;/tmp/e2e/shots 下截图;/tmp/e2e/results.json。 * * 写操作(#11 评分、#12 改状态)会真的写库:结束后自动把这两张卡恢复成初始值(restore())。 * 评分用固定成员「刘洋」、状态改的卡在结束后还原;重复运行是安全的。 */ const puppeteer = require("puppeteer-core"); const fs = require("fs"); const path = require("path"); const BASE = process.argv[2] || "http://127.0.0.1:8765/"; const SHOTS = "/tmp/e2e/shots"; fs.mkdirSync(SHOTS, { recursive: true }); const PARSE = "https://kangshifu.fmode.cn/parse"; const APP = "FKieRbIoGt_main"; const SEED = { user: "刘洋", score: 68, comment: "验收脚本写入:需求真实,工艺要跟" }; /* 无头 shell 默认报告 hover:none(没有真实指针);接上虚拟指针,hover 才按桌面浏览器生效 */ async function enableHover(page) { const cdp = await page.createCDPSession(); await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "hover", value: "hover" }, { name: "pointer", value: "fine" }] }); } function findChrome() { const local = "/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell"; if (fs.existsSync(local)) return local; return null; } const results = []; function record(no, name, ok, detail) { results.push({ no, name, ok, detail }); console.log(`${ok ? "PASS" : "FAIL"} #${no} ${name}${detail ? " — " + detail : ""}`); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /* 服务端直查(公开读,App ID 与前端相同) */ async function lib(cls, where) { const q = where ? "?where=" + encodeURIComponent(JSON.stringify(where)) + "&limit=1000" : "?limit=1000"; const r = await fetch(PARSE + "/classes/" + cls + q, { headers: { "X-Parse-Application-Id": APP } }); return (await r.json()).results || []; } async function main() { const exe = findChrome(); if (!exe) { console.log("NO_CHROME"); process.exit(2); } const browser = await puppeteer.launch({ executablePath: exe, headless: "shell", args: ["--no-sandbox", "--disable-gpu", "--window-size=1440,1000"] }); const page = await browser.newPage(); /* 验收用固定身份:避免依赖本机 localStorage 里上一次的身份(换台机器跑结果也要一样) */ await page.evaluateOnNewDocument((u) => { try { localStorage.setItem("kf.me", u); } catch (e) {} }, SEED.user); const consoleErrors = []; const pageErrors = []; page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); page.on("pageerror", (e) => pageErrors.push(e.message)); const goto = async (hash, sel) => { await page.goto(BASE + hash, { waitUntil: "networkidle0", timeout: 60000 }); if (sel) await page.waitForSelector(sel, { timeout: 30000 }); await sleep(350); }; const text = (sel) => page.$eval(sel, (n) => n.textContent.trim()).catch(() => null); const count = (sel) => page.$$eval(sel, (n) => n.length).catch(() => 0); const shot = (n) => page.screenshot({ path: path.join(SHOTS, n + ".png") }); await enableHover(page); /* ============ 库侧基线 ============ */ const live = {}; for (const c of ["KfProject", "KfArtifact", "KfInsight", "KfSession", "KfSource", "KfOpportunity"]) live[c] = (await lib(c)).length; console.log("库中条数:", JSON.stringify(live)); /* ================= #1 真实取数,与库一致 ================= */ await page.setViewport({ width: 1440, height: 1000 }); await goto("#/overview", ".wb"); const nav = await page.$$eval("#nav a", (ns) => Object.fromEntries(ns.map((a) => [a.getAttribute("href"), (a.querySelector(".cnt") || {}).textContent || ""]))); const pageCounts = await page.evaluate(() => window.__KF__.state.db && { artifacts: window.__KF__.state.db.artifacts.length, projects: window.__KF__.state.db.projects.length, insights: window.__KF__.state.db.insights.length, sources: window.__KF__.state.db.sources.length, sessions: window.__KF__.state.db.sessions.length, opportunities: window.__KF__.state.db.opportunities.length }); const ok1 = pageCounts && pageCounts.artifacts === live.KfArtifact && pageCounts.projects === live.KfProject && pageCounts.insights === live.KfInsight && pageCounts.sources === live.KfSource && pageCounts.opportunities === live.KfOpportunity && pageCounts.sessions === live.KfSession && nav["#/pool"] === String(live.KfOpportunity) && nav["#/artifacts"] === String(live.KfArtifact); record(1, "真实取数,与库一致", ok1, `页面模型 ${JSON.stringify(pageCounts)};库 ${JSON.stringify(live)};导航计数 pool=${nav["#/pool"]} artifacts=${nav["#/artifacts"]}`); /* ================= #2 单选筛选 ================= */ await goto("#/artifacts", "table.tbl"); const allRows = await count("table.tbl tbody tr"); const sInLib = await page.evaluate(async ({ PARSE, APP }) => { const h = { "X-Parse-Application-Id": APP }; const p = (await (await fetch(PARSE + "/classes/KfProject?limit=1000", { headers: h })).json()).results; const projS = new Set(p.filter((x) => x.priority === "S").map((x) => x.objectId)); const a = (await (await fetch(PARSE + "/classes/KfArtifact?limit=1000", { headers: h })).json()).results; return a.filter((x) => projS.has(x.projectId)).length; }, { PARSE, APP }); await page.click('[data-act="facet"][data-dim="priority"][data-value="S"]'); await sleep(250); const sRows = await count("table.tbl tbody tr"); const sNote = Number(await text("[data-rn]")); record(2, "单选筛选(优先级 S)", sNote === sInLib && sRows === sInLib, `库中 S=${sInLib},页面 ${sRows} 行 / 提示 ${sNote}(未筛 ${allRows} 行)`); /* ================= #3 组合筛选交集 ================= */ await page.click('[data-act="facet"][data-dim="stage"][data-value="S3"]'); await sleep(250); const comboNote = Number(await text("[data-rn]")); const echo = await text("[data-active]"); const sS3Lib = await page.evaluate(async ({ PARSE, APP }) => { const h = { "X-Parse-Application-Id": APP }; const p = (await (await fetch(PARSE + "/classes/KfProject?limit=1000", { headers: h })).json()).results; const projS = new Set(p.filter((x) => x.priority === "S").map((x) => x.objectId)); const a = (await (await fetch(PARSE + "/classes/KfArtifact?limit=1000", { headers: h })).json()).results; return a.filter((x) => projS.has(x.projectId) && String(x.stage || "").startsWith("S3")).length; }, { PARSE, APP }); record(3, "组合筛选叠加(S × S3 交集)", comboNote === sS3Lib && /优先级/.test(echo || "") && /S3/.test(echo || ""), "conds=" + JSON.stringify(comboNote === sS3Lib && /优先级/.test(echo || "") && /S3/.test(echo || "")) + " | " + `库中 S∩S3=${sS3Lib},页面 ${comboNote} 条;回显「${echo}」`); /* ================= #4 URL 状态保留 ================= */ const hashWithFilters = await page.evaluate(() => location.hash); const p2 = await browser.newPage(); await p2.goto(BASE + hashWithFilters, { waitUntil: "networkidle0" }); await p2.waitForSelector("[data-rn]"); const reloadNote = Number(await p2.$eval("[data-rn]", (n) => n.textContent)); const chipsOn = await p2.$$eval('.chip[data-on="1"]', (ns) => ns.map((n) => n.dataset.dim + "=" + n.dataset.value)); await p2.reload({ waitUntil: "networkidle0" }); await p2.waitForSelector("[data-rn]"); const afterReload = Number(await p2.$eval("[data-rn]", (n) => n.textContent)); // 机会卡池的筛选同样写 URL(期望张数 = 库中 S 级机会卡) const livePoolS = (await lib("KfOpportunity", { priority: "S" })).length; await p2.goto(BASE + "#/pool?priority=S&sort=score", { waitUntil: "networkidle0" }); await p2.waitForSelector(".ogrid"); const poolUrl = await p2.evaluate(() => ({ n: document.querySelectorAll(".ogrid .ocard").length, note: Number((document.querySelector("[data-rn]") || {}).textContent), on: [...document.querySelectorAll(".chip[data-on='1']")].map((c) => c.dataset.value), sort: !!document.querySelector('.seg button.on[data-sort="score"]') })); await p2.close(); const poolS = livePoolS; record(4, "筛选状态写入 URL,刷新/新标签保留", reloadNote === sS3Lib && afterReload === sS3Lib && chipsOn.length === 2 && poolUrl.n === poolS && poolUrl.on.includes("S") && poolUrl.sort, "conds=" + JSON.stringify(reloadNote === sS3Lib && afterReload === sS3Lib && chipsOn.length === 2 && poolUrl.n === poolS && poolUrl.on.includes("S") && poolUrl.sort) + " | " + `成果 ${hashWithFilters}:新标签 ${reloadNote} / 刷新后 ${afterReload}(库 ${sS3Lib}),chip ${chipsOn.join(",")};卡片池 #/pool?priority=S&sort=score → ${poolUrl.n} 张、排序高亮=${poolUrl.sort}`); await page.bringToFront(); /* ================= #5 hash 路由落位 ================= */ const routes = ["#/pool", "#/overview", "#/artifacts", "#/stages", "#/reportIndex"]; const landed = []; for (const r of routes) { await goto(r); landed.push(await page.evaluate((r) => document.querySelector("#nav a.on") && r, r)); } await goto("#/pool?priority=S&stage=S3", ".ogrid, .empty"); const poolFiltered = await page.$$eval(".ogrid .ocard", (ns) => ns.length).catch(() => 0); record(5, "hash 路由落位", landed.every(Boolean) && poolFiltered > 0 && poolFiltered < live.KfOpportunity, "conds=" + JSON.stringify(landed.every(Boolean) && poolFiltered > 0 && poolFiltered < live.KfOpportunity) + " | " + `直接打开 ${routes.join(" ")} 都有对应页面且导航高亮;#/pool?priority=S&stage=S3 → ${poolFiltered} 张卡(全池 ${live.KfOpportunity})`); /* ================= #6 报告章节导航 + 滚动高亮 ================= */ await goto("#/artifacts", "table.tbl"); await page.click("tr.has-report .r-inline-link"); await page.waitForSelector("[data-report]", { timeout: 20000 }); await sleep(400); const tocN = await count(".r-toc a[data-chapter]"); const firstOn = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null); const chLinks = await page.$$(".r-toc a[data-chapter]"); await chLinks[3].click(); const settle = async () => { let last = -1, still = Date.now(); const t = Date.now(); while (Date.now() - t < 15000) { await sleep(100); const y = await page.evaluate(() => window.scrollY); if (y !== last) { last = y; still = Date.now(); } else if (Date.now() - still > 600) break; } }; await settle(); const on4 = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null); await page.evaluate(() => { const el = document.querySelector('[data-chapter-id="ch6"]'); window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 100, behavior: "instant" }); }); await page.evaluate(async () => { let last = -1, still = Date.now(), t = Date.now(); while (Date.now() - t < 15000) { await new Promise((r) => setTimeout(r, 100)); const y = window.scrollY; if (y !== last) { last = y; still = Date.now(); } else if (Date.now() - still > 500) break; } }); await sleep(400); const on6 = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null); await shot("v4-report-nav"); record(6, "报告章节导航 + 滚动高亮", tocN >= 5 && firstOn === "ch1" && on4 === "ch4" && on6 === "ch6", "conds=" + JSON.stringify(tocN >= 5 && firstOn === "ch1" && on4 === "ch4" && on6 === "ch6") + " | " + `目录 ${tocN} 章;初始 ${firstOn};点第 4 章 → ${on4};滚到第 6 章 → ${on6}`); /* ================= #7 证据链反查 ================= */ await page.evaluate(() => window.scrollTo(0, 0)); await page.evaluate(() => { const el = document.querySelector('[data-chapter-id="ch2"]'); window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 100, behavior: "instant" }); }); await sleep(800); await page.click('[data-chapter-id="ch2"] .r-anchor'); await page.waitForSelector(".drawer.open"); await sleep(400); const ev = await page.evaluate(async () => { const d0 = document.querySelector(".drawer"); /* 等抽屉内容真正换成本次证据链(上一次的抽屉可能是学校页残留) */ for (let i = 0; i < 40 && !d0.querySelector(".trace-item .t[href]"); i++) await new Promise((r) => setTimeout(r, 100)); const d = document.querySelector(".drawer"); /* 结论标题在抽屉头部:标题 = 首个 h3;claim 只是给页面展示用的摘要 */ const h = d.querySelector(".drawer-h h3"); const link = d.querySelector(".trace-item .t[href]"); const gen = d.querySelector(".trace-item .t[href]"); const itemNos = d.querySelectorAll(".trace-item").length; return { claim: h ? h.textContent.trim() : "", items: [...d.querySelectorAll(".trace-item .t")].map((n) => n.textContent.trim()), quotes: d.querySelectorAll(".evi").length, link: link ? link.getAttribute("href") : "" }; }); await shot("v4-evidence"); console.log(" [dbg#7]", JSON.stringify(ev)); console.log(" [dbg#7] hash:", await page.evaluate(() => location.hash), "item:", typeof ev.items, Array.isArray(ev.items), ev.items.length); record(7, "证据链反查列出具体成果", ev.items.length >= 2 && ev.quotes >= 1 && /ev=/.test(ev.link || "") && /\/report\//.test(await page.evaluate(() => location.hash)), "conds=" + JSON.stringify(ev.items.length >= 2 && ev.quotes >= 1 && /ev=/.test(ev.link || "")) + " | " + `结论「${(ev.claim || "").slice(0, 30)}…」→ ${ev.items.length} 条成果:${ev.items.join(" / ")};原文 ${ev.quotes} 条;首条 ${ev.link}`); /* ================= #8 前端无凭据(红线) ================= */ const leaks = await page.evaluate(async () => { const files = ["index.html", "css/tokens.css", "css/base.css", "css/components.css", "css/report.css", "css/responsive.css", "css/cards.css"]; const html = await (await fetch("/index.html")).text(); files.push(...[...html.matchAll(/