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