| 1 | // Run: tsx src/__tests__/file-navigation-owner.test.ts |
| 2 | // |
| 3 | // The navigation instance is exercised in isolation: ports are plain functions, |
| 4 | // so every assertion is about identity, revision and lifetime rather than about |
| 5 | // any store, bridge or React runtime. |
| 6 | |
| 7 | import assert from "node:assert/strict"; |
| 8 | import { fileAccessContext, type FileResourceRef } from "../lib/fileResource"; |
| 9 | import { FILE_PREVIEW_LIMIT, FileNavigationOwner, fileNavigationKey } from "../lib/fileNavigationOwner"; |
| 10 | |
| 11 | const DOCK = "dock-file"; |
| 12 | const scope = { sessionTabId: "session-a", dockTabId: DOCK }; |
| 13 | const key = fileNavigationKey(scope); |
| 14 | const resolutions = new Map<string, { resolve: (path: string) => void }>(); |
| 15 | const owner = new FileNavigationOwner({ |
| 16 | resolve: (ref) => ({ hostId: ref.hostId, path: ref.path, identityPath: ref.path, requestedPath: ref.path, access: fileAccessContext(ref) }), |
| 17 | revealDock: () => DOCK, |
| 18 | }); |
| 19 | const presented = (path: string, toolCallId = "call"): FileResourceRef => |
| 20 | ({ source: "presented", hostId: "local", tabId: "session-a", toolCallId, path }); |
| 21 | const workspace = (path: string): FileResourceRef => |
| 22 | ({ source: "workspace", hostId: "local", tabId: "session-a", path }); |
| 23 | const snapshot = () => owner.getSnapshot(key); |
| 24 | |
| 25 | // ── Identity: the same resource and mode is one entry, one revision ── |
| 26 | owner.open({ ref: presented("app.ts"), params: { action: "preview", view: "files" } }); |
| 27 | const opened = snapshot()!; |
| 28 | assert.equal(opened.selected?.resource.path, "app.ts"); |
| 29 | assert.equal(opened.entries.length, 1); |
| 30 | assert.equal(opened.sourcePaths.length, 0, "a preview entry is not a source entry"); |
| 31 | const contentRevision = opened.contentRevision; |
| 32 | owner.open({ ref: presented("app.ts"), params: { action: "preview", view: "files" } }); |
| 33 | assert.equal(snapshot()!.entries, opened.entries, "an equivalent entry keeps its list identity"); |
| 34 | assert.equal(snapshot()!.selected, opened.selected, "an equivalent entry keeps its identity"); |
| 35 | assert.equal(snapshot()!.contentRevision, contentRevision, "a repeat does not restart the read inputs"); |
| 36 | assert.equal(snapshot()!.navigation!.revision, opened.navigation!.revision + 1, "a repeat is still delivered as its own navigation"); |
| 37 | |
| 38 | // Switching the mode reuses the tab and changes the read inputs. |
| 39 | owner.setSourceMode(scope, "app.ts", true); |
| 40 | assert.equal(snapshot()!.entries.length, 1, "switching to source reuses the preview tab"); |
| 41 | assert.deepEqual(snapshot()!.sourcePaths, ["app.ts"]); |
| 42 | assert.notEqual(snapshot()!.contentRevision, contentRevision, "switching the mode asks for the other representation"); |
| 43 | owner.setSourceMode(scope, "app.ts", false); |
| 44 | assert.deepEqual(snapshot()!.sourcePaths, [], "switching back clears the source mode"); |
| 45 | |
| 46 | // The same path from another entry point is the same resource, new credentials. |
| 47 | const presentedEntry = snapshot()!.selected; |
| 48 | owner.open({ ref: workspace("app.ts"), params: { action: "preview", view: "files" } }); |
| 49 | assert.equal(snapshot()!.entries.length, 1, "one path is one preview tab"); |
| 50 | assert.notEqual(snapshot()!.selected, presentedEntry, "a new access context is a new entry record"); |
| 51 | assert.deepEqual(snapshot()!.selected!.resource.access, { source: "workspace", tabId: "session-a" }); |
| 52 | assert.equal(snapshot()!.selected!.resource.access.toolCallId, undefined, "a workspace reference never inherits a presented tool scope"); |
| 53 | |
| 54 | // ── Tabs: recency, the cap, and the neighbour a close selects ── |
| 55 | for (const path of ["b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]) { |
| 56 | owner.open({ ref: workspace(path), params: { action: "preview", view: "files" } }); |
| 57 | } |
| 58 | assert.equal(snapshot()!.entries.length, FILE_PREVIEW_LIMIT, "the preview tab list keeps its limit"); |
| 59 | assert.deepEqual(snapshot()!.entries.map((entry) => entry.resource.path), ["b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]); |
| 60 | owner.open({ ref: workspace("c.ts"), params: { action: "preview", view: "files" } }); |
| 61 | assert.deepEqual(snapshot()!.entries.map((entry) => entry.resource.path), ["b.ts", "d.ts", "e.ts", "f.ts", "c.ts"], "reopening moves the entry to the most recent position"); |
| 62 | owner.closeEntry(scope, "c.ts"); |
| 63 | assert.deepEqual(snapshot()!.entries.map((entry) => entry.resource.path), ["b.ts", "d.ts", "e.ts", "f.ts"]); |
| 64 | assert.equal(snapshot()!.selected?.resource.path, "f.ts", "closing the selected tab selects the previous one"); |
| 65 | owner.selectEntry(scope, "b.ts"); |
| 66 | assert.equal(snapshot()!.selected?.resource.path, "b.ts"); |
| 67 | owner.closeEntry(scope, "b.ts"); |
| 68 | assert.equal(snapshot()!.selected?.resource.path, "f.ts", "closing an unselected tab keeps the selection"); |
| 69 | const beforeUnknownClose = snapshot(); |
| 70 | owner.closeEntry(scope, "missing.ts"); |
| 71 | assert.equal(snapshot(), beforeUnknownClose, "closing a tab that is not open changes nothing"); |
| 72 | |
| 73 | // ── A scoped list drops the tabs but keeps the selection ── |
| 74 | owner.clearEntries(scope); |
| 75 | assert.deepEqual(snapshot()!.entries, []); |
| 76 | assert.equal(snapshot()!.selected?.resource.path, "f.ts", "a scoped list does not close the preview"); |
| 77 | owner.clearSelection(scope); |
| 78 | assert.equal(snapshot()!.selected, null); |
| 79 | const cleared = snapshot(); |
| 80 | owner.clearSelection(scope); |
| 81 | assert.equal(snapshot(), cleared, "clearing an empty selection produces no new snapshot"); |
| 82 | |
| 83 | // ── Restore carries workspace access only ── |
| 84 | owner.dispose(); |
| 85 | const restoredOwner = new FileNavigationOwner({ |
| 86 | resolve: (ref) => ({ hostId: ref.hostId, path: ref.path, identityPath: ref.path, requestedPath: ref.path, access: fileAccessContext(ref) }), |
| 87 | revealDock: () => DOCK, |
| 88 | }); |
| 89 | restoredOwner.bindScope(scope, { resource: "project", session: "workspace-scope" }); |
| 90 | restoredOwner.restore(scope, { paths: ["a.md", "b.md"], selectedPath: "b.md", hostId: "local" }); |
| 91 | const restored = restoredOwner.getSnapshot(key)!; |
| 92 | assert.deepEqual(restored.entries.map((entry) => entry.resource.path), ["a.md", "b.md"]); |
| 93 | assert.equal(restored.selected?.resource.path, "b.md"); |
| 94 | assert.deepEqual(restored.selected?.resource.access, { source: "workspace", tabId: "session-a" }, "a remembered path never restores a presented scope"); |
| 95 | assert.equal(restored.navigation, null, "a restore is not a command and carries no navigation intent"); |
| 96 | const restoredSnapshot = restoredOwner.getSnapshot(key); |
| 97 | restoredOwner.restore(scope, { paths: ["c.md"], selectedPath: null, hostId: "local" }); |
| 98 | assert.equal(restoredOwner.getSnapshot(key), restoredSnapshot, "a restore never outranks a record a command or an earlier restore wrote"); |
| 99 | |
| 100 | // A slow restore is background hydration. An explicit command that begins |
| 101 | // afterward owns the dock even before its own path resolution completes. |
| 102 | const hydration = new Map<string, { resolve: (resource: ReturnType<typeof workspace>) => void }>(); |
| 103 | const hydrationOwner = new FileNavigationOwner({ |
| 104 | resolve: (ref) => new Promise((resolve) => hydration.set(ref.path, { |
| 105 | resolve: (resource) => resolve({ |
| 106 | hostId: resource.hostId, |
| 107 | path: resource.path, |
| 108 | identityPath: `/repo/${resource.path}`, |
| 109 | requestedPath: resource.path, |
| 110 | access: fileAccessContext(resource), |
| 111 | }), |
| 112 | })), |
| 113 | revealDock: () => DOCK, |
| 114 | }); |
| 115 | hydrationOwner.bindScope(scope, { resource: "project", session: "workspace-scope" }); |
| 116 | const restoring = hydrationOwner.restore(scope, { paths: ["remembered.md"], selectedPath: "remembered.md", hostId: "local" }); |
| 117 | const liveOpen = hydrationOwner.open({ ref: workspace("live.md"), params: { action: "preview", view: "files" } }); |
| 118 | hydration.get("remembered.md")!.resolve(workspace("remembered.md")); |
| 119 | await restoring; |
| 120 | assert.equal(hydrationOwner.getSnapshot(key)!.selected, null, "late hydration cannot commit over a resolving command"); |
| 121 | hydration.get("live.md")!.resolve(workspace("live.md")); |
| 122 | assert.equal((await liveOpen as { status: string }).status, "opened"); |
| 123 | assert.equal(hydrationOwner.getSnapshot(key)!.selected?.resource.path, "live.md"); |
| 124 | |
| 125 | // ── Another session in the same project keeps the previews, not the scope ── |
| 126 | restoredOwner.open({ ref: presented("presented.md"), params: { action: "preview", view: "files" } }); |
| 127 | const scopeSnapshot = restoredOwner.getSnapshot(key)!; |
| 128 | assert.equal(scopeSnapshot.selected?.resource.access.source, "presented"); |
| 129 | restoredOwner.bindScope(scope, { resource: "project", session: "another-session-scope" }); |
| 130 | const rescoped = restoredOwner.getSnapshot(key)!; |
| 131 | assert.equal(rescoped.signal.aborted, false, "another session in the same project keeps this dock's lifetime"); |
| 132 | assert.deepEqual(rescoped.entries.map((entry) => entry.resource.path), ["a.md", "b.md", "presented.md"], |
| 133 | "another session keeps what the dock was showing"); |
| 134 | assert.equal(rescoped.generation, scopeSnapshot.generation, "another session is not a new lifecycle"); |
| 135 | assert(rescoped.contentRevision > scopeSnapshot.contentRevision, "the previews are re-read under the new session"); |
| 136 | assert.equal(rescoped.selected?.resource.access.source, "workspace", "a presented scope does not survive into another session"); |
| 137 | assert.equal(rescoped.selected?.resource.access.toolCallId, undefined); |
| 138 | restoredOwner.bindScope(scope, { resource: "project", session: "another-session-scope" }); |
| 139 | assert.equal(restoredOwner.getSnapshot(key), rescoped, "binding the same session again changes nothing"); |
| 140 | |
| 141 | // ── Another resource space replaces the record outright ── |
| 142 | restoredOwner.bindScope(scope, { resource: "other-project", session: "another-session-scope" }); |
| 143 | const rescoped2 = restoredOwner.getSnapshot(key)!; |
| 144 | assert(rescoped.signal.aborted, "another project ends the previous lifetime"); |
| 145 | assert.deepEqual(rescoped2.entries, [], "another project keeps no entries"); |
| 146 | assert(rescoped2.generation > rescoped.generation, "a rebuilt record advances its generation"); |
| 147 | |
| 148 | restoredOwner.retain([fileNavigationKey({ sessionTabId: "session-a", dockTabId: "other-dock" })]); |
| 149 | assert.equal(restoredOwner.getSnapshot(key), null, "a dock that is no longer open keeps no record"); |
| 150 | assert(rescoped.signal.aborted); |
| 151 | restoredOwner.bindScope(scope, { resource: "other-project", session: "another-session-scope" }); |
| 152 | const reopened = restoredOwner.getSnapshot(key)!; |
| 153 | assert(reopened.generation > rescoped2.generation, "reopening the same dock tab id starts a new lifecycle generation"); |
| 154 | assert.deepEqual(reopened.entries, [], "a lifecycle generation never restores the previous one's previews"); |
| 155 | restoredOwner.open({ ref: workspace("b.md"), params: { action: "preview", view: "files" } }); |
| 156 | |
| 157 | // ── The dock instance names the record; the session only names its credentials ── |
| 158 | const otherDock = fileNavigationKey({ sessionTabId: "session-a", dockTabId: "dock-remote" }); |
| 159 | assert.equal(fileNavigationKey({ sessionTabId: "session-b", dockTabId: DOCK }), key, |
| 160 | "another session on the same dock is the same record"); |
| 161 | assert.equal(restoredOwner.getSnapshot(otherDock), null, "another dock tab is another record"); |
| 162 | restoredOwner.open({ ref: { source: "presented", hostId: "local", tabId: "session-b", toolCallId: "call", path: "other.ts" }, params: { action: "preview", view: "files" } }); |
| 163 | const shared = restoredOwner.getSnapshot(key)!; |
| 164 | assert.equal(shared.selected?.resource.path, "other.ts", "a command from another session lands in the dock it targeted"); |
| 165 | assert.deepEqual(shared.selected?.resource.access, { source: "presented", tabId: "session-b", toolCallId: "call" }, |
| 166 | "and carries that session's access context"); |
| 167 | |
| 168 | // ── A click is never replaced by an earlier command's late result ── |
| 169 | const slow = new FileNavigationOwner({ |
| 170 | resolve: (ref) => new Promise((resolve) => resolutions.set(ref.path, { |
| 171 | resolve: (path) => resolve({ hostId: ref.hostId, path, identityPath: path, requestedPath: ref.path, access: fileAccessContext(ref) }), |
| 172 | })), |
| 173 | revealDock: () => DOCK, |
| 174 | }); |
| 175 | const slowOpen = slow.open({ ref: workspace("slow.md"), params: { action: "preview", view: "files" } }); |
| 176 | assert(resolutions.has("slow.md"), "the slow open is resolving"); |
| 177 | const click = slow.selectPath(scope, { hostId: "local", path: "clicked.md" }); |
| 178 | resolutions.get("clicked.md")!.resolve("clicked.md"); |
| 179 | await click; |
| 180 | assert.equal(slow.getSnapshot(key)!.selected?.resource.path, "clicked.md", "the click lands while the open is still resolving"); |
| 181 | resolutions.get("slow.md")!.resolve("slow.md"); |
| 182 | assert.deepEqual(await slowOpen, { status: "cancelled", reason: "superseded" }); |
| 183 | assert.equal(slow.getSnapshot(key)!.selected?.resource.path, "clicked.md", "the late result does not replace what the user clicked"); |
| 184 | |
| 185 | // ── A command that moves the dock to another host survives the panel's bind ── |
| 186 | const hostScoped = new FileNavigationOwner({ |
| 187 | resolve: (ref) => new Promise((resolve) => resolutions.set("host-b.md", { |
| 188 | resolve: () => resolve({ hostId: ref.hostId, path: "host-b.md", identityPath: "host-b.md", requestedPath: ref.path, access: fileAccessContext(ref) }), |
| 189 | })), |
| 190 | revealDock: () => "dock-remote", |
| 191 | }); |
| 192 | const remoteScope = { sessionTabId: "session-a", dockTabId: "dock-remote" }; |
| 193 | hostScoped.bindScope(remoteScope, { resource: "host-a", session: "session-a" }); |
| 194 | const toHostB = hostScoped.open({ ref: { source: "workspace", hostId: "host-b", tabId: "session-a", path: "host-b.md" }, params: { action: "preview", view: "files" } }); |
| 195 | // The panel renders the host the command moved it to and binds that space. |
| 196 | hostScoped.bindScope(remoteScope, { resource: "host-b", session: "session-a" }); |
| 197 | resolutions.get("host-b.md")!.resolve("host-b.md"); |
| 198 | assert.equal((await toHostB as { status: string }).status, "opened", "the bind of the new host does not cancel the command that caused it"); |
| 199 | assert.equal(hostScoped.getSnapshot("dock-remote")!.selected?.resource.path, "host-b.md"); |
| 200 | |
| 201 | // ── A panel acting on its own contents never picks a dock ── |
| 202 | let reveals = 0; |
| 203 | const panelOwner = new FileNavigationOwner({ |
| 204 | resolve: (ref) => ({ hostId: ref.hostId, path: ref.path, identityPath: ref.path, requestedPath: ref.path, access: fileAccessContext(ref) }), |
| 205 | revealDock: () => { reveals += 1; return "another-dock"; }, |
| 206 | }); |
| 207 | panelOwner.openIn(scope, { ref: workspace("own.ts"), params: { action: "preview", view: "files" } }); |
| 208 | assert.equal(reveals, 0, "a command inside a panel must not ask which dock to open"); |
| 209 | assert.equal(panelOwner.getSnapshot(key)!.selected?.resource.path, "own.ts", "it commits to the dock the caller named"); |
| 210 | assert.equal(panelOwner.getSnapshot(fileNavigationKey({ sessionTabId: "session-a", dockTabId: "another-dock" })), null); |
| 211 | |
| 212 | // ── Canonical identity deduplicates caller spellings of the same file ── |
| 213 | const canonicalOwner = new FileNavigationOwner({ |
| 214 | resolve: (ref) => ({ |
| 215 | hostId: ref.hostId, |
| 216 | path: ref.path, |
| 217 | identityPath: ref.path.startsWith("/repo/") ? ref.path : `/repo/${ref.path}`, |
| 218 | requestedPath: ref.path, |
| 219 | access: fileAccessContext(ref), |
| 220 | }), |
| 221 | revealDock: () => DOCK, |
| 222 | }); |
| 223 | canonicalOwner.open({ ref: workspace("src/a.ts"), params: { action: "preview", view: "files" } }); |
| 224 | canonicalOwner.open({ ref: workspace("/repo/src/a.ts"), params: { action: "preview", view: "files" } }); |
| 225 | assert.deepEqual(canonicalOwner.getSnapshot(key)!.entries.map((entry) => entry.resource.path), ["/repo/src/a.ts"], |
| 226 | "relative and absolute spellings of one canonical file reuse a preview tab"); |
| 227 | |
| 228 | // ── A failed resolution reports to its caller and commits nothing ── |
| 229 | const failing = new FileNavigationOwner({ |
| 230 | resolve: () => { throw new Error("path not permitted"); }, |
| 231 | revealDock: () => DOCK, |
| 232 | }); |
| 233 | assert.deepEqual(failing.open({ ref: workspace("secret.ts"), params: { action: "preview", view: "files" } }), |
| 234 | { status: "failed", error: new Error("path not permitted") }); |
| 235 | assert.equal(failing.getSnapshot(key)?.selected ?? null, null); |
| 236 | |
| 237 | restoredOwner.dispose(); |
| 238 | console.log("PASS file navigation identity, revisions, tabs, restore and lifetimes"); |
| 239 |