From eabaadd2a080bb82a2718638054cb86a42879be3 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 12:57:12 +0000 Subject: [PATCH] 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 --- web/e2e/initial-load-performance.spec.ts | 159 +++++++++++++++++++++++ web/package.json | 3 +- web/src/lib/initialLoadMetrics.test.ts | 39 ++++++ web/src/lib/initialLoadMetrics.ts | 63 +++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 web/e2e/initial-load-performance.spec.ts create mode 100644 web/src/lib/initialLoadMetrics.test.ts create mode 100644 web/src/lib/initialLoadMetrics.ts diff --git a/web/e2e/initial-load-performance.spec.ts b/web/e2e/initial-load-performance.spec.ts new file mode 100644 index 00000000..7607f424 --- /dev/null +++ b/web/e2e/initial-load-performance.spec.ts @@ -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)); + } +}); diff --git a/web/package.json b/web/package.json index 4930e6c5..bed6f6c1 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/initialLoadMetrics.test.ts b/web/src/lib/initialLoadMetrics.test.ts new file mode 100644 index 00000000..e4b24f25 --- /dev/null +++ b/web/src/lib/initialLoadMetrics.test.ts @@ -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); +}); diff --git a/web/src/lib/initialLoadMetrics.ts b/web/src/lib/initialLoadMetrics.ts new file mode 100644 index 00000000..3a482909 --- /dev/null +++ b/web/src/lib/initialLoadMetrics.ts @@ -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, + }; +}