test(web): record login waterfall and init request counts
Give the initial-load baseline a repeatable probe that strips query strings and can assert the login member fanout stays on the current project. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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));
|
||||
}
|
||||
});
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"test:e2e:intent-plan-cards": "RUN_UI_ACCEPTANCE=1 playwright test e2e/intent-plan-cards.spec.ts --project=chromium",
|
||||
"test:e2e:project143-regression": "RUN_UI_ACCEPTANCE=1 playwright test e2e/project143-regression.spec.ts --project=chromium",
|
||||
"test:e2e:workflow-plan-409-frozen-duties": "RUN_UI_ACCEPTANCE=1 playwright test e2e/workflow-plan-409-frozen-duties.spec.ts --project=chromium",
|
||||
"test:e2e:list": "playwright test --list"
|
||||
"test:e2e:list": "playwright test --list",
|
||||
"test:e2e:initial-load": "RUN_INITIAL_LOAD_BASELINE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.11",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { countInitRequests, percentile, redactResourceUrl, summarizeResources } from './initialLoadMetrics';
|
||||
|
||||
test('redactResourceUrl drops query and origin', () => {
|
||||
assert.equal(
|
||||
redactResourceUrl('http://127.0.0.1:8080/api/v1/chat/sessions?project_id=1&token=secret'),
|
||||
'/api/v1/chat/sessions',
|
||||
);
|
||||
assert.equal(redactResourceUrl('/assets/index-abc.js'), '/assets/index-abc.js');
|
||||
});
|
||||
|
||||
test('summarizeResources flags deferred heavy chunks', () => {
|
||||
const summary = summarizeResources([
|
||||
{ path: '/assets/index-abc.js', initiator: 'script', durationMs: 12, transferBytes: 100, decodedBytes: 400 },
|
||||
{ path: '/assets/katex-xyz.js', initiator: 'script', durationMs: 4, transferBytes: 50, decodedBytes: 200 },
|
||||
]);
|
||||
assert.equal(summary.jsCount, 2);
|
||||
assert.equal(summary.transferBytes, 150);
|
||||
assert.deepEqual(summary.heavyChunks, ['/assets/katex-xyz.js']);
|
||||
});
|
||||
|
||||
test('countInitRequests separates member fanout from session lists', () => {
|
||||
const counts = countInitRequests([
|
||||
{ method: 'GET', path: '/api/v1/projects/1/members' },
|
||||
{ method: 'GET', path: '/api/v1/projects/2/members' },
|
||||
{ method: 'POST', path: '/api/v1/projects/2/members' },
|
||||
{ method: 'GET', path: '/api/v1/chat/sessions' },
|
||||
{ method: 'GET', path: '/api/v1/chat/sessions' },
|
||||
]);
|
||||
assert.equal(counts.memberGetCount, 2);
|
||||
assert.equal(counts.distinctMemberProjects, 2);
|
||||
assert.equal(counts.sessionListGetCount, 2);
|
||||
});
|
||||
|
||||
test('percentile uses nearest-rank and stays null on empty input', () => {
|
||||
assert.equal(percentile([], 75), null);
|
||||
assert.equal(percentile([10, 20, 30, 40], 75), 30);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/** 首次加载证据的纯函数。URL 查询串可能带会话参数,证据里只留路径。 */
|
||||
|
||||
export interface ResourceSample {
|
||||
path: string;
|
||||
initiator: string;
|
||||
durationMs: number;
|
||||
transferBytes: number;
|
||||
decodedBytes: number;
|
||||
}
|
||||
|
||||
export interface RequestSample {
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const HEAVY_CHUNK = /katex|mermaid|docx|xlsx|AdminConsole|BlockEditor|FilePreview/i;
|
||||
|
||||
export function redactResourceUrl(raw: string): string {
|
||||
const noQuery = raw.split('?')[0] ?? raw;
|
||||
try {
|
||||
const parsed = new URL(noQuery);
|
||||
return parsed.pathname || '/';
|
||||
} catch {
|
||||
return noQuery.replace(/^https?:\/\/[^/]+/i, '') || '/';
|
||||
}
|
||||
}
|
||||
|
||||
export function percentile(values: number[], p: number): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
||||
return sorted[rank] ?? null;
|
||||
}
|
||||
|
||||
export function summarizeResources(resources: ResourceSample[]) {
|
||||
const transferBytes = resources.reduce((sum, row) => sum + (row.transferBytes || 0), 0);
|
||||
const js = resources.filter((row) => row.path.endsWith('.js'));
|
||||
const heavy = resources.filter((row) => HEAVY_CHUNK.test(row.path)).map((row) => row.path);
|
||||
return {
|
||||
requestCount: resources.length,
|
||||
transferBytes,
|
||||
jsTransferBytes: js.reduce((sum, row) => sum + (row.transferBytes || 0), 0),
|
||||
jsCount: js.length,
|
||||
heavyChunks: [...new Set(heavy)],
|
||||
};
|
||||
}
|
||||
|
||||
export function countInitRequests(requests: RequestSample[]) {
|
||||
const members = requests.filter(
|
||||
(row) => row.method === 'GET' && /^\/api\/v1\/projects\/\d+\/members$/.test(row.path),
|
||||
);
|
||||
const sessions = requests.filter(
|
||||
(row) => row.method === 'GET' && row.path === '/api/v1/chat/sessions',
|
||||
);
|
||||
const projectIds = new Set(
|
||||
members.map((row) => row.path.split('/')[4]).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
return {
|
||||
memberGetCount: members.length,
|
||||
distinctMemberProjects: projectIds.size,
|
||||
sessionListGetCount: sessions.length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user