返回 DeepSeek-Reasonix
session-recovery-runtime-failure.test.ts
根目录 / desktop / frontend / src / __tests__ / session-recovery-runtime-failure.test.ts
1 import assert from "node:assert/strict";
2 import { setImmediate } from "node:timers/promises";
3 import { JSDOM } from "jsdom";
4 import type { AppBindings } from "../lib/bridge";
5 import type { SessionRecoveryEvent } from "../lib/types";
6 import { installDesktopHostStub } from "./desktopHostStub";
7
8 const dom = new JSDOM("", { url: "http://localhost/" });
9 globalThis.window = dom.window as unknown as Window & typeof globalThis;
10 let reconcileCalls = 0;
11 let reads = 0;
12 let rejectReconcile: (error: Error) => void = () => {};
13 const desktopStub = installDesktopHostStub(({ main: { App: {
14 ReconcileRecoveryVersions: () => {
15 reconcileCalls += 1;
16 return new Promise((_resolve, reject) => { rejectReconcile = reject; });
17 },
18 GetRecoveryLineage: async () => {
19 reads += 1;
20 return { groupId: "root", state: "diverged", branchCount: 2, unresolved: 1, cleanupEligible: 0, members: [] };
21 },
22 } as unknown as AppBindings } }).main.App);
23 const { startSessionRecoveryRuntime } = await import("../lib/sessionRecoveryRuntime");
24 const { setFrontendDiagnosticSink } = await import("../lib/frontendDiagnosticBridge");
25 const diagnostics: unknown[] = [];
26 setFrontendDiagnosticSink((_source, type, fields) => diagnostics.push({ type, fields }));
27 const unhandled: unknown[] = [];
28 const onUnhandled = (error: unknown) => unhandled.push(error);
29 process.on("unhandledRejection", onUnhandled);
30 let recovered = 0;
31 const stop = startSessionRecoveryRuntime({ onRecovered: () => { recovered += 1; }, onDiverged: () => {} });
32 const event: SessionRecoveryEvent = {
33 recoveryPath: "/sessions/fork.jsonl", scope: "global", topicId: "topic",
34 recoveryParentId: "root", recoveryReason: "snapshot_conflict",
35 };
36 desktopStub.emit("session:recovered", event);
37 desktopStub.emit("session:recovered", event);
38 assert.equal(reconcileCalls, 1, "duplicate recovery is deduplicated");
39 rejectReconcile(new Error("session version lineage is unavailable: /private/session/path"));
40 await setImmediate(); // Let Node report any unhandled promise rejection.
41 assert.deepEqual(unhandled, [], "a failed background reconcile must not escape to the crash handler");
42 assert.equal(JSON.stringify(diagnostics).includes("/private/session/path"), false, "diagnostics omit raw backend errors");
43 desktopStub.emit("project-tree:changed-v2", { revision: 2, roots: [""], reason: "reconcile" });
44 await setImmediate();
45 assert.equal(reads, 1, "a later catalog revision still classifies the pending recovery");
46 desktopStub.emit("session:recovered", { ...event, recoveryPath: "/sessions/next-fork.jsonl" });
47 assert.equal(reconcileCalls, 2, "failure releases the topic's in-flight guard");
48 assert.equal(recovered, 2, "new recovery events remain usable after failure");
49 stop();
50 rejectReconcile(new Error("stopped coordinator failure"));
51 await setImmediate();
52 assert.deepEqual(unhandled, [], "rejections after disposal are also contained");
53 assert.equal([...desktopStub.events.values()].reduce((n, set) => n + set.size, 0), 0, "all host event subscriptions are disposed");
54 process.off("unhandledRejection", onUnhandled);
55 dom.window.close();
56 console.log(" PASS failed recovery reconciliation stays local and preserves catalog-driven classification");
57
57 lines TYPESCRIPT