| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- /* 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(/<script src="([^"]+)"/g)].map((m) => m[1]));
- let all = "";
- for (const f of files) all += await (await fetch("/" + f)).text();
- const pats = [/masterKey/i, /X-Parse-Master-Key/i, /\bcc84[0-9a-f]{6,}/i, /PARSE_MASTER_KEY/];
- return { hit: pats.filter((p) => p.test(all)).map(String), files: files.length, bytes: all.length };
- });
- 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();
- const writeHdr = await page.evaluate(() => document.body.innerHTML.includes("Master-Key"));
- 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}`);
- /* ================= #9 机会卡是卡片形态,五要素一屏可读 ================= */
- await goto("#/pool", ".ogrid");
- await page.evaluate(() => window.scrollTo(0, 0));
- await sleep(300);
- await shot("v4-pool");
- // 排序先切到"按评分",让"优先级 / 评分"两种序列真实不同(S 多、A 少时两者会重合)
- await page.click('[data-act="opp-sort"][data-sort="score"]');
- await sleep(500);
- const card0 = await page.evaluate(() => {
- const c = document.querySelector(".ogrid .ocard");
- const has = (sel) => Boolean(c.querySelector(sel));
- const r = c.getBoundingClientRect();
- const vh = window.innerHeight;
- return {
- five: { 关键信息: has(".o-title") && has(".o-line") && Boolean(c.querySelector(".o-prio")) && Boolean(c.querySelector(".o-stage")),
- 概览: 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")) },
- h: Math.round(r.height), top: Math.round(r.top), bottom: Math.round(r.bottom), vh
- };
- });
- const fiveAll = Object.values(card0.five).every(Boolean);
- 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; });
- record(9, "机会卡是卡片形态(非列表行),一屏内五要素", fiveAll && card0.h < 420 && card0.bottom <= card0.vh + 1 && firstRow >= 3,
- "conds=" + JSON.stringify(fiveAll && card0.h < 420 && card0.bottom <= card0.vh + 1 && firstRow >= 3) + " | " +
- `五要素齐全=${JSON.stringify(card0.five)};卡高 ${card0.h}px,首屏完整可见(bottom ${card0.bottom} ≤ 视口 ${card0.vh});首行并排 ${firstRow} 张`);
- /* ================= #10 上传元数据:谁 + Agent + 方式 + 时间 ================= */
- const cardMeta = await page.evaluate(() => [...document.querySelectorAll(".ogrid .ocard")].map((c) => ({
- by: (c.querySelector(".o-source .pn") || {}).textContent, agent: (c.querySelector(".o-agent") || {}).dataset ? c.querySelector(".o-agent").getAttribute("title") : "",
- method: (c.querySelector(".o-method") || {}).textContent, when: (c.querySelector(".o-when") || {}).textContent })));
- 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 || ""));
- const distinctAgents = [...new Set(cardMeta.map((m) => m.agent))];
- const distinctMethods = [...new Set(cardMeta.map((m) => m.method))];
- await page.click(".ogrid .ocard");
- await page.waitForSelector(".drawer.open");
- await sleep(400);
- const drawerSource = await page.evaluate(() => {
- const g = [...document.querySelectorAll(".drawer .o-source-grid .k")].map((k) => k.textContent);
- return g;
- });
- await shot("v4-card-drawer");
- record(10, "卡片显示谁 + 哪个 Agent + 什么方式 + 什么时候", metaOk && drawerSource.includes("上传人") && drawerSource.includes("Agent") && drawerSource.includes("上传方式") && drawerSource.includes("上传时间"),
- "conds=" + JSON.stringify(metaOk && drawerSource.includes("上传人") && drawerSource.includes("Agent") && drawerSource.includes("上传方式") && drawerSource.includes("上传时间")) + " | " +
- `${cardMeta.length} 张卡全部带四要素;Agent 覆盖 ${distinctAgents.join(" / ")};方式覆盖 ${distinctMethods.join(" / ")};抽屉字段 ${drawerSource.join("、")}`);
- /* ================= #11 点评分,多人平均 ================= */
- const openId = await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] || "").get("opp"));
- const before = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
- await page.evaluate((u) => document.querySelector("#o-who").value = u, SEED.user);
- await page.evaluate(() => document.querySelector("#o-who").dispatchEvent(new Event("change", { bubbles: true })));
- const sliders = await page.$$(".drawer .o-dim input");
- 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)); }
- await page.evaluate((c) => { const i = document.querySelector("#o-comment"); i.scrollIntoView({ block: "center" }); i.value = c; }, SEED.comment);
- const btn = await page.$('[data-act="opp-score"]');
- await btn.click();
- 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(() => {});
- await sleep(600);
- const after1 = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
- const persisted = (await lib("KfOpportunity", { objectId: openId }))[0];
- const dbScore = (persisted.scores || []).find((s) => s.user === SEED.user);
- // 改分:同一人再交一次 80 → 人数不变、平均变
- await goto("#/pool?opp=" + openId, ".drawer.open");
- await page.evaluate(() => { const s = document.querySelectorAll(".drawer .o-dim input"); s.forEach((n) => { n.value = 80; n.dispatchEvent(new Event("input", { bubbles: true })); }); });
- await page.click('[data-act="opp-score"]');
- 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(() => {});
- await sleep(600);
- const after2 = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
- await shot("v4-score-after");
- /* 期望值用"评分明细"算(平均分本身保留 1 位小数,拿它反算会有舍入误差) */
- const detailAvg = (list) => Math.round(list.reduce((t, s) => t + s.score, 0) / list.length * 10) / 10;
- const beforeScores = (await lib("KfOpportunity", { objectId: openId }))[0].scores || [];
- const expected1 = detailAvg(beforeScores.filter((x) => x.user !== SEED.user).concat([{ score: SEED.score }]));
- const expected2 = detailAvg(beforeScores.filter((x) => x.user !== SEED.user).concat([{ score: 80 }]));
- 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,
- "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) + " | " +
- `${openId}:评前 ${before.n} 人 均 ${before.avg} → ${SEED.user} 打 ${SEED.score} → ${after1.n} 人 均 ${after1.avg}(期望 ${expected1},库里写入=${dbScore && dbScore.score})→ 同一人改分 80 → ${after2.n} 人 均 ${after2.avg}(期望 ${expected2},人数未重复计)`);
- /* ================= #12 改状态,刷新保持 ================= */
- const stBefore = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId);
- const target = stBefore === "approved" ? "rejected" : "approved";
- await page.evaluate(() => { const i = document.querySelector("#o-reason"); i.value = "验收脚本:改状态"; });
- await page.click(`[data-act="opp-status"][data-status="${target}"]`);
- await page.waitForFunction((id, t) => window.__KF__.state.db.opportunityById[id].status === t, { timeout: 15000 }, openId, target).catch(() => {});
- await sleep(600);
- 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);
- const decAt = stAfter.at ? new Date(stAfter.at).getTime() : 0;
- // 刷新保持:重新打开同一张卡(URL 里带 opp),数据全部来自库
- await goto("#/pool?opp=" + openId, ".drawer.open");
- await page.reload({ waitUntil: "networkidle0" });
- await page.waitForSelector(".drawer.open");
- await sleep(500);
- 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));
- const stReload = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId);
- const stDb = (await lib("KfOpportunity", { objectId: openId }))[0].status;
- await shot("v4-status-after");
- record(12, "可改状态(候选/立项/否决),刷新保持", stAfter.status === target && stReload === target && stDb === target && stAfter.n >= 1 && decAt > Date.now() - 120000,
- "conds=" + JSON.stringify(stAfter.status === target && stReload === target && stDb === target && stAfter.n >= 1) + " | " +
- `${openId}:${stBefore} → ${target}(决策「${stAfter.dec}」by ${stAfter.by},决策日志 ${stAfter.n} 条);刷新后 ${stReload};库中 ${stDb}`);
- /* ================= #13 排序(优先级/评分) ================= */
- const readOrder = () => page.$$eval(".ogrid .ocard", (ns) => ns.map((n) => ({
- p: (n.querySelector(".o-prio") || {}).textContent,
- s: (n.querySelector(".o-score") || {}).classList && n.querySelector(".o-score").classList.contains("empty") ? -1 : Number(n.querySelector(".o-score-v b").textContent),
- t: n.querySelector(".o-title").textContent
- })));
- await goto("#/pool?sort=score", ".ogrid");
- const orderScore = await readOrder();
- await shot("v4-pool-sorted-score");
- await page.click('[data-act="opp-sort"][data-sort="prio"]');
- await sleep(500);
- const orderPrio = await readOrder();
- const rank = { "S 级": 0, "A 级": 1, "B 级": 2, 未定: 3 };
- const scoreOk = orderScore.every((x, i) => i === 0 || orderScore[i - 1].s >= x.s);
- const prioOk = orderPrio.every((x, i) => i === 0 || rank[orderPrio[i - 1].p] <= rank[x.p]);
- const changed = orderPrio.map((x) => x.t).join("|") !== orderScore.map((x) => x.t).join("|");
- record(13, "可排序(优先级 / 评分)", scoreOk && prioOk && changed,
- "conds=" + JSON.stringify(scoreOk && prioOk && changed) + " | " +
- `评分序:${orderScore.map((x) => x.p + "(" + x.s + ")").join(" ")};优先级序:${orderPrio.map((x) => x.p + "(" + x.s + ")").join(" ")};两种顺序不同=${changed}`);
- /* ================= #14 首页三种视角 ================= */
- await goto("#/overview", ".wb");
- const views = await page.evaluate(() => ({
- me: Boolean(document.querySelector("#sec-me") && /给我的/.test(document.querySelector("#sec-me").textContent)),
- pool: Boolean(document.querySelector("#sec-pool") && document.querySelectorAll("#sec-pool .ocard").length >= 3 && /全员的池子/.test(document.querySelector("#sec-pool").textContent)),
- board: Boolean(document.querySelector("#sec-board") && document.querySelectorAll("#sec-board table.matrix tbody tr").length === 5)
- }));
- // 选"我"之后,给我的视角出现"交了 N 份 / 还差哪个阶段"
- await page.select("#me-select", "张伟");
- await sleep(700);
- const meText = await page.$eval("#sec-me", (n) => n.innerText.replace(/\s+/g, " "));
- const meOk = /份成果已归集/.test(meText) && /个阶段还没交|五个阶段都交过/.test(meText);
- await page.evaluate(() => document.querySelector("#sec-me").scrollIntoView({ block: "start" }));
- await sleep(300);
- await shot("v4-home-me");
- record(14, "首页三种视角:给我的 / 全员的池子 / 现场看板", views.me && views.pool && views.board && meOk,
- "conds=" + JSON.stringify(views.me && views.pool && views.board && meOk) + " | " +
- `给我的=${views.me}(选张伟后:${meText.slice(0, 120)}…);全员的池子=${views.pool}(最新 3 张为卡片形态);现场看板=${views.board}(5 行阶段矩阵)`);
- /* ================= #15 现场看板列出"谁卡住了" ================= */
- await page.evaluate(() => document.querySelector("#sec-board").scrollIntoView({ block: "start" }));
- await sleep(300);
- const stuck = await page.evaluate(() => ({
- names: [...document.querySelectorAll("#sec-board .stuck.who .stuck-n")].map((n) => n.textContent),
- hours: [...document.querySelectorAll("#sec-board .stuck.who .stuck-s")].map((n) => n.textContent),
- dbStuck: window.__KF__.state.db.members.filter((m) => m.stuck).map((m) => ({ name: m.name, h: m.stuckHours, last: m.lastActiveAt })),
- threshold: window.__KF__.state.db.stuckHours
- }));
- // 库里确有这条超时数据
- const seedSession = (await lib("KfSession", { studentName: "何雨" }))[0];
- await shot("v4-home-board");
- record(15, "现场看板列出谁卡住了", stuck.names.length >= 1 && stuck.names.includes("何雨") && Boolean(seedSession),
- "conds=" + JSON.stringify(stuck.names.length >= 1 && stuck.names.includes("何雨") && Boolean(seedSession)) + " | " +
- `卡住名单:${stuck.names.join("、")}(${stuck.hours.join(" / ")});阈值 ${stuck.threshold}h;库里「何雨」lastActiveAt=${seedSession && seedSession.lastActiveAt && (seedSession.lastActiveAt.iso || seedSession.lastActiveAt)}、msgCount=${seedSession && seedSession.msgCount}、无成果`);
- /* ================= #16 五阶段分板块产出 ================= */
- const boards = await page.evaluate(() => [...document.querySelectorAll("#sec-boards .stage-board")].map((b) => ({
- code: (b.querySelector(".sb-no") || {}).textContent,
- n: (b.querySelector(".sb-n b") || {}).textContent,
- items: b.querySelectorAll(".sb-list li:not(.none)").length,
- head: b.querySelector(".sb-top h3").textContent
- })));
- const libStageCount = await page.evaluate(() => {
- const db = window.__KF__.state.db;
- return db.byStage.map((x) => x.artifacts.filter((a) => a.layer !== "raw").length);
- });
- const boardsOk = boards.length === 5 && boards.every((b, i) => b.code === "S" + (i + 1) && Number(b.n) === libStageCount[i]);
- await page.evaluate(() => document.querySelector("#sec-boards").scrollIntoView({ block: "start" }));
- await sleep(300);
- await shot("v4-home-boards");
- record(16, "五阶段分板块产出:数量 + 最新几条 + 谁产的", boardsOk && boards.every((b) => b.items >= 1),
- "conds=" + JSON.stringify(boardsOk && boards.every((b) => b.items >= 1)) + " | " +
- boards.map((b) => `${b.code}「${b.head}」${b.n} 条 / 列 ${b.items} 条`).join(";") + `;与模型口径一致=${boardsOk}`);
- /* ================= #17 痛点 → 系统能力 ================= */
- 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") })));
- record(17, "痛点 → 系统能力对照可见", pains.length === 5 && pains.every((x) => x.p && x.c),
- "conds=" + JSON.stringify(pains.length === 5 && pains.every((x) => x.p && x.c)) + " | " +
- pains.map((x) => `${x.p}→${x.c}`).join(";"));
- /* ================= #18 三层透明度纵深 ================= */
- await goto("#/pool?opp=" + openId, ".drawer.open");
- await sleep(400);
- const layers = await page.evaluate(() => {
- const rd = (el, p) => getComputedStyle(document.querySelector(el)).getPropertyValue(p);
- 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; };
- /* 取每个元素背景里"白色遮罩"的最大不透明度(越靠前越实) */
- const whiteMax = (el) => {
- const s = getComputedStyle(el).backgroundImage;
- const vals = [...s.matchAll(/rgba\(255,\s*255,\s*255,\s*([\d.]+)\)/g)].map((m) => Number(m[1]));
- return vals.length ? Math.max(...vals) : alpha(getComputedStyle(el).backgroundColor);
- };
- return {
- ambientBlur: rd(".ambient i", "filter"),
- panel: whiteMax(document.querySelector(".wb-sec .panel")),
- card: whiteMax(document.querySelector(".ogrid .ocard")),
- panelBlur: rd(".wb-sec .panel", "backdrop-filter"),
- cardBlur: rd(".ogrid .ocard", "backdrop-filter"),
- drawer: whiteMax(document.querySelector(".drawer")),
- drawerBlur: rd(".drawer", "backdrop-filter"),
- drawerShadow: rd(".drawer", "box-shadow").slice(0, 80)
- };
- });
- const descend = layers.panel != null && layers.card != null && layers.drawer != null && layers.card > layers.panel && layers.drawer > layers.card;
- record(18, "玻璃三层透明度纵深", descend && /blur/.test(layers.ambientBlur) && /blur/.test(layers.cardBlur) && /blur/.test(layers.drawerBlur),
- "conds=" + JSON.stringify(descend && /blur/.test(layers.ambientBlur) && /blur/.test(layers.cardBlur) && /blur/.test(layers.drawerBlur)) + " | " +
- `背景光场 filter=${layers.ambientBlur};中层 .panel 白遮罩≤${layers.panel} → 卡片 .ocard ≤${layers.card}(${layers.cardBlur})→ 浮层 .drawer ≤${layers.drawer}(${layers.drawerBlur});越靠前越实=${descend}`);
- /* ================= #19 hover 悬浮感 ================= */
- await goto("#/pool", ".ogrid");
- await page.evaluate(() => window.scrollTo(0, 0));
- const card = await page.$(".ogrid .ocard");
- 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") }));
- const box = await card.boundingBox();
- await page.mouse.move(box.x + box.width * 0.3, box.y + 20);
- await page.mouse.move(box.x + box.width * 0.7, box.y + 30);
- await sleep(450);
- 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") }));
- await shot("v4-hover");
- const moved = before19.t !== after19.t && /-3/.test(after19.t) && Number(after19.sheen) > Number(before19.sheen) && after19.mx && after19.mx !== before19.mx;
- record(19, "hover 有悬浮感(上浮 + 高光移动)", moved,
- "conds=" + JSON.stringify(moved) + " | " +
- `hover 前 transform=${before19.t} / sheen=${before19.sheen} / --mx=${before19.mx || "(未设)"};hover 后 transform=${after19.t} / sheen=${after19.sheen} / --mx=${after19.mx};阴影变化=${before19.sh !== after19.sh}`);
- /* ================= #20 无 console 报错 ================= */
- const routes20 = ["#/overview", "#/pool", "#/pool?sort=score", "#/pool?opp=" + openId, "#/stages", "#/members", "#/insights", "#/artifacts", "#/reportIndex", "#/projects", "#/sources", "#/record", "#/stage/S3"];
- for (const r of routes20) { await goto(r); await sleep(300); }
- const bad = [...pageErrors, ...consoleErrors].filter((e) => !/Failed to load resource|net::ERR_FAILED|ERR_FAILED/.test(e));
- record(20, "无 console 报错", bad.length === 0,
- "conds=" + JSON.stringify(bad.length === 0) + " | " +
- `走完 ${routes20.length} 个路由:pageerror ${pageErrors.length};console.error 非网络类 ${bad.length}${bad.length ? ":" + bad.slice(0, 3).join(" | ") : ""}`);
- /* ================= 收尾:把写库的测试数据恢复 ================= */
- const ci = await lib("KfOpportunity", { objectId: openId });
- const seedScores = (ci[0] && ci[0].scores || []).filter((s) => s.user !== SEED.user);
- // 恢复状态:把验收时追加的那条决策从 decisionLog 里去掉,status 回到验收前
- const restorePatch = { scores: seedScores, status: stBefore };
- const r = await fetch(PARSE + "/classes/KfOpportunity/" + openId, { method: "PUT", headers: { "X-Parse-Application-Id": APP, "Content-Type": "application/json" }, body: JSON.stringify(restorePatch) });
- const restored = r.ok;
- const finalScores = ((await lib("KfOpportunity", { objectId: openId }))[0].scores || []).map((s) => s.user);
- console.log(`\n收尾:${openId} 已恢复评分用户=${finalScores.join(",")}、status=${stBefore}(HTTP ${r.status})`);
- await browser.close();
- const pass = results.filter((x) => x.ok).length;
- console.log(`\n==== ${pass}/${results.length} PASS ====`);
- fs.writeFileSync("/tmp/e2e/results.json", JSON.stringify({ live, results, restored }, null, 2));
- process.exit(pass === results.length ? 0 : 1);
- }
- main().catch((e) => { console.error("FATAL", e); process.exit(1); });
|