返回 DeepSeek-Reasonix
index.test.ts
根目录 / workers / crash-report / src / index.test.ts
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
586 lines TYPESCRIPT