返回 DeepSeek-Reasonix
session-navigation-draft-fence.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-navigation-draft-fence.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 { installDesktopHostStub } from "./desktopHostStub";
6 import { makeSessionUIMock } from "../lib/sessionUIMock";
7 import type { ManualSessionCreationView, ManualSessionCreationRequest } from "../generated/desktopContract.generated";
8
9 import { useSessionNavigationCommands, type SessionNavigationCommandsInput } from "../app-runtime/useSessionNavigationCommands";
10
11 function deferred() {
12 let resolve!: () => void;
13 const promise = new Promise<void>((done) => { resolve = done; });
14 return { promise, resolve };
15 }
16
17 const dom = new JSDOM("<div id='root'></div>");
18 Object.assign(globalThis, {
19 window: dom.window,
20 document: dom.window.document,
21 IS_REACT_ACT_ENVIRONMENT: true,
22 });
23
24 let intent = 0;
25 let commands!: ReturnType<typeof useSessionNavigationCommands>;
26 const creationStarted = deferred();
27 const finishCreation = deferred();
28 let blockCreation = false;
29 const created: string[] = [];
30 const legacyCalls: string[] = [];
31 const begun: number[] = [];
32 const notices: string[] = [];
33 const retired = (name: string) => async () => { legacyCalls.push(name); throw new Error("retired draft database is unreadable"); };
34 const stub = installDesktopHostStub({
35 ...makeSessionUIMock(async (_scope,_root,id) => {
36 created.push(id);
37 if (blockCreation) { creationStarted.resolve(); await finishCreation.promise; }
38 }),
39 ListSessionDraftSummaries: retired("list"), OpenSessionDraftForTarget: retired("open"),
40 DismissSessionDraft: retired("dismiss"), SetSessionDraftRestoreTarget: retired("restore"),
41 });
42 const enqueued: Array<{ request: unknown; intent: number }> = [];
43
44 function Probe() {
45 commands = useSessionNavigationCommands({
46 activeTab: { id: "fixture", scope: "project", workspaceRoot: "/workspace" },
47 showToast: message => { notices.push(message); },
48 closeTransientOverlays: () => {},
49 clearImDetail: () => {},
50 prepareBlankWorkspace: () => {},
51 navigation: {
52 enqueueNavigation: async () => {},
53 enqueueNavigationWithIntent: async (request, navigationIntent) => {
54 enqueued.push({ request, intent: navigationIntent });
55 },
56 openRemoteProject: async () => ({ status: "cancelled", reason: "superseded" }),
57 },
58 noteNavigationIntent: () => ++intent,
59 beginNavigationSurface: seq => { begun.push(seq); },
60 settleNavigationSurface: () => {},
61 isNavigationIntentCurrent: (candidate) => candidate === intent,
62 markProjectChanged: () => {},
63 refreshTabMetas: async () => {},
64 refreshHistoryView: () => {},
65 enterConversation: () => {},
66 pickWorkspace: async () => "",
67 switchWorkspace: async () => {},
68 ports: {
69 openTaskSessionForTab: async () => ({ ok: false }),
70 listSessionsForTab: async () => [],
71 },
72 } as SessionNavigationCommandsInput);
73 return null;
74 }
75
76 const root = createRoot(document.getElementById("root")!);
77 try {
78 await act(async () => { root.render(<Probe />); });
79 await act(async () => { await commands.handleOpenTopic("project", "/workspace", "existing-topic", "session-id:existing-session"); });
80 assert.deepEqual(enqueued.at(-1)?.request, {
81 kind: "canonical-session", ref: { hostId: "local", sessionId: "existing-session" },
82 }, "sidebar session keeps its exact local identity on the canonical session path");
83 enqueued.length = 0;
84 intent = 0;
85 await act(async () => {
86 await commands.openBlankSession("global", "/ignored/global/root");
87 await commands.openBlankSession("project", "/workspace");
88 await commands.handleNewTab();
89 });
90 assert.deepEqual(legacyCalls, [], "manual new never touches retired input, even if its database is unreadable");
91 assert.equal(new Set(created).size, 3, "each click has its own formal identity");
92 assert.equal(enqueued.length,3);
93 enqueued.length=0;
94 blockCreation = true;
95
96 let stale!: Promise<void>;
97 act(() => {
98 stale = commands.openBlankSession("project", "/workspace");
99 });
100 await act(async () => { await creationStarted.promise; });
101 await act(async () => { await commands.openCanonicalSession({ hostId: "local", sessionId: "session-b" }); });
102 finishCreation.resolve();
103 await act(async () => { await stale; });
104 assert.equal(enqueued.length, 1);
105 assert.equal(enqueued[0]?.intent, 5, "late formal creation cannot override newer navigation");
106 assert.deepEqual(enqueued[0]?.request, {
107 kind: "canonical-session",
108 ref: { hostId: "local", sessionId: "session-b" },
109 });
110 assert.deepEqual(legacyCalls, [], "navigation never waits for or writes old drafts");
111
112 enqueued.length = 0;
113 const pollStarted = deferred();
114 let complete!: (view: ManualSessionCreationView) => void;
115 const pending = new Promise<ManualSessionCreationView>(resolve => { complete = resolve; });
116 let starting!: ManualSessionCreationView;
117 stub.commands.BeginManualSessionCreation = async (request: ManualSessionCreationRequest) => {
118 starting = { operationId: request.operationId, workspaceId: "canonical", scope: "project", workspaceRoot: "/workspace",
119 ref: { hostId: "local", sessionId: "independent-new-session" }, topicId: "topic", phase: "starting", surfaceReady: true,
120 settings: { model: "stub", mode: "normal", toolApprovalMode: "default", disabledMcp: {}, mcpOrder: [] } };
121 return starting;
122 };
123 stub.commands.GetManualSessionCreation = async (operationId: string) => {
124 assert.equal(operationId, starting.operationId, "observation retains the original creation identity");
125 pollStarted.resolve(); return pending;
126 };
127 let creating!: Promise<void>;
128 act(() => { creating = commands.handleNewTab(); });
129 assert.equal(begun.at(-1), 6, "new click fences the old composer before the first asynchronous result");
130 await act(async () => { await pollStarted.promise; });
131 assert.deepEqual(enqueued[0]?.request, { kind: "canonical-session", ref: starting.ref }, "new input surface opens before runtime readiness");
132 await act(async () => { await commands.openCanonicalSession({ hostId: "local", sessionId: "session-c" }); });
133 complete({ ...starting, phase: "ready" });
134 await act(async () => { await creating; });
135 assert.equal(enqueued.length, 2, "runtime completion never reselects the new session after the user leaves");
136 assert.deepEqual(enqueued.at(-1)?.request, { kind: "canonical-session", ref: { hostId: "local", sessionId: "session-c" } });
137 assert.deepEqual(notices, []);
138
139 const failedRequests: string[] = [];
140 stub.commands.BeginManualSessionCreation = async (request: ManualSessionCreationRequest) => {
141 failedRequests.push(request.operationId);
142 throw new Error("transport unavailable");
143 };
144 stub.commands.GetManualSessionCreation = async () => { throw new Error("still unavailable"); };
145 await act(async () => { await commands.handleNewTab(); });
146 assert.equal(commands.manualCreation?.failed, true, "unacknowledged creation has an inline retry state");
147 assert.equal(commands.manualCreation?.pending, false);
148 await act(async () => { await commands.retryCreation(); });
149 assert.equal(failedRequests.length, 2);
150 assert.equal(failedRequests[0], failedRequests[1], "retrying before acknowledgement does not allocate a new operation");
151 assert.deepEqual(notices, [], "inline failure is not duplicated in a toast");
152
153 await act(async () => { root.unmount(); });
154 console.log("session navigation: retired drafts are untouched; late formal creation preserves the newest selection");
155 } finally {
156 dom.window.close();
157 }
158
158 lines Plain Text