mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
99
packages/core/diagnostics/freeze-watchdog.test.ts
Normal file
99
packages/core/diagnostics/freeze-watchdog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user