/** * Performance-measurement harness for the leadership 大屏 (/overview). * * Runs ONLY in the dedicated `perf` Playwright project (see playwright.config.ts * testMatch) so emulated network throttling never pollutes the functional suite. * * What it measures: * 1. LCP (Largest Contentful Paint) of /overview under emulated Fast 3G. * 2. Client-side route-transition time from /overview → /monitoring. * * Throttling model: /api/** is mocked to resolve INSTANTLY (see seedAuthAndMockApi), * so the backend contributes ~0ms. That is deliberate — it isolates the realistic * SPA cost on a slow link: the *static asset graph* (app JS/CSS bundle + the * /wuhan_districts.geojson choropleth payload, which is served by the real dev * server, not mocked). Fast 3G therefore shapes exactly the bytes a cold-cache * leadership client must pull before first paint, which is what LCP should reflect. * * Assertion policy (per the UX-modernization plan): LCP and route-transition * targets (2500ms LCP / 800ms transition) are REPORTED, not hard CI gates — a * miss under throttle on a loaded CI box must not fail the build. We therefore * record each number against its target as a test annotation + console line and * let the test PASS regardless of the target. (Note: `expect.soft` would still * mark the test failed at teardown, so it's the wrong tool for a report-only * target — annotations are.) Hard assertions guard ONLY that the measurement * machinery worked: LCP was observed (> 0) and the nav actually landed. * * Caveat on absolute values: this runs against the Vite DEV server (unbundled, * unminified ESM with per-module requests). Dev LCP under Fast 3G is therefore * far higher than a production build would be — these numbers are a relative * regression signal for this harness, not a production SLA. */ import { test, expect, Page } from '@playwright/test'; import { TESTIDS } from '../src/utils/testids'; // Reported (soft) targets — see file header. const LCP_TARGET_MS = 2500; const ROUTE_TRANSITION_TARGET_MS = 800; // Emulated "Fast 3G" network conditions (Chrome DevTools preset). const FAST_3G = { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps uploadThroughput: (750 * 1024) / 8, // 750 Kbps latency: 150, // ms RTT }; /** * Seed auth + mock /api/** so the page renders hermetically. Mirrors the helper * in user-flows.spec.ts, with one deliberate difference: /wuhan_districts.geojson * is a real static asset and is NOT under /api, so page.route('/api/**') already * lets it pass through to the dev server (the realistic, throttled payload). */ async function seedAuthAndMockApi(page: Page) { await page.addInitScript(() => { localStorage.setItem('cbpoa_token', 'e2e-test-token'); }); // Mock backend responses instantly so Fast-3G shapes only the static asset // graph (JS/CSS + geojson), not API latency. await page.route('/api/**', (route) => { const url = route.request().url(); if (url.includes('/alerts')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ alerts: [], total: 0 }), }); return; } if (url.includes('/history/aggregated')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ aggregations: [], total_records: 0 }), }); return; } if (url.includes('/grids')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ type: 'FeatureCollection', features: [] }), }); return; } if (url.includes('/cases/demographics')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ age_distribution: [], gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, age_diagnosis_matrix: [], }), }); return; } if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); return; } if (url.includes('/cases/districts') || url.includes('/districts')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); return; } if (url.includes('/cases/trend') || url.includes('/cases')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], total: 0 }), }); return; } // Default fallback — safe empty shape. route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }), }); }); } test.describe('Performance — /overview under emulated Fast 3G', () => { test('LCP and route-transition are measured and reported', async ({ page }, testInfo) => { // Fast-3G throttling makes the cold asset-graph download slow; the default 30s // test budget can be eaten by the initial /overview load alone. Give the whole // measurement flow generous headroom — this bounds the harness, not the metrics. test.setTimeout(120_000); await seedAuthAndMockApi(page); // Install the LCP observer BEFORE any navigation so it captures the very // first paint. buffered:true also replays entries emitted before observe(). await page.addInitScript(() => { (window as unknown as { __lcp: number }).__lcp = 0; new PerformanceObserver((list) => { const entries = list.getEntries(); (window as unknown as { __lcp: number }).__lcp = entries[entries.length - 1].startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); }); // Apply Fast 3G throttling via CDP before navigating. const client = await page.context().newCDPSession(page); await client.send('Network.enable'); await client.send('Network.emulateNetworkConditions', FAST_3G); // --- LCP measurement ----------------------------------------------------- // Generous wait: under Fast 3G the throttled JS bundle download dominates, so // first meaningful paint legitimately exceeds the 5s default expect timeout. // The LCP NUMBER we read is the real measured value — this timeout only bounds // how long we'll wait for the asset graph to arrive before failing the harness. await page.goto('/overview'); await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible({ timeout: 30_000 }); // LCP finalizes on the last contentful paint; give the observer a beat to flush // the entry for the kpi-row we just saw before reading it. await page.waitForTimeout(200); const lcp = await page.evaluate(() => (window as unknown as { __lcp: number }).__lcp); // --- Route-transition measurement --------------------------------------- // Expand the 监测 module if its NavLink is collapsed, then click it. const railSel = `[data-testid="${TESTIDS.sidebarRail}"]`; const navMonitoring = page.locator(`${railSel} [data-testid="${TESTIDS.navMonitoring}"]`); if (!(await navMonitoring.isVisible())) { await page.locator(`${railSel} button`).filter({ hasText: '监测' }).first().click(); } await expect(navMonitoring).toBeVisible(); const t0 = await page.evaluate(() => performance.now()); await navMonitoring.click(); await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible({ timeout: 30_000, }); const t1 = await page.evaluate(() => performance.now()); const routeTransitionMs = t1 - t0; // --- Report -------------------------------------------------------------- // eslint-disable-next-line no-console console.log(`[perf] /overview LCP (Fast 3G): ${lcp.toFixed(0)} ms (target < ${LCP_TARGET_MS})`); // eslint-disable-next-line no-console console.log( `[perf] /overview → /monitoring route transition: ${routeTransitionMs.toFixed(0)} ms (target < ${ROUTE_TRANSITION_TARGET_MS})` ); await testInfo.attach('perf-metrics', { contentType: 'application/json', body: JSON.stringify( { lcpMs: Math.round(lcp), lcpTargetMs: LCP_TARGET_MS, routeTransitionMs: Math.round(routeTransitionMs), routeTransitionTargetMs: ROUTE_TRANSITION_TARGET_MS, network: 'Fast 3G (emulated via CDP)', }, null, 2 ), }); // --- Reported targets (NOT gates) --------------------------------------- // Record each metric vs. its target as a passing/over annotation. A miss is // visible in the report and console but does NOT fail the test. const lcpVerdict = lcp < LCP_TARGET_MS ? 'within' : 'over'; const routeVerdict = routeTransitionMs < ROUTE_TRANSITION_TARGET_MS ? 'within' : 'over'; testInfo.annotations.push({ type: 'perf-lcp', description: `${Math.round(lcp)}ms (target ${LCP_TARGET_MS}ms — ${lcpVerdict})`, }); testInfo.annotations.push({ type: 'perf-route-transition', description: `${Math.round(routeTransitionMs)}ms (target ${ROUTE_TRANSITION_TARGET_MS}ms — ${routeVerdict})`, }); if (lcpVerdict === 'over' || routeVerdict === 'over') { // eslint-disable-next-line no-console console.warn( `[perf] target exceeded (LCP ${lcpVerdict}, route ${routeVerdict}) — reported, not gated (dev-server throttled run).` ); } // --- Hard assertions (gates) -------------------------------------------- // Only the measurement machinery is gated: the observer fired and the nav // landed (page-monitoring visibility is already hard-asserted above). expect(lcp, 'LCP observer should have recorded a paint').toBeGreaterThan(0); expect(routeTransitionMs, 'route transition should elapse measurable time').toBeGreaterThan(0); }); });