233 lines
9.8 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|