From 2a7fbf577135adbdeb046ff42ef6485881197525 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:53:32 +0800 Subject: [PATCH] test(web): govern performance dashboard windows (#1683) --- .../scripts/workbench-e2e-server.ts | 49 ++++++++++++++++- .../specs/admin-hwpod-performance.spec.ts | 54 +++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 4ae09a80..db11c260 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -227,7 +227,9 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload()); 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 (state.scenarioId === "performance-empty") return json(response, 200, webPerformanceEmptyPayload(url)); + if (state.scenarioId === "performance-stale-window") return json(response, 200, webPerformanceStaleWindowPayload(url)); + return json(response, 200, 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 } }); @@ -364,6 +366,51 @@ function webPerformanceEmptyPayload(url: URL): JsonRecord { }; } +function webPerformanceStaleWindowPayload(url: URL): JsonRecord { + const sampleWindow = fakePerformanceWindow(url.searchParams.get("window")); + const rows: JsonRecord[] = []; + const collectionHealth = { + schemaVersion: "hwlab-web-performance-collection-health-v1", + status: "waiting", + statusLabel: "等待数据", + reason: "no_samples_in_window", + reasonLabel: "当前窗口暂无样本", + sampleCount: 0, + retainedSampleCount: 37, + sampleSeries: 0, + lowSampleThreshold: 5, + firstSampleAt: null, + lastSampleAt: null, + lastRetainedSampleAt: "2026-06-18T03:20:00.000Z", + secondsSinceLastSample: 4200, + windowCoverageSeconds: null, + sourceFamilyCounts: [{ sourceFamily: "web", rowCount: 0, sampleCount: 37 }], + unknownAttribution: { workbenchRows: 0, rowsWithUnknown: 0, samplesWithUnknown: 0, backendUnknownRows: 0, transportUnknownRows: 0 }, + diagnostics: [{ code: "no_samples_in_window", label: "当前窗口无样本", tone: "warn", detail: "保留样本存在,但当前窗口没有性能样本。" }] + }; + 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, retainedSampleCount: 37, sampleSeries: 0, problemCount: 0 }), + backendEvidence: { schemaVersion: "hwlab-backend-performance-evidence-v1", source: "fake-server-backend-performance-store", observedAt: sampleWindow.generatedAt, sampleCount: 0, retainedSampleCount: 37, routeCount: 0, rows: [], available: true, valuesRedacted: true }, + collectionHealth, + dashboard: webPerformanceDashboardPayload(rows, sampleWindow, collectionHealth), + rows, + problems: [], + webVitals: [], + apiRoutes: [], + longTasks: [], + workbenchJourneys: [], + workbenchEventPhases: [], + workbenchBackendEvents: [] + }; +} + function performanceRow(row: JsonRecord, sampleWindow: FakePerformanceWindow): JsonRecord { const count = Number(row.count ?? 0); const sampleState = count === 0 ? "empty" : count < 5 ? "low-sample" : "ok"; 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 4c48d575..0422feb8 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 @@ -1,4 +1,18 @@ -import { expect, test } from "../fixtures/test"; +import type { Page } from "@playwright/test"; + +import { expect, fakeServerState, test } from "../fixtures/test"; + +type RequestLedgerEntry = { path?: string; query?: Record }; + +async function performanceSummaryRequests(page: Page): Promise { + const state = await fakeServerState(page); + const ledger = Array.isArray(state.requestLedger) ? state.requestLedger : []; + return ledger.filter((entry): entry is RequestLedgerEntry => Boolean(entry && typeof entry === "object" && (entry as RequestLedgerEntry).path === "/v1/web-performance/summary")); +} + +function countWindowRequests(entries: RequestLedgerEntry[], windowId: string): number { + return entries.filter((entry) => entry.query?.window === windowId).length; +} test("admin HWPOD groups renders node-ops as structured UI before raw JSON is requested", async ({ page }) => { await page.goto("/admin/hwpod-groups"); @@ -44,8 +58,25 @@ test("performance dashboard renders Chinese charts and keeps drill-down reachabl 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 小时"); + const windowChecks = [ + { tab: "5 分钟", windowId: "5m", label: "最近 5 分钟" }, + { tab: "15 分钟", windowId: "15m", label: "最近 15 分钟" }, + { tab: "1 小时", windowId: "1h", label: "最近 1 小时" }, + { tab: "6 小时", windowId: "6h", label: "最近 6 小时" }, + { tab: "24 小时", windowId: "24h", label: "最近 24 小时" }, + { tab: "全部", windowId: "all", label: "全部样本" } + ]; + for (const item of windowChecks) { + await page.getByRole("tab", { name: item.tab, exact: true }).click(); + await expect(page.locator(".performance-contract-item").filter({ hasText: "观测窗口" })).toContainText(item.label); + } + const summaryRequests = await performanceSummaryRequests(page); + for (const item of windowChecks) expect(countWindowRequests(summaryRequests, item.windowId)).toBeGreaterThan(0); + + const allRequestsBeforeRefresh = countWindowRequests(summaryRequests, "all"); + await page.getByLabel("采集新鲜度").getByRole("button", { name: "刷新" }).click(); + await expect.poll(async () => countWindowRequests(await performanceSummaryRequests(page), "all")).toBeGreaterThan(allRequestsBeforeRefresh); + await expect(page.locator(".performance-contract-item").filter({ hasText: "观测窗口" })).toContainText("全部样本"); await page.screenshot({ path: ".state/workbench-e2e/performance-dashboard-desktop.png", fullPage: true }); @@ -88,6 +119,23 @@ test.describe("performance dashboard empty collection", () => { }); }); +test.describe("performance dashboard stale current window", () => { + test.use({ scenarioId: "performance-stale-window" }); + + test("keeps retained samples separate from the empty current window", async ({ page }) => { + await page.goto("/performance"); + + await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本"); + await expect(page.locator(".performance-collection-item").filter({ hasText: "最近样本" })).toContainText("当前窗口暂无样本"); + await expect(page.locator(".performance-collection-item").filter({ hasText: "当前窗口样本" })).toContainText("0 样本"); + await expect(page.locator(".performance-collection-item").filter({ hasText: "当前窗口样本" })).toContainText("保留 37 样本"); + await expect(page.getByText("当前窗口无样本")).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" });