| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { createPollingOwner, type PollClock } from "../app-runtime/pollingOwner"; |
| 6 | import { useRuntimeStatus } from "../app-runtime/useRuntimeStatus"; |
| 7 | import type { BackgroundRuntimeView, WorkspaceConflictView } from "../lib/types"; |
| 8 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 9 | |
| 10 | function deferred<T>() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; |
| 11 | const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } |
| 12 | let nextTimer = 0; |
| 13 | const timers = new Map<number, () => void>(); |
| 14 | const clock: PollClock = { setTimeout: callback => { const id = ++nextTimer; timers.set(id, callback); return id; }, |
| 15 | clearTimeout: handle => { timers.delete(handle as number); } }; |
| 16 | const fire = () => { const queued = [...timers.values()]; timers.clear(); for (const callback of queued) callback(); }; |
| 17 | let gate = deferred<number>(); let reads = 0; let operations = 0; |
| 18 | const results: number[] = []; const errors: unknown[] = []; |
| 19 | const owner = createPollingOwner({ target: { kind: "application" }, periodMs: 1000, clock, |
| 20 | read: () => { reads++; return gate.promise; }, publish: value => results.push(value), failed: error => errors.push(error), |
| 21 | }, delta => { operations += delta; }); |
| 22 | const first = owner.refresh(); |
| 23 | assert.equal(owner.refresh(), first, "manual refresh and timer share one in-flight request"); |
| 24 | fire(); assert.equal(reads, 1); assert.equal(operations, 1); |
| 25 | gate.resolve(1); await first; |
| 26 | assert.deepEqual(results, [1]); assert.equal(operations, 0); assert.equal(timers.size, 1); |
| 27 | gate = deferred<number>(); fire(); assert.equal(reads, 2); |
| 28 | const second = owner.refresh(); gate.reject("fixture failure"); await second; |
| 29 | assert.deepEqual(errors, ["fixture failure"]); assert.equal(operations, 0); |
| 30 | const staleTimer = [...timers.values()][0]; |
| 31 | owner.dispose(); owner.dispose(); staleTimer(); await owner.refresh(); |
| 32 | assert.equal(reads, 2); assert.equal(timers.size, 0); |
| 33 | |
| 34 | const dom = new JSDOM("<div id='root'></div>"); |
| 35 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 36 | const background = deferred<BackgroundRuntimeView[]>(); |
| 37 | const requests: { tab: string; value: ReturnType<typeof deferred<WorkspaceConflictView>> }[] = []; |
| 38 | let backgroundReads = 0; |
| 39 | installDesktopHostStub({ |
| 40 | BackgroundRuntimes: () => { backgroundReads++; return background.promise; }, |
| 41 | WorkspaceConflictForTab: (tab: string) => { const value = deferred<WorkspaceConflictView>(); requests.push({ tab, value }); return value.promise; }, |
| 42 | }); |
| 43 | const root = createRoot(document.getElementById("root")!); |
| 44 | let current!: ReturnType<typeof useRuntimeStatus>; |
| 45 | function Probe({ tab, generation = 1, running = true }: { tab: string; generation?: number; running?: boolean }) { |
| 46 | current = useRuntimeStatus({ tabId: tab, sessionKey: `${tab}:${generation}`, running }, clock); |
| 47 | return <div>{current.workspaceConflict?.ownerTitle ?? "clear"}</div>; |
| 48 | } |
| 49 | const paint = (tab: string, generation = 1, running = true) => act(async () => root.render(<Probe tab={tab} generation={generation} running={running} />)); |
| 50 | const conflict = (ownerTitle: string) => ({ state: "local", ownerTitle } as WorkspaceConflictView); |
| 51 | try { |
| 52 | await paint("A"); |
| 53 | const refresh = current.refreshBackgroundRuntimes; |
| 54 | const manual = refresh(); fire(); |
| 55 | assert.equal(backgroundReads, 1); |
| 56 | await paint("B"); await paint("A", 2); |
| 57 | assert.deepEqual(requests.map(request => request.tab), ["A", "B", "A"]); |
| 58 | await act(async () => { |
| 59 | requests[0].value.resolve(conflict("old A")); requests[1].value.resolve(conflict("B")); |
| 60 | requests[2].value.resolve(conflict("new A")); background.resolve([]); await manual; |
| 61 | }); |
| 62 | assert.equal(document.body.textContent, "new A", "only the current resource generation publishes a conflict"); |
| 63 | await paint("A", 2, false); |
| 64 | assert.equal(document.body.textContent, "clear"); |
| 65 | await act(async () => current.setWorkspaceConflict(conflict("fixture decision"))); |
| 66 | assert.equal(document.body.textContent, "fixture decision", "explicit decision fixtures remain reachable without a running turn"); |
| 67 | await act(async () => current.setWorkspaceConflict(null)); |
| 68 | const queuedTimers = [...timers.values()]; |
| 69 | await act(async () => root.unmount()); |
| 70 | assert.equal(timers.size, 0); |
| 71 | queuedTimers.forEach(callback => callback()); await refresh(); |
| 72 | assert.equal(backgroundReads, 1, "queued timer and retained manual refresh are inert after unmount"); |
| 73 | assert.equal(requests.length, 3); |
| 74 | console.log("runtime polling: single flight, deterministic timers, terminal counts, source replacement and synchronous disposal passed"); |
| 75 | } finally { dom.window.close(); } |
| 76 |