From 037043b7c80a87746c117be9824c00dca5dd053f Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:26:13 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=94=B6=E6=95=9B=20Performance=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E9=AA=8C=E8=AF=81=20(#1649)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/web-performance.ts | 1 - .../scripts/workbench-e2e-server.ts | 144 +++++++++++++++--- web/hwlab-cloud-web/src/types/index.ts | 2 +- .../specs/admin-hwpod-performance.spec.ts | 39 +++++ 4 files changed, 163 insertions(+), 23 deletions(-) diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index a0c5f3fa..0e391489 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -1307,7 +1307,6 @@ function buildPerformanceDashboard(input: { generatedAt: input.summary.generatedAt ?? input.observedAt, aggregationKind: input.summary.aggregationKind ?? "exact", approximation: input.summary.approximation ?? "exact", - bucketKind: "snapshot", buckets: [{ id: input.sampleWindow.id, label: input.sampleWindow.label, observedAt: input.observedAt, sampleCount }] }, freshness: { diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 6a5d369f..f5372949 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -42,6 +42,15 @@ interface ScenarioState { liveBackfillReadyAtMs: number | null; } +interface FakePerformanceWindow { + id: string; + label: string; + seconds: number | null; + windowFrom: string | null; + windowTo: string; + generatedAt: string; +} + const cwd = process.cwd(); const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { @@ -214,7 +223,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] }); if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload()); if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload()); - if (path === "/v1/web-performance/summary") return json(response, 200, webPerformanceSummaryPayload()); + if (path === "/v1/web-performance/summary") { + if (state.scenarioId === "performance-summary-error") return json(response, 503, { ok: false, status: 503, error: { code: "performance_summary_unavailable", message: "performance summary read model is unavailable" } }); + return json(response, 200, state.scenarioId === "performance-empty" ? webPerformanceEmptyPayload(url) : webPerformanceSummaryPayload(url)); + } if (path.startsWith("/v1/") || path.startsWith("/auth/") || path.startsWith("/health")) return json(response, 500, { ok: false, status: 500, error: { code: "unmocked_request", path } }); return staticFile(response, path); @@ -261,24 +273,28 @@ function hwpodNodeOpsPayload(): JsonRecord { }; } -function webPerformanceSummaryPayload(): JsonRecord { +function webPerformanceSummaryPayload(url: URL): JsonRecord { + const sampleWindow = fakePerformanceWindow(url.searchParams.get("window")); const rows = [ - performanceRow({ kind: "workbench-journey", metric: "session-switch-full-load", route: "/workbench/sessions/:sessionId", count: 21, p50: 0.18, p75: 0.31, p95: 1.12, tone: "warn", backend: "agentrun", transport: "sse", targetState: "completed" }), - performanceRow({ kind: "workbench-event-phase", metric: "trace-event-projected", route: "/workbench", count: 28, p50: 0.08, p75: 0.14, p95: 0.42, tone: "ok", eventType: "tool_result", phase: "render", source: "rum" }), - performanceRow({ kind: "workbench-backend-event", metric: "backend-event-visible", route: "/v1/workbench/events", count: 15, p50: 0.2, p75: 0.34, p95: 1.38, tone: "warn", eventType: "agentrun.stdout", backend: "agentrun", cache: "miss" }), - performanceRow({ kind: "web-vital", metric: "LCP", route: "/performance", count: 9, p50: 1.2, p75: 1.8, p95: 2.9, tone: "ok" }), - performanceRow({ kind: "api", metric: "fetchJson", route: "/v1/web-performance/summary", method: "GET", statusClass: "2xx", outcome: "ok", count: 17, p50: 0.09, p75: 0.15, p95: 0.33, tone: "ok" }), - performanceRow({ kind: "long-task", metric: "longtask", route: "/workbench", count: 3, p50: 0.08, p75: 0.11, p95: 0.2, tone: "warn", problem: "main-thread busy" }) + performanceRow({ kind: "workbench-journey", metric: "session-switch-full-load", route: "/workbench/sessions/:sessionId", count: 21, p50: 0.18, p75: 0.31, p95: 1.12, tone: "warn", backend: "agentrun", transport: "sse", targetState: "completed", sourceFamily: "workbench" }, sampleWindow), + performanceRow({ kind: "workbench-event-phase", metric: "trace-event-projected", route: "/workbench", count: 28, p50: 0.08, p75: 0.14, p95: 0.42, tone: "ok", eventType: "tool_result", phase: "render", source: "rum", sourceFamily: "workbench" }, sampleWindow), + performanceRow({ kind: "workbench-backend-event", metric: "backend-event-visible", route: "/v1/workbench/events", count: 15, p50: 0.2, p75: 0.34, p95: 1.38, tone: "warn", eventType: "agentrun.stdout", backend: "agentrun", cache: "miss", sourceFamily: "backend" }, sampleWindow), + performanceRow({ kind: "web-vital", metric: "LCP", route: "/performance", count: 9, p50: 1.2, p75: 1.8, p95: 2.9, tone: "ok", sourceFamily: "web" }, sampleWindow), + performanceRow({ kind: "api", metric: "api_request", route: "/v1/web-performance/summary", method: "GET", statusClass: "2xx", outcome: "ok", count: 17, p50: 0.09, p75: 0.15, p95: 0.33, tone: "ok", sourceFamily: "web" }, sampleWindow), + performanceRow({ kind: "api", metric: "api_request", route: "/v1/workbench/sessions", method: "GET", statusClass: "2xx", outcome: "ok", count: 3, p50: 2.5, p75: 5, p95: 10, tone: "warn", problem: "slow-api_request", sampleState: "low-sample", freshness: "low-sample", aggregationKind: "histogram", approximation: "bucketed", sourceFamily: "web" }, sampleWindow), + performanceRow({ kind: "long-task", metric: "longtask", route: "/workbench", count: 3, p50: 0.08, p75: 0.11, p95: 0.2, tone: "warn", problem: "main-thread busy", sourceFamily: "web" }, sampleWindow) ]; + const sampleCount = performanceSampleCount(rows); return { ok: true, status: "ready", source: "fake-server", - observedAt: "2026-06-18T04:30:00.000Z", + observedAt: sampleWindow.generatedAt, namespace: "hwlab-v03", gitopsTarget: "D601/v03", - summary: { status: "ready", sampleCount: 93, sampleSeries: 12, problemCount: 2, lowSampleThreshold: 5, workbenchJourneySeries: 1, workbenchEventPhaseSeries: 1, workbenchBackendEventVisibleSeries: 1 }, - dashboard: webPerformanceDashboardPayload(rows), + sampleWindow: performanceSampleWindowPayload(sampleWindow), + summary: performanceSummaryContract(sampleWindow, rows, { status: "ready", sampleCount, sampleSeries: 12, problemCount: rows.filter((row) => row.tone === "warn").length }), + dashboard: webPerformanceDashboardPayload(rows, sampleWindow), rows, problems: rows.filter((row) => row.tone === "warn"), webVitals: rows.filter((row) => row.kind === "web-vital"), @@ -290,25 +306,68 @@ function webPerformanceSummaryPayload(): JsonRecord { }; } -function performanceRow(row: JsonRecord): JsonRecord { - return { method: "GET", statusClass: "2xx", outcome: "ok", unit: "seconds", sampleState: "ok", ...row }; +function webPerformanceEmptyPayload(url: URL): JsonRecord { + const sampleWindow = fakePerformanceWindow(url.searchParams.get("window")); + const rows: JsonRecord[] = []; + return { + ok: true, + status: "waiting", + source: "fake-server", + observedAt: sampleWindow.generatedAt, + namespace: "hwlab-v03", + gitopsTarget: "D601/v03", + sampleWindow: performanceSampleWindowPayload(sampleWindow), + summary: performanceSummaryContract(sampleWindow, rows, { status: "waiting", sampleCount: 0, sampleSeries: 0, problemCount: 0 }), + dashboard: webPerformanceDashboardPayload(rows, sampleWindow), + rows, + problems: [], + webVitals: [], + apiRoutes: [], + longTasks: [], + workbenchJourneys: [], + workbenchEventPhases: [], + workbenchBackendEvents: [] + }; } -function webPerformanceDashboardPayload(rows: JsonRecord[]): JsonRecord { +function performanceRow(row: JsonRecord, sampleWindow: FakePerformanceWindow): JsonRecord { + const count = Number(row.count ?? 0); + const sampleState = count === 0 ? "empty" : count < 5 ? "low-sample" : "ok"; + return { + method: "GET", + statusClass: "2xx", + outcome: "ok", + unit: "seconds", + sampleState, + sampleCount: count, + sourceFamily: "mixed", + freshness: sampleState === "ok" ? "fresh" : sampleState, + aggregationKind: "exact", + approximation: "exact", + windowFrom: sampleWindow.windowFrom, + windowTo: sampleWindow.windowTo, + generatedAt: sampleWindow.generatedAt, + ...row + }; +} + +function webPerformanceDashboardPayload(rows: JsonRecord[], sampleWindow: FakePerformanceWindow): JsonRecord { const workbench = rows.filter((row) => String(row.kind).startsWith("workbench")); const api = rows.filter((row) => row.kind === "api"); const vital = rows.filter((row) => row.kind === "web-vital"); const longTask = rows.filter((row) => row.kind === "long-task"); + const sampleCount = performanceSampleCount(rows); + const problemCount = rows.filter((row) => row.tone === "warn").length; return { schemaVersion: "hwlab-web-performance-dashboard-v1", - generatedAt: "2026-06-18T04:30:00.000Z", - sampleWindow: { label: "当前采集窗口", bucketKind: "snapshot", buckets: [{ id: "current", label: "当前窗口", observedAt: "2026-06-18T04:30:00.000Z", sampleCount: 93 }] }, - freshness: { observedAt: "2026-06-18T04:30:00.000Z", source: "fake-server", namespace: "hwlab-v03", gitopsTarget: "D601/v03", status: "watch", statusLabel: "预警", lowSampleThreshold: 5 }, + generatedAt: sampleWindow.generatedAt, + sampleWindow: { ...performanceSampleWindowPayload(sampleWindow), buckets: [{ id: sampleWindow.id, label: sampleWindow.label, observedAt: sampleWindow.generatedAt, sampleCount }] }, + freshness: { observedAt: sampleWindow.generatedAt, generatedAt: sampleWindow.generatedAt, windowFrom: sampleWindow.windowFrom, windowTo: sampleWindow.windowTo, sampleCount, source: "fake-server", sourceFamily: "mixed", namespace: "hwlab-v03", gitopsTarget: "D601/v03", status: sampleCount === 0 ? "waiting" : "watch", statusLabel: sampleCount === 0 ? "等待数据" : "预警", lowSampleThreshold: 5 }, cards: [ - { id: "health", label: "采集状态", value: "预警", unit: "状态", tone: "warn", detail: "fake-server" }, - { id: "samples", label: "样本数", value: 93, unit: "样本", tone: "ok", detail: "12 个序列" }, - { id: "workbench", label: "工作台序列", value: 3, unit: "序列", tone: "ok", detail: "hwlab-v03 / D601/v03" }, - { id: "problems", label: "问题行", value: 2, unit: "条", tone: "warn", detail: "低样本阈值 5 个样本" } + { id: "health", label: "采集状态", value: sampleCount === 0 ? "等待数据" : "预警", unit: "状态", tone: sampleCount === 0 ? "source" : "warn", detail: "fake-server" }, + { id: "samples", label: "样本数", value: sampleCount, unit: "样本", tone: sampleCount === 0 ? "source" : "ok", detail: `${sampleWindow.label} · ${rows.length} 个序列` }, + { id: "workbench", label: "工作台序列", value: workbench.length, unit: "序列", tone: workbench.length > 0 ? "ok" : "source", detail: "hwlab-v03 / D601/v03" }, + { id: "problems", label: "问题行", value: problemCount, unit: "条", tone: problemCount > 0 ? "warn" : "ok", detail: "低样本阈值 5 个样本" } ], trends: [ { id: "workbench-experience", title: "工作台体感趋势", subtitle: "首屏、会话切换和提交首个可见结果的 P95", unit: "seconds", points: workbench.map(dashboardPoint) }, @@ -327,6 +386,49 @@ function webPerformanceDashboardPayload(rows: JsonRecord[]): JsonRecord { }; } +function fakePerformanceWindow(value: string | null): FakePerformanceWindow { + const id = ["5m", "15m", "1h", "6h", "24h", "all"].includes(String(value)) ? String(value) : "15m"; + const generatedAt = "2026-06-18T04:30:00.000Z"; + const specs: Record> = { + "5m": { label: "最近 5 分钟", seconds: 300, windowFrom: "2026-06-18T04:25:00.000Z" }, + "15m": { label: "最近 15 分钟", seconds: 900, windowFrom: "2026-06-18T04:15:00.000Z" }, + "1h": { label: "最近 1 小时", seconds: 3600, windowFrom: "2026-06-18T03:30:00.000Z" }, + "6h": { label: "最近 6 小时", seconds: 21600, windowFrom: "2026-06-17T22:30:00.000Z" }, + "24h": { label: "最近 24 小时", seconds: 86400, windowFrom: "2026-06-17T04:30:00.000Z" }, + all: { label: "全部样本", seconds: null, windowFrom: null } + }; + const spec = specs[id] ?? { label: "最近 15 分钟", seconds: 900, windowFrom: "2026-06-18T04:15:00.000Z" }; + return { id, generatedAt, windowTo: generatedAt, ...spec }; +} + +function performanceSampleWindowPayload(sampleWindow: FakePerformanceWindow): JsonRecord { + return { id: sampleWindow.id, label: sampleWindow.label, seconds: sampleWindow.seconds, windowFrom: sampleWindow.windowFrom, windowTo: sampleWindow.windowTo, generatedAt: sampleWindow.generatedAt, aggregationKind: "exact", approximation: "exact" }; +} + +function performanceSummaryContract(sampleWindow: FakePerformanceWindow, rows: JsonRecord[], summary: JsonRecord): JsonRecord { + return { + routeCount: rows.length, + durationSeries: rows.filter((row) => row.unit === "seconds").length, + lowSampleThreshold: 5, + workbenchJourneySeries: rows.filter((row) => row.kind === "workbench-journey").length, + workbenchEventPhaseSeries: rows.filter((row) => row.kind === "workbench-event-phase").length, + workbenchBackendEventVisibleSeries: rows.filter((row) => row.kind === "workbench-backend-event").length, + window: sampleWindow.id, + windowLabel: sampleWindow.label, + windowSeconds: sampleWindow.seconds, + windowFrom: sampleWindow.windowFrom, + windowTo: sampleWindow.windowTo, + generatedAt: sampleWindow.generatedAt, + aggregationKind: "exact", + approximation: "exact", + ...summary + }; +} + +function performanceSampleCount(rows: JsonRecord[]): number { + return rows.reduce((total, row) => total + (Number(row.count) || 0), 0); +} + function dashboardPoint(row: JsonRecord): JsonRecord { return { label: dashboardMetricLabel(row), detail: dashboardRouteLabel(row.route), value: row.p95, p50: row.p50, p75: row.p75, p95: row.p95, count: row.count, tone: row.tone, sampleState: row.sampleState }; } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index abb6f7d8..445545f3 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -432,7 +432,7 @@ export interface WebPerformanceDashboardCard extends Record { export interface WebPerformanceDashboard extends Record { schemaVersion?: string; generatedAt?: string; - sampleWindow?: WebPerformanceSampleWindow & { bucketKind?: string; buckets?: Array>; [key: string]: unknown }; + sampleWindow?: WebPerformanceSampleWindow & { buckets?: Array>; [key: string]: unknown }; freshness?: { observedAt?: string; source?: string; namespace?: string; gitopsTarget?: string; status?: string; statusLabel?: string; lowSampleThreshold?: number; generatedAt?: string; windowFrom?: string | null; windowTo?: string; sampleCount?: number; sourceFamily?: string; [key: string]: unknown }; cards?: WebPerformanceDashboardCard[]; trends?: WebPerformanceDashboardTrend[]; diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts index d8223f21..3670c826 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts @@ -20,14 +20,25 @@ test("performance dashboard renders Chinese charts and keeps drill-down reachabl await page.goto("/performance"); await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.locator(".performance-contract-item")).toHaveCount(5); + await expect(page.locator(".performance-contract-item").filter({ hasText: "观测窗口" })).toContainText("最近 15 分钟"); + await expect(page.locator(".performance-contract-item").filter({ hasText: "生成时间" })).toContainText("fake-server"); + await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("路由"); + await expect(page.locator(".performance-contract-item").filter({ hasText: "数据质量" })).toContainText("低样本阈值 5 个样本"); + await expect(page.locator(".performance-contract-item").filter({ hasText: "统计口径" })).toContainText("精确窗口分位"); await expect(page.getByText("工作台体感趋势")).toBeVisible(); await expect(page.getByText("状态分布")).toBeVisible(); await expect(page.getByText("慢 API TopN")).toBeVisible(); await expect(page.locator("svg.performance-line-chart")).toHaveCount(3); await expect(page.locator(".chart-point")).not.toHaveCount(0); + await expect(page.locator(".performance-row-contract").filter({ hasText: "桶化估算" }).first()).toBeVisible(); + await expect(page.locator(".performance-row-contract").filter({ hasText: "低样本" }).first()).toBeVisible(); await expect(page.locator("body")).not.toContainText(/Metric|Route|Count|Status|Samples|Problems|Workbench|Web Vitals|API Timing|Long Tasks/u); await expect(page.locator("body")).not.toContainText(/sessionId|traceId|runId|stdout|stderr|Secret/u); + await page.getByRole("tab", { name: "1 小时" }).click(); + await expect(page.locator(".performance-contract-item").filter({ hasText: "观测窗口" })).toContainText("最近 1 小时"); + await page.screenshot({ path: ".state/workbench-e2e/performance-dashboard-desktop.png", fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); @@ -49,3 +60,31 @@ test("performance dashboard renders Chinese charts and keeps drill-down reachabl const documentScrollWidth = await page.evaluate(() => Math.round(document.documentElement.scrollWidth)); expect(documentScrollWidth).toBeLessThanOrEqual(390); }); + +test.describe("performance dashboard empty collection", () => { + test.use({ scenarioId: "performance-empty" }); + + test("renders the empty summary contract without invented rows", async ({ page }) => { + await page.goto("/performance"); + + await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.locator(".performance-contract-item")).toHaveCount(5); + await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本"); + await expect(page.getByText("等待数据").first()).toBeVisible(); + await expect(page.getByText("暂无性能样本。请先打开工作台或执行一次真实页面操作后刷新。")).toBeVisible(); + await expect(page.locator(".performance-row-contract")).toHaveCount(0); + }); +}); + +test.describe("performance dashboard summary error", () => { + test.use({ scenarioId: "performance-summary-error" }); + + test("surfaces the summary read-model failure without stale dashboard data", async ({ page }) => { + await page.goto("/performance"); + + await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.getByText("性能监控加载失败")).toBeVisible(); + await expect(page.getByRole("button", { name: "重试" })).toBeVisible(); + await expect(page.locator(".performance-contract-item")).toHaveCount(0); + }); +});