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