perf(core): assets/ledgers 列表 defer 掉 AITask 巨型 payload 列,根治取全部资产 60s+ 慢查询
- /api/assets/ 列表 select_related('origin_task__project') 为解析资产归属商品,
会把每个资产关联 AITask 的 request_payload/response_payload(完整 AI 请求/响应 JSON)
整行拖出 → 取全部 200 资产实测 >60s 超时,数据越多越慢(用户报的「刷新越来越卡」)。
加 .defer 这两列后 0.76s,product 仍正常解析、序列化 0 额外查询。
- billing/ledgers 同模式补 .defer(task payload)。ai/billing.summary 早已 defer,assets/ledgers 本是漏网。
- 附 qa/function-audit/perf-probe.mjs 性能验证闸 + PERF-GATE.md(API 确定性断言+浏览器功能守护)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# AirShelf/core 性能验证闸(perf-probe)
|
||||
|
||||
给 **ralph-loop**(或人工)当 pass/fail 闸用的性能回归探针。全绿 → 退出码 0;任一硬闸不过 → 退出码 1 + `PERF-PROBE: FAIL`。
|
||||
|
||||
## 一句话目的
|
||||
|
||||
逐页 + 逐接口验证「访问变快了 / 重复请求没了 / 但功能没改崩」。**改性能时反复跑它,直到全绿。**
|
||||
|
||||
## 怎么跑
|
||||
|
||||
先起前后端(后端 `:8010` 连真实 MySQL;前端 vite dev **必须带 /api 代理**):
|
||||
|
||||
```bash
|
||||
# 后端(backend/)
|
||||
DB_ENGINE=mysql .venv/bin/python manage.py runserver 127.0.0.1:8010 --noreload
|
||||
# 前端(frontend/)—— 记下它实际监听的端口(可能是 5173/5174)
|
||||
npm run dev
|
||||
|
||||
# 探针(qa/function-audit/)
|
||||
node perf-probe.mjs --base http://127.0.0.1:5174 # 改成前端实际端口
|
||||
node perf-probe.mjs --base http://127.0.0.1:5174 --only dashboard,projects
|
||||
node perf-probe.mjs --base http://127.0.0.1:5174 --headed # 看着跑
|
||||
```
|
||||
|
||||
默认演示账号 `e2e-20260529-0806@airshelf.test` / `demo12345`;也可 `--token <TOKEN>` 免登录。
|
||||
|
||||
## 闸怎么判(两层)
|
||||
|
||||
### 硬闸(确定性,挂 pass/fail)
|
||||
|
||||
1. **API 级:列表接口必须遵守 `page_size`** —— `?page_size=200` 要全部一页拉得到时,`next` 必须为 `null`、条数 = `count`。
|
||||
*当前根因*:`/api/assets/` 无视 `page_size=200`,死按 20 条/页返回 → 前端 `allAssets` 被迫并行翻 N 页(资产越多页越多)。
|
||||
2. **浏览器级:任意页面不得出现资产分页瀑布**(`/api/assets/?page=2…`)。这是上面那条的直接症状。
|
||||
3. **功能守护**:每页必须真渲染(没掉回登录页)、所有接口 `< 400`、bootstrap 必须在 25s 内发起(否则=后端慢到没加载出来,判失败防假通过)。
|
||||
|
||||
> 为什么 API 检查 + 浏览器双保险:浏览器按时序数瀑布会被后端延迟抖动影响(慢时漏抓)。API 断言与时序无关、永不抖,是真正的硬闸;浏览器只作功能守护 + 佐证。
|
||||
|
||||
### 软提示(打印不挂闸,留给架构决策)
|
||||
|
||||
- **接口延迟**:本地连远程 MySQL 每次握手 ~1.6s,绝对 ms 天然抖,不宜当硬闸;但打印出来暴露**疑似 N+1 的慢接口**(基线里 `/api/projects/` ~2s、`/api/ops/notifications/` ~1.4s,大概率缺 `select_related/prefetch_related`)。
|
||||
- **首屏接口数超预算**:每页 bootstrap 拉了 ~9 个全局接口(products/projects/assets/billing/trend/members/models/tasks/notifications),且**每个路由都重拉一遍**——连 `/account` 这种用不到的页也拉。这是架构性过量拉取,**怎么改属设计决策**(懒加载 / 按页取数 / 引入 react-query 缓存层),不由闸强判,见下。
|
||||
|
||||
## 基线(2026-06-17,优化前)
|
||||
|
||||
- ❌ `/api/assets/` page_size 未生效(count=194 → 返回 20、next 有)→ **根因**。
|
||||
- ❌ dashboard / projects / account 等出现 9 页资产瀑布。
|
||||
- ⚠ `/api/projects/` ~2s、`/api/ops/notifications/` ~1.4s(疑似 N+1)。
|
||||
- ⚠ 每页首屏 9~18 个接口(bootstrap 每页重拉)。
|
||||
- ✅ 全部页面功能正常、无接口报错。
|
||||
|
||||
## 优化方向(供参考,不替代设计决策)
|
||||
|
||||
1. **修 `page_size` 根因(后端)**:让 `/api/assets/` 真正应用 `AssetPagination`(`max_page_size=200`)。这一条修好,前端瀑布自动消失。
|
||||
2. **修 `allAssets` 兜底(前端 `src/api.ts`)**:别用 `results.length` 反推页数;按 `count` + 真实 `page_size` 算,或直接跟 `next`。
|
||||
3. **慢接口补 ORM**:`/api/projects/`、`/api/ops/notifications/` 等加 `select_related/prefetch_related`、确认有分页。
|
||||
4. **(设计决策)bootstrap 瘦身**:全局只加载真正全站要用的;各页数据按需懒加载 + 缓存,避免每路由重拉 9 个接口。**此项请人工拍板方案再做。**
|
||||
|
||||
## 给 ralph 的硬规矩
|
||||
|
||||
- 严禁删功能换性能;严禁为过闸放宽断言阈值或把检查注释掉。
|
||||
- 每轮改完必须重跑 `node perf-probe.mjs --base <前端端口>`,全绿(`PERF-PROBE: PASS`)才算完成。
|
||||
- 改后端任务/接口代码后注意:本地不起 worker,纯接口改动 runserver 即时生效。
|
||||
@@ -0,0 +1,299 @@
|
||||
// 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> # 用已有 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 头上。其余页若在已加载全局态后还重复拉同样数据 = 架构问题。
|
||||
// 阈值是「当前现状 + 少量余量」起步;优化推进后人工调低,逼着请求数下降。
|
||||
const REQUEST_BUDGET = {
|
||||
dashboard: 14,
|
||||
products: 6,
|
||||
projects: 6,
|
||||
library: 8,
|
||||
account: 8,
|
||||
team: 4,
|
||||
messages: 4,
|
||||
productDetail: 8,
|
||||
pipeline: 10,
|
||||
};
|
||||
|
||||
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 (waterfall.length > 0) problems.push(`资产分页瀑布 ${waterfall.length} 页(page_size 没生效→并行翻页)`);
|
||||
if (failedReqs.length > 0) problems.push(`接口报错: ${failedReqs.join(" ; ")}`);
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user