| 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 { installDesktopHostStub } from "./desktopHostStub"; |
| 6 | import { makeSessionUIMock } from "../lib/sessionUIMock"; |
| 7 | import { flushAllSessionComposers, resumeSessionComposerEditing, sendPersistedComposer, useSessionComposerPersistence } from "../lib/sessionComposerPersistence"; |
| 8 | import type { SessionRef } from "../lib/sessionRef"; |
| 9 | |
| 10 | const dom = new JSDOM("<div id='root'></div>", {url:"http://localhost"}); |
| 11 | Object.assign(globalThis,{window:dom.window,document:dom.window.document,IS_REACT_ACT_ENVIRONMENT:true}); |
| 12 | const backend = makeSessionUIMock(async()=>{}); |
| 13 | installDesktopHostStub(backend); |
| 14 | let editor!: ReturnType<typeof useSessionComposerPersistence>; |
| 15 | const a:SessionRef={hostId:"local",sessionId:"persist-a"}, b:SessionRef={hostId:"local",sessionId:"persist-b"}; |
| 16 | function Probe({refValue}:{refValue:SessionRef}) { editor=useSessionComposerPersistence(refValue,refValue.sessionId); return null; } |
| 17 | const root=createRoot(document.getElementById("root")!); |
| 18 | async function paint(ref:SessionRef) { await act(async()=>{root.render(<Probe refValue={ref}/>); await new Promise(resolve=>setTimeout(resolve,0));}); } |
| 19 | function patch(text:string) { const target=editor.target!; target.onPatch!(target.draftId,target.generation,{text}); } |
| 20 | async function persist() { await act(async()=>{await flushAllSessionComposers();resumeSessionComposerEditing();}); } |
| 21 | try { |
| 22 | await paint(a); |
| 23 | await act(async()=>patch("source A")); |
| 24 | const source=editor.target!; |
| 25 | await paint(b); |
| 26 | await act(async()=>patch("source B")); |
| 27 | await act(async()=>source.onPatch!(source.draftId,source.generation,{text:"late A"})); |
| 28 | await persist(); |
| 29 | assert.equal(JSON.parse((await backend.GetSessionComposerState(a)).contentJson).text,"late A"); |
| 30 | assert.equal(JSON.parse((await backend.GetSessionComposerState(b)).contentJson).text,"source B"); |
| 31 | |
| 32 | // Exit waits for source-bound attachment work and persists its late result. |
| 33 | let resolveTask!:()=>void; |
| 34 | const attachment=new Promise<void>(resolve=>{resolveTask=resolve;}); |
| 35 | const target=editor.target!; |
| 36 | const work=attachment.then(()=>target.onPatch!(target.draftId,target.generation,{text:"attachment completed"})); |
| 37 | target.trackTask!(target.draftId,target.generation,work); |
| 38 | let exited=false; |
| 39 | const exit=act(async()=>{await flushAllSessionComposers();exited=true;}); |
| 40 | assert.equal(exited,false); resolveTask(); await exit; |
| 41 | assert.equal(JSON.parse((await backend.GetSessionComposerState(b)).contentJson).text,"attachment completed"); |
| 42 | await act(async()=>resumeSessionComposerEditing()); |
| 43 | |
| 44 | // A second writer must not replace what this window is editing. |
| 45 | const prior=await backend.GetSessionComposerState(b); |
| 46 | await backend.SaveSessionComposerState({ref:b,expectedRevision:prior.revision,contentJson:'{"text":"other window"}',contentVersion:1}); |
| 47 | await act(async()=>patch("local window")); |
| 48 | await persist(); |
| 49 | assert.equal(editor.blocked,false); |
| 50 | assert.equal(JSON.parse((await backend.GetSessionComposerState(b)).contentJson).text,"local window"); |
| 51 | |
| 52 | // A lost response preserves input and must never call the send callback again. |
| 53 | const rejected=Object.assign(new Error("settings pending"),{data:{submissionOutcome:"not_accepted"}}); |
| 54 | await act(async()=>{await assert.rejects(sendPersistedComposer(b.sessionId,"local window","local window","configuration-rejected",async()=>{throw rejected;}));}); |
| 55 | assert.equal(editor.blocked,false,"definite rejection releases submission occupancy"); |
| 56 | assert.equal(JSON.parse((await backend.GetSessionComposerState(b)).contentJson).text,"local window","configuration rejection preserves input"); |
| 57 | let sends=0; |
| 58 | const unknown=Object.assign(new Error("submission not accepted\ntransport acknowledgement lost"),{data:{submissionOutcome:"unknown"}}); |
| 59 | await act(async()=>{await assert.rejects(sendPersistedComposer(b.sessionId,"local window","local window","submission-lost",async()=>{sends++;throw unknown;}));}); |
| 60 | await act(async()=>editor.retry()); |
| 61 | assert.equal(sends,1); assert.equal(editor.blocked,true); |
| 62 | assert.equal(JSON.parse((await backend.GetSessionComposerState(b)).contentJson).text,"local window"); |
| 63 | await act(async()=>{await assert.rejects(sendPersistedComposer(b.sessionId,"local window","local window","different-id",async()=>{sends++;}));}); |
| 64 | assert.equal(sends,1); |
| 65 | await act(async()=>root.unmount()); |
| 66 | console.log("session input: source isolation, exit barrier, CAS and unknown submission passed"); |
| 67 | } finally { resumeSessionComposerEditing();dom.window.close(); } |
| 68 |