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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-06-16 15:33:03 +08:00
parent 075765c460
commit e7dabd0cba
2 changed files with 193 additions and 0 deletions

View File

@@ -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<typeof makeWindow>) {
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();
});
});

View File

@@ -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<typeof vi.fn>,
};
}
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();
});
});