| 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 |