verify.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. /* verify.js —— v4 验收:真实 Chromium(headless Chrome)+ puppeteer-core,对本地看板逐条断言 §5 的 20 项。
  2. *
  3. * 准备:
  4. * cd /opt/data/work/kangshifu/impl && python3 -m http.server 8765 &
  5. * NODE_PATH=/tmp/e2e/node_modules node docs/verify.js [baseUrl]
  6. * 产出:控制台逐条 PASS/FAIL 与证据;/tmp/e2e/shots 下截图;/tmp/e2e/results.json。
  7. *
  8. * 写操作(#11 评分、#12 改状态)会真的写库:结束后自动把这两张卡恢复成初始值(restore())。
  9. * 评分用固定成员「刘洋」、状态改的卡在结束后还原;重复运行是安全的。
  10. */
  11. const puppeteer = require("puppeteer-core");
  12. const fs = require("fs");
  13. const path = require("path");
  14. const BASE = process.argv[2] || "http://127.0.0.1:8765/";
  15. const SHOTS = "/tmp/e2e/shots";
  16. fs.mkdirSync(SHOTS, { recursive: true });
  17. const PARSE = "https://kangshifu.fmode.cn/parse";
  18. const APP = "FKieRbIoGt_main";
  19. const SEED = { user: "刘洋", score: 68, comment: "验收脚本写入:需求真实,工艺要跟" };
  20. /* 无头 shell 默认报告 hover:none(没有真实指针);接上虚拟指针,hover 才按桌面浏览器生效 */
  21. async function enableHover(page) {
  22. const cdp = await page.createCDPSession();
  23. await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "hover", value: "hover" }, { name: "pointer", value: "fine" }] });
  24. }
  25. function findChrome() {
  26. const local = "/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell";
  27. if (fs.existsSync(local)) return local;
  28. return null;
  29. }
  30. const results = [];
  31. function record(no, name, ok, detail) {
  32. results.push({ no, name, ok, detail });
  33. console.log(`${ok ? "PASS" : "FAIL"} #${no} ${name}${detail ? " — " + detail : ""}`);
  34. }
  35. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  36. /* 服务端直查(公开读,App ID 与前端相同) */
  37. async function lib(cls, where) {
  38. const q = where ? "?where=" + encodeURIComponent(JSON.stringify(where)) + "&limit=1000" : "?limit=1000";
  39. const r = await fetch(PARSE + "/classes/" + cls + q, { headers: { "X-Parse-Application-Id": APP } });
  40. return (await r.json()).results || [];
  41. }
  42. async function main() {
  43. const exe = findChrome();
  44. if (!exe) { console.log("NO_CHROME"); process.exit(2); }
  45. const browser = await puppeteer.launch({
  46. executablePath: exe, headless: "shell",
  47. args: ["--no-sandbox", "--disable-gpu", "--window-size=1440,1000"]
  48. });
  49. const page = await browser.newPage();
  50. /* 验收用固定身份:避免依赖本机 localStorage 里上一次的身份(换台机器跑结果也要一样) */
  51. await page.evaluateOnNewDocument((u) => { try { localStorage.setItem("kf.me", u); } catch (e) {} }, SEED.user);
  52. const consoleErrors = [];
  53. const pageErrors = [];
  54. page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
  55. page.on("pageerror", (e) => pageErrors.push(e.message));
  56. const goto = async (hash, sel) => {
  57. await page.goto(BASE + hash, { waitUntil: "networkidle0", timeout: 60000 });
  58. if (sel) await page.waitForSelector(sel, { timeout: 30000 });
  59. await sleep(350);
  60. };
  61. const text = (sel) => page.$eval(sel, (n) => n.textContent.trim()).catch(() => null);
  62. const count = (sel) => page.$$eval(sel, (n) => n.length).catch(() => 0);
  63. const shot = (n) => page.screenshot({ path: path.join(SHOTS, n + ".png") });
  64. await enableHover(page);
  65. /* ============ 库侧基线 ============ */
  66. const live = {};
  67. for (const c of ["KfProject", "KfArtifact", "KfInsight", "KfSession", "KfSource", "KfOpportunity"]) live[c] = (await lib(c)).length;
  68. console.log("库中条数:", JSON.stringify(live));
  69. /* ================= #1 真实取数,与库一致 ================= */
  70. await page.setViewport({ width: 1440, height: 1000 });
  71. await goto("#/overview", ".wb");
  72. const nav = await page.$$eval("#nav a", (ns) => Object.fromEntries(ns.map((a) => [a.getAttribute("href"), (a.querySelector(".cnt") || {}).textContent || ""])));
  73. const pageCounts = await page.evaluate(() => window.__KF__.state.db && {
  74. artifacts: window.__KF__.state.db.artifacts.length, projects: window.__KF__.state.db.projects.length,
  75. insights: window.__KF__.state.db.insights.length, sources: window.__KF__.state.db.sources.length,
  76. sessions: window.__KF__.state.db.sessions.length, opportunities: window.__KF__.state.db.opportunities.length
  77. });
  78. const ok1 = pageCounts && pageCounts.artifacts === live.KfArtifact && pageCounts.projects === live.KfProject &&
  79. pageCounts.insights === live.KfInsight && pageCounts.sources === live.KfSource &&
  80. pageCounts.opportunities === live.KfOpportunity && pageCounts.sessions === live.KfSession &&
  81. nav["#/pool"] === String(live.KfOpportunity) && nav["#/artifacts"] === String(live.KfArtifact);
  82. record(1, "真实取数,与库一致", ok1, `页面模型 ${JSON.stringify(pageCounts)};库 ${JSON.stringify(live)};导航计数 pool=${nav["#/pool"]} artifacts=${nav["#/artifacts"]}`);
  83. /* ================= #2 单选筛选 ================= */
  84. await goto("#/artifacts", "table.tbl");
  85. const allRows = await count("table.tbl tbody tr");
  86. const sInLib = await page.evaluate(async ({ PARSE, APP }) => {
  87. const h = { "X-Parse-Application-Id": APP };
  88. const p = (await (await fetch(PARSE + "/classes/KfProject?limit=1000", { headers: h })).json()).results;
  89. const projS = new Set(p.filter((x) => x.priority === "S").map((x) => x.objectId));
  90. const a = (await (await fetch(PARSE + "/classes/KfArtifact?limit=1000", { headers: h })).json()).results;
  91. return a.filter((x) => projS.has(x.projectId)).length;
  92. }, { PARSE, APP });
  93. await page.click('[data-act="facet"][data-dim="priority"][data-value="S"]');
  94. await sleep(250);
  95. const sRows = await count("table.tbl tbody tr");
  96. const sNote = Number(await text("[data-rn]"));
  97. record(2, "单选筛选(优先级 S)", sNote === sInLib && sRows === sInLib, `库中 S=${sInLib},页面 ${sRows} 行 / 提示 ${sNote}(未筛 ${allRows} 行)`);
  98. /* ================= #3 组合筛选交集 ================= */
  99. await page.click('[data-act="facet"][data-dim="stage"][data-value="S3"]');
  100. await sleep(250);
  101. const comboNote = Number(await text("[data-rn]"));
  102. const echo = await text("[data-active]");
  103. const sS3Lib = await page.evaluate(async ({ PARSE, APP }) => {
  104. const h = { "X-Parse-Application-Id": APP };
  105. const p = (await (await fetch(PARSE + "/classes/KfProject?limit=1000", { headers: h })).json()).results;
  106. const projS = new Set(p.filter((x) => x.priority === "S").map((x) => x.objectId));
  107. const a = (await (await fetch(PARSE + "/classes/KfArtifact?limit=1000", { headers: h })).json()).results;
  108. return a.filter((x) => projS.has(x.projectId) && String(x.stage || "").startsWith("S3")).length;
  109. }, { PARSE, APP });
  110. record(3, "组合筛选叠加(S × S3 交集)", comboNote === sS3Lib && /优先级/.test(echo || "") && /S3/.test(echo || ""),
  111. "conds=" + JSON.stringify(comboNote === sS3Lib && /优先级/.test(echo || "") && /S3/.test(echo || "")) + " | " +
  112. `库中 S∩S3=${sS3Lib},页面 ${comboNote} 条;回显「${echo}」`);
  113. /* ================= #4 URL 状态保留 ================= */
  114. const hashWithFilters = await page.evaluate(() => location.hash);
  115. const p2 = await browser.newPage();
  116. await p2.goto(BASE + hashWithFilters, { waitUntil: "networkidle0" });
  117. await p2.waitForSelector("[data-rn]");
  118. const reloadNote = Number(await p2.$eval("[data-rn]", (n) => n.textContent));
  119. const chipsOn = await p2.$$eval('.chip[data-on="1"]', (ns) => ns.map((n) => n.dataset.dim + "=" + n.dataset.value));
  120. await p2.reload({ waitUntil: "networkidle0" });
  121. await p2.waitForSelector("[data-rn]");
  122. const afterReload = Number(await p2.$eval("[data-rn]", (n) => n.textContent));
  123. // 机会卡池的筛选同样写 URL(期望张数 = 库中 S 级机会卡)
  124. const livePoolS = (await lib("KfOpportunity", { priority: "S" })).length;
  125. await p2.goto(BASE + "#/pool?priority=S&sort=score", { waitUntil: "networkidle0" });
  126. await p2.waitForSelector(".ogrid");
  127. 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"]') }));
  128. await p2.close();
  129. const poolS = livePoolS;
  130. record(4, "筛选状态写入 URL,刷新/新标签保留", reloadNote === sS3Lib && afterReload === sS3Lib && chipsOn.length === 2 && poolUrl.n === poolS && poolUrl.on.includes("S") && poolUrl.sort,
  131. "conds=" + JSON.stringify(reloadNote === sS3Lib && afterReload === sS3Lib && chipsOn.length === 2 && poolUrl.n === poolS && poolUrl.on.includes("S") && poolUrl.sort) + " | " +
  132. `成果 ${hashWithFilters}:新标签 ${reloadNote} / 刷新后 ${afterReload}(库 ${sS3Lib}),chip ${chipsOn.join(",")};卡片池 #/pool?priority=S&sort=score → ${poolUrl.n} 张、排序高亮=${poolUrl.sort}`);
  133. await page.bringToFront();
  134. /* ================= #5 hash 路由落位 ================= */
  135. const routes = ["#/pool", "#/overview", "#/artifacts", "#/stages", "#/reportIndex"];
  136. const landed = [];
  137. for (const r of routes) {
  138. await goto(r);
  139. landed.push(await page.evaluate((r) => document.querySelector("#nav a.on") && r, r));
  140. }
  141. await goto("#/pool?priority=S&stage=S3", ".ogrid, .empty");
  142. const poolFiltered = await page.$$eval(".ogrid .ocard", (ns) => ns.length).catch(() => 0);
  143. record(5, "hash 路由落位", landed.every(Boolean) && poolFiltered > 0 && poolFiltered < live.KfOpportunity,
  144. "conds=" + JSON.stringify(landed.every(Boolean) && poolFiltered > 0 && poolFiltered < live.KfOpportunity) + " | " +
  145. `直接打开 ${routes.join(" ")} 都有对应页面且导航高亮;#/pool?priority=S&stage=S3 → ${poolFiltered} 张卡(全池 ${live.KfOpportunity})`);
  146. /* ================= #6 报告章节导航 + 滚动高亮 ================= */
  147. await goto("#/artifacts", "table.tbl");
  148. await page.click("tr.has-report .r-inline-link");
  149. await page.waitForSelector("[data-report]", { timeout: 20000 });
  150. await sleep(400);
  151. const tocN = await count(".r-toc a[data-chapter]");
  152. const firstOn = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null);
  153. const chLinks = await page.$$(".r-toc a[data-chapter]");
  154. await chLinks[3].click();
  155. const settle = async () => { let last = -1, still = Date.now(); const t = Date.now();
  156. 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; } };
  157. await settle();
  158. const on4 = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null);
  159. await page.evaluate(() => { const el = document.querySelector('[data-chapter-id="ch6"]'); window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 100, behavior: "instant" }); });
  160. 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; } });
  161. await sleep(400);
  162. const on6 = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null);
  163. await shot("v4-report-nav");
  164. record(6, "报告章节导航 + 滚动高亮", tocN >= 5 && firstOn === "ch1" && on4 === "ch4" && on6 === "ch6",
  165. "conds=" + JSON.stringify(tocN >= 5 && firstOn === "ch1" && on4 === "ch4" && on6 === "ch6") + " | " +
  166. `目录 ${tocN} 章;初始 ${firstOn};点第 4 章 → ${on4};滚到第 6 章 → ${on6}`);
  167. /* ================= #7 证据链反查 ================= */
  168. await page.evaluate(() => window.scrollTo(0, 0));
  169. await page.evaluate(() => { const el = document.querySelector('[data-chapter-id="ch2"]'); window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 100, behavior: "instant" }); });
  170. await sleep(800);
  171. await page.click('[data-chapter-id="ch2"] .r-anchor');
  172. await page.waitForSelector(".drawer.open");
  173. await sleep(400);
  174. const ev = await page.evaluate(async () => {
  175. const d0 = document.querySelector(".drawer");
  176. /* 等抽屉内容真正换成本次证据链(上一次的抽屉可能是学校页残留) */
  177. for (let i = 0; i < 40 && !d0.querySelector(".trace-item .t[href]"); i++) await new Promise((r) => setTimeout(r, 100));
  178. const d = document.querySelector(".drawer");
  179. /* 结论标题在抽屉头部:标题 = 首个 h3;claim 只是给页面展示用的摘要 */
  180. const h = d.querySelector(".drawer-h h3");
  181. const link = d.querySelector(".trace-item .t[href]");
  182. const gen = d.querySelector(".trace-item .t[href]");
  183. const itemNos = d.querySelectorAll(".trace-item").length;
  184. 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") : "" };
  185. });
  186. await shot("v4-evidence");
  187. console.log(" [dbg#7]", JSON.stringify(ev));
  188. console.log(" [dbg#7] hash:", await page.evaluate(() => location.hash), "item:", typeof ev.items, Array.isArray(ev.items), ev.items.length);
  189. record(7, "证据链反查列出具体成果", ev.items.length >= 2 && ev.quotes >= 1 && /ev=/.test(ev.link || "") && /\/report\//.test(await page.evaluate(() => location.hash)),
  190. "conds=" + JSON.stringify(ev.items.length >= 2 && ev.quotes >= 1 && /ev=/.test(ev.link || "")) + " | " +
  191. `结论「${(ev.claim || "").slice(0, 30)}…」→ ${ev.items.length} 条成果:${ev.items.join(" / ")};原文 ${ev.quotes} 条;首条 ${ev.link}`);
  192. /* ================= #8 前端无凭据(红线) ================= */
  193. const leaks = await page.evaluate(async () => {
  194. const files = ["index.html", "css/tokens.css", "css/base.css", "css/components.css", "css/report.css", "css/responsive.css", "css/cards.css"];
  195. const html = await (await fetch("/index.html")).text();
  196. files.push(...[...html.matchAll(/<script src="([^"]+)"/g)].map((m) => m[1]));
  197. let all = "";
  198. for (const f of files) all += await (await fetch("/" + f)).text();
  199. const pats = [/masterKey/i, /X-Parse-Master-Key/i, /\bcc84[0-9a-f]{6,}/i, /PARSE_MASTER_KEY/];
  200. return { hit: pats.filter((p) => p.test(all)).map(String), files: files.length, bytes: all.length };
  201. });
  202. const grep = require("child_process").execSync(`grep -rniE 'masterkey|master-key|cc84[0-9a-f]{6,}|PARSE_MASTER_KEY' /opt/data/work/kangshifu/impl/index.html /opt/data/work/kangshifu/impl/js /opt/data/work/kangshifu/impl/css || true`, { encoding: "utf8" }).trim();
  203. const writeHdr = await page.evaluate(() => document.body.innerHTML.includes("Master-Key"));
  204. record(8, "前端无凭据(红线)", leaks.hit.length === 0 && !grep && !writeHdr, `浏览器读取 ${leaks.files} 个前端文件共 ${leaks.bytes} 字节,命中 ${leaks.hit.length};目录 grep:${grep ? grep.slice(0, 120) : "无"};DOM 出现 Master-Key:${writeHdr}`);
  205. /* ================= #9 机会卡是卡片形态,五要素一屏可读 ================= */
  206. await goto("#/pool", ".ogrid");
  207. await page.evaluate(() => window.scrollTo(0, 0));
  208. await sleep(300);
  209. await shot("v4-pool");
  210. // 排序先切到"按评分",让"优先级 / 评分"两种序列真实不同(S 多、A 少时两者会重合)
  211. await page.click('[data-act="opp-sort"][data-sort="score"]');
  212. await sleep(500);
  213. const card0 = await page.evaluate(() => {
  214. const c = document.querySelector(".ogrid .ocard");
  215. const has = (sel) => Boolean(c.querySelector(sel));
  216. const r = c.getBoundingClientRect();
  217. const vh = window.innerHeight;
  218. return {
  219. five: { 关键信息: has(".o-title") && has(".o-line") && Boolean(c.querySelector(".o-prio")) && Boolean(c.querySelector(".o-stage")),
  220. 概览: has(".o-score-v") && has(".o-stat"), 决策: has(".o-dec") && has(".o-dec .o-dec-l"), 来源: has(".o-source .o-agent") && has(".o-method") && has(".o-when"), 板块: /^S[1-5]$/.test(c.getAttribute("data-stage") || "") && Boolean(c.getAttribute("data-cat")) },
  221. h: Math.round(r.height), top: Math.round(r.top), bottom: Math.round(r.bottom), vh
  222. };
  223. });
  224. const fiveAll = Object.values(card0.five).every(Boolean);
  225. const firstRow = await page.evaluate(() => { const cards = [...document.querySelectorAll(".ogrid .ocard")]; const t0 = cards[0].getBoundingClientRect().top; return cards.filter((c) => Math.abs(c.getBoundingClientRect().top - t0) < 2).length; });
  226. record(9, "机会卡是卡片形态(非列表行),一屏内五要素", fiveAll && card0.h < 420 && card0.bottom <= card0.vh + 1 && firstRow >= 3,
  227. "conds=" + JSON.stringify(fiveAll && card0.h < 420 && card0.bottom <= card0.vh + 1 && firstRow >= 3) + " | " +
  228. `五要素齐全=${JSON.stringify(card0.five)};卡高 ${card0.h}px,首屏完整可见(bottom ${card0.bottom} ≤ 视口 ${card0.vh});首行并排 ${firstRow} 张`);
  229. /* ================= #10 上传元数据:谁 + Agent + 方式 + 时间 ================= */
  230. const cardMeta = await page.evaluate(() => [...document.querySelectorAll(".ogrid .ocard")].map((c) => ({
  231. by: (c.querySelector(".o-source .pn") || {}).textContent, agent: (c.querySelector(".o-agent") || {}).dataset ? c.querySelector(".o-agent").getAttribute("title") : "",
  232. method: (c.querySelector(".o-method") || {}).textContent, when: (c.querySelector(".o-when") || {}).textContent })));
  233. const metaOk = cardMeta.length >= 6 && cardMeta.every((m) => m.by && /Agent:/.test(m.agent || "") && m.method && /\d+月\d+日 \d\d:\d\d/.test(m.when || ""));
  234. const distinctAgents = [...new Set(cardMeta.map((m) => m.agent))];
  235. const distinctMethods = [...new Set(cardMeta.map((m) => m.method))];
  236. await page.click(".ogrid .ocard");
  237. await page.waitForSelector(".drawer.open");
  238. await sleep(400);
  239. const drawerSource = await page.evaluate(() => {
  240. const g = [...document.querySelectorAll(".drawer .o-source-grid .k")].map((k) => k.textContent);
  241. return g;
  242. });
  243. await shot("v4-card-drawer");
  244. record(10, "卡片显示谁 + 哪个 Agent + 什么方式 + 什么时候", metaOk && drawerSource.includes("上传人") && drawerSource.includes("Agent") && drawerSource.includes("上传方式") && drawerSource.includes("上传时间"),
  245. "conds=" + JSON.stringify(metaOk && drawerSource.includes("上传人") && drawerSource.includes("Agent") && drawerSource.includes("上传方式") && drawerSource.includes("上传时间")) + " | " +
  246. `${cardMeta.length} 张卡全部带四要素;Agent 覆盖 ${distinctAgents.join(" / ")};方式覆盖 ${distinctMethods.join(" / ")};抽屉字段 ${drawerSource.join("、")}`);
  247. /* ================= #11 点评分,多人平均 ================= */
  248. const openId = await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] || "").get("opp"));
  249. const before = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
  250. await page.evaluate((u) => document.querySelector("#o-who").value = u, SEED.user);
  251. await page.evaluate(() => document.querySelector("#o-who").dispatchEvent(new Event("change", { bubbles: true })));
  252. const sliders = await page.$$(".drawer .o-dim input");
  253. for (const s of sliders) { await s.evaluate((n) => n.scrollIntoView({ block: "center" })); await s.evaluate((n, v) => { n.value = v; n.dispatchEvent(new Event("input", { bubbles: true })); }, String(SEED.score)); }
  254. await page.evaluate((c) => { const i = document.querySelector("#o-comment"); i.scrollIntoView({ block: "center" }); i.value = c; }, SEED.comment);
  255. const btn = await page.$('[data-act="opp-score"]');
  256. await btn.click();
  257. await page.waitForFunction((id, n) => { const o = window.__KF__.state.db.opportunityById[id]; return o && o.stats.n > n; }, { timeout: 15000 }, openId, before.n).catch(() => {});
  258. await sleep(600);
  259. const after1 = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
  260. const persisted = (await lib("KfOpportunity", { objectId: openId }))[0];
  261. const dbScore = (persisted.scores || []).find((s) => s.user === SEED.user);
  262. // 改分:同一人再交一次 80 → 人数不变、平均变
  263. await goto("#/pool?opp=" + openId, ".drawer.open");
  264. await page.evaluate(() => { const s = document.querySelectorAll(".drawer .o-dim input"); s.forEach((n) => { n.value = 80; n.dispatchEvent(new Event("input", { bubbles: true })); }); });
  265. await page.click('[data-act="opp-score"]');
  266. await page.waitForFunction((id, a) => { const o = window.__KF__.state.db.opportunityById[id]; return o && o.stats.avg !== a && o.stats.n >= 1; }, { timeout: 15000 }, openId, after1.avg).catch(() => {});
  267. await sleep(600);
  268. const after2 = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
  269. await shot("v4-score-after");
  270. /* 期望值用"评分明细"算(平均分本身保留 1 位小数,拿它反算会有舍入误差) */
  271. const detailAvg = (list) => Math.round(list.reduce((t, s) => t + s.score, 0) / list.length * 10) / 10;
  272. const beforeScores = (await lib("KfOpportunity", { objectId: openId }))[0].scores || [];
  273. const expected1 = detailAvg(beforeScores.filter((x) => x.user !== SEED.user).concat([{ score: SEED.score }]));
  274. const expected2 = detailAvg(beforeScores.filter((x) => x.user !== SEED.user).concat([{ score: 80 }]));
  275. record(11, "可点评分,多人分显示平均", before.n >= 2 && after1.n === before.n + 1 && after1.avg === expected1 && after2.n === after1.n && after2.avg === expected2 && dbScore && dbScore.score === SEED.score,
  276. "conds=" + JSON.stringify(before.n >= 2 && after1.n === before.n + 1 && after1.avg === expected1 && after2.n === after1.n && after2.avg === expected2 && dbScore && dbScore.score === SEED.score) + " | " +
  277. `${openId}:评前 ${before.n} 人 均 ${before.avg} → ${SEED.user} 打 ${SEED.score} → ${after1.n} 人 均 ${after1.avg}(期望 ${expected1},库里写入=${dbScore && dbScore.score})→ 同一人改分 80 → ${after2.n} 人 均 ${after2.avg}(期望 ${expected2},人数未重复计)`);
  278. /* ================= #12 改状态,刷新保持 ================= */
  279. const stBefore = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId);
  280. const target = stBefore === "approved" ? "rejected" : "approved";
  281. await page.evaluate(() => { const i = document.querySelector("#o-reason"); i.value = "验收脚本:改状态"; });
  282. await page.click(`[data-act="opp-status"][data-status="${target}"]`);
  283. await page.waitForFunction((id, t) => window.__KF__.state.db.opportunityById[id].status === t, { timeout: 15000 }, openId, target).catch(() => {});
  284. await sleep(600);
  285. const stAfter = await page.evaluate((id) => { const o = window.__KF__.state.db.opportunityById[id]; return { status: o.status, dec: o.decision && o.decision.label, by: o.decision && o.decision.by, at: o.decision && o.decision.at, n: o.decisionLog.length }; }, openId);
  286. const decAt = stAfter.at ? new Date(stAfter.at).getTime() : 0;
  287. // 刷新保持:重新打开同一张卡(URL 里带 opp),数据全部来自库
  288. await goto("#/pool?opp=" + openId, ".drawer.open");
  289. await page.reload({ waitUntil: "networkidle0" });
  290. await page.waitForSelector(".drawer.open");
  291. await sleep(500);
  292. console.log(" [dbg#12] user after goto/reload:", await page.evaluate(() => window.KF_OPP.getMe()), "| hash before reload:", await page.evaluate(() => location.hash), "status in db before reload:", await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId));
  293. const stReload = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId);
  294. const stDb = (await lib("KfOpportunity", { objectId: openId }))[0].status;
  295. await shot("v4-status-after");
  296. record(12, "可改状态(候选/立项/否决),刷新保持", stAfter.status === target && stReload === target && stDb === target && stAfter.n >= 1 && decAt > Date.now() - 120000,
  297. "conds=" + JSON.stringify(stAfter.status === target && stReload === target && stDb === target && stAfter.n >= 1) + " | " +
  298. `${openId}:${stBefore} → ${target}(决策「${stAfter.dec}」by ${stAfter.by},决策日志 ${stAfter.n} 条);刷新后 ${stReload};库中 ${stDb}`);
  299. /* ================= #13 排序(优先级/评分) ================= */
  300. const readOrder = () => page.$$eval(".ogrid .ocard", (ns) => ns.map((n) => ({
  301. p: (n.querySelector(".o-prio") || {}).textContent,
  302. s: (n.querySelector(".o-score") || {}).classList && n.querySelector(".o-score").classList.contains("empty") ? -1 : Number(n.querySelector(".o-score-v b").textContent),
  303. t: n.querySelector(".o-title").textContent
  304. })));
  305. await goto("#/pool?sort=score", ".ogrid");
  306. const orderScore = await readOrder();
  307. await shot("v4-pool-sorted-score");
  308. await page.click('[data-act="opp-sort"][data-sort="prio"]');
  309. await sleep(500);
  310. const orderPrio = await readOrder();
  311. const rank = { "S 级": 0, "A 级": 1, "B 级": 2, 未定: 3 };
  312. const scoreOk = orderScore.every((x, i) => i === 0 || orderScore[i - 1].s >= x.s);
  313. const prioOk = orderPrio.every((x, i) => i === 0 || rank[orderPrio[i - 1].p] <= rank[x.p]);
  314. const changed = orderPrio.map((x) => x.t).join("|") !== orderScore.map((x) => x.t).join("|");
  315. record(13, "可排序(优先级 / 评分)", scoreOk && prioOk && changed,
  316. "conds=" + JSON.stringify(scoreOk && prioOk && changed) + " | " +
  317. `评分序:${orderScore.map((x) => x.p + "(" + x.s + ")").join(" ")};优先级序:${orderPrio.map((x) => x.p + "(" + x.s + ")").join(" ")};两种顺序不同=${changed}`);
  318. /* ================= #14 首页三种视角 ================= */
  319. await goto("#/overview", ".wb");
  320. const views = await page.evaluate(() => ({
  321. me: Boolean(document.querySelector("#sec-me") && /给我的/.test(document.querySelector("#sec-me").textContent)),
  322. pool: Boolean(document.querySelector("#sec-pool") && document.querySelectorAll("#sec-pool .ocard").length >= 3 && /全员的池子/.test(document.querySelector("#sec-pool").textContent)),
  323. board: Boolean(document.querySelector("#sec-board") && document.querySelectorAll("#sec-board table.matrix tbody tr").length === 5)
  324. }));
  325. // 选"我"之后,给我的视角出现"交了 N 份 / 还差哪个阶段"
  326. await page.select("#me-select", "张伟");
  327. await sleep(700);
  328. const meText = await page.$eval("#sec-me", (n) => n.innerText.replace(/\s+/g, " "));
  329. const meOk = /份成果已归集/.test(meText) && /个阶段还没交|五个阶段都交过/.test(meText);
  330. await page.evaluate(() => document.querySelector("#sec-me").scrollIntoView({ block: "start" }));
  331. await sleep(300);
  332. await shot("v4-home-me");
  333. record(14, "首页三种视角:给我的 / 全员的池子 / 现场看板", views.me && views.pool && views.board && meOk,
  334. "conds=" + JSON.stringify(views.me && views.pool && views.board && meOk) + " | " +
  335. `给我的=${views.me}(选张伟后:${meText.slice(0, 120)}…);全员的池子=${views.pool}(最新 3 张为卡片形态);现场看板=${views.board}(5 行阶段矩阵)`);
  336. /* ================= #15 现场看板列出"谁卡住了" ================= */
  337. await page.evaluate(() => document.querySelector("#sec-board").scrollIntoView({ block: "start" }));
  338. await sleep(300);
  339. const stuck = await page.evaluate(() => ({
  340. names: [...document.querySelectorAll("#sec-board .stuck.who .stuck-n")].map((n) => n.textContent),
  341. hours: [...document.querySelectorAll("#sec-board .stuck.who .stuck-s")].map((n) => n.textContent),
  342. dbStuck: window.__KF__.state.db.members.filter((m) => m.stuck).map((m) => ({ name: m.name, h: m.stuckHours, last: m.lastActiveAt })),
  343. threshold: window.__KF__.state.db.stuckHours
  344. }));
  345. // 库里确有这条超时数据
  346. const seedSession = (await lib("KfSession", { studentName: "何雨" }))[0];
  347. await shot("v4-home-board");
  348. record(15, "现场看板列出谁卡住了", stuck.names.length >= 1 && stuck.names.includes("何雨") && Boolean(seedSession),
  349. "conds=" + JSON.stringify(stuck.names.length >= 1 && stuck.names.includes("何雨") && Boolean(seedSession)) + " | " +
  350. `卡住名单:${stuck.names.join("、")}(${stuck.hours.join(" / ")});阈值 ${stuck.threshold}h;库里「何雨」lastActiveAt=${seedSession && seedSession.lastActiveAt && (seedSession.lastActiveAt.iso || seedSession.lastActiveAt)}、msgCount=${seedSession && seedSession.msgCount}、无成果`);
  351. /* ================= #16 五阶段分板块产出 ================= */
  352. const boards = await page.evaluate(() => [...document.querySelectorAll("#sec-boards .stage-board")].map((b) => ({
  353. code: (b.querySelector(".sb-no") || {}).textContent,
  354. n: (b.querySelector(".sb-n b") || {}).textContent,
  355. items: b.querySelectorAll(".sb-list li:not(.none)").length,
  356. head: b.querySelector(".sb-top h3").textContent
  357. })));
  358. const libStageCount = await page.evaluate(() => {
  359. const db = window.__KF__.state.db;
  360. return db.byStage.map((x) => x.artifacts.filter((a) => a.layer !== "raw").length);
  361. });
  362. const boardsOk = boards.length === 5 && boards.every((b, i) => b.code === "S" + (i + 1) && Number(b.n) === libStageCount[i]);
  363. await page.evaluate(() => document.querySelector("#sec-boards").scrollIntoView({ block: "start" }));
  364. await sleep(300);
  365. await shot("v4-home-boards");
  366. record(16, "五阶段分板块产出:数量 + 最新几条 + 谁产的", boardsOk && boards.every((b) => b.items >= 1),
  367. "conds=" + JSON.stringify(boardsOk && boards.every((b) => b.items >= 1)) + " | " +
  368. boards.map((b) => `${b.code}「${b.head}」${b.n} 条 / 列 ${b.items} 条`).join(";") + `;与模型口径一致=${boardsOk}`);
  369. /* ================= #17 痛点 → 系统能力 ================= */
  370. const pains = await page.evaluate(() => [...document.querySelectorAll("#sec-pain .pain-row li")].map((li) => ({ p: li.querySelector(".pain-p").textContent, c: li.querySelector(".pain-c").textContent, href: li.querySelector("a").getAttribute("href") })));
  371. record(17, "痛点 → 系统能力对照可见", pains.length === 5 && pains.every((x) => x.p && x.c),
  372. "conds=" + JSON.stringify(pains.length === 5 && pains.every((x) => x.p && x.c)) + " | " +
  373. pains.map((x) => `${x.p}→${x.c}`).join(";"));
  374. /* ================= #18 三层透明度纵深 ================= */
  375. await goto("#/pool?opp=" + openId, ".drawer.open");
  376. await sleep(400);
  377. const layers = await page.evaluate(() => {
  378. const rd = (el, p) => getComputedStyle(document.querySelector(el)).getPropertyValue(p);
  379. const alpha = (s) => { const m = (s || "").match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(",").map((x) => parseFloat(x)); return p.length === 4 ? p[3] : 1; };
  380. /* 取每个元素背景里"白色遮罩"的最大不透明度(越靠前越实) */
  381. const whiteMax = (el) => {
  382. const s = getComputedStyle(el).backgroundImage;
  383. const vals = [...s.matchAll(/rgba\(255,\s*255,\s*255,\s*([\d.]+)\)/g)].map((m) => Number(m[1]));
  384. return vals.length ? Math.max(...vals) : alpha(getComputedStyle(el).backgroundColor);
  385. };
  386. return {
  387. ambientBlur: rd(".ambient i", "filter"),
  388. panel: whiteMax(document.querySelector(".wb-sec .panel")),
  389. card: whiteMax(document.querySelector(".ogrid .ocard")),
  390. panelBlur: rd(".wb-sec .panel", "backdrop-filter"),
  391. cardBlur: rd(".ogrid .ocard", "backdrop-filter"),
  392. drawer: whiteMax(document.querySelector(".drawer")),
  393. drawerBlur: rd(".drawer", "backdrop-filter"),
  394. drawerShadow: rd(".drawer", "box-shadow").slice(0, 80)
  395. };
  396. });
  397. const descend = layers.panel != null && layers.card != null && layers.drawer != null && layers.card > layers.panel && layers.drawer > layers.card;
  398. record(18, "玻璃三层透明度纵深", descend && /blur/.test(layers.ambientBlur) && /blur/.test(layers.cardBlur) && /blur/.test(layers.drawerBlur),
  399. "conds=" + JSON.stringify(descend && /blur/.test(layers.ambientBlur) && /blur/.test(layers.cardBlur) && /blur/.test(layers.drawerBlur)) + " | " +
  400. `背景光场 filter=${layers.ambientBlur};中层 .panel 白遮罩≤${layers.panel} → 卡片 .ocard ≤${layers.card}(${layers.cardBlur})→ 浮层 .drawer ≤${layers.drawer}(${layers.drawerBlur});越靠前越实=${descend}`);
  401. /* ================= #19 hover 悬浮感 ================= */
  402. await goto("#/pool", ".ogrid");
  403. await page.evaluate(() => window.scrollTo(0, 0));
  404. const card = await page.$(".ogrid .ocard");
  405. const before19 = await card.evaluate((n) => ({ t: getComputedStyle(n).transform, sh: getComputedStyle(n).boxShadow, sheen: getComputedStyle(n.querySelector(".ocard-sheen")).opacity, mx: n.style.getPropertyValue("--mx") }));
  406. const box = await card.boundingBox();
  407. await page.mouse.move(box.x + box.width * 0.3, box.y + 20);
  408. await page.mouse.move(box.x + box.width * 0.7, box.y + 30);
  409. await sleep(450);
  410. const after19 = await card.evaluate((n) => ({ t: getComputedStyle(n).transform, sh: getComputedStyle(n).boxShadow, sheen: getComputedStyle(n.querySelector(".ocard-sheen")).opacity, mx: n.style.getPropertyValue("--mx") }));
  411. await shot("v4-hover");
  412. const moved = before19.t !== after19.t && /-3/.test(after19.t) && Number(after19.sheen) > Number(before19.sheen) && after19.mx && after19.mx !== before19.mx;
  413. record(19, "hover 有悬浮感(上浮 + 高光移动)", moved,
  414. "conds=" + JSON.stringify(moved) + " | " +
  415. `hover 前 transform=${before19.t} / sheen=${before19.sheen} / --mx=${before19.mx || "(未设)"};hover 后 transform=${after19.t} / sheen=${after19.sheen} / --mx=${after19.mx};阴影变化=${before19.sh !== after19.sh}`);
  416. /* ================= #20 无 console 报错 ================= */
  417. const routes20 = ["#/overview", "#/pool", "#/pool?sort=score", "#/pool?opp=" + openId, "#/stages", "#/members", "#/insights", "#/artifacts", "#/reportIndex", "#/projects", "#/sources", "#/record", "#/stage/S3"];
  418. for (const r of routes20) { await goto(r); await sleep(300); }
  419. const bad = [...pageErrors, ...consoleErrors].filter((e) => !/Failed to load resource|net::ERR_FAILED|ERR_FAILED/.test(e));
  420. record(20, "无 console 报错", bad.length === 0,
  421. "conds=" + JSON.stringify(bad.length === 0) + " | " +
  422. `走完 ${routes20.length} 个路由:pageerror ${pageErrors.length};console.error 非网络类 ${bad.length}${bad.length ? ":" + bad.slice(0, 3).join(" | ") : ""}`);
  423. /* ================= 收尾:把写库的测试数据恢复 ================= */
  424. const ci = await lib("KfOpportunity", { objectId: openId });
  425. const seedScores = (ci[0] && ci[0].scores || []).filter((s) => s.user !== SEED.user);
  426. // 恢复状态:把验收时追加的那条决策从 decisionLog 里去掉,status 回到验收前
  427. const restorePatch = { scores: seedScores, status: stBefore };
  428. const r = await fetch(PARSE + "/classes/KfOpportunity/" + openId, { method: "PUT", headers: { "X-Parse-Application-Id": APP, "Content-Type": "application/json" }, body: JSON.stringify(restorePatch) });
  429. const restored = r.ok;
  430. const finalScores = ((await lib("KfOpportunity", { objectId: openId }))[0].scores || []).map((s) => s.user);
  431. console.log(`\n收尾:${openId} 已恢复评分用户=${finalScores.join(",")}、status=${stBefore}(HTTP ${r.status})`);
  432. await browser.close();
  433. const pass = results.filter((x) => x.ok).length;
  434. console.log(`\n==== ${pass}/${results.length} PASS ====`);
  435. fs.writeFileSync("/tmp/e2e/results.json", JSON.stringify({ live, results, restored }, null, 2));
  436. process.exit(pass === results.length ? 0 : 1);
  437. }
  438. main().catch((e) => { console.error("FATAL", e); process.exit(1); });