返回 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 {
47 const f = await fixture();
48 await act(async () => { await f.owner.open("project", "/tmp/b"); });
49 act(() => { f.owner.reportTaskError("a", 1, "attachment failed in A"); });
50 assert.equal(f.owner.surface!.taskError, undefined, "background errors do not appear on B");
51 await act(async () => { await f.owner.open("project", "/tmp/a"); });
52 assert.equal(f.owner.surface!.taskError, "attachment failed in A");
53 act(() => { f.owner.reportTaskError("a", 0, "stale error"); });
54 assert.equal(f.owner.surface!.taskError, "attachment failed in A", "old generations cannot replace task errors");
55 await f.close();
56 }
57 {
58 const requests: { requestId: string }[] = [];
59 const f = await fixture({
60 GetSessionDraftState: async () => ({ operation: undefined }),
61 BeginDraftSubmission: async (request: { requestId: string }) => {
62 requests.push(request);
63 if (requests.length === 1) throw new Error("connection lost before the first transaction completed");
64 return { operationId: "same-op", requestId: request.requestId, draftId: "a", phase: "accepted", revision: 2, submissionId: "same-submit", updatedAt: 1, session: { hostId: "local", sessionId: "same-session" } };
65 },
66 });
67 let capture: ReturnType<typeof f.owner.captureSubmission>;
68 act(() => { capture = f.owner.captureSubmission("a", 1); });
69 await act(async () => { await f.owner.submit("text-a", "text-a", undefined, undefined, capture); });
70 assert.equal(requests.length, 2);
71 assert.equal(requests[0].requestId, requests[1].requestId, "absence during a lost Begin retries the original preparation identity");
72 assert.equal(f.owner.surface, null);
73 await f.close();
74 }
75 {
76 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" } };
77 const calls: unknown[] = [];
78 const f = await fixture({
79 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: [] }),
80 ResumeDraftSubmission: async (...args: unknown[]) => { calls.push(args); return { ...operation, phase: "accepted", revision: 8 }; },
81 });
82 assert.equal(f.owner.surface!.operation!.operationId, "original-op");
83 assert.equal(f.owner.captureSubmission("a", 1), null, "restored operation cannot accidentally prepare a new submission");
84 await act(async () => { await f.owner.resumeSubmission(); });
85 assert.deepEqual(calls, [["original-op", 7]], "explicit continuation uses the original operation and expected version");
86 await f.close();
87 }
88 {
89 const timers: (() => void)[] = [];
90 const originalTimer = window.setTimeout;
91 window.setTimeout = ((callback: () => void, delay?: number) => delay && delay >= 250 ? (timers.push(callback), timers.length) : originalTimer(callback, delay)) as typeof window.setTimeout;
92 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" } };
93 const f = await fixture({
94 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: [] }),
95 CancelDraftSubmission: async () => ({ ...operation, phase: "accepted", revision: 6 }),
96 GetDraftSubmission: async () => ({ ...operation, revision: 5 }),
97 });
98 assert.ok(timers.length, "shell dispatch keeps an application-level verification loop");
99 await act(async () => { await f.owner.cancelSubmission(); });
100 assert.equal(f.owner.surface, null, "accepted cancellation converts immediately without waiting for the poll timer");
101 await act(async () => { timers.shift()?.(); });
102 assert.equal(f.owner.surface, null, "an older poll cannot resurrect the accepted draft");
103 await f.close();
104 window.setTimeout = originalTimer;
105 }
106 {
107 const f = await fixture();
108 act(() => { f.owner.captureSubmission("a", 1); });
109 act(() => { f.owner.updateSettings({ model: "fixture/new" }); f.owner.updateContent(content("new text")); });
110 assert.equal(f.owner.surface!.preparingSubmission, true, "capture synchronously locks the owner before context expansion");
111 assert.equal(f.owner.surface!.settings.model, "fixture/old");
112 assert.equal(f.owner.surface!.content.text, "text-a");
113 await f.close();
114 }
115 {
116 const confirmation = deferred<boolean>();
117 const started = deferred<void>();
118 const f = await fixture({ ConfirmAction: () => { started.resolve(); return confirmation.promise; } });
119 let pending!: Promise<void>;
120 act(() => { pending = f.owner.confirmDiscard({ title: "", message: "", detail: "", confirmLabel: "", cancelLabel: "" }); });
121 await started.promise;
122 await act(async () => { await f.owner.open("project", "/tmp/b"); });
123 await act(async () => { confirmation.resolve(true); await pending; });
124 assert.deepEqual(f.deleted, ["a"], "confirmation remains bound to A");
125 assert.equal(f.owner.surface!.draft.id, "b");
126 await f.close();
127 }
128 {
129 const finished = deferred<void>();
130 const f = await fixture({ DiscardSessionDraft: () => finished.promise });
131 let pending!: Promise<void>;
132 act(() => { pending = f.owner.discard(); });
133 await act(async () => { await f.owner.open("project", "/tmp/b"); });
134 await act(async () => { finished.resolve(); await pending; });
135 assert.equal(f.owner.surface!.draft.id, "b", "late discard cannot dismiss B");
136 await f.close();
137 }
138 {
139 const task = deferred<void>();
140 const f = await fixture();
141 let tracked!: Promise<void>;
142 act(() => { tracked = f.owner.trackTask("a", 1, task.promise); f.owner.updateContent(content("local")); });
143 const saved = f.records.get("a")!;
144 f.records.set("a", { ...saved, revision: 2, contentJson: JSON.stringify(content("remote")) });
145 await act(async () => { await f.owner.flush(); });
146 assert.equal(f.owner.surface!.saveState, "conflict");
147 act(() => { f.owner.useSavedConflict(); });
148 await act(async () => { task.resolve(); await tracked; });
149 assert.equal(f.owner.surface!.pendingTasks, 0, "old generation still releases its task registration");
150 await act(async () => { await window.__reasonixFlushSessionDraft!(); });
151 await f.close();
152 }
153 console.log("draft ownership: synchronous capture, target-bound discard and task settlement passed");
154 } finally { dom.window.close(); }
155
155 lines Plain Text