返回 DeepSeek-Reasonix
session-draft-ownership.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-draft-ownership.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 { useSessionDraftSurface } from "../app-runtime/useSessionDraftSurface";
6 import { installDesktopHostStub } from "./desktopHostStub";
7
8 const dom = new JSDOM("<body></body>", { url: "http://localhost" });
9 Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true,
10 requestAnimationFrame: (fn: FrameRequestCallback) => setTimeout(() => fn(0), 0), cancelAnimationFrame: clearTimeout });
11 dom.window.requestAnimationFrame = globalThis.requestAnimationFrame;
12 dom.window.cancelAnimationFrame = globalThis.cancelAnimationFrame;
13 function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>(r => { resolve = r; }); return { promise, resolve }; }
14 const content = (text: string) => ({ text, invocations: [], attachments: [], workspaceRefs: [], pastedBlocks: [], openPastedLabels: [], sessionRefs: [], selectedTextRefs: [] });
15 const settings = { model: "fixture/old", effort: "high", mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", disabledMcp: {}, mcpOrder: [] };
16 async function fixture(overrides: Record<string, unknown> = {}) {
17 const records = new Map(["a", "b"].map(id => [id, { id, workspaceId: `ws-${id}`, scope: "project", workspaceRoot: `/tmp/${id}`, revision: 1, contentJson: JSON.stringify(content(`text-${id}`)), settings: structuredClone(settings), status: "active", updatedAt: 1 }]));
18 const deleted: string[] = [];
19 const stub = installDesktopHostStub({
20 ListSessionDraftSummaries: async () => [],
21 OpenSessionDraftForTarget: async (_: string, path: string) => structuredClone(records.get(path.split("/").pop()!)),
22 GetDraftContext: async (id: string) => ({ draft: structuredClone(records.get(id)), commands: [], servers: [] }),
23 GetSessionDraft: async (id: string) => structuredClone(records.get(id)),
24 SetSessionDraftRestoreTarget: async () => {}, DismissSessionDraft: async () => {},
25 SaveSessionDraft: async (req: { draftId: string; revision: number; contentJson: string; settings: typeof settings }) => {
26 const old = records.get(req.draftId)!;
27 if (req.revision !== old.revision) return { draft: structuredClone(old), conflict: true, outcome: "conflict" };
28 const saved = { ...old, contentJson: req.contentJson, settings: req.settings, revision: old.revision + 1 };
29 records.set(req.draftId, saved);
30 return { draft: structuredClone(saved), conflict: false, outcome: "saved" };
31 },
32 DiscardSessionDraft: async (id: string) => { deleted.push(id); },
33 ...overrides,
34 });
35 let owner!: ReturnType<typeof useSessionDraftSurface>;
36 function Probe() { owner = useSessionDraftSurface({ onAccepted: () => {}, onChanged: () => {} }); return null; }
37 const node = document.createElement("div"); document.body.append(node);
38 const root = createRoot(node);
39 await act(async () => { root.render(<Probe />); });
40 await act(async () => { await owner.open("project", "/tmp/a"); });
41 return { get owner() { return owner; }, records, deleted,
42 close: async () => { await act(async () => { root.unmount(); }); stub.uninstall(); node.remove(); } };
43 }
44
45 try {
46 for (const field of ["attachments", "workspaceRefs", "invocations", "pastedBlocks", "sessionRefs", "selectedTextRefs"]) {
47 let confirmations = 0;
48 const f = await fixture({ ConfirmAction: async () => { confirmations++; return false; } });
49 const payload = JSON.parse(JSON.stringify(content("")));
50 payload[field] = [{ id: "saved-content", name: "keep", path: "/tmp/keep", text: "keep" }];
51 act(() => f.owner.updateContent(payload));
52 await act(async () => { await f.owner.confirmDiscard({ title: "Discard", message: "Discard", detail: "Cannot restore", confirmLabel: "Discard", cancelLabel: "Cancel" }); });
53 assert.equal(confirmations, 1, `${field} without body text requires confirmation`);
54 assert.deepEqual(f.deleted, [], `${field} survives cancellation`);
55 await f.close();
56 }
57 {
58 let confirmations = 0;
59 const f = await fixture({ ConfirmAction: async () => { confirmations++; return false; } });
60 const labels = { title: "Discard", message: "Discard", detail: "Cannot restore", confirmLabel: "Discard", cancelLabel: "Cancel" };
61 await act(async () => { await f.owner.confirmDiscard(labels); });
62 assert.deepEqual(f.deleted, [], "cancel keeps the saved draft");
63 assert.equal(f.owner.surface!.content.text, "text-a");
64 act(() => f.owner.updateContent(content("")));
65 await act(async () => { await f.owner.confirmDiscard(labels); });
66 assert.equal(confirmations, 1, "an empty draft needs no confirmation");
67 assert.deepEqual(f.deleted, ["a"]);
68 assert.equal(f.owner.surface, null);
69 await f.close();
70 }
71 {
72 const f = await fixture({ DiscardSessionDraft: async () => { throw new Error("write failed"); } });
73 await act(async () => { await assert.rejects(f.owner.discard(), /write failed/); });
74 assert.equal(f.owner.surface!.content.text, "text-a", "failed discard retains content");
75 assert.equal(f.owner.surface!.discarding, false, "failed discard unlocks retry");
76 await f.close();
77 }
78 {
79 const gate = deferred<void>();
80 let calls = 0;
81 const f = await fixture({ DiscardSessionDraft: () => { calls++; return gate.promise; } });
82 let first!: Promise<void>;
83 act(() => { first = f.owner.discard(); });
84 await act(async () => { await f.owner.discard(); });
85 assert.equal(calls, 1, "duplicate clicks share the in-flight deletion");
86 assert.equal(f.owner.surface!.discarding, true);
87 await act(async () => { gate.resolve(); await first; });
88 await f.close();
89 }
90 {
91 const confirmation = deferred<boolean>();
92 const f = await fixture({ ConfirmAction: () => confirmation.promise });
93 let pending!: Promise<void>;
94 act(() => { pending = f.owner.confirmDiscard({ title: "", message: "", detail: "", confirmLabel: "", cancelLabel: "" }); });
95 act(() => f.owner.updateContent(content("new edit after confirmation opened")));
96 await act(async () => { confirmation.resolve(true); await assert.rejects(pending, /draft changed/); });
97 assert.deepEqual(f.deleted, [], "confirmation cannot discard unseen edits");
98 assert.equal(f.owner.surface!.content.text, "new edit after confirmation opened");
99 await f.close();
100 }
101 {
102 const f = await fixture();
103 act(() => { f.owner.captureSubmission("a", 1); });
104 await act(async () => { await assert.rejects(f.owner.discard(), /Cancel the submission/); });
105 assert.deepEqual(f.deleted, [], "submission owns the draft until cancellation is acknowledged");
106 await f.close();
107 }
108 {
109 const f = await fixture();
110 await act(async () => { await f.owner.open("project", "/tmp/b"); });
111 act(() => { f.owner.reportTaskError("a", 1, "attachment failed in A"); });
112 assert.equal(f.owner.surface!.taskError, undefined, "background errors do not appear on B");
113 await act(async () => { await f.owner.open("project", "/tmp/a"); });
114 assert.equal(f.owner.surface!.taskError, "attachment failed in A");
115 act(() => { f.owner.reportTaskError("a", 0, "stale error"); });
116 assert.equal(f.owner.surface!.taskError, "attachment failed in A", "old generations cannot replace task errors");
117 await f.close();
118 }
119 {
120 const requests: { requestId: string }[] = [];
121 const f = await fixture({
122 GetSessionDraftState: async () => ({ operation: undefined }),
123 BeginDraftSubmission: async (request: { requestId: string }) => {
124 requests.push(request);
125 if (requests.length === 1) throw new Error("connection lost before the first transaction completed");
126 return { operationId: "same-op", requestId: request.requestId, draftId: "a", phase: "accepted", revision: 2, submissionId: "same-submit", updatedAt: 1, session: { hostId: "local", sessionId: "same-session" } };
127 },
128 });
129 let capture: ReturnType<typeof f.owner.captureSubmission>;
130 act(() => { capture = f.owner.captureSubmission("a", 1); });
131 await act(async () => { await f.owner.submit("text-a", "text-a", undefined, undefined, capture); });
132 assert.equal(requests.length, 2);
133 assert.equal(requests[0].requestId, requests[1].requestId, "absence during a lost Begin retries the original preparation identity");
134 assert.equal(f.owner.surface, null);
135 await f.close();
136 }
137 {
138 const operation = { operationId: "original-op", draftId: "a", phase: "resume_required", revision: 7, submissionId: "original-submit", canResume: true, canEdit: false, canCancel: true, canDiscard: false, updatedAt: 1, session: { hostId: "local", sessionId: "original-session" } };
139 const calls: unknown[] = [];
140 const f = await fixture({
141 GetDraftContext: async () => ({ draft: { id: "a", workspaceId: "ws-a", scope: "project", workspaceRoot: "/tmp/a", revision: 1, contentJson: JSON.stringify(content("text-a")), settings, status: "active", updatedAt: 1 }, operation, commands: [], servers: [] }),
142 ResumeDraftSubmission: async (...args: unknown[]) => { calls.push(args); return { ...operation, phase: "accepted", revision: 8 }; },
143 });
144 assert.equal(f.owner.surface!.operation!.operationId, "original-op");
145 assert.equal(f.owner.captureSubmission("a", 1), null, "restored operation cannot accidentally prepare a new submission");
146 await act(async () => { await f.owner.resumeSubmission(); });
147 assert.deepEqual(calls, [["original-op", 7]], "explicit continuation uses the original operation and expected version");
148 await f.close();
149 }
150 {
151 const operation = { operationId: "failed-runtime", draftId: "a", phase: "runtime_failed", revision: 7, submissionId: "original-submit", canResume: true, canEdit: false, canCancel: true, canDiscard: false, updatedAt: 1 };
152 const calls: unknown[] = [];
153 const retry = deferred<typeof operation>();
154 const f = await fixture({
155 GetDraftContext: async () => ({ draft: { id: "a", workspaceId: "ws-a", scope: "project", workspaceRoot: "/tmp/a", revision: 1, contentJson: JSON.stringify(content("keep my draft")), settings, status: "active", updatedAt: 1 }, operation, commands: [], servers: [] }),
156 ResumeDraftSubmission: async (...args: unknown[]) => {
157 calls.push(args);
158 if (calls.length === 1) throw new Error("create session runtime: path unavailable");
159 return retry.promise;
160 },
161 BeginDraftSubmission: async () => { throw new Error("retry must reuse the original operation"); },
162 });
163 await act(async () => { await f.owner.resumeSubmission(); });
164 assert.equal(f.owner.surface!.saveState, "saved", "runtime failure is not a persistence failure");
165 assert.equal(f.owner.surface!.error, undefined);
166 assert.equal(f.owner.surface!.submissionError, "create session runtime: path unavailable");
167 assert.equal(f.owner.surface!.content.text, "keep my draft");
168 let pending!: Promise<void>;
169 await act(async () => {
170 pending = f.owner.resumeSubmission();
171 await f.owner.resumeSubmission();
172 });
173 assert.equal(f.owner.surface!.resumingSubmission, true);
174 assert.deepEqual(calls, [["failed-runtime", 7], ["failed-runtime", 7]], "overlapping retries share one in-flight resume");
175 await act(async () => { retry.resolve({ ...operation, phase: "runtime_failed", revision: 8 }); await pending; });
176 assert.equal(f.owner.surface!.submissionError, undefined, "successful resume RPC clears its previous error");
177 assert.equal(f.owner.surface!.resumingSubmission, false);
178 assert.equal(f.owner.surface!.content.text, "keep my draft");
179 await f.close();
180 }
181 {
182 const timers: (() => void)[] = [];
183 const originalTimer = window.setTimeout;
184 window.setTimeout = ((callback: () => void, delay?: number) => delay && delay >= 250 ? (timers.push(callback), timers.length) : originalTimer(callback, delay)) as typeof window.setTimeout;
185 const operation = { operationId: "unknown-op", draftId: "a", phase: "dispatching_shell", revision: 4, submissionId: "shell-submit", canResume: false, canEdit: false, canCancel: true, canDiscard: false, updatedAt: 1, session: { hostId: "local", sessionId: "shell-session" } };
186 const f = await fixture({
187 GetDraftContext: async () => ({ draft: { id: "a", workspaceId: "ws-a", scope: "project", workspaceRoot: "/tmp/a", revision: 1, contentJson: JSON.stringify(content("text-a")), settings, status: "active", updatedAt: 1 }, operation, commands: [], servers: [] }),
188 CancelDraftSubmission: async () => ({ ...operation, phase: "accepted", revision: 6 }),
189 GetDraftSubmission: async () => ({ ...operation, revision: 5 }),
190 });
191 assert.ok(timers.length, "shell dispatch keeps an application-level verification loop");
192 await act(async () => { await f.owner.cancelSubmission(); });
193 assert.equal(f.owner.surface, null, "accepted cancellation converts immediately without waiting for the poll timer");
194 await act(async () => { timers.shift()?.(); });
195 assert.equal(f.owner.surface, null, "an older poll cannot resurrect the accepted draft");
196 await f.close();
197 window.setTimeout = originalTimer;
198 }
199 {
200 const f = await fixture();
201 act(() => { f.owner.captureSubmission("a", 1); });
202 act(() => { f.owner.updateSettings({ model: "fixture/new" }); f.owner.updateContent(content("new text")); });
203 assert.equal(f.owner.surface!.preparingSubmission, true, "capture synchronously locks the owner before context expansion");
204 assert.equal(f.owner.surface!.settings.model, "fixture/old");
205 assert.equal(f.owner.surface!.content.text, "text-a");
206 await f.close();
207 }
208 {
209 const confirmation = deferred<boolean>();
210 const started = deferred<void>();
211 const f = await fixture({ ConfirmAction: () => { started.resolve(); return confirmation.promise; } });
212 let pending!: Promise<void>;
213 act(() => { pending = f.owner.confirmDiscard({ title: "", message: "", detail: "", confirmLabel: "", cancelLabel: "" }); });
214 await started.promise;
215 await act(async () => { await f.owner.open("project", "/tmp/b"); });
216 await act(async () => { confirmation.resolve(true); await pending; });
217 assert.deepEqual(f.deleted, ["a"], "confirmation remains bound to A");
218 assert.equal(f.owner.surface!.draft.id, "b");
219 await f.close();
220 }
221 {
222 const finished = deferred<void>();
223 const f = await fixture({ DiscardSessionDraft: () => finished.promise });
224 let pending!: Promise<void>;
225 act(() => { pending = f.owner.discard(); });
226 await act(async () => { await f.owner.open("project", "/tmp/b"); });
227 await act(async () => { finished.resolve(); await pending; });
228 assert.equal(f.owner.surface!.draft.id, "b", "late discard cannot dismiss B");
229 await f.close();
230 }
231 {
232 const task = deferred<void>();
233 const f = await fixture();
234 let tracked!: Promise<void>;
235 act(() => { tracked = f.owner.trackTask("a", 1, task.promise); f.owner.updateContent(content("local")); });
236 const saved = f.records.get("a")!;
237 f.records.set("a", { ...saved, revision: 2, contentJson: JSON.stringify(content("remote")) });
238 await act(async () => { await f.owner.flush(); });
239 assert.equal(f.owner.surface!.saveState, "conflict");
240 act(() => { f.owner.useSavedConflict(); });
241 await act(async () => { task.resolve(); await tracked; });
242 assert.equal(f.owner.surface!.pendingTasks, 0, "old generation still releases its task registration");
243 await act(async () => { await window.__reasonixFlushSessionDraft!(); });
244 await f.close();
245 }
246 console.log("draft ownership: synchronous capture, target-bound discard and task settlement passed");
247 } finally { dom.window.close(); }
248
248 lines Plain Text