| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { |
| 3 | effectiveGroupSeverity, |
| 4 | isDevelopmentGroup, |
| 5 | normalizeForFingerprint, |
| 6 | Ping, |
| 7 | Metrics, |
| 8 | CLI_TELEMETRY_SCHEMA_SQL, |
| 9 | ensureCLITelemetrySchema, |
| 10 | nativeWebRuntimeFingerprintBasis, |
| 11 | telemetryTableNames, |
| 12 | } from "./index"; |
| 13 | import { |
| 14 | compareReleaseVersions, |
| 15 | groupFingerprintFromPath, |
| 16 | isDevelopmentReport, |
| 17 | isKnownNonCrashDiagnostic, |
| 18 | maxSeverity, |
| 19 | namespaceReportFingerprint, |
| 20 | regressionDecisionForReport, |
| 21 | reportSubjectIdentity, |
| 22 | severityForReport, |
| 23 | } from "./report_classification"; |
| 24 | import type { Env } from "./env"; |
| 25 | import { renderStats } from "./stats"; |
| 26 | import clientSurfaceMigrationSQL from "../migrate-client-surface.sql?raw"; |
| 27 | |
| 28 | const base = { |
| 29 | kind: "crash", |
| 30 | source: "frontend.global", |
| 31 | label: "window.error", |
| 32 | errorType: "Error", |
| 33 | errorMessage: "boom", |
| 34 | topFrame: "at render (assets/index.js:1:2)", |
| 35 | }; |
| 36 | |
| 37 | describe("metrics compatibility", () => { |
| 38 | it("defaults old ping and metrics payloads to desktop", () => { |
| 39 | const ping = Ping.parse({ |
| 40 | installId: "a".repeat(32), |
| 41 | version: "v1.20.0", |
| 42 | os: "darwin", |
| 43 | arch: "arm64", |
| 44 | }); |
| 45 | const metrics = Metrics.parse({ |
| 46 | version: "v1.20.0", |
| 47 | os: "darwin", |
| 48 | counters: [{ signal: "turns", bucket: "count", count: 1 }], |
| 49 | }); |
| 50 | expect(ping.surface).toBe("desktop"); |
| 51 | expect(metrics.surface).toBe("desktop"); |
| 52 | }); |
| 53 | |
| 54 | it("accepts CLI surface and fixed CLI signals", () => { |
| 55 | const parsed = Metrics.safeParse({ |
| 56 | surface: "cli", |
| 57 | version: "v1.20.0", |
| 58 | os: "linux", |
| 59 | counters: [ |
| 60 | { signal: "cli_mode", bucket: "run", count: 1 }, |
| 61 | { signal: "cli_profile", bucket: "delivery", count: 1 }, |
| 62 | { signal: "cli_turn_latency", bucket: "s_5_15", count: 1 }, |
| 63 | { signal: "cli_exit", bucket: "success", count: 1 }, |
| 64 | ], |
| 65 | }); |
| 66 | expect(parsed.success).toBe(true); |
| 67 | if (parsed.success) expect(parsed.data.surface).toBe("cli"); |
| 68 | }); |
| 69 | |
| 70 | it("rejects invalid client surfaces", () => { |
| 71 | expect( |
| 72 | Metrics.safeParse({ |
| 73 | surface: "server", |
| 74 | version: "v1.20.0", |
| 75 | os: "linux", |
| 76 | counters: [{ signal: "turns", bucket: "count", count: 1 }], |
| 77 | }).success, |
| 78 | ).toBe(false); |
| 79 | }); |
| 80 | |
| 81 | it("drops unknown signals without rejecting known counters in the batch", () => { |
| 82 | const payload = { |
| 83 | version: "v1.17.16", |
| 84 | os: "darwin", |
| 85 | counters: [ |
| 86 | { signal: "settings_auto_plan", bucket: "off", count: 1 }, |
| 87 | { signal: "cache_hit", bucket: "90_100", count: 1 }, |
| 88 | ], |
| 89 | }; |
| 90 | |
| 91 | const parsed = Metrics.safeParse(payload); |
| 92 | expect(parsed.success).toBe(true); |
| 93 | if (!parsed.success) return; |
| 94 | expect(parsed.data.counters).toEqual([{ signal: "cache_hit", bucket: "90_100", count: 1 }]); |
| 95 | }); |
| 96 | |
| 97 | it("accepts an all-unknown batch as an empty no-op", () => { |
| 98 | const parsed = Metrics.safeParse({ |
| 99 | version: "v1.18.0", |
| 100 | os: "darwin", |
| 101 | counters: [{ signal: "future_signal", arbitrary: "future payload" }], |
| 102 | }); |
| 103 | |
| 104 | expect(parsed.success).toBe(true); |
| 105 | if (!parsed.success) return; |
| 106 | expect(parsed.data.counters).toEqual([]); |
| 107 | }); |
| 108 | |
| 109 | it("still rejects malformed counters for known signals", () => { |
| 110 | expect( |
| 111 | Metrics.safeParse({ |
| 112 | version: "v1.18.0", |
| 113 | os: "darwin", |
| 114 | counters: [{ signal: "cache_hit", bucket: "not allowed", count: 1 }], |
| 115 | }).success, |
| 116 | ).toBe(false); |
| 117 | }); |
| 118 | |
| 119 | it("accepts desktop lifecycle and Windows diagnostic counters", () => { |
| 120 | const parsed = Metrics.safeParse({ |
| 121 | version: "v1.19.0", |
| 122 | os: "windows", |
| 123 | counters: [ |
| 124 | { signal: "desktop_exit_phase", bucket: "healthy", count: 2 }, |
| 125 | { signal: "desktop_uptime", bucket: "m_2_10", count: 2 }, |
| 126 | { signal: "desktop_webview2_failure", bucket: "gpu_process_exited", count: 1 }, |
| 127 | { signal: "desktop_restore", bucket: "timeout", count: 1 }, |
| 128 | ], |
| 129 | }); |
| 130 | |
| 131 | expect(parsed.success).toBe(true); |
| 132 | if (!parsed.success) return; |
| 133 | expect(parsed.data.counters).toHaveLength(4); |
| 134 | }); |
| 135 | }); |
| 136 | |
| 137 | describe("telemetry deployment order compatibility", () => { |
| 138 | it("keeps the released Desktop tables unchanged and isolates CLI rows", () => { |
| 139 | expect(telemetryTableNames("desktop")).toEqual({ |
| 140 | pings: "pings", |
| 141 | metrics: "metrics", |
| 142 | }); |
| 143 | expect(telemetryTableNames("cli")).toEqual({ |
| 144 | pings: "cli_pings", |
| 145 | metrics: "cli_metrics", |
| 146 | }); |
| 147 | }); |
| 148 | |
| 149 | it("keeps the migration additive when it runs before the released Worker", () => { |
| 150 | expect(clientSurfaceMigrationSQL).not.toMatch(/\b(?:DROP|ALTER)\b/); |
| 151 | expect(clientSurfaceMigrationSQL).not.toMatch( |
| 152 | /CREATE TABLE(?: IF NOT EXISTS)?\s+(?:pings|metrics|metric_users)\b/, |
| 153 | ); |
| 154 | for (const table of ["cli_pings", "cli_metrics", "cli_metric_users"]) { |
| 155 | expect(clientSurfaceMigrationSQL).toMatch( |
| 156 | new RegExp(`CREATE TABLE IF NOT EXISTS\\s+${table}\\b`), |
| 157 | ); |
| 158 | } |
| 159 | }); |
| 160 | |
| 161 | it("extends the legacy CLI migration with the diagnostics bootstrap columns", () => { |
| 162 | const runtimeSchema = CLI_TELEMETRY_SCHEMA_SQL.join("\n"); |
| 163 | expect(runtimeSchema).toContain("os_build INTEGER NOT NULL DEFAULT 0"); |
| 164 | expect(runtimeSchema).toContain("os_revision INTEGER NOT NULL DEFAULT 0"); |
| 165 | expect(runtimeSchema).toContain("arch TEXT NOT NULL"); |
| 166 | }); |
| 167 | |
| 168 | it("uses additive idempotent DDL when the Worker deploys before the migration", async () => { |
| 169 | const prepared: string[] = []; |
| 170 | let batches = 0; |
| 171 | const db = { |
| 172 | prepare(sql: string) { |
| 173 | prepared.push(sql); |
| 174 | return { sql }; |
| 175 | }, |
| 176 | async batch() { |
| 177 | batches++; |
| 178 | return []; |
| 179 | }, |
| 180 | } as unknown as D1Database; |
| 181 | |
| 182 | await Promise.all([ |
| 183 | ensureCLITelemetrySchema({ DB: db }), |
| 184 | ensureCLITelemetrySchema({ DB: db }), |
| 185 | ]); |
| 186 | |
| 187 | expect(batches).toBe(1); |
| 188 | expect(prepared).toEqual([...CLI_TELEMETRY_SCHEMA_SQL]); |
| 189 | expect(prepared.every((sql) => /CREATE (?:TABLE|INDEX) IF NOT EXISTS/.test(sql))).toBe(true); |
| 190 | expect(prepared.join("\n")).not.toMatch(/\b(?:DROP|ALTER)\b/); |
| 191 | }); |
| 192 | |
| 193 | it("retries schema initialization after a transient D1 failure", async () => { |
| 194 | let batches = 0; |
| 195 | const db = { |
| 196 | prepare(sql: string) { |
| 197 | return { sql }; |
| 198 | }, |
| 199 | async batch() { |
| 200 | batches++; |
| 201 | if (batches === 1) throw new Error("temporary D1 failure"); |
| 202 | return []; |
| 203 | }, |
| 204 | } as unknown as D1Database; |
| 205 | |
| 206 | await expect(ensureCLITelemetrySchema({ DB: db })).rejects.toThrow("temporary D1 failure"); |
| 207 | await expect(ensureCLITelemetrySchema({ DB: db })).resolves.toBeUndefined(); |
| 208 | expect(batches).toBe(2); |
| 209 | }); |
| 210 | }); |
| 211 | |
| 212 | describe("diagnostic classification", () => { |
| 213 | it("only confirms a regression at or after the resolved release", () => { |
| 214 | expect(compareReleaseVersions("v1.38.3", "v1.38.4")).toBeLessThan(0); |
| 215 | expect(compareReleaseVersions("v1.38.4", "v1.38.4")).toBe(0); |
| 216 | expect(compareReleaseVersions("v1.39.0", "v1.38.4")).toBeGreaterThan(0); |
| 217 | expect(compareReleaseVersions("dev", "v1.38.4")).toBeNull(); |
| 218 | }); |
| 219 | |
| 220 | it("classifies resolved-group reports by fixed version and applicability", () => { |
| 221 | const resolved = { |
| 222 | status: "resolved", |
| 223 | fixedIn: "v1.38.4", |
| 224 | resolutionPlatform: "windows", |
| 225 | resolutionRuntime: "webview2", |
| 226 | os: "windows", |
| 227 | runtime: "webview2", |
| 228 | }; |
| 229 | |
| 230 | expect(regressionDecisionForReport({ ...resolved, subjectVersion: "v1.38.3" })).toBe("historical"); |
| 231 | expect(regressionDecisionForReport({ ...resolved, subjectVersion: "v1.38.4" })).toBe("confirmed"); |
| 232 | expect(regressionDecisionForReport({ ...resolved, subjectVersion: "dev" })).toBe("suspected"); |
| 233 | expect(regressionDecisionForReport({ ...resolved, subjectVersion: "v1.39.0", os: "linux" })).toBe("none"); |
| 234 | expect(regressionDecisionForReport({ ...resolved, subjectVersion: "v1.39.0", runtime: "webkitgtk" })).toBe("none"); |
| 235 | }); |
| 236 | |
| 237 | it("keeps native runtime fingerprints independent from recovery outcomes", () => { |
| 238 | const failure = { engine: "webview2", kind: "render_process_exited", reason: "crashed", exitCode: 1 }; |
| 239 | expect(nativeWebRuntimeFingerprintBasis(failure)).toBe( |
| 240 | nativeWebRuntimeFingerprintBasis({ ...failure }), |
| 241 | ); |
| 242 | expect(nativeWebRuntimeFingerprintBasis({ ...failure, reason: "out_of_memory" })).not.toBe( |
| 243 | nativeWebRuntimeFingerprintBasis(failure), |
| 244 | ); |
| 245 | }); |
| 246 | |
| 247 | it("bounds unknown runtime buckets and WebView2 unresponsive exit code", () => { |
| 248 | expect(nativeWebRuntimeFingerprintBasis({ |
| 249 | engine: "webkitgtk", kind: "random_kind_123", reason: "random_reason_456", |
| 250 | })).toBe("webkitgtk\nunknown\nunknown\nunknown"); |
| 251 | expect(nativeWebRuntimeFingerprintBasis({ |
| 252 | engine: "webview2", kind: "render_process_unresponsive", reason: "unresponsive", exitCode: 259, |
| 253 | })).toBe("webview2\nrender_process_unresponsive\nunresponsive\nunknown"); |
| 254 | }); |
| 255 | |
| 256 | it("only upgrades aggregate severity", () => { |
| 257 | expect(maxSeverity("high", "low")).toBe("high"); |
| 258 | expect(maxSeverity("low", "high")).toBe("high"); |
| 259 | expect(maxSeverity("critical", "high")).toBe("critical"); |
| 260 | }); |
| 261 | it("keeps development reports out of release crash priority", () => { |
| 262 | expect(isDevelopmentReport({ ...base, version: "dev-32bit" })).toBe(true); |
| 263 | expect(isDevelopmentReport({ ...base, version: "v1.40.0", channel: "dev" })).toBe(true); |
| 264 | expect(severityForReport({ ...base, version: "dev" })).toBe("low"); |
| 265 | }); |
| 266 | |
| 267 | it("classifies relayed reports by the failing process identity", () => { |
| 268 | const identity = reportSubjectIdentity({ |
| 269 | version: "v1.40.0", |
| 270 | channel: "stable", |
| 271 | diagnostics: { |
| 272 | subjectVersion: "dev-32bit", |
| 273 | subjectChannel: "test", |
| 274 | }, |
| 275 | }); |
| 276 | expect(identity).toEqual({ version: "dev-32bit", channel: "test" }); |
| 277 | expect(isDevelopmentReport({ ...base, ...identity })).toBe(true); |
| 278 | }); |
| 279 | |
| 280 | it("downranks browser notices and recovered React renders", () => { |
| 281 | expect( |
| 282 | isKnownNonCrashDiagnostic({ ...base, errorMessage: "ResizeObserver loop limit exceeded" }), |
| 283 | ).toBe(true); |
| 284 | expect( |
| 285 | isKnownNonCrashDiagnostic({ ...base, errorMessage: "Minified React error #520; recovered" }), |
| 286 | ).toBe(true); |
| 287 | expect( |
| 288 | severityForReport({ ...base, errorMessage: "additional File object is not a file on the disk" }), |
| 289 | ).toBe("low"); |
| 290 | }); |
| 291 | |
| 292 | it("keeps actionable release crashes high", () => { |
| 293 | expect(severityForReport({ ...base, version: "v1.40.0", channel: "stable" })).toBe("high"); |
| 294 | }); |
| 295 | |
| 296 | it("reclassifies historical groups before dashboard prioritization", () => { |
| 297 | expect( |
| 298 | effectiveGroupSeverity({ |
| 299 | fingerprint: "a".repeat(64), |
| 300 | severity: "high", |
| 301 | title: "[window.error] ResizeObserver loop limit exceeded", |
| 302 | }), |
| 303 | ).toBe("low"); |
| 304 | expect( |
| 305 | effectiveGroupSeverity({ |
| 306 | fingerprint: `dev:${"b".repeat(64)}`, |
| 307 | severity: "critical", |
| 308 | title: "[window.error] ResizeObserver loop limit exceeded", |
| 309 | }), |
| 310 | ).toBe("critical"); |
| 311 | }); |
| 312 | |
| 313 | it("keeps ambiguous legacy history out of the development-only lane", () => { |
| 314 | const fingerprint = "c".repeat(64); |
| 315 | const stableThenDevelopment = { |
| 316 | fingerprint, |
| 317 | severity: "high", |
| 318 | title: "[window.error] actionable release crash", |
| 319 | first_version: "v1.17.15", |
| 320 | last_version: "dev-32bit", |
| 321 | last_channel: "dev", |
| 322 | }; |
| 323 | const developmentThenStable = { |
| 324 | ...stableThenDevelopment, |
| 325 | first_version: "dev-32bit", |
| 326 | last_version: "v1.17.15", |
| 327 | last_channel: "stable", |
| 328 | }; |
| 329 | // A retained first/last summary cannot distinguish a dev-only group from |
| 330 | // dev -> stable -> dev once the middle release sample has been pruned. |
| 331 | const developmentAroundStable = { |
| 332 | ...stableThenDevelopment, |
| 333 | first_version: "dev-32bit", |
| 334 | last_version: "dev-32bit", |
| 335 | last_channel: "dev", |
| 336 | }; |
| 337 | expect(isDevelopmentGroup(stableThenDevelopment)).toBe(false); |
| 338 | expect(effectiveGroupSeverity(stableThenDevelopment)).toBe("high"); |
| 339 | expect(isDevelopmentGroup(developmentThenStable)).toBe(false); |
| 340 | expect(effectiveGroupSeverity(developmentThenStable)).toBe("high"); |
| 341 | expect(isDevelopmentGroup(developmentAroundStable)).toBe(false); |
| 342 | expect(effectiveGroupSeverity(developmentAroundStable)).toBe("high"); |
| 343 | }); |
| 344 | }); |
| 345 | |
| 346 | describe("development fingerprint namespace", () => { |
| 347 | const hash = "d".repeat(64); |
| 348 | |
| 349 | it("preserves stable fingerprints and isolates development reports", () => { |
| 350 | expect(namespaceReportFingerprint(hash, false)).toBe(hash); |
| 351 | expect(namespaceReportFingerprint(hash, true)).toBe(`dev:${hash}`); |
| 352 | }); |
| 353 | |
| 354 | it("recognizes namespaced development groups independently of version labels", () => { |
| 355 | expect( |
| 356 | isDevelopmentGroup({ |
| 357 | fingerprint: `dev:${hash}`, |
| 358 | }), |
| 359 | ).toBe(true); |
| 360 | }); |
| 361 | |
| 362 | it("keeps namespaced fingerprints reachable from dashboard links", () => { |
| 363 | expect(groupFingerprintFromPath(`/stats/group/dev:${hash}`)).toBe(`dev:${hash}`); |
| 364 | expect(groupFingerprintFromPath(`/stats/group/${hash}`)).toBe(hash); |
| 365 | expect(groupFingerprintFromPath("/stats/group/dev:not-a-hash")).toBe(null); |
| 366 | }); |
| 367 | }); |
| 368 | |
| 369 | describe("opaque crash fingerprints", () => { |
| 370 | const opaque = { |
| 371 | kind: "crash", |
| 372 | source: "frontend.global", |
| 373 | label: "window.error", |
| 374 | errorType: "string", |
| 375 | errorMessage: "Script error.", |
| 376 | message: "[window.error]\n\nScript error.", |
| 377 | topFrame: "", |
| 378 | }; |
| 379 | |
| 380 | it("splits locationless Script error reports by safe context hint", () => { |
| 381 | const startup = normalizeForFingerprint({ ...opaque, fingerprintHint: "build:abc|view:app://reasonix/|cats:startup>tabs" }); |
| 382 | const markdown = normalizeForFingerprint({ ...opaque, fingerprintHint: "build:abc|view:app://reasonix/|cats:render>markdown" }); |
| 383 | expect(startup).not.toBe(markdown); |
| 384 | }); |
| 385 | |
| 386 | it("preserves grouping when old clients omit the optional hint", () => { |
| 387 | expect(normalizeForFingerprint(opaque)).toBe(normalizeForFingerprint({ ...opaque, fingerprintHint: "" })); |
| 388 | expect(normalizeForFingerprint(opaque)).toBe( |
| 389 | "crash\nfrontend.global\nwindow.error\nstring\n\nScript error.", |
| 390 | ); |
| 391 | }); |
| 392 | }); |
| 393 | |
| 394 | describe("diagnostics dashboard lanes", () => { |
| 395 | it("keeps release, performance, development, and notices out of one another's priority lists", () => { |
| 396 | type StatsData = Parameters<typeof renderStats>[0]; |
| 397 | const row = { |
| 398 | fingerprint: "fingerprint", |
| 399 | kind: "crash", |
| 400 | count: 1, |
| 401 | first_version: "v1.40.0", |
| 402 | last_version: "v1.40.0", |
| 403 | seen: "2026-07-19", |
| 404 | status: "open", |
| 405 | title: "release-actionable", |
| 406 | source: "frontend.global", |
| 407 | label: "window.error", |
| 408 | error_type: "Error", |
| 409 | top_frame: "at render", |
| 410 | severity: "high", |
| 411 | last_os: "windows", |
| 412 | last_arch: "amd64", |
| 413 | last_channel: "stable", |
| 414 | regressed_at: "", |
| 415 | }; |
| 416 | const data: StatsData = { |
| 417 | daily: [], |
| 418 | versions: [], |
| 419 | platforms: [], |
| 420 | crashes: [ |
| 421 | row, |
| 422 | { ...row, fingerprint: "perf", kind: "performance", title: "performance-only", severity: "medium" }, |
| 423 | { |
| 424 | ...row, |
| 425 | fingerprint: `dev:${"e".repeat(64)}`, |
| 426 | title: "development-only", |
| 427 | last_version: "dev-32bit", |
| 428 | last_channel: "DEV", |
| 429 | severity: "low", |
| 430 | development: true, |
| 431 | }, |
| 432 | { ...row, fingerprint: "notice", title: "browser-notice-only", severity: "low" }, |
| 433 | ], |
| 434 | metrics: [], |
| 435 | previousMetrics: [], |
| 436 | sources: [], |
| 437 | overview: { latestAdoptionPct: null, openReports: 4, newLatestReports: 0, regressedReports: 0, criticalOpenReports: 1 }, |
| 438 | latestVersion: "v1.40.0", |
| 439 | filters: { |
| 440 | surface: "desktop", |
| 441 | status: "", |
| 442 | source: "", |
| 443 | version: "", |
| 444 | os: "", |
| 445 | platform: "", |
| 446 | newLatest: false, |
| 447 | regressed: false, |
| 448 | windowDays: 30, |
| 449 | }, |
| 450 | }; |
| 451 | |
| 452 | const html = renderStats( |
| 453 | data, |
| 454 | { id: 1, email: "admin@example.com", role: "admin", created_at: "", approved_at: "" }, |
| 455 | "diagnostics", |
| 456 | ); |
| 457 | const releaseLane = html.slice(html.indexOf("Needs attention"), html.indexOf("Performance signals")); |
| 458 | const performanceLane = html.slice(html.indexOf("Performance signals"), html.indexOf("Development diagnostics")); |
| 459 | const developmentLane = html.slice(html.indexOf("Development diagnostics"), html.indexOf("Report filters")); |
| 460 | |
| 461 | expect(releaseLane).toContain("release-actionable"); |
| 462 | expect(releaseLane).not.toContain("performance-only"); |
| 463 | expect(releaseLane).not.toContain("development-only"); |
| 464 | expect(releaseLane).not.toContain("browser-notice-only"); |
| 465 | expect(performanceLane).toContain("performance-only"); |
| 466 | expect(performanceLane).not.toContain("development-only"); |
| 467 | expect(developmentLane).toContain("development-only"); |
| 468 | }); |
| 469 | |
| 470 | it("preserves the CLI surface in dashboard navigation and filters", () => { |
| 471 | type StatsData = Parameters<typeof renderStats>[0]; |
| 472 | const data: StatsData = { |
| 473 | daily: [], |
| 474 | versions: [], |
| 475 | platforms: [], |
| 476 | crashes: [], |
| 477 | metrics: [], |
| 478 | previousMetrics: [], |
| 479 | sources: [], |
| 480 | overview: { latestAdoptionPct: null, openReports: 0, newLatestReports: 0, regressedReports: 0, criticalOpenReports: 0 }, |
| 481 | latestVersion: "", |
| 482 | filters: { |
| 483 | surface: "cli", |
| 484 | status: "", |
| 485 | source: "", |
| 486 | version: "", |
| 487 | os: "", |
| 488 | platform: "", |
| 489 | newLatest: false, |
| 490 | regressed: false, |
| 491 | windowDays: 30, |
| 492 | }, |
| 493 | }; |
| 494 | const html = renderStats( |
| 495 | data, |
| 496 | { id: 1, email: "viewer@example.com", role: "viewer", created_at: "", approved_at: "" }, |
| 497 | "usage", |
| 498 | ); |
| 499 | expect(html).toContain("surface=cli"); |
| 500 | expect(html).toContain('aria-label="Client surface"'); |
| 501 | expect(html).toContain('href="/stats"'); |
| 502 | }); |
| 503 | |
| 504 | it("makes triage priorities scannable with labeled metrics and explicit status", () => { |
| 505 | type StatsData = Parameters<typeof renderStats>[0]; |
| 506 | const row = { |
| 507 | fingerprint: "a".repeat(64), |
| 508 | kind: "crash", |
| 509 | count: 102373, |
| 510 | first_version: "v1.24.0", |
| 511 | last_version: "v1.36.0", |
| 512 | seen: "2026-09-03", |
| 513 | status: "open", |
| 514 | title: "A long lifecycle failure summary that should remain available to assistive technology", |
| 515 | source: "native.lifecycle", |
| 516 | label: "", |
| 517 | error_type: "Error", |
| 518 | top_frame: "at render", |
| 519 | severity: "high", |
| 520 | last_os: "windows", |
| 521 | last_arch: "amd64", |
| 522 | last_channel: "stable", |
| 523 | regressed_at: "", |
| 524 | affected_installs: 16406, |
| 525 | window_events: 34941, |
| 526 | identified_events: 34941, |
| 527 | identity_coverage: 1, |
| 528 | dimension_coverage: 1, |
| 529 | impact_rate: 0.137, |
| 530 | }; |
| 531 | const data: StatsData = { |
| 532 | daily: [], |
| 533 | versions: [], |
| 534 | platforms: [], |
| 535 | crashes: [row, { ...row, fingerprint: "b".repeat(64), status: "resolved" }, { ...row, fingerprint: "c".repeat(64), status: "ignored" }], |
| 536 | metrics: [], |
| 537 | previousMetrics: [], |
| 538 | sources: [], |
| 539 | overview: { latestAdoptionPct: null, openReports: 3, newLatestReports: 0, regressedReports: 0, criticalOpenReports: 0 }, |
| 540 | latestVersion: "v1.36.0", |
| 541 | filters: { |
| 542 | surface: "desktop", |
| 543 | status: "", |
| 544 | source: "", |
| 545 | version: "", |
| 546 | os: "", |
| 547 | platform: "", |
| 548 | newLatest: false, |
| 549 | regressed: false, |
| 550 | windowDays: 30, |
| 551 | }, |
| 552 | }; |
| 553 | |
| 554 | const html = renderStats( |
| 555 | data, |
| 556 | { id: 1, email: "admin@example.com", role: "admin", created_at: "", approved_at: "" }, |
| 557 | "diagnostics", |
| 558 | ); |
| 559 | |
| 560 | expect(html).toContain("Past 30 days"); |
| 561 | expect(html).toContain("ranked by affected installs"); |
| 562 | expect(html).toContain("受影响安装"); |
| 563 | expect(html).toContain("受影响安装(30天)"); |
| 564 | expect(html).toContain("影响率"); |
| 565 | expect(html).toContain("窗口事件"); |
| 566 | expect(html).toContain("身份覆盖率"); |
| 567 | expect(html).toContain("累计"); |
| 568 | expect(html).toContain("最新正式版 v1.36.0"); |
| 569 | expect(html).toContain("version=v1.36.0"); |
| 570 | expect(html).toContain('aria-label="A long lifecycle failure summary that should remain available to assistive technology"'); |
| 571 | expect(html).toContain("status-open"); |
| 572 | expect(html).toContain("status-resolved"); |
| 573 | expect(html).toContain("status-ignored"); |
| 574 | expect(html).toContain(":focus-visible"); |
| 575 | |
| 576 | const sevenDayHtml = renderStats( |
| 577 | { ...data, filters: { ...data.filters, windowDays: 7 } }, |
| 578 | { id: 1, email: "admin@example.com", role: "admin", created_at: "", approved_at: "" }, |
| 579 | "diagnostics", |
| 580 | ); |
| 581 | expect(sevenDayHtml).toContain("受影响安装(7天)"); |
| 582 | expect(sevenDayHtml).not.toContain("受影响安装(30天)"); |
| 583 | }); |
| 584 | |
| 585 | }); |
| 586 |