All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
- qa/function-audit: playwright 行为审计工具(audit.mjs/verify-modals.mjs/pages.json) + 18 页审计产出(*.audit.md/json、summary、运行日志) - qa/visual-parity: 调试/测量辅助脚本(_dbg*.mjs/_measure.mjs/_off.mjs) - core/还原度核对报告.md: 18 页 pixelmatch 核对结果(含 vite 代理陈旧坑记录) - core/还原与接口待办.md: 逐页还原度/真实数据/交互接入待办总表 - .claude/skills/pixel-perfect-react: 像素级还原 React 的 SKILL 文档 - frontend/public/_devlogin.html: 临时本地登录辅助页(可删) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
480 lines
24 KiB
JavaScript
480 lines
24 KiB
JavaScript
// 逐页「功能审计」驱动 —— 区别于 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/<page>.audit.json + output/<page>.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;且排除纯展示 <span> 徽章(无 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 <page>` 复跑。");
|
||
sl.push("- ❌dead = 点了五路探针(URL/浮层/自身状态/网络/DOM)全无反应,优先修。");
|
||
sl.push("- 🔍missing = 设计稿 `/exact` 有、React 没渲染出来的控件(启发式,需人工过)。");
|
||
sl.push("- ⏭skip = 破坏性按钮(删除/充值/生成…)未自动点,需人工验。");
|
||
sl.push("- 单页明细见 `output/<page>.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"))}`);
|
||
})();
|