| 1 | import assert from "node:assert/strict"; |
| 2 | import test from "node:test"; |
| 3 | import React, { act } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import { JSDOM } from "jsdom"; |
| 6 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 7 | import { makeSessionUIMock } from "../lib/sessionUIMock"; |
| 8 | import { sendPersistedComposer, useSessionComposerPersistence } from "../lib/sessionComposerPersistence"; |
| 9 | import type { SessionRef } from "../lib/sessionRef"; |
| 10 | |
| 11 | function deferred() { |
| 12 | let resolve!: () => void; |
| 13 | const promise = new Promise<void>(done => { resolve = done; }); |
| 14 | return { promise, resolve }; |
| 15 | } |
| 16 | |
| 17 | test("composer submission boundaries", async t => { |
| 18 | const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" }); |
| 19 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 20 | const root = createRoot(document.getElementById("root")!); |
| 21 | const backend = makeSessionUIMock(async () => {}); |
| 22 | const host = installDesktopHostStub({ ...backend }); |
| 23 | let editor!: ReturnType<typeof useSessionComposerPersistence>; |
| 24 | function Probe({ session }: { session: string }) { |
| 25 | editor = useSessionComposerPersistence({ hostId: "local", sessionId: session }, "race-tab"); |
| 26 | return null; |
| 27 | } |
| 28 | const paint = (session: string) => act(async () => { |
| 29 | root.render(<Probe session={session} />); |
| 30 | await new Promise(resolve => setTimeout(resolve, 0)); |
| 31 | }); |
| 32 | const patch = (text: string) => { |
| 33 | const target = editor.target!; |
| 34 | target.onPatch!(target.draftId, target.generation, { text }); |
| 35 | }; |
| 36 | try { |
| 37 | await t.test("registration freezes the captured input before the RPC returns", async () => { |
| 38 | await paint("begin-race"); |
| 39 | await act(async () => patch("submitted text")); |
| 40 | const entered = deferred(), release = deferred(); |
| 41 | host.commands.BeginSessionComposerSubmission = async (...args: Parameters<typeof backend.BeginSessionComposerSubmission>) => { |
| 42 | const result = await backend.BeginSessionComposerSubmission(...args); |
| 43 | entered.resolve(); await release.promise; return result; |
| 44 | }; |
| 45 | let pending!: Promise<unknown>; |
| 46 | try { |
| 47 | await act(async () => { |
| 48 | pending = sendPersistedComposer("race-tab", "submitted text", "submitted text", "begin-race-id", async () => {}); |
| 49 | await entered.promise; |
| 50 | }); |
| 51 | assert.equal(editor.blocked, true); |
| 52 | await act(async () => patch("must not silently replace captured input")); |
| 53 | assert.equal(editor.target!.initial.text, "submitted text"); |
| 54 | } finally { |
| 55 | await act(async () => { release.resolve(); await pending; }); |
| 56 | host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission; |
| 57 | } |
| 58 | }); |
| 59 | |
| 60 | await t.test("late acceptance preserves edits made after receipt reconciliation", async () => { |
| 61 | const ref: SessionRef = { hostId: "local", sessionId: "receipt-race" }; |
| 62 | await paint(ref.sessionId); |
| 63 | await act(async () => patch("first input")); |
| 64 | const sent = deferred(), response = deferred(); |
| 65 | let pending!: Promise<unknown>; |
| 66 | await act(async () => { |
| 67 | pending = sendPersistedComposer("race-tab", "first input", "first input", "receipt-race-id", async () => { |
| 68 | await backend.CompleteSessionComposerSubmission(ref, "receipt-race-id", "accepted"); |
| 69 | sent.resolve(); await response.promise; |
| 70 | }); |
| 71 | await sent.promise; |
| 72 | }); |
| 73 | await act(async () => editor.retry()); |
| 74 | assert.equal(editor.blocked, false); |
| 75 | await act(async () => patch("new unsent input")); |
| 76 | await act(async () => { response.resolve(); await pending; }); |
| 77 | assert.equal(editor.target!.initial.text, "new unsent input"); |
| 78 | await act(async () => editor.retry()); |
| 79 | assert.equal(JSON.parse((await backend.GetSessionComposerState(ref)).contentJson).text, "new unsent input"); |
| 80 | }); |
| 81 | |
| 82 | await t.test("departing and returning cannot revive an old send intent", async () => { |
| 83 | await paint("navigation-race"); |
| 84 | await act(async () => patch("do not send after leaving")); |
| 85 | const entered = deferred(), release = deferred(); |
| 86 | host.commands.BeginSessionComposerSubmission = async (...args: Parameters<typeof backend.BeginSessionComposerSubmission>) => { |
| 87 | const result = await backend.BeginSessionComposerSubmission(...args); |
| 88 | entered.resolve(); await release.promise; return result; |
| 89 | }; |
| 90 | let pending!: Promise<unknown>, sends = 0; |
| 91 | await act(async () => { |
| 92 | pending = sendPersistedComposer("race-tab", "old", "old", "navigation-race-id", async () => { sends++; }); |
| 93 | await entered.promise; |
| 94 | }); |
| 95 | await paint("other-session"); |
| 96 | await paint("navigation-race"); |
| 97 | await act(async () => { |
| 98 | release.resolve(); |
| 99 | await assert.rejects(pending, /inbox_not_submitted/); |
| 100 | }); |
| 101 | assert.equal(sends, 0); |
| 102 | assert.equal(editor.target!.initial.text, "do not send after leaving"); |
| 103 | host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission; |
| 104 | }); |
| 105 | |
| 106 | await t.test("rejected registration automatically recovers without replacing current input", async () => { |
| 107 | const ref: SessionRef = { hostId: "local", sessionId: "registration-conflict" }; |
| 108 | await paint(ref.sessionId); |
| 109 | await act(async () => patch("keep this window input")); |
| 110 | host.commands.BeginSessionComposerSubmission = async () => { |
| 111 | const current = await backend.GetSessionComposerState(ref); |
| 112 | await backend.SaveSessionComposerState({ ref, expectedRevision: current.revision, contentVersion: 1, contentJson: '{"text":"other window input"}' }); |
| 113 | throw new Error("session_operation:input_conflict:The input changed. Both versions are preserved; review before continuing."); |
| 114 | }; |
| 115 | let sends = 0; |
| 116 | try { |
| 117 | await act(async () => { |
| 118 | await assert.rejects(sendPersistedComposer("race-tab", "keep this window input", "keep this window input", "conflicted-guidance", async () => { sends++; }, undefined, undefined, "guidance"), /input_conflict/); |
| 119 | }); |
| 120 | assert.equal(editor.blocked, false, "automatic recovery must restore editability"); |
| 121 | assert.equal(editor.target!.initial.text, "keep this window input"); |
| 122 | await act(async () => editor.retry()); |
| 123 | assert.equal(JSON.parse((await backend.GetSessionComposerState(ref)).contentJson).text, "keep this window input"); |
| 124 | assert.equal(sends, 0, "conflict recovery never submits or replays the message"); |
| 125 | } finally { |
| 126 | host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission; |
| 127 | } |
| 128 | }); |
| 129 | |
| 130 | await t.test("lost registration response releases only the registration that was never sent", async () => { |
| 131 | const ref: SessionRef = { hostId: "local", sessionId: "lost-registration" }; |
| 132 | await paint(ref.sessionId); |
| 133 | await act(async () => { editor.setGoalDraft(true); patch("retained goal input"); }); |
| 134 | host.commands.BeginSessionComposerSubmission = async (...args: Parameters<typeof backend.BeginSessionComposerSubmission>) => { |
| 135 | await backend.BeginSessionComposerSubmission(...args); |
| 136 | throw new Error("lost registration response"); |
| 137 | }; |
| 138 | let sends = 0; |
| 139 | await act(async () => { |
| 140 | await assert.rejects(sendPersistedComposer("race-tab", "goal", "goal", "lost-registration-id", async () => { sends++; }), /lost registration/); |
| 141 | }); |
| 142 | assert.equal(sends, 0); |
| 143 | assert.equal(editor.blocked, false); |
| 144 | assert.equal(editor.target!.initial.text, "retained goal input"); |
| 145 | assert.equal(editor.target!.initial.goalDraft, true); |
| 146 | assert.equal(sends, 0); |
| 147 | await act(async () => editor.retry()); |
| 148 | assert.equal(editor.blocked, false); |
| 149 | assert.equal((await backend.GetSessionComposerState(ref)).submissionId, undefined); |
| 150 | assert.equal(editor.target!.initial.goalDraft, true); |
| 151 | host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission; |
| 152 | }); |
| 153 | |
| 154 | await t.test("unknown delivery is automatically reconciled without a second send", async () => { |
| 155 | const ref: SessionRef = { hostId: "local", sessionId: "automatic-receipt" }; |
| 156 | await paint(ref.sessionId); |
| 157 | await act(async () => patch("accepted once")); |
| 158 | let sends = 0; |
| 159 | host.commands.GetSessionComposerState = async value => { |
| 160 | const state = await backend.GetSessionComposerState(value); |
| 161 | if (value.sessionId === ref.sessionId && state.submissionId) return backend.CompleteSessionComposerSubmission(value, state.submissionId, "accepted"); |
| 162 | return state; |
| 163 | }; |
| 164 | try { |
| 165 | await act(async () => { |
| 166 | await assert.rejects(sendPersistedComposer("race-tab", "accepted once", "accepted once", "automatic-receipt-id", async () => { sends++; throw Error("reply lost"); }), /reply lost/); |
| 167 | }); |
| 168 | assert.equal(sends, 1); |
| 169 | assert.equal(editor.blocked, false); |
| 170 | assert.equal(editor.target!.initial.text, ""); |
| 171 | assert.equal(editor.settledId, "automatic-receipt-id"); |
| 172 | } finally { host.commands.GetSessionComposerState = backend.GetSessionComposerState; } |
| 173 | }); |
| 174 | |
| 175 | await t.test("history changes retain input and are acknowledged on explicit send", async () => { |
| 176 | const ref: SessionRef = { hostId: "local", sessionId: "history-recovery" }; |
| 177 | await backend.SaveSessionComposerState({ ref, expectedRevision: "0", contentVersion: 1, contentJson: '{"text":"next instruction"}' }); |
| 178 | host.commands.GetSessionComposerState = async value => ({ ...await backend.GetSessionComposerState(value), historyChanged: value.sessionId === ref.sessionId }); |
| 179 | let acknowledged = false, sends = 0; |
| 180 | host.commands.SaveSessionComposerState = async req => { |
| 181 | if (req.ref.sessionId === ref.sessionId) acknowledged = req.acknowledgeHistory === true; |
| 182 | return backend.SaveSessionComposerState(req); |
| 183 | }; |
| 184 | try { |
| 185 | await paint(ref.sessionId); |
| 186 | assert.equal(editor.blocked, false); |
| 187 | assert.equal(editor.target!.initial.text, "next instruction"); |
| 188 | await act(async () => { await sendPersistedComposer("race-tab", "next instruction", "next instruction", "history-recovery-id", async () => { sends++; }); }); |
| 189 | assert.equal(acknowledged, true); |
| 190 | assert.equal(sends, 1); |
| 191 | } finally { |
| 192 | host.commands.GetSessionComposerState = backend.GetSessionComposerState; |
| 193 | host.commands.SaveSessionComposerState = backend.SaveSessionComposerState; |
| 194 | } |
| 195 | }); |
| 196 | |
| 197 | await t.test("another pending submission cannot consume this window's unsent input", async () => { |
| 198 | const ref: SessionRef = { hostId: "local", sessionId: "other-pending" }; |
| 199 | await paint(ref.sessionId); |
| 200 | await act(async () => patch("my unsent input")); |
| 201 | const saved = await backend.SaveSessionComposerState({ ref, expectedRevision: "0", contentVersion: 1, contentJson: '{"text":"other instruction"}' }); |
| 202 | await backend.BeginSessionComposerSubmission(ref, saved.revision, "other-submission", "{}"); |
| 203 | let sends = 0; |
| 204 | await act(async () => { |
| 205 | await assert.rejects(sendPersistedComposer("race-tab", "my unsent input", "my unsent input", "unsent-id", async () => { sends++; })); |
| 206 | }); |
| 207 | assert.equal(editor.blocked, true); |
| 208 | assert.equal(editor.target!.initial.text, "my unsent input"); |
| 209 | await backend.CompleteSessionComposerSubmission(ref, "other-submission", "accepted"); |
| 210 | await act(async () => editor.retry()); |
| 211 | assert.equal(editor.blocked, false); |
| 212 | assert.equal(editor.target!.initial.text, "my unsent input"); |
| 213 | assert.equal(sends, 0); |
| 214 | }); |
| 215 | |
| 216 | await t.test("lost settlement response follows durable content instead of reviving input", async () => { |
| 217 | await paint("lost-settlement"); |
| 218 | await act(async () => patch("already received input")); |
| 219 | host.commands.CompleteSessionComposerSubmission = async (...args: Parameters<typeof backend.CompleteSessionComposerSubmission>) => { |
| 220 | const result = await backend.CompleteSessionComposerSubmission(...args); |
| 221 | if (args[2] === "accepted") throw new Error("lost settlement response"); |
| 222 | return result; |
| 223 | }; |
| 224 | let sends = 0; |
| 225 | await act(async () => { |
| 226 | await assert.rejects(sendPersistedComposer("race-tab", "received", "received", "lost-settlement-id", async () => { sends++; }), /lost settlement/); |
| 227 | }); |
| 228 | assert.equal(sends, 1); |
| 229 | assert.equal(editor.blocked, false); |
| 230 | assert.equal(editor.target!.initial.text, ""); |
| 231 | host.commands.CompleteSessionComposerSubmission = backend.CompleteSessionComposerSubmission; |
| 232 | }); |
| 233 | |
| 234 | await t.test("a stale settlement snapshot cannot overwrite a newer recovered revision", async () => { |
| 235 | const ref: SessionRef = { hostId: "local", sessionId: "stale-settlement" }; |
| 236 | await paint(ref.sessionId); |
| 237 | await act(async () => patch("first input")); |
| 238 | const settled = deferred(), response = deferred(); |
| 239 | host.commands.CompleteSessionComposerSubmission = async (...args: Parameters<typeof backend.CompleteSessionComposerSubmission>) => { |
| 240 | const result = await backend.CompleteSessionComposerSubmission(...args); |
| 241 | settled.resolve(); await response.promise; return result; |
| 242 | }; |
| 243 | let pending!: Promise<unknown>; |
| 244 | await act(async () => { |
| 245 | pending = sendPersistedComposer("race-tab", "first", "first", "stale-settlement-id", async () => {}); |
| 246 | await settled.promise; |
| 247 | }); |
| 248 | const current = await backend.GetSessionComposerState(ref); |
| 249 | await backend.SaveSessionComposerState({ ref, expectedRevision: current.revision, contentVersion: 1, contentJson: '{"text":"another window input"}' }); |
| 250 | await act(async () => editor.retry()); |
| 251 | await act(async () => { response.resolve(); await pending; }); |
| 252 | assert.equal(editor.target!.initial.text, "another window input"); |
| 253 | host.commands.CompleteSessionComposerSubmission = backend.CompleteSessionComposerSubmission; |
| 254 | }); |
| 255 | } finally { |
| 256 | await act(async () => root.unmount()); |
| 257 | dom.window.close(); |
| 258 | } |
| 259 | }); |
| 260 |