| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 4 | import { performResourceAction } from "../lib/fileNavigationCommands"; |
| 5 | import { createFileNavigationOwner, setFileNavigationOwner } from "../lib/fileNavigationCommands"; |
| 6 | import { fileNavigationKey, type FileNavigationSnapshot } from "../lib/fileNavigationOwner"; |
| 7 | import { useActivityBarStore } from "../store/activityBar"; |
| 8 | import { useBrowserPanelStore } from "../lib/browserPanelStore"; |
| 9 | |
| 10 | function deferred<T>() { |
| 11 | let resolve!: (value: T) => void; |
| 12 | let reject!: (error: Error) => void; |
| 13 | const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); |
| 14 | return { promise, resolve, reject }; |
| 15 | } |
| 16 | const dom = new JSDOM("", { url: "http://localhost" }); |
| 17 | Object.assign(globalThis, { window: dom.window, document: dom.window.document }); |
| 18 | const paths = new Map<string, ReturnType<typeof deferred<string>>>(); |
| 19 | const creations = new Map<string, ReturnType<typeof deferred<string>>>(); |
| 20 | const revoked: string[] = []; |
| 21 | const stub = installDesktopHostStub({ |
| 22 | ResolveRemoteWorkspacePathForTab: (_tab: string, _host: string, _tool: string, path: string) => { |
| 23 | const pending = deferred<string>(); paths.set(path, pending); return pending.promise; |
| 24 | }, |
| 25 | CreatePresentedBrowserPreviewForTab: (_tab: string, _tool: string, path: string) => { |
| 26 | if (path === "slow.html") { const pending = deferred<string>(); creations.set(path, pending); return pending.promise; } |
| 27 | return Promise.resolve(`http://preview.test/${path}`); |
| 28 | }, |
| 29 | RevokeWorkspaceBrowserPreview: async (url: string) => { revoked.push(url); }, |
| 30 | }); |
| 31 | const owner = createFileNavigationOwner(); |
| 32 | setFileNavigationOwner(owner); |
| 33 | // A remote reference targets the remote dock; a local one targets the file dock. |
| 34 | const dockTabId = useActivityBarStore.getState().openEntry("remote", "Remote"); |
| 35 | const fileDockTabId = useActivityBarStore.getState().openEntry("file", "Files"); |
| 36 | const ref = { hostId: "remote", tabId: "session", source: "workspace" as const, toolCallId: "tool" }; |
| 37 | const snapshot = (): FileNavigationSnapshot | null => |
| 38 | owner.getSnapshot(fileNavigationKey({ sessionTabId: "session", dockTabId })); |
| 39 | |
| 40 | // A resolves after B: the late result of the superseded command must not win. |
| 41 | const first = performResourceAction({ ...ref, path: "first" }, "preview"); |
| 42 | const second = performResourceAction({ ...ref, path: "second" }, "source"); |
| 43 | paths.get("second")!.resolve("/second"); await second; |
| 44 | const current = snapshot(); |
| 45 | paths.get("first")!.resolve("/first"); await first; |
| 46 | assert.equal(snapshot(), current, "a superseded resolution must not produce a new snapshot"); |
| 47 | assert.equal(current?.selected?.resource.path, "/second"); |
| 48 | assert.equal(current?.selected?.source, true); |
| 49 | |
| 50 | // Closing the target dock ends the record: a late rejection is a cancellation, |
| 51 | // not a failure reported back to the row that asked. |
| 52 | const cancelled = performResourceAction({ ...ref, path: "cancelled" }, "preview"); |
| 53 | owner.retain([fileNavigationKey({ sessionTabId: "session", dockTabId: fileDockTabId })]); |
| 54 | paths.get("cancelled")!.reject(new Error("obsolete failure")); |
| 55 | assert.deepEqual(await cancelled, { status: "cancelled", reason: "superseded" }); |
| 56 | assert.equal(snapshot(), null, "a closed dock keeps no record to restore"); |
| 57 | |
| 58 | // A resolution that lands after its dock was closed is a cancellation too: the |
| 59 | // record it belonged to is gone, and nothing restores it. |
| 60 | const switched = performResourceAction({ ...ref, path: "switched" }, "preview"); |
| 61 | owner.retain([fileNavigationKey({ sessionTabId: "session", dockTabId: fileDockTabId })]); |
| 62 | paths.get("switched")!.resolve("/switched"); |
| 63 | assert.deepEqual(await switched, { status: "cancelled", reason: "superseded" }); |
| 64 | assert.equal(snapshot(), null, "a closed dock leaves no record behind"); |
| 65 | |
| 66 | const opened = deferred<{ id: string }>(); |
| 67 | const opening = deferred<void>(); |
| 68 | const closed: string[] = []; |
| 69 | // The first call is held open so the dock can close mid-flight; later calls |
| 70 | // answer immediately with their own tab, the way a real host does. |
| 71 | let hostOpens = 0; |
| 72 | const host = { |
| 73 | open: () => { |
| 74 | if (hostOpens++ > 0) return Promise.resolve({ id: `open-tab-${hostOpens}` }); |
| 75 | opening.resolve(); |
| 76 | return opened.promise; |
| 77 | }, |
| 78 | close: async (id: string) => { closed.push(id); }, |
| 79 | }; |
| 80 | useBrowserPanelStore.setState({ host: host as unknown as NonNullable<ReturnType<typeof useBrowserPanelStore.getState>["host"]> }); |
| 81 | const browser = performResourceAction({ hostId: "local", tabId: "session", source: "presented", toolCallId: "tool", path: "one.html" }, "browser"); |
| 82 | await opening.promise; |
| 83 | owner.retain([]); |
| 84 | opened.resolve({ id: "only-owned-tab" }); |
| 85 | assert.deepEqual(await browser, { status: "cancelled", reason: "superseded" }); |
| 86 | assert.deepEqual(revoked, ["http://preview.test/one.html"], "a URL created after its dock closed is revoked once"); |
| 87 | assert(!useBrowserPanelStore.getState().tabs.some(tab => tab.id === "only-owned-tab")); |
| 88 | assert.deepEqual(closed, ["only-owned-tab"]); |
| 89 | |
| 90 | // A preview URL minted before its operation lost the dock is released too: a |
| 91 | // second browser command supersedes the first while its creation is in flight. |
| 92 | const slow = performResourceAction({ hostId: "local", tabId: "session", source: "presented", toolCallId: "tool", path: "slow.html" }, "browser"); |
| 93 | for (let tick = 0; tick < 20 && !creations.has("slow.html"); tick += 1) await new Promise((resolve) => setTimeout(resolve, 0)); |
| 94 | assert(creations.has("slow.html"), "the first preview creation is in flight"); |
| 95 | const fast = performResourceAction({ hostId: "local", tabId: "session", source: "presented", toolCallId: "tool", path: "fast.html" }, "browser"); |
| 96 | creations.get("slow.html")!.resolve("http://preview.test/slow"); |
| 97 | assert.deepEqual(await slow, { status: "cancelled", reason: "superseded" }); |
| 98 | assert.deepEqual(revoked, ["http://preview.test/one.html", "http://preview.test/slow"], |
| 99 | "a URL created after its operation lost the dock is revoked"); |
| 100 | await fast.catch(() => undefined); |
| 101 | |
| 102 | // A host that never arrives must not open a page, and its URL is released. |
| 103 | useBrowserPanelStore.setState({ host: null }); |
| 104 | const waiting = performResourceAction({ hostId: "local", tabId: "session", source: "presented", toolCallId: "tool", path: "two.html" }, "browser"); |
| 105 | await new Promise((resolve) => setTimeout(resolve, 2200)); |
| 106 | assert.deepEqual(await waiting, { status: "failed", error: new Error("Built-in browser is not ready") }); |
| 107 | assert.deepEqual(revoked, ["http://preview.test/one.html", "http://preview.test/slow", "http://preview.test/two.html"], "every preview URL this session minted is released exactly once"); |
| 108 | const openTabs = useBrowserPanelStore.getState().tabs; |
| 109 | assert.deepEqual(openTabs.map((tab) => tab.id), ["open-tab-2"], "only the preview whose host arrived opened a page"); |
| 110 | assert(!openTabs.some((tab) => tab.id === "only-owned-tab"), "a tab from a dock that closed never opens"); |
| 111 | |
| 112 | // Direct bridge actions keep the public outcome contract even when the host |
| 113 | // rejects: callers receive `failed` instead of an escaping promise rejection. |
| 114 | Object.assign(stub.commands, { |
| 115 | OpenWorkspacePathForTab: async () => { throw new Error("open denied"); }, |
| 116 | RevealWorkspacePathForTab: async () => { throw new Error("reveal denied"); }, |
| 117 | SaveWorkspacePathAsForTab: async () => { throw new Error("save denied"); }, |
| 118 | }); |
| 119 | for (const [action, message] of [ |
| 120 | ["open-native", "open denied"], |
| 121 | ["reveal-native", "reveal denied"], |
| 122 | ["save-copy", "save denied"], |
| 123 | ] as const) { |
| 124 | const outcome = await performResourceAction( |
| 125 | { hostId: "local", tabId: "session", source: "workspace", path: "failed.txt" }, |
| 126 | action, |
| 127 | ); |
| 128 | assert.equal(outcome.status, "failed"); |
| 129 | assert.equal((outcome as { error: Error }).error.message, message); |
| 130 | } |
| 131 | |
| 132 | // Answer-named references join the same owner, but preserve their dedicated |
| 133 | // host revalidation path for navigation and every direct action. |
| 134 | const referenceCalls: string[] = []; |
| 135 | Object.assign(stub.commands, { |
| 136 | ResolveReferencePathForTab: async (_tab: string, path: string) => { |
| 137 | referenceCalls.push(`resolve:${path}`); |
| 138 | return `/repo/${path}`; |
| 139 | }, |
| 140 | OpenReferencePathForTab: async (_tab: string, path: string) => { referenceCalls.push(`open:${path}`); }, |
| 141 | RevealReferencePathForTab: async (_tab: string, path: string) => { referenceCalls.push(`reveal:${path}`); }, |
| 142 | SaveReferencePathAsForTab: async (_tab: string, path: string) => { referenceCalls.push(`save:${path}`); return `/copy/${path}`; }, |
| 143 | }); |
| 144 | const reference = { hostId: "local", tabId: "session", source: "reference" as const, path: "answer.md" }; |
| 145 | const referenceOpen = await performResourceAction(reference, "preview"); |
| 146 | assert.equal(referenceOpen.status, "opened"); |
| 147 | assert.equal(referenceOpen.status === "opened" ? referenceOpen.resource.identityPath : "", "/repo/answer.md"); |
| 148 | const referenceSnapshot = owner.getSnapshot(fileNavigationKey({ sessionTabId: "session", dockTabId: fileDockTabId })); |
| 149 | assert.equal(referenceSnapshot?.selected?.resource.access.source, "reference"); |
| 150 | for (const action of ["open-native", "reveal-native", "save-copy"] as const) { |
| 151 | assert.equal((await performResourceAction(reference, action)).status, "opened"); |
| 152 | } |
| 153 | assert.deepEqual(referenceCalls, ["resolve:answer.md", "open:answer.md", "reveal:answer.md", "save:answer.md"]); |
| 154 | stub.uninstall(); dom.window.close(); |
| 155 | console.log("PASS navigation ordering, cancellation, failed outcomes and browser resource cleanup"); |
| 156 |