| 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 { useSessionOperations, type SessionResource } from "../app-runtime/useSessionOperations"; |
| 6 | import { useSessionControlCommands } from "../app-runtime/useSessionControlCommands"; |
| 7 | import { sessionIdentityKey } from "../app-runtime/sessionTarget"; |
| 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 | // Remote tab metas carry the canonical id as a top-level `sessionId` with no |
| 12 | // SessionRef (desktop/remote_projects.go), and their generation is a pump |
| 13 | // reconnect counter. The runtime identity must still fence sessions apart. |
| 14 | const remoteHost = { hostId: "gpu-box" }; |
| 15 | assert.notEqual( |
| 16 | sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionGeneration: 3 }), |
| 17 | sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-b", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionGeneration: 3 }), |
| 18 | "canonical remote session IDs must fence reused remote tabs separately", |
| 19 | ); |
| 20 | assert.equal( |
| 21 | sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a", sessionGeneration: 3 }), |
| 22 | sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a", sessionGeneration: 4 }), |
| 23 | "a remote pump reconnect (generation bump) does not change the session identity", |
| 24 | ); |
| 25 | assert.notEqual( |
| 26 | sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a" }), |
| 27 | sessionIdentityKey({ tabId: "remote", remote: { hostId: "other-box" }, sessionId: "session-a" }), |
| 28 | "the same session id on two hosts stays two identities", |
| 29 | ); |
| 30 | assert.notEqual( |
| 31 | sessionIdentityKey({ tabId: "remote", sessionId: "session-a" }), |
| 32 | sessionIdentityKey({ tabId: "remote", sessionId: "session-b" }), |
| 33 | "a compatibility sessionId without a host still keys the session, not the tab", |
| 34 | ); |
| 35 | const legacyLocalKey = sessionIdentityKey({ tabId: "local", sessionPath: "/sessions/local.jsonl", sessionGeneration: 3 }); |
| 36 | assert.equal( |
| 37 | sessionIdentityKey({ tabId: "local", sessionPath: "/sessions/local.jsonl", sessionGeneration: 3, sessionId: "" }), |
| 38 | legacyLocalKey, |
| 39 | "legacy local sessions keep their path+generation identity when no ID exists", |
| 40 | ); |
| 41 | assert.notEqual( |
| 42 | sessionIdentityKey({ tabId: "local", sessionPath: "", sessionGeneration: 3, sessionId: "local-v3" }), |
| 43 | legacyLocalKey, |
| 44 | "canonical local sessions use their immutable ID only when the v3 identity is present", |
| 45 | ); |
| 46 | const resource = (tabId: string, generation = 1): SessionResource => ({ tabId, |
| 47 | sessionKey: sessionIdentityKey({ tabId, sessionPath: `/${tabId}`, sessionGeneration: generation }) }); |
| 48 | const a = resource("A"), b = resource("B"); |
| 49 | const calls: string[] = [], errors: string[] = []; |
| 50 | let finish: ((value: boolean) => void) | undefined; |
| 51 | let started: (() => void) | undefined; |
| 52 | let delayed = false; |
| 53 | let cancelError: string | undefined; |
| 54 | let commands!: ReturnType<typeof useSessionControlCommands>; |
| 55 | function Probe({ visible = a, resources = [a, b] }: { visible?: SessionResource; resources?: SessionResource[] }) { |
| 56 | const operations = useSessionOperations({ visible, resources }); |
| 57 | commands = useSessionControlCommands({ activeTabId: visible.tabId, resources, operations, |
| 58 | showToast: message => errors.push(message), clearWorkspaceConflict() {}, ports: { |
| 59 | cancel: async () => ({ discardedItemIds: [] }), cancelForTab: async tab => { |
| 60 | calls.push(`stop:${tab}`); |
| 61 | return { discardedItemIds: [], error: cancelError }; |
| 62 | }, |
| 63 | acceptDelivery: async () => {}, disconnectRemote: async () => {}, |
| 64 | cancelJobForTab: async (tab, job) => { |
| 65 | calls.push(`${tab}:${job}`); |
| 66 | if (!delayed) return true; |
| 67 | const result = new Promise<boolean>(resolve => { finish = resolve; }); |
| 68 | started?.(); |
| 69 | return result; |
| 70 | }, refreshBackgroundRuntimes: async () => { calls.push("refresh"); }, |
| 71 | } }); |
| 72 | return null; |
| 73 | } |
| 74 | const root = createRoot(document.getElementById("root")!); |
| 75 | const paint = (resources = [a, b], visible = a) => act(async () => root.render(React.createElement(Probe, { resources, visible }))); |
| 76 | try { |
| 77 | await paint(); |
| 78 | const retained = commands.cancelRuntimeJob; |
| 79 | assert.equal(await retained("B", "background"), true); |
| 80 | assert.deepEqual(calls, ["B:background"], "background cancellation reaches its source port without taking active UI ownership"); |
| 81 | assert.equal(await retained("A", "active"), true); |
| 82 | assert.deepEqual(calls.slice(1), ["A:active", "refresh"]); |
| 83 | assert.equal(await retained("missing", "gone"), false); |
| 84 | assert.equal(calls.length, 3, "removed resources never reach the bridge"); |
| 85 | |
| 86 | cancelError = "cancellation transport failed"; |
| 87 | await assert.rejects(commands.handleStopActive(), /cancellation transport failed/, |
| 88 | "decision Stop rejects the controller's failure receipt so its lock can recover"); |
| 89 | assert.equal(calls[calls.length - 1], "stop:A"); |
| 90 | cancelError = undefined; |
| 91 | const stop = commands.handleStopActive(); |
| 92 | await paint([a, b], b); |
| 93 | await stop; |
| 94 | assert.equal(calls[calls.length - 1], "stop:A", "Stop captures its source before a navigation commit"); |
| 95 | await paint(); |
| 96 | |
| 97 | delayed = true; |
| 98 | let entered = new Promise<void>(resolve => { started = resolve; }); |
| 99 | const stale = retained("B", "old-generation"); |
| 100 | await entered; |
| 101 | await paint([a, resource("B", 2)]); |
| 102 | finish!(true); |
| 103 | assert.equal(await stale, false, "replacement invalidates an in-flight cancellation result"); |
| 104 | assert.equal(calls[calls.length - 1], "B:old-generation", "stale results cannot refresh replacement UI"); |
| 105 | |
| 106 | entered = new Promise<void>(resolve => { started = resolve; }); |
| 107 | const switched = retained("B", "current-generation"); |
| 108 | await entered; |
| 109 | await paint([a, resource("B", 2)], resource("B", 2)); |
| 110 | finish!(true); |
| 111 | assert.equal(await switched, true, "switching visible tabs does not cancel a live source operation"); |
| 112 | assert.deepEqual(errors, []); |
| 113 | await act(async () => root.unmount()); |
| 114 | const count = calls.length; |
| 115 | await retained("B", "unmounted"); |
| 116 | assert.equal(calls.length, count, "retained commands are inert after unmount"); |
| 117 | console.log("session control commands: canonical background target, active target, replacement, navigation and disposal passed"); |
| 118 | } finally { dom.window.close(); } |
| 119 |