// 逐页「功能审计」驱动 —— 区别于 visual-parity(只比像素),这里比「行为」。 // // 它把每个页面在真实 React 路由里打开,枚举所有可交互元素(按钮 / 标签 tab / // 切换 toggle / 下拉 chip / 可点卡片 …),逐个真实点击,用 MutationObserver + // URL + 浮层 + 网络请求 + 自身状态(class/aria/checked)五路探针判断「点完到底有没有 // 反应」。没反应的 = DEAD(功能没接 / 假按钮)。再拿设计稿 /exact/*.html 的控件清单 // 对一遍,报出 React 里「压根没渲染出来」的 MISSING 控件。 // // 用法(先起前后端 :5173 / :8010): // cd AirShelf/core/qa/function-audit && npm install // node audit.mjs # 全量,逐元素 reload 隔离(最准) // node audit.mjs --mode quick # 不 reload,快但有级联噪声 // node audit.mjs --only pipeline,account # 只跑指定页 // node audit.mjs --include-pointer # 额外把 cursor:pointer 的 div 也当候选(查隐藏死交互) // node audit.mjs --headed # 看着浏览器跑 // // 产物:output/.audit.json + output/.audit.md + output/summary.md import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, "../../.."); function arg(name, fallback = "") { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : fallback; } function boolArg(name) { return process.argv.includes(`--${name}`); } const BASE = arg("base", "http://127.0.0.1:5173").replace(/\/+$/, ""); const EMAIL = arg("email", "e2e-20260529-0806@airshelf.test"); const PASSWORD = arg("password", "demo12345"); let TOKEN = arg("token", ""); const MODE = arg("mode", "isolated"); // isolated | quick const ONLY = arg("only", "").split(",").map((s) => s.trim()).filter(Boolean); const INCLUDE_POINTER = boolArg("include-pointer"); const HEADED = boolArg("headed"); const SETTLE = Number.parseInt(arg("settle", "700"), 10); // 点击后观察窗口 ms const outDir = path.resolve(here, "output"); fs.mkdirSync(outDir, { recursive: true }); // 只跳「单击即不可逆」的真实付费/真生成/真导出 + 弹窗里的最终确认按钮。 // 触发型(删除/移除/下线/充值/退出登录 = 点了只是开二次确认弹窗或跳转)不在此列,照常点验。 const DESTRUCTIVE = /(微信支付|支付宝|立即支付|去支付|确认支付|确认充值|立即生成|生成脚本|生成基础资产|生成故事板|生成分镜|提交片段|提交视频|提交生成|提交导出|重新导出|导出\s*MP4|确认删除|确认移除|确认退出|确认下线)/i; async function login() { if (TOKEN) return TOKEN; const res = await fetch(`${BASE}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: EMAIL, password: PASSWORD }) }); if (!res.ok) throw new Error(`登录失败 ${res.status}:${await res.text()}\n请确认后端 :8010 在跑、演示账号正确。`); const data = await res.json(); return data.token; } async function fetchIds() { const headers = { Authorization: `Token ${TOKEN}` }; const out = { productId: "", projectId: "" }; try { const p = await (await fetch(`${BASE}/api/products/?page_size=1`, { headers })).json(); out.productId = (p.results || p)[0]?.id || ""; } catch {} try { const j = await (await fetch(`${BASE}/api/projects/?page_size=1`, { headers })).json(); out.projectId = (j.results || j)[0]?.id || ""; } catch {} return out; } // ── 注入页面的工具函数(收集 + 探针),作为字符串在 page.evaluate 里复用 ── const PAGE_HELPERS = ` window.__audit = window.__audit || {}; // 强候选:语义上就该可交互的元素,无条件纳入 window.__audit.SEL_STRONG = [ 'button','[role=button]','[role=tab]','[role=switch]','[role=menuitem]','[role=option]','[role=checkbox]', 'a[href]','input[type=checkbox]','input[type=radio]','select','summary','[onclick]' ].join(','); // 弱候选:靠类名猜的,必须 cursor:pointer 才算(滤掉装饰性状态徽章 .pill「通过」等) window.__audit.SEL_WEAK = [ '.tab','.pill','.chip','.seg','.stage-pill','.stage-step','.view-tog > *', '.switch','.toggle','.sidebar-toggle','.nav-item','.shortcut','.qa-item','.mi' ].join(','); window.__audit.isVisible = function(el){ const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) return false; const s = getComputedStyle(el); if (s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0) return false; // 移出视口(translateX 隐藏的抽屉/侧拉、关闭的浮层)不算可见 —— 避免点到「隐藏但已渲染」的死控件 const vw = window.innerWidth, vh = window.innerHeight; if (r.right <= 0 || r.left >= vw || r.bottom <= 0 || r.top >= vh) return false; return true; }; // 容器去重:若元素自身不是强交互元素,但内部已含强交互后代(如 .chip-wrap 里有 .chip 按钮), // 则它是包装容器,不当候选(真控件是里面那个)。 window.__audit.isWrapper = function(el){ if (el.matches(window.__audit.SEL_STRONG)) return false; return !!el.querySelector(window.__audit.SEL_STRONG); }; window.__audit.label = function(el){ let t = (el.getAttribute('aria-label') || el.title || el.value || el.textContent || '').replace(/\\s+/g,' ').trim(); if (!t) t = el.getAttribute('data-key') || el.getAttribute('data-value') || el.getAttribute('data-filter') || ''; return t.slice(0,48); }; // 确定性枚举(文档序),去重,过滤可见;同时给每个打 data-audit-idx 方便点击定位 window.__audit.collect = function(includePointer){ const strong = Array.from(document.querySelectorAll(window.__audit.SEL_STRONG)); // 弱候选:必须 cursor:pointer;且排除纯展示 徽章(无 role/tabindex/onclick,如状态 pill「reserved」) const weak = Array.from(document.querySelectorAll(window.__audit.SEL_WEAK)) .filter(el => getComputedStyle(el).cursor === 'pointer') .filter(el => !(el.tagName === 'SPAN' && !el.getAttribute('role') && !el.hasAttribute('tabindex') && !el.hasAttribute('onclick'))); let nodes = strong.concat(weak); if (includePointer){ const extra = Array.from(document.querySelectorAll('div,li,span,article,section')).filter(el=>{ if (getComputedStyle(el).cursor!=='pointer') return false; return !el.querySelector(window.__audit.SEL_STRONG); // 只取叶子可点 }); nodes = nodes.concat(extra); } // 文档序排序 + 去重 nodes = Array.from(new Set(nodes)).sort((a,b)=>{ const p = a.compareDocumentPosition(b); if (p & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (p & Node.DOCUMENT_POSITION_PRECEDING) return 1; return 0; }); const seen = new Set(); const ordered = []; for (const el of nodes){ if (seen.has(el)) continue; seen.add(el); if (!window.__audit.isVisible(el)) continue; if (window.__audit.isWrapper(el)) continue; // 跳过包装容器(真控件在其内部) ordered.push(el); } return ordered.map((el,i)=>{ el.setAttribute('data-audit-idx', String(i)); const disabled = el.disabled || el.getAttribute('aria-disabled')==='true' || el.classList.contains('is-disabled') || el.classList.contains('disabled'); const active = el.classList.contains('active') || el.classList.contains('is-active') || el.classList.contains('selected') || el.getAttribute('aria-selected')==='true' || el.getAttribute('aria-checked')==='true' || el.checked===true; return { idx:i, tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', cls: (el.className && typeof el.className==='string') ? el.className.split(/\\s+/).slice(0,3).join('.') : '', label: window.__audit.label(el), href: el.getAttribute('href') || '', disabled: !!disabled, active: !!active }; }); }; // 装探针:记录基线 + 启动 MutationObserver,然后点击目标 window.__audit.arm = function(idx){ const el = document.querySelector('[data-audit-idx="'+idx+'"]'); if (!el) return { ok:false, reason:'not-found' }; const overlaySel = '.modal,.drawer,[role=dialog],.toast,.chip-menu,.dropdown,.menu,.popover,[data-open=true]'; const visOverlays = ()=> Array.from(document.querySelectorAll(overlaySel)).filter(window.__audit.isVisible).length; window.__audit.base = { url: location.href, overlays: visOverlays, selfSig: el.className+'|'+el.getAttribute('aria-expanded')+'|'+el.getAttribute('aria-selected')+'|'+el.getAttribute('aria-checked')+'|'+(el.checked??''), overlaysN: visOverlays() }; window.__audit.mut = 0; // 探测「触发文件选择框」:按钮 onClick 里调用隐藏 input[type=file].click() 会弹原生对话框, // DOM 无变化 → 否则会被误判 dead。这里 hook 一下,捕获到即算「有反应」。 window.__audit.filePick = 0; if (!window.__audit._origInputClick) { window.__audit._origInputClick = HTMLInputElement.prototype.click; HTMLInputElement.prototype.click = function () { if (this.type === 'file') window.__audit.filePick = (window.__audit.filePick || 0) + 1; return window.__audit._origInputClick.apply(this, arguments); }; } window.__audit.obs && window.__audit.obs.disconnect(); window.__audit.obs = new MutationObserver(muts=>{ window.__audit.mut += muts.length; }); window.__audit.obs.observe(document.body, { subtree:true, childList:true, attributes:true, characterData:true }); window.__audit.el = el; try { el.click(); } catch(e){ return { ok:false, reason:'click-threw:'+e.message }; } return { ok:true }; }; // 读差量 window.__audit.read = function(){ const b = window.__audit.base; const el = window.__audit.el; window.__audit.obs && window.__audit.obs.disconnect(); const overlaySel = '.modal,.drawer,[role=dialog],.toast,.chip-menu,.dropdown,.menu,.popover,[data-open=true]'; const overlaysNow = Array.from(document.querySelectorAll(overlaySel)).filter(window.__audit.isVisible).length; const selfSigNow = el ? (el.className+'|'+el.getAttribute('aria-expanded')+'|'+el.getAttribute('aria-selected')+'|'+el.getAttribute('aria-checked')+'|'+(el.checked??'')) : ''; return { urlChanged: location.href !== b.url, overlayChanged: overlaysNow !== b.overlaysN, selfChanged: el ? (selfSigNow !== b.selfSig) : false, mutations: window.__audit.mut, filePick: window.__audit.filePick || 0, isSelect: el ? el.tagName === 'SELECT' : false }; }; // 设计稿/任意页的控件文字清单(用于 MISSING 对比) window.__audit.labels = function(){ const strong = Array.from(document.querySelectorAll(window.__audit.SEL_STRONG)); const weak = Array.from(document.querySelectorAll(window.__audit.SEL_WEAK)).filter(el => getComputedStyle(el).cursor === 'pointer'); return strong.concat(weak) .filter(window.__audit.isVisible) .filter(el => !window.__audit.isWrapper(el)) .map(window.__audit.label) .filter(Boolean); }; `; // 就绪门:等到 app 外壳(aside.sidebar)出现。若落到登录页(.auth-wrap)= boot 时 // api.me() 挂了(远程库抖动 / 单线程 Django 被高频 reload 打爆),返回 not-ready。 async function waitForReady(page, timeout = 8000) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const state = await page.evaluate(() => { if (document.querySelector(".auth-wrap")) return "auth"; if (document.querySelector("aside.sidebar")) return "ready"; return "pending"; }).catch(() => "pending"); if (state === "ready") return true; if (state === "auth") return false; await page.waitForTimeout(200); } return false; } // 进目标路由并确保 app 真的加载出来;落登录页就重注 token 重试(治瞬时 boot 失败)。 async function gotoWithToken(page, url, tries = 3) { for (let attempt = 1; attempt <= tries; attempt++) { await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }).catch(() => {}); await page.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN).catch(() => {}); await page.goto(url, { waitUntil: "networkidle" }).catch(() => {}); if (await waitForReady(page)) { await page.addStyleTag({ content: `*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important}` }).catch(() => {}); return true; } await page.waitForTimeout(600 * attempt); // 退避后重试,给后端喘息 } return false; // 仍未就绪 = 页面 blocked(后端/数据不可用),由调用方标记 } const norm = (s) => s.replace(/\s+/g, "").replace(/[0-9]+/g, "").toLowerCase(); async function collectDesignLabels(context, exactName) { const page = await context.newPage(); try { // 设计稿镜像:宽松导航即可(shell.js 注入侧栏),不走严格就绪门 / 重试 await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }).catch(() => {}); await page.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN).catch(() => {}); await page.goto(`${BASE}/exact/${exactName}`, { waitUntil: "networkidle" }).catch(() => {}); await page.waitForTimeout(1200); await page.evaluate(PAGE_HELPERS); return await page.evaluate(() => window.__audit.labels()); } catch { return []; } finally { await page.close(); } } async function auditPage(context, item, ids) { const route = item.route .replace(":productId", ids.productId || "x") .replace(":projectId", ids.projectId || "x"); const url = `${BASE}${route}`; const page = await context.newPage(); // 探针:网络命中(node 侧带时间戳)/ 真·JS 异常(pageerror → error 判定)/ console.error(仅备注) const apiHits = []; const pageErrors = []; // 真正抛出的 JS 异常 —— 才算 error const consoleErrs = []; // console.error(含 React 警告 / 资源 404)—— 只做备注,不改判定 page.on("request", (r) => { if (r.url().includes("/api/")) apiHits.push({ t: Date.now(), u: r.url() }); }); page.on("console", (m) => { if (m.type() === "error") consoleErrs.push({ t: Date.now(), m: m.text().slice(0, 200) }); }); page.on("pageerror", (e) => pageErrors.push({ t: Date.now(), m: String(e).slice(0, 200) })); const ensureFresh = async () => { const ready = await gotoWithToken(page, url); if (!ready) return null; await page.evaluate(PAGE_HELPERS); return page.evaluate((ip) => window.__audit.collect(ip), INCLUDE_POINTER); }; const recollect = async () => { await page.evaluate(PAGE_HELPERS).catch(() => {}); return page.evaluate((ip) => window.__audit.collect(ip), INCLUDE_POINTER).catch(() => null); }; let inventory = await ensureFresh(); if (!inventory) { // 页面始终落登录页/未就绪 —— 后端/数据不可用,标 blocked,绝不谎报 0 dead await page.close(); return { name: item.name, route, url, mode: MODE, blocked: true, tally: { total: 0, works: 0, dead: 0, error: 0, disabled: 0, skipped: 0, noop: 0, blocked: 1 }, results: [], missing: [] }; } const total = inventory.length; const results = []; for (let i = 0; i < total; i++) { // 复位取基线:isolated 每个元素都 reload(确定性枚举,索引稳,不漏控件); // quick 不 reload,仅在漂出本路由时才 reload(快但有级联漂移)。 if (MODE === "isolated") { const fresh = await ensureFresh(); if (!fresh) { results.push({ idx: i, label: inventory[i]?.label || "", tag: "", verdict: "blocked" }); continue; } inventory = fresh; } else { const cur = await page.evaluate(() => location.href).catch(() => ""); if (!(cur.startsWith(url) || cur.includes(route))) { const fresh = await ensureFresh(); if (!fresh) { results.push({ idx: i, label: "", tag: "", verdict: "blocked" }); continue; } inventory = fresh; } else { const re = await recollect(); if (re) inventory = re; } } const meta = inventory[i]; if (!meta) { results.push({ idx: i, label: "", tag: "", verdict: "stale" }); continue; } if (meta.disabled) { results.push({ ...meta, verdict: "disabled" }); continue; } if (DESTRUCTIVE.test(meta.label)) { results.push({ ...meta, verdict: "skipped-destructive" }); continue; } const tBefore = Date.now(); const armed = await page.evaluate((idx) => window.__audit.arm(idx), meta.idx); if (!armed.ok) { results.push({ ...meta, verdict: "stale", detail: armed.reason }); continue; } await page.waitForTimeout(SETTLE); let delta; try { delta = await page.evaluate(() => window.__audit.read()); } catch { delta = { urlChanged: true, overlayChanged: false, selfChanged: false, mutations: 0 }; // 上下文销毁=跳转=有反应 } const net = apiHits.filter((h) => h.t >= tBefore).length; const thrown = pageErrors.filter((e) => e.t >= tBefore).map((e) => e.m); const cerr = consoleErrs.filter((e) => e.t >= tBefore).map((e) => e.m); // 文件选择框(原生对话框)/ 原生 select(原生下拉)点击 DOM 无变化但确属可用控件,算 works const reacted = delta.urlChanged || delta.overlayChanged || delta.selfChanged || net > 0 || delta.mutations >= 1 || delta.filePick > 0 || delta.isSelect; // 点「当前已选中」的 tab/chip 本就该无反应 —— 记 noop-active,不算缺陷 let verdict = thrown.length ? "error" : reacted ? "works" : (meta.active ? "noop-active" : "dead"); results.push({ ...meta, verdict, signals: { url: delta.urlChanged, overlay: delta.overlayChanged, self: delta.selfChanged, mutations: delta.mutations, api: net }, ...(thrown.length ? { errors: thrown } : {}), ...(cerr.length ? { consoleNote: cerr.slice(0, 2) } : {}) }); if (MODE === "quick") await page.keyboard.press("Escape").catch(() => {}); } // MISSING:设计稿有、React 没渲染出来的控件(按文字模糊匹配,启发式) const designLabels = await collectDesignLabels(context, item.exact); const implSet = new Set(inventory.map((m) => norm(m.label)).filter(Boolean)); const missing = []; const seenD = new Set(); for (const dl of designLabels) { const n = norm(dl); if (!n || n.length < 2 || seenD.has(n)) continue; seenD.add(n); if (!implSet.has(n)) missing.push(dl.slice(0, 48)); } await page.close(); const tally = { total: results.length, works: 0, dead: 0, error: 0, disabled: 0, skipped: 0, noop: 0, blocked: 0 }; for (const r of results) { if (r.verdict === "works") tally.works++; else if (r.verdict === "dead") tally.dead++; else if (r.verdict === "error") tally.error++; else if (r.verdict === "disabled") tally.disabled++; else if (r.verdict === "noop-active") tally.noop++; else if (r.verdict === "blocked") tally.blocked++; else tally.skipped++; } return { name: item.name, route, url, mode: MODE, tally, results, missing }; } function writePageReport(rep) { fs.writeFileSync(path.join(outDir, `${rep.name}.audit.json`), JSON.stringify(rep, null, 2) + "\n"); const lines = []; lines.push(`# 功能审计 · ${rep.name}`); lines.push(""); if (rep.blocked) { lines.push(`> ⛔ **BLOCKED**:路由 \`${rep.route}\` 始终落到登录页/未就绪,后端或数据不可用,本页未审计。`); lines.push("> 先确认后端 :8010 健康(\`.venv/bin/python\`)+ 远程库可达,再单独 \`--only " + rep.name + "\` 复跑。"); fs.writeFileSync(path.join(outDir, `${rep.name}.audit.md`), lines.join("\n") + "\n"); return; } lines.push(`路由:\`${rep.route}\` · 模式:${rep.mode}`); lines.push(`合计 ${rep.tally.total} · ✅ works ${rep.tally.works} · ❌ **dead ${rep.tally.dead}** · 🛑 error ${rep.tally.error} · ⏭ skipped(破坏性)${rep.tally.skipped} · ◷ noop-active ${rep.tally.noop} · ⚪ disabled ${rep.tally.disabled}`); lines.push(""); const dead = rep.results.filter((r) => r.verdict === "dead"); if (dead.length) { lines.push("## ❌ 点了没反应(DEAD —— 重点修)"); lines.push("| # | 文字 | 标签 | 类名 |"); lines.push("|---|---|---|---|"); for (const r of dead) lines.push(`| ${r.idx} | ${r.label || "(空)"} | \`${r.tag}${r.role ? "/" + r.role : ""}\` | \`${r.cls}\` |`); lines.push(""); } const errs = rep.results.filter((r) => r.verdict === "error"); if (errs.length) { lines.push("## 🛑 点击报错(ERROR)"); for (const r of errs) lines.push(`- **${r.label}** \`${r.tag}\` — ${(r.errors || [r.detail]).join("; ")}`); lines.push(""); } if (rep.missing.length) { lines.push("## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)"); lines.push("> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。"); lines.push(""); for (const m of rep.missing) lines.push(`- ${m}`); lines.push(""); } const skip = rep.results.filter((r) => r.verdict === "skipped-destructive"); if (skip.length) { lines.push("## ⏭ 破坏性按钮(未自动点,需人工验)"); for (const r of skip) lines.push(`- ${r.label} \`${r.tag}\``); lines.push(""); } fs.writeFileSync(path.join(outDir, `${rep.name}.audit.md`), lines.join("\n") + "\n"); } (async () => { TOKEN = await login(); const ids = await fetchIds(); let manifest = JSON.parse(fs.readFileSync(path.join(here, "pages.json"), "utf8")); if (ONLY.length) manifest = manifest.filter((m) => ONLY.some((o) => m.name.includes(o))); const browser = await chromium.launch({ headless: !HEADED }); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" }); const summary = []; for (const item of manifest) { process.stdout.write(`\n[function-audit] ${item.name} … `); try { const rep = await auditPage(context, item, ids); writePageReport(rep); summary.push({ page: rep.name, blocked: !!rep.blocked, ...rep.tally, missing: rep.missing.length }); process.stdout.write(rep.blocked ? "⛔ BLOCKED(后端/数据不可用)" : `dead ${rep.tally.dead} / ${rep.tally.total}, missing ${rep.missing.length}`); } catch (e) { process.stdout.write(`FAILED ${e.message}`); summary.push({ page: item.name, total: 0, works: 0, dead: 0, error: 0, disabled: 0, skipped: 0, missing: 0, failed: e.message }); } } await browser.close(); // 汇总 const sl = ["# 功能审计汇总", "", `生成时间:${new Date().toISOString()} · 模式:${MODE}`, ""]; sl.push("| 页面 | 合计 | ✅works | ❌dead | 🛑error | ⏭skip | 🔍missing | 状态 |"); sl.push("|---|---|---|---|---|---|---|---|"); for (const s of summary) { const st = s.blocked ? "⛔BLOCKED" : (s.failed ? "⚠️FAILED" : ""); sl.push(`| ${s.page} | ${s.total} | ${s.works} | **${s.dead}** | ${s.error} | ${s.skipped} | ${s.missing} | ${st} |`); } sl.push(""); sl.push("- ⛔blocked = 页面始终落登录页/未就绪(后端或远程库不可用),**未审计**,非「0 缺陷」。修好后端再单独 `--only ` 复跑。"); sl.push("- ❌dead = 点了五路探针(URL/浮层/自身状态/网络/DOM)全无反应,优先修。"); sl.push("- 🔍missing = 设计稿 `/exact` 有、React 没渲染出来的控件(启发式,需人工过)。"); sl.push("- ⏭skip = 破坏性按钮(删除/充值/生成…)未自动点,需人工验。"); sl.push("- 单页明细见 `output/.audit.md`。"); fs.writeFileSync(path.join(outDir, "summary.md"), sl.join("\n") + "\n"); fs.writeFileSync(path.join(outDir, "summary.json"), JSON.stringify(summary, null, 2) + "\n"); console.log("\n\n[function-audit] summary"); console.table(summary.map((s) => ({ page: s.page, total: s.total, works: s.works, dead: s.dead, error: s.error, missing: s.missing }))); console.log(`\n报告:${path.relative(repoRoot, path.join(outDir, "summary.md"))}`); })();