// AirShelf/core 性能验证闸 —— 逐页测「请求数 / 重复请求 / 加载耗时 / 功能是否还在」。 // 这是给 ralph-loop 当 pass/fail 闸用的:全绿 → 退出码 0;任一页超阈值/功能崩 → 退出码 1。 // // 核心信号(均与数据量无关,故确定性强、可当回归闸): // 1. 重复请求:一次页面加载里,同一个 (method + 路径 + query) 被打 ≥2 次 = 死罪(用户报的「连续重复请求」)。 // 2. 请求数:每页加载触发的 /api 请求总数超阈值 = 失败(架构性过量拉取)。 // 3. 功能守护:页面必须真渲染出来(侧边栏在 + 没掉回登录页 + 所有接口 <400),防止「为了变快把功能改崩」。 // 4. 耗时:仅作参考记录(随机器/网络抖动,不作硬闸)。 // // 用法(先起前后端 :5173 / :8010): // node perf-probe.mjs # 用默认演示账号登录跑全部页 // node perf-probe.mjs --token # 用已有 token(免登录) // node perf-probe.mjs --only dashboard,products // node perf-probe.mjs --headed # 看着跑 // node perf-probe.mjs --update-baseline # 把当前结果写成新基线(仅人工确认变更时用) import { chromium } from "playwright"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); function arg(name, def = "") { const i = process.argv.indexOf(`--${name}`); return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--") ? process.argv[i + 1] : def; } 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 ONLY = arg("only", "").split(",").map((s) => s.trim()).filter(Boolean); const HEADED = boolArg("headed"); const UPDATE_BASELINE = boolArg("update-baseline"); const QUIET_MS = Number.parseInt(arg("quiet", "2500"), 10); // 非轮询请求静默多久算首屏结束 const HARD_CAP = Number.parseInt(arg("cap", "25000"), 10); // 单页最长等待硬上限 ms(兜底慢后端) // 这些端点是合法的稳态轮询(进度/通知),窗口内重发不算「重复请求」死罪,但仍计入请求总数。 const POLLING = /\/api\/(ops\/notifications|ai\/tasks|projects\/[^/]+\/(poll-video|poll-export)|assets\/poll-reviews)/; const outDir = path.resolve(here, "output"); fs.mkdirSync(outDir, { recursive: true }); const baselinePath = path.resolve(here, "perf-baseline.json"); // ── 每页的请求数硬阈值 ────────────────────────────────────────────── // 注:App.tsx 启动会并行拉 ~10 个全局接口(products/projects/assets/billing/...)。 // 这些是「全局首屏」开销,记在 dashboard 头上。其余页若在已加载全局态后还重复拉同样数据 = 架构问题。 // 阈值是「当前现状 + 少量余量」起步;优化推进后人工调低,逼着请求数下降。 // 资产+非资产全局态改按页懒加载后的新基线(shell 全局 ~6 个 + 各页自取少量)+ 小余量,作回归护栏。 const REQUEST_BUDGET = { dashboard: 7, products: 6, projects: 6, library: 9, account: 9, team: 7, messages: 6, productDetail: 7, pipeline: 8, }; 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 在跑、演示账号正确,或用 --token。`); return (await res.json()).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; } // 规范化一个请求 → 去重 key(忽略缓存破坏参数,但保留真实 query 语义) function reqKey(method, url) { try { const u = new URL(url); u.searchParams.delete("_"); u.searchParams.delete("t"); const qs = [...u.searchParams.entries()].sort().map(([k, v]) => `${k}=${v}`).join("&"); return `${method} ${u.pathname}${qs ? "?" + qs : ""}`; } catch { return `${method} ${url}`; } } async function probePage(context, name, route, ids) { const url = `${BASE}${route}`; const page = await context.newPage(); const reqs = []; // { key, method, url, status, failed } const t0 = Date.now(); page.on("request", (r) => { const u = r.url(); if (!u.includes("/api/")) return; // 只关心后端接口,静态资源不计 reqs.push({ key: reqKey(r.method(), u), method: r.method(), url: u, status: null }); }); page.on("requestfinished", async (r) => { if (!r.url().includes("/api/")) return; const resp = await r.response().catch(() => null); const rec = reqs.find((x) => x.url === r.url() && x.status === null); if (rec) rec.status = resp ? resp.status() : 0; }); page.on("requestfailed", (r) => { if (!r.url().includes("/api/")) return; const rec = reqs.find((x) => x.url === r.url() && x.status === null); if (rec) rec.status = -1; }); // 直接单跳目标页(token 已在 context 级 addInitScript 预注入), // 避免「先跳 / 再跳目标」把首屏在途请求取消、低估真实冷加载成本。 await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {}); // 两段式等待,确保「冷加载首屏请求」全部捕获且不被轮询拖住: const nonPoll = () => reqs.filter((r) => !POLLING.test(r.key)).length; // ① 先等 bootstrap 真的发出(哨兵 /api/products/)。吸收「me() 偶发变慢」的空档, // 避免窗口在 loadData 发出前到点而漏抓(实测真实 MySQL 并发下 me() 会偶发 >8s)。 while (Date.now() - t0 < HARD_CAP && !reqs.some((r) => r.key === "GET /api/products/")) { await page.waitForTimeout(200); } const bootstrapStarted = reqs.some((r) => r.key === "GET /api/products/"); // ② 再对「非轮询请求」做静默检测:资产分页瀑布算活动会续命,轮询不算 → 瀑布跑完即收。 let prev = nonPoll(); let idleSince = Date.now(); while (Date.now() - t0 < HARD_CAP) { await page.waitForTimeout(250); const n = nonPoll(); if (n > prev) { prev = n; idleSince = Date.now(); } else if (Date.now() - idleSince >= QUIET_MS) break; } const elapsed = Date.now() - t0; // 功能守护:有没有掉回登录页 / app 有没有真渲染出来。 const onLogin = await page.locator('input[type="password"]').count().catch(() => 0); const hasApp = await page.locator(".app-shell, .sidebar, nav, [class*='sidebar']").count().catch(() => 0); await page.close(); // 统计 const counts = {}; for (const r of reqs) counts[r.key] = (counts[r.key] || 0) + 1; // ★ 主指标:去重后的「非轮询接口数」。轮询重发(notifications/tasks)被排除, // 故跟窗口长短无关、稳定;资产瀑布 page=2..10 因 query 不同各算一个 → 过量拉取照样现形。 const uniqueKeys = Object.keys(counts).filter((k) => !POLLING.test(k)); const unique = uniqueKeys.length; // 同一非轮询接口被打 ≥2 次(用户报的「连续重复请求」)。偶发重试可能误报,仅作信息。 const duplicates = Object.entries(counts) .filter(([k, n]) => n >= 2 && !POLLING.test(k)) .map(([k, n]) => ({ key: k, n })); const failedReqs = reqs.filter((r) => r.status === -1 || (r.status && r.status >= 400)).map((r) => `${r.key} → ${r.status}`); const breakdown = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)); const budget = REQUEST_BUDGET[name] ?? 99; // 资产分页瀑布:出现 ?page=2(及以后)= 前端在并行翻一堆页。这是 page_size 被后端截断的直接症状。 const waterfall = uniqueKeys.filter((k) => /\/api\/assets\/\?.*page=([2-9]|\d{2,})/.test(k)); const problems = []; // 硬失败:判 pass/fail const warnings = []; // 软提示:打印但不挂闸(留给架构决策的项) if (onLogin > 0 && hasApp === 0) problems.push("掉回登录页/未渲染(功能崩或登录态失效)"); // 没等到 bootstrap 完成 = 后端太慢拖到超时。这不是「请求少=快」,而是「慢到没加载出来」,判失败防假通过。 if (!bootstrapStarted) problems.push(`bootstrap 未完成(${HARD_CAP}ms 内 /api/products/ 未发出,后端过慢)`); if (failedReqs.length > 0) problems.push(`接口报错: ${failedReqs.join(" ; ")}`); // 资产翻页本身:page_size 是否生效已由 API 级硬闸确定性守住。这里翻 1~2 页可能是「资产真超 200」的 // 合法分页,故只在「明显过量(≥3 页 = page_size 没生效的老症状)」时提示,不硬挂闸(避免误报)。 if (waterfall.length >= 3) warnings.push(`资产分页瀑布 ${waterfall.length} 页(疑似 page_size 失效,查 API 硬闸)`); if (unique > budget) warnings.push(`首屏接口数 ${unique} > 预算 ${budget}(架构性过量拉取,待决策)`); return { name, route: url, unique, budget, rawRequests: reqs.length, waterfall: waterfall.length, duplicates, failed: failedReqs, breakdown, elapsedMs: elapsed, problems, warnings, pass: problems.length === 0 }; } // ── API 级确定性断言 ──────────────────────────────────────────────── // 不经浏览器,直接打后端。结果与时序/并发/轮询无关 → 永不抖,是 pass/fail 的硬主闸。 async function apiChecks() { const headers = { Authorization: `Token ${TOKEN}` }; const checks = []; const get = async (p) => { const t = Date.now(); const res = await fetch(`${BASE}${p}`, { headers }); const ms = Date.now() - t; let body = null; try { body = await res.json(); } catch {} return { status: res.status, ms, body }; }; // 1) 列表接口必须遵守 page_size(根因:assets 把 page_size=200 截成 20 → 前端并行翻 10 页)。 // 断言:要全部一页拉得到时,next 必须为 null、条数 = min(count, 上限)。 for (const ep of ["/api/assets/", "/api/products/", "/api/projects/"]) { const r = await get(`${ep}?page_size=200`); if (r.status !== 200) { checks.push({ name: `page_size ${ep}`, pass: false, detail: `HTTP ${r.status}` }); continue; } const count = r.body?.count ?? (Array.isArray(r.body?.results) ? r.body.results.length : 0); const len = r.body?.results?.length ?? 0; const next = r.body?.next ?? null; const ok = count <= 200 ? (next === null && len === count) : (len === 200); checks.push({ name: `page_size 生效 ${ep}`, pass: ok, detail: `count=${count} 返回=${len} next=${next ? "有" : "无"}` + (ok ? "" : " ← page_size 未生效,前端会被迫并行翻页") }); } // 2) 关键列表接口响应延迟(代表「快」)。软提示不硬挂闸:本地连远程 MySQL 每次握手 ~1.6s、 // 绝对 ms 天然抖,不宜当确定性闸;但打印出来给 ralph 看趋势 + 暴露疑似 N+1 的慢接口。 const LAT = { "/api/assets/?page_size=200": 1200, "/api/products/?page_size=200": 1200, "/api/projects/?page_size=200": 1200, "/api/ops/notifications/?page_size=100": 1200 }; for (const [p, budget] of Object.entries(LAT)) { const r = await get(p); const ok = r.status === 200 && r.ms <= budget; checks.push({ name: `延迟 ${p}`, pass: ok, soft: true, detail: `${r.ms}ms / 参考 ${budget}ms (HTTP ${r.status})` }); } return checks; } async function main() { TOKEN = await login(); const ids = await fetchIds(); // —— 硬主闸:API 级断言(确定性)—— console.log("── API 级检查(确定性硬闸)──"); const api = await apiChecks(); for (const c of api) console.log(`${c.pass ? "✅" : (c.soft ? "⚠" : "❌")} ${c.name.padEnd(34)} ${c.detail}`); const apiFailed = api.filter((c) => !c.pass && !c.soft); // 软项(延迟)不挂闸 console.log(""); const ROUTES = { dashboard: "/dashboard", products: "/products", projects: "/projects", library: "/library", account: "/account", team: "/team", messages: "/messages", productDetail: ids.productId ? `/products/${encodeURIComponent(ids.productId)}` : null, pipeline: ids.projectId ? `/pipeline/${encodeURIComponent(ids.projectId)}` : null, }; const targets = Object.entries(ROUTES) .filter(([n, r]) => r && (ONLY.length === 0 || ONLY.includes(n))); const browser = await chromium.launch({ headless: !HEADED }); const results = []; for (const [name, route] of targets) { // 每页全新 context:隔离缓存/内存态,测的是「冷进这一页」真实触发多少请求。 const context = await browser.newContext(); // context 级预注入登录态,页面一打开就带 token,无需先跳登录页。 await context.addInitScript((t) => { try { localStorage.setItem("airshelf_token", t); } catch {} }, TOKEN); const r = await probePage(context, name, route, ids); await context.close(); results.push(r); const tag = r.pass ? "✅" : "❌"; const warn = r.warnings.length ? " ⚠ " + r.warnings.join(" ;; ") : ""; console.log(`${tag} ${name.padEnd(14)} 首屏接口 ${String(r.unique).padStart(2)} (原始${r.rawRequests}) 瀑布${r.waterfall} ${r.elapsedMs}ms ${r.problems.join(" ;; ") || "OK"}${warn}`); await new Promise((res) => setTimeout(res, 800)); // 页间留空隙,别把后端打到过载(否则测量被自造的并发拖偏) } await browser.close(); const failed = results.filter((r) => !r.pass); const report = { ts: new Date().toISOString(), base: BASE, apiChecks: api, pageFailed: failed.length, results }; fs.writeFileSync(path.resolve(outDir, "perf-report.json"), JSON.stringify(report, null, 2)); if (UPDATE_BASELINE) { const baseline = {}; for (const r of results) baseline[r.name] = { unique: r.unique, elapsedMs: r.elapsedMs }; fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2)); console.log(`\n📌 已写入基线 ${baselinePath}`); } const warned = results.filter((r) => r.warnings.length); console.log(`\n${"=".repeat(56)}`); const ok = apiFailed.length === 0 && failed.length === 0; if (ok) { console.log(`✅ API 检查全过 + 全部 ${results.length} 页功能正常、无分页瀑布`); if (warned.length) console.log(`⚠ 但有 ${warned.length} 页首屏接口数超预算(架构性过量拉取,见下,非硬闸):`); for (const r of warned) console.log(` - ${r.name}: ${r.warnings.join(" ;; ")}`); console.log("PERF-PROBE: PASS"); process.exit(0); } else { if (apiFailed.length) { console.log(`❌ API 级检查 ${apiFailed.length} 项未过:`); for (const c of apiFailed) console.log(` - ${c.name}: ${c.detail}`); } if (failed.length) { console.log(`❌ ${failed.length}/${results.length} 页未通过:`); for (const r of failed) console.log(` - ${r.name}: ${r.problems.join(" ;; ")}`); } console.log("PERF-PROBE: FAIL"); process.exit(1); } } main().catch((e) => { console.error("探针崩溃:", e.message); console.log("PERF-PROBE: FAIL"); process.exit(1); });