merge: feature/loading-perf-closeout-2026-09-22

This commit is contained in:
WeiCHEN
2026-09-22 21:56:09 +08:00
16 changed files with 2341 additions and 12 deletions
+146 -7
View File
@@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url';
import { expect, test, type Page, type Request } from '@playwright/test';
import {
countInitRequests,
firstPaintHeavyHits,
redactResourceUrl,
summarizeLatencies,
summarizeResources,
type RequestSample,
type ResourceSample,
@@ -13,17 +15,27 @@ import {
/**
* U1 登录态基线。只测导航、资源瀑布和初始化请求次数。
* 不发送聊天消息,不把查询串、令牌或正文写入证据。
*
* 默认 INITIAL_LOAD_SAMPLES=20(方案要求每场景 ≥20)。
* 预算失败时不得通过放宽阈值“关账”;设 INITIAL_LOAD_ENFORCE_BUDGETS=1 时硬断言。
*/
const enabled = process.env.RUN_INITIAL_LOAD_BASELINE === '1';
const username = process.env.ADMIN_USERNAME?.trim() || '';
const password = process.env.ADMIN_PASSWORD?.trim() || '';
const samples = Math.max(1, Number(process.env.INITIAL_LOAD_SAMPLES || '1'));
const samples = Math.max(1, Number(process.env.INITIAL_LOAD_SAMPLES || '20'));
const enforceBudgets = process.env.INITIAL_LOAD_ENFORCE_BUDGETS === '1';
const evidenceName = process.env.INITIAL_LOAD_EVIDENCE || 'login-waterfall.json';
const evidenceDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../.doc/test-reports/assets/2026-09-19-initial-load-u1',
'../../.doc/test-reports/assets/2026-09-22-initial-load-closeout',
);
/** 方案拟定预算:10 Mbps / 2 Mbps、CPU 4x 下登录表单可操作 p75。不得在失败后放宽。 */
const BUDGET_FORM_P75_MS: Record<string, number> = {
'10mbps-cpu4': 1_500,
'2mbps-cpu4': 3_000,
};
test.skip(!enabled, 'set RUN_INITIAL_LOAD_BASELINE=1 against the real dev stack');
interface Capture {
@@ -104,6 +116,7 @@ async function login(page: Page) {
await page.fill('input[placeholder*="登录密码"]', password);
const started = Date.now();
await page.click('button:has-text("进入医疗协同工作台")');
// 工作台首可操作:新建会话 + 输入区。不得依赖 katex/mermaid/docx 等延迟模块。
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('textarea[placeholder*="向项目助手提问"]')).toBeVisible({ timeout: 30_000 });
return Date.now() - started;
@@ -121,12 +134,19 @@ test('logged-in workbench records waterfall and init request counts', async ({ b
const capture = watchRequests(page);
const anonymousVisibleMs = await openAnonymous(page);
const anonymousPerf = await readPerformance(page);
const anonymousResources = anonymousPerf.resources as ResourceSample[];
const anonymousHeavy = firstPaintHeavyHits(anonymousResources);
expect(anonymousHeavy, `anonymous first-paint heavy chunks (sample ${sample})`).toEqual([]);
const workbenchReadyMs = await login(page);
await page.waitForTimeout(1500);
const loggedInPerf = await readPerformance(page);
const counts = countInitRequests(capture.requests);
const anonymousResources = anonymousPerf.resources as ResourceSample[];
const loggedInResources = loggedInPerf.resources as ResourceSample[];
const loggedInHeavy = firstPaintHeavyHits(loggedInResources);
// U3:延迟模块不得挤进登录后首可操作路径;首按钮/输入区已可用时仍无 katex/mermaid/docx。
expect(loggedInHeavy, `logged-in first-ready heavy chunks (sample ${sample})`).toEqual([]);
const counts = countInitRequests(capture.requests);
const row = {
sample,
anonymousVisibleMs,
@@ -135,12 +155,14 @@ test('logged-in workbench records waterfall and init request counts', async ({ b
navigation: anonymousPerf.navigation,
paint: anonymousPerf.paint,
summary: summarizeResources(anonymousResources),
heavyHits: anonymousHeavy,
longTaskCount: anonymousPerf.longTasks.length,
},
loggedIn: {
navigation: loggedInPerf.navigation,
paint: loggedInPerf.paint,
summary: summarizeResources(loggedInResources),
heavyHits: loggedInHeavy,
longTaskCount: loggedInPerf.longTasks.length,
},
initRequests: counts,
@@ -149,9 +171,24 @@ test('logged-in workbench records waterfall and init request counts', async ({ b
writeFileSync(path.join(evidenceDir, evidenceName), JSON.stringify(rows, null, 2));
await context.close();
}
const percentiles = {
samples,
anonymousVisibleMs: summarizeLatencies(rows.map((row) => row.anonymousVisibleMs)),
workbenchReadyMs: summarizeLatencies(rows.map((row) => row.workbenchReadyMs)),
};
writeFileSync(
path.join(evidenceDir, evidenceName.replace(/\.json$/i, '-percentiles.json')),
JSON.stringify(percentiles, null, 2),
);
const last = rows[rows.length - 1];
expect(last?.anonymous.summary.requestCount).toBeGreaterThan(0);
expect(last?.workbenchReadyMs).toBeGreaterThan(0);
// Closeout requires n>=20; INITIAL_LOAD_ALLOW_SHORT=1 only for local smoke.
if (process.env.INITIAL_LOAD_ALLOW_SHORT !== '1') {
expect(samples).toBeGreaterThanOrEqual(20);
}
const memberCap = process.env.EXPECT_INIT_MEMBER_PROJECTS;
if (memberCap) {
expect(last?.initRequests.distinctMemberProjects).toBeLessThanOrEqual(Number(memberCap));
@@ -168,7 +205,14 @@ test('throttled login still reaches the workbench', async ({ browser }) => {
{ name: '10mbps-cpu4', download: 1_250_000, upload: 625_000 },
{ name: '2mbps-cpu4', download: 250_000, upload: 125_000 },
];
const rows = [];
const rows: {
profile: string;
sample: number;
anonymousVisibleMs: number;
workbenchReadyMs: number;
anonymousHeavy: string[];
loggedInHeavy: string[];
}[] = [];
for (const profile of profiles) {
for (let sample = 1; sample <= samples; sample += 1) {
const context = await browser.newContext();
@@ -183,11 +227,106 @@ test('throttled login still reaches the workbench', async ({ browser }) => {
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.route(/fonts\.(googleapis|gstatic)\.com/, (route) => route.abort());
const anonymousVisibleMs = await openAnonymous(page);
const anonymousPerf = await readPerformance(page);
const anonymousHeavy = firstPaintHeavyHits(anonymousPerf.resources as ResourceSample[]);
expect(anonymousHeavy, `${profile.name} anonymous heavy (sample ${sample})`).toEqual([]);
const workbenchReadyMs = await login(page);
rows.push({ profile: profile.name, sample, anonymousVisibleMs, workbenchReadyMs });
writeFileSync(path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_EVIDENCE || 'throttled.json'), JSON.stringify(rows, null, 2));
await page.waitForTimeout(800);
const loggedInPerf = await readPerformance(page);
const loggedInHeavy = firstPaintHeavyHits(loggedInPerf.resources as ResourceSample[]);
expect(loggedInHeavy, `${profile.name} logged-in heavy (sample ${sample})`).toEqual([]);
rows.push({
profile: profile.name,
sample,
anonymousVisibleMs,
workbenchReadyMs,
anonymousHeavy,
loggedInHeavy,
});
writeFileSync(
path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_EVIDENCE || 'throttled.json'),
JSON.stringify(rows, null, 2),
);
await context.close();
}
}
const byProfile: Record<
string,
{
samples: number;
budgetFormP75Ms: number;
anonymousVisibleMs: ReturnType<typeof summarizeLatencies>;
workbenchReadyMs: ReturnType<typeof summarizeLatencies>;
formP75MeetsBudget: boolean | null;
}
> = {};
for (const profile of profiles) {
const subset = rows.filter((row) => row.profile === profile.name);
const form = summarizeLatencies(subset.map((row) => row.anonymousVisibleMs));
const ready = summarizeLatencies(subset.map((row) => row.workbenchReadyMs));
const budget = BUDGET_FORM_P75_MS[profile.name] ?? Number.POSITIVE_INFINITY;
const meets = form.p75 == null ? null : form.p75 <= budget;
byProfile[profile.name] = {
samples: subset.length,
budgetFormP75Ms: budget,
anonymousVisibleMs: form,
workbenchReadyMs: ready,
formP75MeetsBudget: meets,
};
expect(subset.length).toBeGreaterThanOrEqual(
process.env.INITIAL_LOAD_ALLOW_SHORT === '1' ? 1 : 20,
);
if (enforceBudgets && form.p75 != null) {
expect(
form.p75,
`${profile.name} form p75 ${form.p75}ms exceeds budget ${budget}ms (do not relax)`,
).toBeLessThanOrEqual(budget);
}
}
writeFileSync(
path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_PERCENTILES || 'throttled-percentiles.json'),
JSON.stringify({ samples, byProfile, enforceBudgets }, null, 2),
);
expect(rows.every((row) => row.workbenchReadyMs > 0)).toBe(true);
});
test('post-ready login form visibility for U7 browser gap', async ({ browser }) => {
test.skip(process.env.INITIAL_LOAD_U7_FORM !== '1', 'set INITIAL_LOAD_U7_FORM=1 to measure form after /ready');
if (!username || !password) {
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
}
mkdirSync(evidenceDir, { recursive: true });
const formSamples = Math.max(1, Number(process.env.INITIAL_LOAD_U7_SAMPLES || String(samples)));
const rows = [];
for (let sample = 1; sample <= formSamples; sample += 1) {
const context = await browser.newContext();
const page = await context.newPage();
const started = Date.now();
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('input[placeholder*="系统用户名"]').waitFor({ state: 'visible' });
const formVisibleMs = Date.now() - started;
const perf = await readPerformance(page);
const heavy = firstPaintHeavyHits(perf.resources as ResourceSample[]);
expect(heavy, `U7 form heavy (sample ${sample})`).toEqual([]);
rows.push({ sample, formVisibleMs, heavyHits: heavy, summary: summarizeResources(perf.resources as ResourceSample[]) });
writeFileSync(path.join(evidenceDir, 'u7-form-visibility.json'), JSON.stringify(rows, null, 2));
await context.close();
}
const summary = {
samples: formSamples,
formVisibleMs: summarizeLatencies(rows.map((row) => row.formVisibleMs)),
// 历史诊断「重启后首访约十几秒」→ 50% 即 ≤5s;此处测的是 /ready 已绿后的冷导航表单可见,不是整机重启。
historicalBaselineMsApprox: 10_000,
fiftyPercentTargetMs: 5_000,
meetsFiftyPercentVsApproxBaseline:
summarizeLatencies(rows.map((row) => row.formVisibleMs)).p75 != null &&
(summarizeLatencies(rows.map((row) => row.formVisibleMs)).p75 as number) <= 5_000,
scopeNote:
'Measures cold navigation to login form while app already /ready. Does not restart postgres/app; prior U7 restart→ready timings remain separate evidence.',
};
writeFileSync(path.join(evidenceDir, 'u7-form-visibility-percentiles.json'), JSON.stringify(summary, null, 2));
expect(summary.formVisibleMs.n).toBe(formSamples);
});
+4 -1
View File
@@ -20,7 +20,10 @@
"test:e2e:project143-regression": "RUN_UI_ACCEPTANCE=1 playwright test e2e/project143-regression.spec.ts --project=chromium",
"test:e2e:workflow-plan-409-frozen-duties": "RUN_UI_ACCEPTANCE=1 playwright test e2e/workflow-plan-409-frozen-duties.spec.ts --project=chromium",
"test:e2e:list": "playwright test --list",
"test:e2e:initial-load": "RUN_INITIAL_LOAD_BASELINE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium"
"test:e2e:initial-load": "RUN_INITIAL_LOAD_BASELINE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium",
"test:e2e:initial-load:throttle": "RUN_INITIAL_LOAD_BASELINE=1 INITIAL_LOAD_THROTTLE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium -g throttled",
"test:e2e:initial-load:u7-form": "RUN_INITIAL_LOAD_BASELINE=1 INITIAL_LOAD_U7_FORM=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium -g 'U7 browser'",
"test:e2e:initial-load:msedge": "PLAYWRIGHT_CHANNEL=msedge npm run test:e2e:initial-load"
},
"dependencies": {
"@tanstack/react-virtual": "^3.14.11",
+7 -1
View File
@@ -46,7 +46,13 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: {
...devices['Desktop Chrome'],
// Prefer PLAYWRIGHT_CHANNEL (e.g. msedge) when bundled Chromium cannot be downloaded.
...(process.env.PLAYWRIGHT_CHANNEL
? { channel: process.env.PLAYWRIGHT_CHANNEL as 'chrome' | 'chrome-beta' | 'chrome-dev' | 'msedge' | 'msedge-beta' | 'msedge-dev' }
: {}),
},
},
],
});
+45 -1
View File
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { countInitRequests, percentile, redactResourceUrl, summarizeResources } from './initialLoadMetrics';
import {
countInitRequests,
firstPaintHeavyHits,
percentile,
redactResourceUrl,
summarizeLatencies,
summarizeResources,
} from './initialLoadMetrics';
test('redactResourceUrl drops query and origin', () => {
assert.equal(
@@ -20,6 +27,23 @@ test('summarizeResources flags deferred heavy chunks', () => {
assert.deepEqual(summary.heavyChunks, ['/assets/katex-xyz.js']);
});
test('firstPaintHeavyHits lists only deferred module paths', () => {
assert.deepEqual(
firstPaintHeavyHits([
{ path: '/assets/index-abc.js', initiator: 'script', durationMs: 1, transferBytes: 10, decodedBytes: 40 },
{ path: '/assets/mermaid-abc.js', initiator: 'script', durationMs: 2, transferBytes: 20, decodedBytes: 80 },
{ path: '/assets/docx-abc.js', initiator: 'script', durationMs: 3, transferBytes: 30, decodedBytes: 120 },
]),
['/assets/mermaid-abc.js', '/assets/docx-abc.js'],
);
assert.deepEqual(
firstPaintHeavyHits([
{ path: '/assets/index-abc.js', initiator: 'script', durationMs: 1, transferBytes: 10, decodedBytes: 40 },
]),
[],
);
});
test('countInitRequests separates member fanout from session lists', () => {
const counts = countInitRequests([
{ method: 'GET', path: '/api/v1/projects/1/members' },
@@ -37,3 +61,23 @@ test('percentile uses nearest-rank and stays null on empty input', () => {
assert.equal(percentile([], 75), null);
assert.equal(percentile([10, 20, 30, 40], 75), 30);
});
test('summarizeLatencies reports p50/p75/p95', () => {
assert.deepEqual(summarizeLatencies([]), {
n: 0,
p50: null,
p75: null,
p95: null,
min: null,
max: null,
});
// 20 samples: p50=rank10 → 1000; p75=rank15 → 1500; p95=rank19 → 1900
const values = Array.from({ length: 20 }, (_, i) => (i + 1) * 100);
const summary = summarizeLatencies(values);
assert.equal(summary.n, 20);
assert.equal(summary.p50, 1000);
assert.equal(summary.p75, 1500);
assert.equal(summary.p95, 1900);
assert.equal(summary.min, 100);
assert.equal(summary.max, 2000);
});
+31 -1
View File
@@ -13,7 +13,17 @@ export interface RequestSample {
path: string;
}
const HEAVY_CHUNK = /katex|mermaid|docx|xlsx|AdminConsole|BlockEditor|FilePreview/i;
/** 首屏/首可操作前不得出现的延迟重模块路径片段。 */
export const HEAVY_CHUNK = /katex|mermaid|docx|xlsx|AdminConsole|BlockEditor|FilePreview/i;
export interface LatencySummary {
n: number;
p50: number | null;
p75: number | null;
p95: number | null;
min: number | null;
max: number | null;
}
export function redactResourceUrl(raw: string): string {
const noQuery = raw.split('?')[0] ?? raw;
@@ -32,6 +42,21 @@ export function percentile(values: number[], p: number): number | null {
return sorted[rank] ?? null;
}
export function summarizeLatencies(values: number[]): LatencySummary {
if (values.length === 0) {
return { n: 0, p50: null, p75: null, p95: null, min: null, max: null };
}
const sorted = [...values].sort((a, b) => a - b);
return {
n: values.length,
p50: percentile(sorted, 50),
p75: percentile(sorted, 75),
p95: percentile(sorted, 95),
min: sorted[0] ?? null,
max: sorted[sorted.length - 1] ?? null,
};
}
export function summarizeResources(resources: ResourceSample[]) {
const transferBytes = resources.reduce((sum, row) => sum + (row.transferBytes || 0), 0);
const js = resources.filter((row) => row.path.endsWith('.js'));
@@ -45,6 +70,11 @@ export function summarizeResources(resources: ResourceSample[]) {
};
}
/** 首屏路径不得包含延迟重模块;空列表表示通过。 */
export function firstPaintHeavyHits(resources: ResourceSample[]): string[] {
return [...new Set(resources.filter((row) => HEAVY_CHUNK.test(row.path)).map((row) => row.path))];
}
export function countInitRequests(requests: RequestSample[]) {
const members = requests.filter(
(row) => row.method === 'GET' && /^\/api\/v1\/projects\/\d+\/members$/.test(row.path),