@@ -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 ) ;
} ) ;