| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import type { Meta, TabMeta } from "../lib/types"; |
| 4 | import type { ControllerRecoveryPorts } from "../lib/controllerEventRecovery"; |
| 5 | |
| 6 | const dom = new JSDOM("<!doctype html><html><body></body></html>"); |
| 7 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 8 | const { installDesktopHostStub } = await import("./desktopHostStub"); |
| 9 | const { startControllerEventRecovery } = await import("../lib/controllerEventRecovery"); |
| 10 | const tab = { id: "task-a", sessionPath: "new", sessionGeneration: 2 } as TabMeta; |
| 11 | let reads = 0; |
| 12 | const stub = installDesktopHostStub({ ListTabs: async () => { reads++; return [tab]; } }); |
| 13 | let meta = { sessionPath: "old", sessionGeneration: 1 } as Meta; |
| 14 | const hydrations: Array<{ done(): void; current(): boolean }> = []; |
| 15 | let runtimeUpdates = 0; |
| 16 | let sessionLoadSeq = 0; |
| 17 | const ports: ControllerRecoveryPorts = { |
| 18 | navigation: () => 1, |
| 19 | bindings: () => new Map([[tab.id, JSON.stringify([meta.sessionPath, meta.sessionGeneration, sessionLoadSeq])]]), |
| 20 | meta: () => meta, |
| 21 | now: () => 0, |
| 22 | flush: () => {}, |
| 23 | prepare: next => { meta = { ...meta, sessionPath: next.sessionPath, sessionGeneration: next.sessionGeneration }; }, |
| 24 | reset: () => {}, |
| 25 | runtime: () => { runtimeUpdates++; }, |
| 26 | hydrate: (_tab, current) => { |
| 27 | sessionLoadSeq++; |
| 28 | return new Promise<void>(done => hydrations.push({ done, current })); |
| 29 | }, |
| 30 | }; |
| 31 | const stop = startControllerEventRecovery(ports); |
| 32 | const tick = () => new Promise(resolve => setImmediate(resolve)); |
| 33 | const waitFor = async (ready: () => boolean) => { |
| 34 | for (let attempt = 0; attempt < 100 && !ready(); attempt++) await new Promise(resolve => setTimeout(resolve, 5)); |
| 35 | assert.ok(ready(), "lazy recovery worker loaded"); |
| 36 | }; |
| 37 | assert.equal(reads, 0, "mounting does not eagerly request recovery data"); |
| 38 | stub.emit("desktop:resync", { generation: "g0", reason: "gap" }); |
| 39 | stub.emit("desktop:resync", { generation: "g1", reason: "generation" }); |
| 40 | assert.equal(reads, 0, "signals during module loading wait for the authoritative read"); |
| 41 | await waitFor(() => hydrations.length === 1); |
| 42 | assert.equal(hydrations.length, 1); |
| 43 | assert.equal(reads, 1, "loading-time signals coalesce into one fresh snapshot"); |
| 44 | stub.emit("desktop:resync", { generation: "g2", reason: "generation" }); await tick(); |
| 45 | assert.equal(hydrations.length, 2, "new generation reissues a superseded session hydrate even after optimistic metadata moved"); |
| 46 | assert.equal(hydrations[0].current(), false); |
| 47 | hydrations[0].done(); await tick(); |
| 48 | assert.equal(runtimeUpdates, 0, "old-generation hydration cannot apply runtime state"); |
| 49 | hydrations[1].done(); await tick(); |
| 50 | assert.equal(runtimeUpdates, 1); |
| 51 | for (const [label, replaceBinding] of [ |
| 52 | ["load sequence", () => { sessionLoadSeq++; }], |
| 53 | ["session path", () => { meta = { ...meta, sessionPath: "background" }; }], |
| 54 | ["session generation", () => { meta = { ...meta, sessionGeneration: 99 }; }], |
| 55 | ] as const) { |
| 56 | meta = { ...meta, sessionPath: "old", sessionGeneration: 1 }; |
| 57 | stub.emit("desktop:resync", { generation: "g2", reason: "gap" }); await tick(); |
| 58 | const pending: (typeof hydrations)[number] = hydrations[hydrations.length - 1]!; |
| 59 | replaceBinding(); |
| 60 | assert.equal(pending.current(), true, `${label} changes without navigation or another desktop:resync`); |
| 61 | pending.done(); await tick(); |
| 62 | assert.equal(runtimeUpdates, 1, `superseded ${label} cannot commit the old runtime after hydration returns`); |
| 63 | } |
| 64 | stop(); |
| 65 | const readsBeforeDispose = reads; |
| 66 | const disposeDuringLoad = startControllerEventRecovery(ports); |
| 67 | stub.emit("desktop:resync", { generation: "g3", reason: "generation" }); |
| 68 | disposeDuringLoad(); |
| 69 | await tick(); await tick(); |
| 70 | assert.equal(reads, readsBeforeDispose, "disposing during lazy loading cannot start a recovery read"); |
| 71 | stub.uninstall(); dom.window.close(); |
| 72 | console.log("controller event recovery: generation and post-hydration tab bindings fence stale runtime commits"); |
| 73 |