返回 DeepSeek-Reasonix
file-navigation-owner.test.ts
根目录 / desktop / frontend / src / __tests__ / file-navigation-owner.test.ts
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
239 lines TYPESCRIPT