Files
CA/frontend/e2e/perf.spec.ts
Akiba So 33f0f497d3 feat: Phase 4 — responsive analysis pages + perf harness + god-component splits
Final phase of the UX modernization. Four conflict-free lanes.

Responsive (D4 — desktop+mobile 并重):
- 7 analysis pages made usable at 375px: grid-cols-4/5 → grid-cols-2 sm:*
  responsive variants; raw tables wrapped in overflow-x-auto; page overflow guards
- new e2e/responsive.spec.ts loops all 7 analysis routes at 375px asserting no
  horizontal scroll

Perf harness:
- playwright.config.ts gains an isolated `perf` project (testMatch /perf/), default
  chromium project excludes it (testIgnore)
- new e2e/perf.spec.ts: CDP Network.emulateNetworkConditions (Fast 3G) +
  PerformanceObserver LCP on /overview kpi-row + route-transition timing; numbers
  reported as a relative regression signal (dev-server, not a prod SLA), not gated

God-component splits (pure refactors, behavior-preserving):
- MonitoringDashboard 686 → 239 lines: extracted components/monitoring/* (StatsBar,
  OverviewTab, CaseStatsTab, DistrictStatsTab) + useMonitoringData hook; URL-granularity
  source-of-truth + drilldown reconcile kept in the orchestrator (no desync regression)
- AlertsDashboard 816 → 301 lines: extracted components/alerts/* (Toolbar, List,
  RiskPanel, MapPanel, DetailModal, …); role/privacy/grid-hide logic kept in the
  orchestrator — doctor-view privacy invariant (zero patient-point) still holds

Gates: tsc 0 · vitest 75 · functional e2e 37/37 (incl doctor-view privacy +
granularity + responsive) · build ok · perf project runs + reports

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:00:15 +08:00

233 lines
9.8 KiB
TypeScript

/**
* 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);
});
});