Files
Agent-System/web/e2e/initial-load-performance.spec.ts
T
weichenandCursor 26d14b534b docs(test): record schema 53 rebuild and login fanout on dev
The rebuilt workbench issues one member request and one session list request. Slow-network samples improved, but twenty-run percentiles and the restart matrix are still open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 13:22:38 +00:00

194 lines
7.4 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,
redactResourceUrl,
summarizeResources,
type RequestSample,
type ResourceSample,
} from '../src/lib/initialLoadMetrics.ts';
/**
* U1 登录态基线。只测导航、资源瀑布和初始化请求次数。
* 不发送聊天消息,不把查询串、令牌或正文写入证据。
*/
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 || '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-19-initial-load-u1',
);
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("进入医疗协同工作台")');
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 workbenchReadyMs = await login(page);
await page.waitForTimeout(1500);
const loggedInPerf = await readPerformance(page);
const counts = countInitRequests(capture.requests);
const anonymousResources = anonymousPerf.resources as ResourceSample[];
const loggedInResources = loggedInPerf.resources as ResourceSample[];
const row = {
sample,
anonymousVisibleMs,
workbenchReadyMs,
anonymous: {
navigation: anonymousPerf.navigation,
paint: anonymousPerf.paint,
summary: summarizeResources(anonymousResources),
longTaskCount: anonymousPerf.longTasks.length,
},
loggedIn: {
navigation: loggedInPerf.navigation,
paint: loggedInPerf.paint,
summary: summarizeResources(loggedInResources),
longTaskCount: loggedInPerf.longTasks.length,
},
initRequests: counts,
};
rows.push(row);
writeFileSync(path.join(evidenceDir, evidenceName), JSON.stringify(rows, null, 2));
await context.close();
}
const last = rows[rows.length - 1];
expect(last?.anonymous.summary.requestCount).toBeGreaterThan(0);
expect(last?.workbenchReadyMs).toBeGreaterThan(0);
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 = [];
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 workbenchReadyMs = await login(page);
rows.push({ profile: profile.name, sample, anonymousVisibleMs, workbenchReadyMs });
writeFileSync(path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_EVIDENCE || 'throttled.json'), JSON.stringify(rows, null, 2));
await context.close();
}
}
expect(rows.every((row) => row.workbenchReadyMs > 0)).toBe(true);
});