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 = { '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; workbenchReadyMs: ReturnType; 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); });