| 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 |