| 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 { useControllerProfileCommands } from "../lib/useControllerProfileCommands"; |
| 6 | import { useSessionOperations } from "../app-runtime/useSessionOperations"; |
| 7 | import type { ControllerProfileResource } from "../app-runtime/controllerProfileOwner"; |
| 8 | |
| 9 | const dom = new JSDOM("<div id='root'></div>"); |
| 10 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 11 | const root = createRoot(document.getElementById("root")!); |
| 12 | function deferred() { |
| 13 | let resolve!: (value: boolean) => void; let reject!: (error: Error) => void; |
| 14 | const promise = new Promise<boolean>((yes, no) => { resolve = yes; reject = no; }); |
| 15 | return { promise, resolve, reject }; |
| 16 | } |
| 17 | const calls: string[] = [], errors: unknown[] = []; |
| 18 | let pending = deferred(); |
| 19 | let profileFailure: Error | undefined; |
| 20 | let profileGate: ReturnType<typeof deferred> | undefined; |
| 21 | const ports = { |
| 22 | model: async (tab: string, name: string) => { calls.push(`model:${tab}:${name}`); return pending.promise; }, |
| 23 | profile: async (tab: string, collaboration: string, approval: string, goal: string) => { |
| 24 | calls.push(`profile:${tab}:${collaboration}:${approval}:${goal}`); |
| 25 | if (profileGate) return profileGate.promise; |
| 26 | if (profileFailure) throw profileFailure; |
| 27 | return true; |
| 28 | }, |
| 29 | }; |
| 30 | let commands!: ReturnType<typeof useControllerProfileCommands>; |
| 31 | function Probe({ tab, generation, plan, ready, epoch, remote }: { tab: string; generation: number; plan: boolean; ready: boolean; epoch: string; remote: boolean }) { |
| 32 | const profiles: ControllerProfileResource[] = ["A", "B"].map(tabId => ({ |
| 33 | target: { tabId, sessionKey: tabId + generation }, remote: remote && tabId === "A", |
| 34 | profile: { collaboration: tabId === "A" && plan ? "plan" : "normal", approval: "ask", goal: tabId === "B" ? "B goal" : "" }, |
| 35 | })); |
| 36 | const target = profiles.find(value => value.target.tabId === tab)!.target; |
| 37 | const operations = useSessionOperations({ visible: target, resources: profiles.map(value => value.target) }); |
| 38 | commands = useControllerProfileCommands({ target, profiles, ready, runtimeEpoch: epoch, remote: remote && tab === "A", operations, ports, |
| 39 | remoteModel: async name => { calls.push(`remote:${tab}:${name}`); await pending.promise; }, report: error => errors.push(error) }); |
| 40 | return null; |
| 41 | } |
| 42 | const paint = (tab = "A", plan = true, generation = 1, ready = false, epoch = "runtime-1", remote = false) => act(async () => root.render( |
| 43 | <Probe tab={tab} generation={generation} plan={plan} ready={ready} epoch={epoch} remote={remote} />)); |
| 44 | try { |
| 45 | await paint(); |
| 46 | const change = commands.switchModel("first"); |
| 47 | await paint("B", false); |
| 48 | pending.resolve(true); assert.equal(await change, false, "a completed source write does not regain UI ownership on B"); |
| 49 | assert.deepEqual(calls, ["model:A:first", "profile:A:normal:ask:"], "post-rebuild profile is the latest committed source value, not the old render or B"); |
| 50 | |
| 51 | calls.length = 0; pending = deferred(); await paint(); |
| 52 | const stale = commands.switchModel("replaced"); |
| 53 | await paint("A", true, 2); pending.resolve(true); |
| 54 | assert.equal(await stale, false); |
| 55 | assert.deepEqual(calls, ["model:A:replaced"], "replacement session rejects old post-model profile writes"); |
| 56 | |
| 57 | calls.length = 0; pending = deferred(); await paint(); |
| 58 | const first = commands.switchModel("old"); |
| 59 | const old = pending; pending = deferred(); |
| 60 | const second = commands.switchModel("new"); |
| 61 | old.resolve(true); assert.equal(await first, false); |
| 62 | pending.resolve(true); assert.equal(await second, true); |
| 63 | assert.deepEqual(calls, ["model:A:old", "model:A:new", "profile:A:plan:ask:"], "superseded model cannot restore its profile or clear the new request"); |
| 64 | |
| 65 | calls.length = 0; errors.length = 0; pending = deferred(); |
| 66 | const failure = commands.switchModelFromUi("failure"); |
| 67 | await paint("B"); await paint("A"); pending.reject(Error("old source failure")); |
| 68 | assert.equal(await failure, false); assert.deepEqual(errors, [], "A-B-A cannot revive old error UI"); |
| 69 | |
| 70 | pending = deferred(); |
| 71 | const currentFailure = commands.switchModelFromUi("current-failure"); |
| 72 | const error = Error("model failed"); pending.reject(error); |
| 73 | assert.equal(await currentFailure, false); assert.deepEqual(errors, [error], "UI failure is presented exactly once"); |
| 74 | errors.length = 0; pending = deferred(); |
| 75 | const directFailure = commands.switchModel("slash-model"); pending.reject(error); |
| 76 | await assert.rejects(directFailure, error); |
| 77 | assert.deepEqual(errors, [], "awaiting callers retain the reject contract without duplicate UI handling"); |
| 78 | |
| 79 | profileFailure = Error("restore failed"); |
| 80 | assert.equal(await commands.applyProfile("A", false), false, "send readiness retains false-on-failure semantics"); |
| 81 | await paint("A", true, 1, true); |
| 82 | assert.deepEqual(errors, [profileFailure], "background restoration reports its real error once"); |
| 83 | profileFailure = undefined; errors.length = 0; await paint(); |
| 84 | |
| 85 | calls.length = 0; await paint("A", true, 1, true); |
| 86 | assert.deepEqual(calls, ["profile:A:plan:ask:"], "ready restoration shares source-profile execution"); |
| 87 | await paint("A", false, 1, true); |
| 88 | assert.equal(calls.at(-1), "profile:A:normal:ask:"); |
| 89 | calls.length = 0; |
| 90 | await paint("A", false, 1, true, "runtime-2"); |
| 91 | assert.deepEqual(calls, ["profile:A:normal:ask:"], "same profile on a replacement runtime is restored without relying on object churn"); |
| 92 | |
| 93 | for (const modelFirst of [true, false]) { |
| 94 | await paint(); calls.length = 0; errors.length = 0; |
| 95 | profileGate = deferred(); pending = deferred(); |
| 96 | if (!modelFirst) await paint("A", true, 1, true); |
| 97 | const overlapping = commands.switchModelFromUi("overlap"); |
| 98 | await act(async () => pending.resolve(true)); |
| 99 | if (modelFirst) await paint("A", true, 1, true); |
| 100 | const sharedError = Error("one Controller application failed"); |
| 101 | await act(async () => profileGate!.reject(sharedError)); |
| 102 | assert.equal(await overlapping, false); |
| 103 | assert.deepEqual(errors, [sharedError], "model and readiness observers share one failure owner in either completion order"); |
| 104 | profileGate = undefined; |
| 105 | } |
| 106 | |
| 107 | calls.length = 0; pending = deferred(); await paint("A", true, 1, true, "runtime-1", true); |
| 108 | const remote = commands.switchModel("remote-model"); |
| 109 | await paint("B", true, 1, false, "runtime-1", true); |
| 110 | pending.resolve(true); assert.equal(await remote, false); |
| 111 | assert.deepEqual(calls, ["remote:A:remote-model"], "remote model stays source-bound and never uses local profile restoration"); |
| 112 | |
| 113 | await paint(); calls.length = 0; pending = deferred(); |
| 114 | const disposed = commands.switchModel("disposed"); |
| 115 | await act(async () => root.unmount()); pending.resolve(true); await disposed; |
| 116 | commands.switchModel("after-unmount"); |
| 117 | assert.deepEqual(calls, ["model:A:disposed"], "unmount revokes continuation and stable entry immediately"); |
| 118 | console.log("controller profile lifecycle: committed source, replacement, ordering, ABA, ready restore and disposal passed"); |
| 119 | } finally { dom.window.close(); } |
| 120 |