333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
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,
|
|
} from '../src/lib/initialLoadMetrics.ts';
|
|
|
|
/**
|
|
* 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 || '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-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 {
|
|
requests: RequestSample[];
|
|
}
|
|
|
|
function watchRequests(page: Page): Capture {
|
|
const capture: Capture = { requests: [] };
|
|
const onRequest = (request: Request) => {
|
|
capture.requests.push({
|
|
method: request.method(),
|
|
path: redactResourceUrl(request.url()),
|
|
});
|
|
};
|
|
page.on('request', onRequest);
|
|
return capture;
|
|
}
|
|
|
|
async function readPerformance(page: Page) {
|
|
return page.evaluate(() => {
|
|
const navigation = performance.getEntriesByType('navigation').map((entry) => {
|
|
const nav = entry as PerformanceNavigationTiming;
|
|
return {
|
|
ttfb: nav.responseStart,
|
|
dcl: nav.domContentLoadedEventEnd,
|
|
load: nav.loadEventEnd,
|
|
};
|
|
});
|
|
const paint = performance.getEntriesByType('paint').map((entry) => ({
|
|
name: entry.name,
|
|
ms: entry.startTime,
|
|
}));
|
|
const resources = performance.getEntriesByType('resource').map((entry) => {
|
|
const resource = entry as PerformanceResourceTiming;
|
|
const pathOnly = resource.name.split('?')[0] ?? resource.name;
|
|
let pathname = pathOnly;
|
|
try {
|
|
pathname = new URL(pathOnly).pathname;
|
|
} catch {
|
|
pathname = pathOnly.replace(/^https?:\/\/[^/]+/i, '');
|
|
}
|
|
return {
|
|
path: pathname || '/',
|
|
initiator: resource.initiatorType,
|
|
durationMs: resource.duration,
|
|
transferBytes: resource.transferSize,
|
|
decodedBytes: resource.decodedBodySize,
|
|
};
|
|
});
|
|
const longTasks = ((window as unknown as { __longTasks?: { duration: number }[] }).__longTasks || [])
|
|
.map((entry) => entry.duration);
|
|
return { navigation, paint, resources, longTasks };
|
|
});
|
|
}
|
|
|
|
async function openAnonymous(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const target = window as unknown as { __longTasks: { duration: number }[] };
|
|
target.__longTasks = [];
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
target.__longTasks.push({ duration: entry.duration });
|
|
}
|
|
}).observe({ type: 'longtask', buffered: true });
|
|
} catch {
|
|
/* 部分无头环境没有 longtask */
|
|
}
|
|
});
|
|
const started = Date.now();
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.locator('input[placeholder*="系统用户名"]').waitFor({ state: 'visible' });
|
|
return Date.now() - started;
|
|
}
|
|
|
|
async function login(page: Page) {
|
|
await page.fill('input[placeholder*="系统用户名"]', username);
|
|
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;
|
|
}
|
|
|
|
test('logged-in workbench records waterfall and init request counts', async ({ browser }) => {
|
|
if (!username || !password) {
|
|
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
|
|
}
|
|
mkdirSync(evidenceDir, { recursive: true });
|
|
const rows = [];
|
|
for (let sample = 1; sample <= samples; sample += 1) {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
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 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,
|
|
workbenchReadyMs,
|
|
anonymous: {
|
|
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,
|
|
};
|
|
rows.push(row);
|
|
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));
|
|
}
|
|
});
|
|
|
|
test('throttled login still reaches the workbench', async ({ browser }) => {
|
|
test.skip(process.env.INITIAL_LOAD_THROTTLE !== '1', 'set INITIAL_LOAD_THROTTLE=1 to measure slow-network budgets');
|
|
if (!username || !password) {
|
|
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
|
|
}
|
|
mkdirSync(evidenceDir, { recursive: true });
|
|
const profiles = [
|
|
{ name: '10mbps-cpu4', download: 1_250_000, upload: 625_000 },
|
|
{ name: '2mbps-cpu4', download: 250_000, upload: 125_000 },
|
|
];
|
|
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();
|
|
const page = await context.newPage();
|
|
const client = await context.newCDPSession(page);
|
|
await client.send('Network.emulateNetworkConditions', {
|
|
offline: false,
|
|
latency: 80,
|
|
downloadThroughput: profile.download,
|
|
uploadThroughput: profile.upload,
|
|
});
|
|
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);
|
|
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);
|
|
});
|