| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { installDom, installBridgeApp } from "./composerInboxHarness"; |
| 5 | import { useInboxQueueEditor } from "../lib/useInboxQueueEditor"; |
| 6 | import { createInboxQueuePreview } from "../lib/inboxQueuePreview"; |
| 7 | import type { InboxQueueRequest } from "../lib/inboxQueueCommands"; |
| 8 | import type { InboxTarget } from "../lib/pendingFollowup"; |
| 9 | |
| 10 | const dom = installDom(); |
| 11 | globalThis.sessionStorage = dom.window.sessionStorage; |
| 12 | const root = createRoot(document.getElementById("root")!); |
| 13 | let editor!: ReturnType<typeof useInboxQueueEditor>; |
| 14 | let applied = 0; |
| 15 | function Harness({ scope }: { scope: string }) { |
| 16 | editor = useInboxQueueEditor(scope, "tab-a", "session-a", () => { applied++; }, () => {}); |
| 17 | return <p>{editor.drafts.edit?.value}</p>; |
| 18 | } |
| 19 | const render = (scope: string) => act(async () => { root.render(<Harness key={scope} scope={scope} />); }); |
| 20 | function deferred() { |
| 21 | let resolve!: () => void; |
| 22 | const promise = new Promise<void>(done => { resolve = done; }); |
| 23 | return { promise, resolve }; |
| 24 | } |
| 25 | const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 1, selection: 1, remote: true }; |
| 26 | const failures: string[] = []; |
| 27 | async function check(name: string, test: () => Promise<void>) { |
| 28 | try { await test(); console.log(`PASS ${name}`); } |
| 29 | catch (error) { failures.push(`${name}: ${String(error)}`); } |
| 30 | } |
| 31 | |
| 32 | await check("late save must not clear a remounted editor's new draft", async () => { |
| 33 | const fixture = createInboxQueuePreview(); |
| 34 | const committed = deferred(), reply = deferred(); |
| 35 | installBridgeApp({ |
| 36 | CaptureInboxTarget: async () => target, |
| 37 | InboxQueueForTarget: async (captured: InboxTarget, request: InboxQueueRequest) => { |
| 38 | const result = await fixture.command(captured, request); |
| 39 | if (request.kind === "edit") { committed.resolve(); await reply.promise; } |
| 40 | return result; |
| 41 | }, |
| 42 | }); |
| 43 | await render("save-a"); |
| 44 | await act(async () => { await editor.edit("queue-save"); editor.update("submitted text"); }); |
| 45 | let save!: Promise<void>; |
| 46 | await act(async () => { save = editor.save(); await committed.promise; }); |
| 47 | await render("save-b"); |
| 48 | await render("save-a"); |
| 49 | await act(async () => { editor.update("newer text after returning"); }); |
| 50 | const before = applied; |
| 51 | await act(async () => { reply.resolve(); await save; }); |
| 52 | assert.equal(editor.drafts.edit?.value, "newer text after returning"); |
| 53 | assert.equal(applied, before, "unmounted owner cannot publish snapshots"); |
| 54 | }); |
| 55 | |
| 56 | await check("target capture resolving after unmount must not issue a command", async () => { |
| 57 | const fixture = createInboxQueuePreview(); |
| 58 | const captured = deferred(), release = deferred(); |
| 59 | const requests: InboxQueueRequest[] = []; |
| 60 | installBridgeApp({ |
| 61 | CaptureInboxTarget: async () => { captured.resolve(); await release.promise; return target; }, |
| 62 | InboxQueueForTarget: async (binding: InboxTarget, request: InboxQueueRequest) => { requests.push(request); return fixture.command(binding, request); }, |
| 63 | }); |
| 64 | await render("capture-a"); |
| 65 | let pause!: Promise<void>; |
| 66 | await act(async () => { pause = editor.mutate({ kind: "pause", paused: true }); await captured.promise; }); |
| 67 | await render("capture-b"); |
| 68 | await act(async () => { release.resolve(); await pause; }); |
| 69 | assert.equal(requests.length, 0); |
| 70 | assert.equal(fixture.snapshot().paused, false); |
| 71 | }); |
| 72 | |
| 73 | await check("late read cannot replace an edit opened by the new owner", async () => { |
| 74 | const fixture = createInboxQueuePreview(); |
| 75 | const read = deferred(), reply = deferred(); |
| 76 | installBridgeApp({ |
| 77 | CaptureInboxTarget: async () => target, |
| 78 | InboxQueueForTarget: async (binding: InboxTarget, request: InboxQueueRequest) => { |
| 79 | const result = await fixture.command(binding, request); |
| 80 | if (request.kind === "read" && request.itemId === "queue-save") { read.resolve(); await reply.promise; } |
| 81 | return result; |
| 82 | }, |
| 83 | }); |
| 84 | await render("read-a"); |
| 85 | let pending!: Promise<void>; |
| 86 | await act(async () => { pending = editor.edit("queue-save"); await read.promise; }); |
| 87 | await render("read-b"); |
| 88 | await render("read-a"); |
| 89 | await act(async () => { await editor.edit("queue-tests"); editor.update("new owner's draft"); }); |
| 90 | const before = applied; |
| 91 | await act(async () => { reply.resolve(); await pending; }); |
| 92 | assert.equal(editor.drafts.edit?.id, "queue-tests"); |
| 93 | assert.equal(editor.drafts.edit?.value, "new owner's draft"); |
| 94 | assert.equal(applied, before); |
| 95 | }); |
| 96 | |
| 97 | await check("late failed save does not reconcile or overwrite a replacement draft", async () => { |
| 98 | const fixture = createInboxQueuePreview(); |
| 99 | const started = deferred(), reply = deferred(); |
| 100 | const requests: InboxQueueRequest[] = []; |
| 101 | installBridgeApp({ |
| 102 | CaptureInboxTarget: async () => target, |
| 103 | InboxQueueForTarget: async (binding: InboxTarget, request: InboxQueueRequest) => { |
| 104 | requests.push(request); |
| 105 | if (request.kind === "edit") { started.resolve(); await reply.promise; throw new Error("reply lost"); } |
| 106 | return fixture.command(binding, request); |
| 107 | }, |
| 108 | }); |
| 109 | await render("failure-a"); |
| 110 | await act(async () => { await editor.edit("queue-save"); editor.update("submitted"); }); |
| 111 | let pending!: Promise<void>; |
| 112 | await act(async () => { pending = editor.save(); await started.promise; }); |
| 113 | await render("failure-b"); |
| 114 | await render("failure-a"); |
| 115 | await act(async () => { editor.update("preserve newer input"); }); |
| 116 | await act(async () => { reply.resolve(); await pending; }); |
| 117 | assert.equal(editor.drafts.edit?.value, "preserve newer input"); |
| 118 | assert.equal(editor.drafts.edit?.reason, undefined); |
| 119 | assert.equal(requests.filter(request => request.kind === "snapshot").length, 0); |
| 120 | }); |
| 121 | |
| 122 | await check("load latest reacquires current selection instead of reusing stale target", async () => { |
| 123 | const fixture = createInboxQueuePreview(); |
| 124 | let selection = 1; |
| 125 | installBridgeApp({ |
| 126 | CaptureInboxTarget: async () => ({ ...target, selection }), |
| 127 | InboxQueueForTarget: async (binding: InboxTarget, request: InboxQueueRequest) => binding.selection !== selection |
| 128 | ? { outcome: "unavailable", reason: "session_changed", snapshot: fixture.snapshot("session-a") } |
| 129 | : fixture.command(binding, request), |
| 130 | }); |
| 131 | await render("reload-a"); |
| 132 | await act(async () => { await editor.edit("queue-save"); editor.update("keep this local edit"); }); |
| 133 | selection = 2; |
| 134 | await act(async () => { await editor.save(); }); |
| 135 | assert.equal(editor.drafts.edit?.reason, "session_changed"); |
| 136 | await act(async () => { await editor.reload(); }); |
| 137 | assert.equal(editor.drafts.edit?.reason, undefined); |
| 138 | assert.equal(editor.drafts.edit?.target.selection, 2); |
| 139 | assert.equal(editor.drafts.recovered.at(-1)?.value, "keep this local edit"); |
| 140 | }); |
| 141 | |
| 142 | await act(async () => { root.unmount(); }); |
| 143 | dom.window.close(); |
| 144 | assert.deepEqual(failures, []); |
| 145 |