返回 DeepSeek-Reasonix
session-draft-surface-lifecycle.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-draft-surface-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
6 import { useSessionDraftSurface } from "../app-runtime/useSessionDraftSurface";
7 import type { PersistentComposerDraft } from "../components/Composer";
8 import type {
9 SessionDraftSubmissionRequest,
10 SessionDraftSubmissionView,
11 SessionDraftView,
12 ServerView,
13 } from "../generated/desktopContract.generated";
14 import { installDesktopHostStub } from "./desktopHostStub";
15
16 function deferred<T>() {
17 let resolve!: (value: T) => void;
18 const promise = new Promise<T>((next) => { resolve = next; });
19 return { promise, resolve };
20 }
21
22 const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
23 Object.assign(globalThis, {
24 window: dom.window,
25 document: dom.window.document,
26 IS_REACT_ACT_ENVIRONMENT: true,
27 requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
28 cancelAnimationFrame: (id: number) => clearTimeout(id),
29 });
30 dom.window.requestAnimationFrame = globalThis.requestAnimationFrame;
31 dom.window.cancelAnimationFrame = globalThis.cancelAnimationFrame;
32
33 const settings = {
34 model: "fixture-model",
35 mode: "normal",
36 toolApprovalMode: "ask",
37 disabledMcp: {},
38 mcpOrder: [],
39 };
40 const drafts = new Map<string, SessionDraftView>();
41 for (const [id, root] of [["draft-a", "/workspace/a"], ["draft-b", "/workspace/b"]]) {
42 drafts.set(id, {
43 id,
44 workspaceId: `workspace-${id}`,
45 scope: "project",
46 workspaceRoot: root,
47 revision: 1,
48 contentJson: "{}",
49 settings,
50 status: "active",
51 updatedAt: 1,
52 });
53 }
54
55 const saveStarted = deferred<void>();
56 const releaseSave = deferred<void>();
57 const beginStarted = deferred<void>();
58 const releaseBegin = deferred<SessionDraftSubmissionView>();
59 const releaseAttachment = deferred<void>();
60 const submissions: SessionDraftSubmissionRequest[] = [];
61 const themeModes: string[] = [];
62 let saveCalls = 0;
63 const server: ServerView = {
64 name: "fixture-mcp", transport: "stdio", status: "deferred", enabled: true, installed: true,
65 autoStart: true, tools: 0, toolCount: 0, prompts: 0, resources: 0, toolList: [],
66 };
67 const commands = [
68 { name: "compact", description: "Compact", kind: "builtin" as const, draftBehavior: "unavailable" as const },
69 { name: "theme", description: "Theme", kind: "builtin" as const, draftBehavior: "direct" as const },
70 ];
71
72 const stub = installDesktopHostStub({
73 ListSessionDraftSummaries: async () => [],
74 OpenSessionDraftForTarget: async (_scope: string, root: string) => structuredClone(root.endsWith("/a") ? drafts.get("draft-a") : drafts.get("draft-b")),
75 GetDraftContext: async (id: string) => ({ draft: structuredClone(drafts.get(id)), commands, servers: [server] }),
76 SaveSessionDraft: async (request: { draftId: string; revision: number; contentJson: string; settings: typeof settings }) => {
77 saveCalls++;
78 saveStarted.resolve();
79 await releaseSave.promise;
80 const current = drafts.get(request.draftId)!;
81 assert.equal(request.revision, current.revision, "concurrent flushes share one CAS save");
82 const next = { ...current, revision: current.revision + 1, contentJson: request.contentJson, settings: request.settings };
83 drafts.set(request.draftId, next);
84 return { draft: structuredClone(next), conflict: false, outcome: "saved" };
85 },
86 BeginDraftSubmission: async (request: SessionDraftSubmissionRequest) => {
87 submissions.push(request);
88 beginStarted.resolve();
89 return releaseBegin.promise;
90 },
91 GetDraftSubmission: async () => { throw new Error("accepted submission must not poll"); },
92 AttachmentDataURLForTarget: async () => "",
93 DismissSessionDraft: async () => {},
94 SetSessionDraftRestoreTarget: async () => {},
95 GetSessionDraft: async (id: string) => structuredClone(drafts.get(id)!),
96 RestoreSessionDraft: async () => null,
97 ListTabs: async () => [{ id: "formal" }],
98 SetDesktopAppearance: async (mode: string) => { themeModes.push(mode); },
99 GetThemeExperience: async () => ({ themeMode: themeModes.at(-1) ?? "auto", baseStyle: "graphite", effectiveStyle: "graphite" }),
100 });
101
102 let owner!: ReturnType<typeof useSessionDraftSurface>;
103 const accepted: string[] = [];
104 function Probe() {
105 owner = useSessionDraftSurface({
106 onAccepted: (ref) => { accepted.push(ref.sessionId); },
107 onChanged: () => {},
108 });
109 return null;
110 }
111
112 const root = createRoot(document.getElementById("root")!);
113 const edited: PersistentComposerDraft = {
114 text: "run A",
115 invocations: [],
116 attachments: [],
117 workspaceRefs: [],
118 pastedBlocks: [],
119 openPastedLabels: [],
120 sessionRefs: [],
121 selectedTextRefs: [],
122 };
123
124 try {
125 await act(async () => { root.render(<Probe />); });
126 await act(async () => { await owner.open("project", "/workspace/a"); });
127 act(() => owner.updateContent(edited));
128 const draftAId = owner.surface!.draft.id;
129 const draftAGeneration = owner.surface!.generation;
130
131 let submit!: Promise<void>;
132 let openB!: Promise<void>;
133 let exitFlush!: Promise<void>;
134 let exitFlushFinished = false;
135 act(() => { submit = owner.submit("run A"); });
136 await saveStarted.promise;
137 act(() => {
138 owner.updateContentFor(draftAId, draftAGeneration, { ...edited, text: "late mutation" });
139 owner.updateSettingsFor(draftAId, draftAGeneration, { model: "late/model" });
140 });
141 await act(async () => {
142 openB = owner.open("project", "/workspace/b");
143 await openB;
144 });
145 assert.equal(owner.surface?.draft.id, "draft-b", "the test observes B after a real intermediate React commit");
146 await act(async () => {
147 const submittedBeforeCommands = submissions.length;
148 await assert.rejects(owner.submit("/compact"), /existing session/, "history-only commands stay unavailable in a draft");
149 assert.equal(submissions.length, submittedBeforeCommands, "unavailable draft commands never reserve an operation");
150 await owner.submit("/theme dark");
151 assert.deepEqual(themeModes, ["dark"], "direct theme command updates appearance without draft submission");
152 owner.setMCPEnabled(server, false);
153 });
154 const draftB = owner.surface!;
155 const attachmentTask = releaseAttachment.promise.then(() => {
156 owner.updateContentFor(draftB.draft.id, draftB.generation, {
157 ...edited,
158 text: "run B",
159 attachments: [{ path: ".reasonix/attachments/late.txt", displayName: "late.txt" }],
160 });
161 });
162 act(() => { owner.trackTask(draftB.draft.id, draftB.generation, attachmentTask); });
163 assert.equal(owner.surface?.pendingTasks, 1, "the captured attachment belongs to B before exit starts");
164 assert.ok(window.__reasonixFlushSessionDraft, "draft owner installs the Electron exit flush hook");
165 act(() => { exitFlush = window.__reasonixFlushSessionDraft!().then(() => { exitFlushFinished = true; }); });
166 await Promise.resolve();
167 assert.equal(exitFlushFinished, false, "exit flush waits for the active CAS save acknowledgement");
168 await act(async () => {
169 releaseSave.resolve();
170 await beginStarted.promise;
171 await Promise.resolve();
172 assert.equal(exitFlushFinished, false, "exit flush also waits for captured attachment work");
173 releaseAttachment.resolve();
174 await attachmentTask;
175 assert.equal(exitFlushFinished, false, "exit waits until the preparation has a persisted operation");
176 releaseBegin.resolve({ operationId: "operation-a", draftId: "draft-a", phase: "accepted", submissionId: "submission-a", session: { hostId: "local", sessionId: "session-a" }, updatedAt: 1, revision: 1, canResume: false, canEdit: false, canCancel: true, canDiscard: false });
177 await exitFlush;
178 });
179 assert.equal(saveCalls, 2, "A and B save independently and B flushes the attachment produced before exit");
180 assert.equal(owner.surface?.draft.id, "draft-b", "navigation can reveal B after A is durably saved");
181 assert.equal(submissions[0]?.draftId, "draft-a", "A submission retains its source DraftID after B becomes visible");
182 assert.equal(submissions[0]?.input, "run A", "A submission retains its source payload");
183 assert.equal(JSON.parse(drafts.get("draft-a")!.contentJson).text, "run A",
184 "preparing submission synchronously locks the captured content");
185 assert.equal(drafts.get("draft-a")!.settings.model, "fixture-model",
186 "preparing submission synchronously locks the captured settings");
187
188 await act(async () => {
189 releaseBegin.resolve({
190 operationId: "operation-a",
191 draftId: "draft-a",
192 phase: "accepted",
193 submissionId: "submission-a",
194 session: { hostId: "local", sessionId: "session-a" },
195 updatedAt: 2,
196 });
197 await submit;
198 });
199 assert.equal(owner.surface?.draft.id, "draft-b", "accepted A does not navigate away from visible B");
200 assert.deepEqual(accepted, [], "stale accepted operation does not claim visible navigation ownership");
201
202 assert.equal(submissions.length, 1, "direct draft commands never reserve an operation");
203 assert.ok(drafts.get("draft-b")?.settings.disabledMcp[server.name], "MCP selection persists in draft settings without a Controller");
204 assert.equal(JSON.parse(drafts.get("draft-b")!.contentJson).attachments[0]?.path, ".reasonix/attachments/late.txt",
205 "attachment completion during exit is saved before shutdown continues");
206
207 assert.ok(window.__reasonixResumeSessionDraftEditing, "draft owner installs the exit-veto recovery hook");
208 act(() => { window.__reasonixResumeSessionDraftEditing!(); });
209 act(() => owner.updateContent({ ...edited, text: "editing resumed after veto" }));
210 await act(async () => { await owner.flush(); });
211 assert.equal(JSON.parse(drafts.get("draft-b")!.contentJson).text, "editing resumed after veto",
212 "a cancelled exit releases the renderer barrier and accepts new edits");
213
214 await act(async () => { root.unmount(); });
215 assert.equal(window.__reasonixFlushSessionDraft, undefined, "unmount removes the renderer exit hook");
216 assert.equal(window.__reasonixResumeSessionDraftEditing, undefined, "unmount removes the exit-veto recovery hook");
217 console.log("session draft surface: save coalescing, source submission and stale acceptance isolation passed");
218 } finally {
219 stub.uninstall();
220 dom.window.close();
221 }
222
222 lines Plain Text