| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync } from "node:fs"; |
| 3 | import React, { act } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import { JSDOM } from "jsdom"; |
| 6 | import { useSessionComposerLifecycle } from "../app-runtime/useSessionComposerLifecycle"; |
| 7 | import { useSessionComposerPersistence } from "../lib/sessionComposerPersistence"; |
| 8 | import { makeSessionUIMock } from "../lib/sessionUIMock"; |
| 9 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 10 | |
| 11 | const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" }); |
| 12 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 13 | const backend = makeSessionUIMock(async () => {}); |
| 14 | let failSave = false; |
| 15 | const legacyCalls: string[] = []; |
| 16 | const retired = (name: string) => async () => { legacyCalls.push(name); throw Error("corrupt old draft database"); }; |
| 17 | const stub = installDesktopHostStub({ ...backend, |
| 18 | SaveSessionComposerState: async (request: Parameters<typeof backend.SaveSessionComposerState>[0]) => { |
| 19 | if (failSave) throw Error("formal input save failed"); |
| 20 | return backend.SaveSessionComposerState(request); |
| 21 | }, |
| 22 | ListSessionDraftSummaries: retired("list"), RestoreSessionDraft: retired("restore"), SaveSessionDraft: retired("save"), |
| 23 | }); |
| 24 | const ref = { hostId: "local", sessionId: "formal-lifecycle" }; |
| 25 | let editor!: ReturnType<typeof useSessionComposerPersistence>; |
| 26 | function Probe() { |
| 27 | useSessionComposerLifecycle(); |
| 28 | editor = useSessionComposerPersistence(ref, ref.sessionId); |
| 29 | return null; |
| 30 | } |
| 31 | const root = createRoot(document.getElementById("root")!); |
| 32 | const patch = (text: string) => { |
| 33 | const target = editor.target!; |
| 34 | target.onPatch!(target.draftId, target.generation, { text }); |
| 35 | }; |
| 36 | try { |
| 37 | await act(async () => { root.render(<Probe />); }); |
| 38 | await act(async () => { patch("keep formal input"); }); |
| 39 | let release!: () => void; |
| 40 | const pending = new Promise<void>(resolve => { release = resolve; }); |
| 41 | const target = editor.target!; |
| 42 | target.trackTask!(target.draftId, target.generation, pending.then(() => patch("attachment finished"))); |
| 43 | let finished = false; |
| 44 | let flushing!: Promise<void>; |
| 45 | act(() => { flushing = window.__reasonixFlushSessionDraft!().then(() => { finished = true; }); }); |
| 46 | assert.equal(finished, false); |
| 47 | await act(async () => { release(); await flushing; }); |
| 48 | assert.equal(JSON.parse((await backend.GetSessionComposerState(ref)).contentJson).text, "attachment finished"); |
| 49 | await act(async () => { window.__reasonixResumeSessionDraftEditing!(); }); |
| 50 | failSave = true; |
| 51 | await act(async () => patch("keep on failure")); |
| 52 | await act(async () => { await assert.rejects(window.__reasonixFlushSessionDraft!(), /formal input save failed/); }); |
| 53 | failSave = false; |
| 54 | await act(async () => { await editor.retry(); }); |
| 55 | assert.equal(editor.blocked, false, "failed exit resumes formal editing"); |
| 56 | assert.deepEqual(legacyCalls, [], "startup and shutdown never inspect retired drafts"); |
| 57 | |
| 58 | // The production mount must use this owner, otherwise isolated hook tests |
| 59 | // would miss a reintroduced legacy startup/read/exit path. |
| 60 | const runtime = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8"); |
| 61 | assert.match(runtime, /useSessionComposerLifecycle\(\)/); |
| 62 | assert.doesNotMatch(runtime, /useSessionDraftSurface|acceptedDraftSession/); |
| 63 | const view = readFileSync(new URL("../app-shell/AppRuntimeView.tsx", import.meta.url), "utf8"); |
| 64 | assert.doesNotMatch(view, /draft\.surface|PreviousInputRecovery|DraftTopicbarActions/); |
| 65 | await act(async () => root.unmount()); |
| 66 | assert.equal(window.__reasonixFlushSessionDraft, undefined); |
| 67 | assert.equal(window.__reasonixResumeSessionDraftEditing, undefined); |
| 68 | console.log("PASS formal input lifecycle: exit attachment barrier, save failure, unmount, no legacy reads"); |
| 69 | } finally { |
| 70 | stub.uninstall(); |
| 71 | dom.window.close(); |
| 72 | } |
| 73 |