返回 DeepSeek-Reasonix
desktop-navigation-lifecycle.test.tsx
根目录 / desktop / frontend / src / __tests__ / desktop-navigation-lifecycle.test.tsx
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 { useDesktopNavigation } from "../app-runtime/useDesktopNavigation";
6 import type { DesktopNavigationPorts } from "../app-runtime/desktopNavigationOwner";
7 import type { SessionMeta, TabMeta } from "../lib/types";
8 import type { SidebarImConnection } from "../app-runtime/sidebarImProjection";
9 import type { Translator } from "../lib/i18n";
10 import { __emitMockRemoteTabOpened } from "../lib/remoteTabEvents";
11 import { useRemoteTabOpened } from "../lib/useRemoteTabOpened";
12 import { projectTreeTopicOpenRequest } from "../lib/projectTreeTopic";
13
14 function deferred<T>() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void;
15 const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
16 const dom = new JSDOM("<div id='root'></div>");
17 Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
18 const root = createRoot(document.getElementById("root")!);
19 const tab = (id: string) => ({ id, label: id } as TabMeta);
20 const pending = new Map<string, ReturnType<typeof deferred<TabMeta>>>();
21 const calls: string[] = [];
22 const acceptedTopics: number[] = [];
23 let intent = 0;
24 let registration: ReturnType<typeof deferred<string>> | undefined;
25 let preparationReads = 0;
26 let lastTopicPath = "";
27 let api!: ReturnType<typeof useDesktopNavigation>;
28 const activate = (id: string) => { calls.push(`open:${id}`); const request = deferred<TabMeta>(); pending.set(id, request); return request.promise; };
29 const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
30 isNavigationIntentCurrent: seq => seq === intent,
31 registeredNavigationIntent: async seq => registration ? registration.promise : String(seq),
32 openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`),
33 switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); },
34 activateTopic: async (_scope, _workspace, id, path) => { lastTopicPath = path; return activate(id); },
35 openCanonicalSession: async (ref) => { await activate(`canonical:${ref.sessionId}`); },
36 openTopicSession: async (_scope, _workspace, id) => { calls.push("tab-session"); return activate(id); },
37 openGlobalTab: async id => { calls.push("tab-global"); return activate(id); },
38 openProjectTab: async (_workspace, id) => { calls.push("tab-project"); return activate(id); },
39 ensureBlankSurface: async (_scope, workspace) => activate(`blank:${workspace}`),
40 ensureBlankTab: async (_scope, workspace) => { calls.push("tab-blank"); return activate(`blank:${workspace}`); },
41 createIsolatedWorktree: async workspace => ({ tab: await activate(`worktree:${workspace}`), branch: "fixture", sourceDirty: true }) as Awaited<ReturnType<DesktopNavigationPorts["createIsolatedWorktree"]>>,
42 openChannelSession: async (path, id) => { calls.push(`channel:${id}:${path}`); },
43 resumeSession: async (path, id) => { calls.push(`resume:${id}:${path}`); },
44 listTabs: async () => [], applyTabs: () => { calls.push("tabs"); }, seedTab: value => { calls.push(`seed:${value.id}`); },
45 listSessions: async () => { calls.push("history-refresh"); return []; },
46 prepareSession: async selector => { calls.push(`prepare:${selector.source?.sourceKey}`); return { operationId: "prepare-legacy", sourceKey: selector.source?.sourceKey || "", status: "queued", revision: 1, retryable: false }; },
47 getSessionPreparation: async () => {
48 preparationReads++;
49 return preparationReads < 2
50 ? { operationId: "prepare-legacy", sourceKey: "legacy", status: "preparing", revision: 2, retryable: false }
51 : { operationId: "prepare-legacy", sourceKey: "legacy", status: "ready", revision: 3, target: { hostId: "local", sessionId: "prepared-target" }, retryable: false };
52 },
53 topicAccepted: seq => { acceptedTopics.push(seq); },
54 };
55 function Probe({ visible = "A" }: { visible?: string }) {
56 useRemoteTabOpened(meta => { calls.push(`resource:${meta.id}`); }, () => {});
57 api = useDesktopNavigation({ visible: { tabId: visible, sessionKey: visible }, ports,
58 setTabRevealSignal: () => { calls.push("reveal-tab"); },
59 setProjectRevision: () => { calls.push("project"); }, setHistory: () => { calls.push("history-close"); },
60 t: ((key: string) => key) as Translator, showToast: message => { calls.push(`notice:${message}`); },
61 noteIntent: () => ++intent, beginSurface: seq => { calls.push(`begin:${seq}`); },
62 settleSurface: seq => { if (seq === intent) calls.push(`settle:${seq}`); }, showChat: () => {},
63 });
64 return null;
65 }
66 const paint = (visible = "A") => act(async () => root.render(<Probe visible={visible} />));
67 const topic = (id: string) => api.enqueueNavigation({ kind: "topic", scope: "project", workspaceRoot: "fixture", topicId: id });
68 async function finish(id: string, task: Promise<void>) {
69 const deadline = Date.now() + 5000;
70 while (!pending.has(id) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 1));
71 assert.ok(pending.has(id), `navigation reached ${id}`);
72 pending.get(id)!.resolve(tab(id)); await task;
73 }
74 try {
75 await paint();
76 const canonical = api.enqueueNavigation({ kind: "canonical-session", ref: { hostId: "local", sessionId: "canonical-target" } });
77 await finish("canonical:canonical-target", canonical);
78 assert.ok(calls.includes("history-close") && calls.includes("tabs") && calls.includes("reveal-tab"));
79 calls.length = 0; intent = 0;
80 const entry = api.enqueueNavigation;
81 const a = topic("A"), b = topic("B"), c = topic("C");
82 await b;
83 assert.deepEqual(calls.filter(value => value.startsWith("open:")), ["open:A"]);
84 await finish("A", a);
85 assert.deepEqual(calls.filter(value => value.startsWith("open:")), ["open:A", "open:C"]);
86 await finish("C", c);
87 assert.deepEqual(calls.filter(value => value.startsWith("seed:")), ["seed:C"]);
88 assert.deepEqual(acceptedTopics, [3], "only the accepted queue target can release an automation link");
89 assert.deepEqual(calls.filter(value => value.startsWith("settle:")), ["settle:3"], "old finally cannot settle the current surface");
90 calls.length = 0;
91 const stale = topic("stale"); intent++;
92 await paint("B"); await paint("A");
93 assert.equal(api.enqueueNavigation, entry);
94 await finish("stale", stale);
95 assert.deepEqual(calls.filter(value => /^(seed|tabs|notice|reveal|settle)/.test(value)), [], "ABA never restores old UI rights");
96
97 calls.length = 0;
98 const connection = { sessionId: "path:channel.jsonl", sessionSource: "auto", scope: "project", workspaceRoot: "im", title: "fixture" } as SidebarImConnection;
99 const im = api.enqueueNavigation({ kind: "sidebar-im", connection });
100 intent++;
101 await finish("blank:im", im);
102 assert.ok(!calls.some(value => value.startsWith("channel:")), "cancellation between blank activation and hydrate prevents a second mutation");
103 calls.length = 0;
104 const validIM = api.enqueueNavigation({ kind: "sidebar-im", connection });
105 await finish("blank:im", validIM);
106 assert.ok(calls.includes("channel:blank:im:channel.jsonl"));
107
108 calls.length = 0;
109 const botConnection = { ...connection, sessionId: "session:local:bot-conversation" };
110 const botNavigation = api.enqueueNavigation({ kind: "sidebar-im", connection: botConnection });
111 await finish("", botNavigation);
112 assert.equal(lastTopicPath, "bot-session:local:bot-conversation");
113 assert.ok(!calls.some(value => value.startsWith("open:blank:") || value.startsWith("channel:")),
114 "canonical bot sessions open through the shared runtime surface, not a new blank tab");
115
116 calls.length = 0;
117 const isolated = api.enqueueNavigation({ kind: "isolated-worktree", workspaceRoot: "dirty" });
118 await paint(); // Normal commits do not change the request epoch.
119 await finish("worktree:dirty", isolated);
120 assert.ok(calls.includes("notice:projectTree.worktreeCreatedDirty"));
121 assert.ok(calls.includes("project"));
122
123 calls.length = 0;
124 const history = api.enqueueNavigation({ kind: "resume-session", session: { scope: "global", topicId: "history", path: "history.jsonl" } as SessionMeta });
125 await finish("history", history);
126 assert.ok(calls.includes("open:history"), "resuming a session activates its topic surface");
127 assert.ok(!calls.includes("tab-session"), "every layout style takes the surface path, never a legacy tab");
128 assert.ok(calls.includes("history-close"));
129
130 calls.length = 0; preparationReads = 0;
131 const legacy = api.enqueueNavigation({ kind: "resume-session", session: { scope: "global", topicId: "legacy-topic", title: "Legacy", path: "legacy.jsonl",
132 source: { hostId: "local", sourceKey: "legacy", path: "legacy.jsonl" } } as SessionMeta });
133 await finish("legacy-topic", legacy);
134 assert.ok(!calls.includes("prepare:legacy"), "legacy navigation opens the source without implicit conversion");
135 assert.equal(preparationReads, 0, "normal navigation never polls a migration task");
136
137 calls.length = 0; preparationReads = 0;
138 const sidebarRequest = projectTreeTopicOpenRequest({ kind: "global_topic", key: "cold-v4", label: "Cold v4",
139 topicId: "cold-topic", source: { hostId: "local", sourceKey: "cold-v4", path: "cold-store" } });
140 assert.ok(sidebarRequest?.sessionPath?.startsWith("session-source:"), "headless canonical sources keep their explicit identity");
141 const sidebar = api.enqueueNavigation({ kind: "topic", ...sidebarRequest! });
142 const sidebarIntent = intent;
143 await finish("cold-topic", sidebar);
144 assert.ok(!calls.includes("prepare:cold-v4"), "sidebar opens the historical source without implicit conversion");
145 assert.equal(preparationReads, 0);
146 assert.equal(acceptedTopics.at(-1), sidebarIntent, "prepared sidebar navigation retains topic acceptance");
147
148 calls.length = 0;
149 const conflicting = api.enqueueNavigation({ kind: "resume-session", session: {
150 scope: "project", workspaceRoot: "D:\\J\\GIT\\ai-workspace", topicId: "conflicting-history",
151 path: "history.jsonl", source: { hostId: "local", sourceKey: "conflicting", path: "history.jsonl" },
152 } as SessionMeta });
153 pending.get("conflicting-history")!.reject(new Error("session_operation:source_ambiguous:conflicting receipts"));
154 await conflicting;
155 assert.deepEqual(calls.filter(value => value.startsWith("notice:")), ["notice:projectTree.sessionError.sourceAmbiguous"],
156 "migration failures must not claim the project directory does not exist");
157
158 calls.length = 0;
159 const failed = topic("failed");
160 pending.get("failed")!.reject(new Error("fixture failure")); await failed;
161 assert.deepEqual(calls.filter(value => value.startsWith("notice:")), ["notice:history.failedOpenSession"]);
162
163 calls.length = 0;
164 registration = deferred<string>();
165 const waitingRemote = api.openRemoteProject({ hostId: "fixture", workspace: "waiting" }, { newSession: true });
166 const winsRegistration = topic("wins-registration");
167 registration.resolve("registered");
168 assert.equal((await waitingRemote).status, "cancelled");
169 assert.ok(!pending.has("remote:waiting"), "superseded registration cannot issue an OpenRemoteProjectTab request");
170 await finish("wins-registration", winsRegistration);
171 registration = undefined;
172
173 calls.length = 0;
174 const remote = api.openRemoteProject({ hostId: "fixture", workspace: "remote" }, { sessionName: "selected" });
175 await act(async () => {});
176 const remoteMeta = { ...tab("remote:remote"), remote: { hostId: "fixture", workspace: "remote" } };
177 await act(async () => __emitMockRemoteTabOpened(remoteMeta));
178 assert.deepEqual(calls.filter(value => /^(seed|remote-switch)/.test(value)), [], "opened event before the response cannot independently navigate");
179 const localWins = topic("local-wins");
180 pending.get("remote:remote")!.resolve(remoteMeta);
181 assert.equal((await remote).status, "cancelled");
182 await finish("local-wins", localWins);
183 assert.ok(!calls.some(value => value.startsWith("remote-switch:")));
184 calls.length = 0;
185 const successfulRemote = api.openRemoteProject({ hostId: "fixture", workspace: "success" }, {});
186 await act(async () => {});
187 pending.get("remote:success")!.resolve({ ...remoteMeta, id: "remote:success" });
188 const outcome = await successfulRemote;
189 assert.equal(outcome.status, "completed");
190 assert.ok(calls.includes(`remote-switch:remote:success:${intent}`), "the request's exact intent reaches dedicated remote activation");
191 assert.ok(!calls.includes("tab-session"));
192
193 calls.length = 0;
194 const retainedRemote = api.openRemoteProject;
195 const disposed = topic("disposed"), queued = topic("never");
196 await act(async () => root.unmount());
197 await finish("disposed", disposed); await queued;
198 entry({ kind: "blank", scope: "global", workspaceRoot: "" });
199 assert.deepEqual(calls.filter(value => /^(open|seed|tabs|notice|reveal|settle)/.test(value)), ["open:disposed"], "unmount releases pending input and fences queued and running continuations");
200 assert.deepEqual(await retainedRemote({ hostId: "fixture", workspace: "disposed" }, {}), { status: "cancelled", reason: "disposed" });
201 console.log("desktop navigation: queue ownership, ABA, IM hydrate, dirty-worktree warning, tab resume, failure and disposal passed");
202 } finally { dom.window.close(); }
203
203 lines Plain Text