From e7dabd0cba776cea2e637b8eeb217f7507132b3e Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:33:03 +0800 Subject: [PATCH] test: cover breadcrumb state machine and freeze watchdog The breadcrumb persist/clear orchestration is the correctness-critical part and was untested. Cover: hang->write, recover->clear (no double-count), recover-before-delay->no-op, force-quit->retained, crash->write-and-never- clear, clean-exit->no-write. Add watchdog tests (threshold, idempotent, SSR/PerformanceObserver no-op) via a fake observer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/renderer-recovery.test.ts | 94 ++++++++++++++++++ .../core/diagnostics/freeze-watchdog.test.ts | 99 +++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 packages/core/diagnostics/freeze-watchdog.test.ts diff --git a/apps/desktop/src/main/renderer-recovery.test.ts b/apps/desktop/src/main/renderer-recovery.test.ts index c216131ee..b4dda9d41 100644 --- a/apps/desktop/src/main/renderer-recovery.test.ts +++ b/apps/desktop/src/main/renderer-recovery.test.ts @@ -175,3 +175,97 @@ describe("installRendererRecoveryHandlers", () => { expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}"); }); }); + +describe("freeze/crash breadcrumb state machine", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.useRealTimers()); + + function install(fixture: ReturnType) { + const persistBreadcrumb = vi.fn(); + const clearBreadcrumb = vi.fn(); + installRendererRecoveryHandlers(fixture.window, { + isDev: false, + showReloadPrompt: vi.fn(async () => "dismiss" as const), + persistBreadcrumb, + clearBreadcrumb, + unresponsivePromptDelayMs: 100, + }); + return { persistBreadcrumb, clearBreadcrumb }; + } + + it("a sustained hang writes exactly one unresponsive breadcrumb", async () => { + vi.useFakeTimers(); + const fixture = makeWindow(); + const { persistBreadcrumb, clearBreadcrumb } = install(fixture); + + fixture.windowHandlers.get("unresponsive")?.(); + await vi.advanceTimersByTimeAsync(100); + + expect(persistBreadcrumb).toHaveBeenCalledTimes(1); + expect(persistBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ kind: "unresponsive" }), + ); + expect(clearBreadcrumb).not.toHaveBeenCalled(); + }); + + it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => { + vi.useFakeTimers(); + const fixture = makeWindow(); + const { persistBreadcrumb, clearBreadcrumb } = install(fixture); + + fixture.windowHandlers.get("unresponsive")?.(); + await vi.advanceTimersByTimeAsync(100); + expect(persistBreadcrumb).toHaveBeenCalledTimes(1); + + fixture.windowHandlers.get("responsive")?.(); + expect(clearBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => { + vi.useFakeTimers(); + const fixture = makeWindow(); + const { persistBreadcrumb, clearBreadcrumb } = install(fixture); + + fixture.windowHandlers.get("unresponsive")?.(); + fixture.windowHandlers.get("responsive")?.(); + await vi.advanceTimersByTimeAsync(100); + + expect(persistBreadcrumb).not.toHaveBeenCalled(); + expect(clearBreadcrumb).not.toHaveBeenCalled(); + }); + + it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => { + vi.useFakeTimers(); + const fixture = makeWindow(); + const { persistBreadcrumb, clearBreadcrumb } = install(fixture); + + fixture.windowHandlers.get("unresponsive")?.(); + await vi.advanceTimersByTimeAsync(100); + + // No "responsive" ever fires — the breadcrumb must survive uncleared. + expect(persistBreadcrumb).toHaveBeenCalledTimes(1); + expect(clearBreadcrumb).not.toHaveBeenCalled(); + }); + + it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => { + const fixture = makeWindow(); + const { persistBreadcrumb, clearBreadcrumb } = install(fixture); + + fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" }); + + expect(persistBreadcrumb).toHaveBeenCalledTimes(1); + expect(persistBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ kind: "render-process-gone" }), + ); + expect(clearBreadcrumb).not.toHaveBeenCalled(); + }); + + it("a clean (non-crash) renderer exit writes no breadcrumb", () => { + const fixture = makeWindow(); + const { persistBreadcrumb } = install(fixture); + + fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" }); + + expect(persistBreadcrumb).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/diagnostics/freeze-watchdog.test.ts b/packages/core/diagnostics/freeze-watchdog.test.ts new file mode 100644 index 000000000..c2db07bc3 --- /dev/null +++ b/packages/core/diagnostics/freeze-watchdog.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../analytics", () => ({ captureEvent: vi.fn() })); + +// A controllable PerformanceObserver stand-in: records the callback so a test +// can fire synthetic long-task entries, and counts constructions so we can +// assert idempotent install. +let lastCallback: ((list: { getEntries: () => Array<{ duration: number }> }) => void) | null; +let constructed: number; +let observeCalls: number; + +class FakePerformanceObserver { + constructor(cb: (list: { getEntries: () => Array<{ duration: number }> }) => void) { + constructed += 1; + lastCallback = cb; + } + observe() { + observeCalls += 1; + } +} + +function fireLongTask(duration: number) { + lastCallback?.({ getEntries: () => [{ duration }] }); +} + +async function load() { + vi.resetModules(); + const mod = await import("./freeze-watchdog"); + const { captureEvent } = await import("../analytics"); + return { + installFreezeWatchdog: mod.installFreezeWatchdog, + captureEvent: captureEvent as unknown as ReturnType, + }; +} + +beforeEach(() => { + lastCallback = null; + constructed = 0; + observeCalls = 0; + vi.stubGlobal("window", {}); + vi.stubGlobal("location", { pathname: "/acme/issues" }); + vi.stubGlobal("PerformanceObserver", FakePerformanceObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("installFreezeWatchdog", () => { + it("reports a long task at or above the 2s threshold with duration + path", async () => { + const { installFreezeWatchdog, captureEvent } = await load(); + installFreezeWatchdog(); + + fireLongTask(2300); + + expect(captureEvent).toHaveBeenCalledTimes(1); + expect(captureEvent).toHaveBeenCalledWith("client_unresponsive", { + source: "longtask", + duration_ms: 2300, + path: "/acme/issues", + }); + }); + + it("ignores blocks below the threshold (normal render cost)", async () => { + const { installFreezeWatchdog, captureEvent } = await load(); + installFreezeWatchdog(); + + fireLongTask(600); + fireLongTask(1999); + + expect(captureEvent).not.toHaveBeenCalled(); + }); + + it("is idempotent — a second install does not add a second observer", async () => { + const { installFreezeWatchdog } = await load(); + installFreezeWatchdog(); + installFreezeWatchdog(); + + expect(constructed).toBe(1); + expect(observeCalls).toBe(1); + }); + + it("is a no-op on the server (no window)", async () => { + vi.stubGlobal("window", undefined); + const { installFreezeWatchdog, captureEvent } = await load(); + + expect(() => installFreezeWatchdog()).not.toThrow(); + expect(constructed).toBe(0); + expect(captureEvent).not.toHaveBeenCalled(); + }); + + it("is a no-op when PerformanceObserver is unavailable", async () => { + vi.stubGlobal("PerformanceObserver", undefined); + const { installFreezeWatchdog } = await load(); + + expect(() => installFreezeWatchdog()).not.toThrow(); + }); +});