| 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 { usePendingPlanRevisions } from "../lib/usePendingPlanRevisions"; |
| 6 | import { useSessionOperations } from "../app-runtime/useSessionOperations"; |
| 7 | import { useSessionSubmission } from "../lib/useSessionSubmission"; |
| 8 | |
| 9 | const dom = new JSDOM("<div id='root'></div>"); |
| 10 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 11 | const root = createRoot(document.getElementById("root")!); |
| 12 | function deferred() { |
| 13 | let resolve!: () => void; let reject!: (error: Error) => void; |
| 14 | const promise = new Promise<void>((yes, no) => { resolve = yes; reject = no; }); |
| 15 | return { promise, resolve, reject }; |
| 16 | } |
| 17 | const requests: { tab: string; text: string; gate: ReturnType<typeof deferred> }[] = []; |
| 18 | const errors: unknown[] = []; |
| 19 | let remember!: ReturnType<typeof usePendingPlanRevisions>; |
| 20 | function Probe({ tab, gen, running, ready }: { tab: string; gen: number; running: boolean; ready: boolean }) { |
| 21 | const resources = ["A", "B"].map(tabId => ({ tabId, sessionKey: tabId + gen })); |
| 22 | const visible = resources.find(target => target.tabId === tab)!; |
| 23 | const operations = useSessionOperations({ visible, resources }); |
| 24 | const submission = useSessionSubmission({ target: visible, operations, missingSource: "missing", |
| 25 | resources: resources.map(target => ({ target, remote: false, ready: true, unavailable: "", goalDraft: false, |
| 26 | collaboration: "normal", approval: "ask" })), |
| 27 | ports: { |
| 28 | send: (tab, text) => { const gate = deferred(); requests.push({ tab, text, gate }); return gate.promise; }, |
| 29 | clearUndo: () => {}, setGoal: async () => {}, patchGoal: () => {}, profile: async () => true, |
| 30 | }, |
| 31 | }); |
| 32 | remember = usePendingPlanRevisions({ visible, resources, running, ready, operations, |
| 33 | send: submission.sendRevision, |
| 34 | report: error => { errors.push(error); }, |
| 35 | }); |
| 36 | return null; |
| 37 | } |
| 38 | const paint = (tab = "A", gen = 1, running = false, ready = true) => act(async () => root.render(<Probe tab={tab} gen={gen} running={running} ready={ready} />)); |
| 39 | try { |
| 40 | await paint("A", 1, true); remember("A", "first"); |
| 41 | assert.equal(requests.length, 0, "running turn holds its revision"); |
| 42 | await paint("A", 1, false, false); |
| 43 | assert.equal(requests.length, 0, "an idle but uncommitted navigation surface cannot submit a revision"); |
| 44 | await paint("B"); assert.equal(requests.length, 0, "B does not submit A's pending revision"); |
| 45 | remember("B", "B revision"); assert.equal(requests[0].tab, "B"); |
| 46 | await paint("A"); assert.deepEqual(requests.map(({ tab, text }) => [tab, text]), [["B", "B revision"], ["A", "first"]]); |
| 47 | |
| 48 | remember("A", "same text"); remember("A", "same text"); |
| 49 | await paint("A", 2); remember("A", "replacement"); |
| 50 | assert.equal(requests.length, 3, "replacement resource can start while the old resource's transport is pending"); |
| 51 | remember("A", "latest"); |
| 52 | await act(async () => requests[1].gate.resolve()); |
| 53 | assert.equal(requests.length, 3, "old finally cannot release the new request or start its queued successor"); |
| 54 | await act(async () => requests[2].gate.resolve()); |
| 55 | assert.equal(requests[3].text, "latest", "matching completion starts exactly the replacement revision"); |
| 56 | await act(async () => requests[3].gate.resolve()); |
| 57 | await paint("A", 2); assert.equal(requests.length, 4, "terminal revisions leave no retried request"); |
| 58 | await act(async () => requests[0].gate.resolve()); |
| 59 | |
| 60 | remember("A", "failure"); await paint("B", 2); await paint("A", 2); |
| 61 | await act(async () => requests[4].gate.reject(Error("old failure"))); |
| 62 | assert.deepEqual(errors, [], "A-B-A does not restore failure UI ownership"); |
| 63 | await paint("B", 2); await paint("A", 2); |
| 64 | assert.equal(requests[5].text, "failure", "source data survives suppressed old error UI and can be retried on a new activation"); |
| 65 | await act(async () => requests[5].gate.resolve()); |
| 66 | remember("A", "retryable revision"); |
| 67 | await act(async () => requests[6].gate.reject(Error("current failure"))); |
| 68 | assert.equal(errors.length, 1); |
| 69 | await paint("A", 2); assert.equal(requests.length, 7, "unrelated commits do not loop on a failed revision"); |
| 70 | await paint("B", 2); await paint("A", 2); |
| 71 | assert.equal(requests[7].text, "retryable revision", "explicit source reactivation retains the existing retryable revision"); |
| 72 | await act(async () => requests[7].gate.resolve()); |
| 73 | remember("A", "disposed"); remember("A", "must not follow"); |
| 74 | await act(async () => root.unmount()); |
| 75 | remember("A", "after unmount"); |
| 76 | await act(async () => requests[8].gate.resolve()); |
| 77 | assert.equal(requests.length, 9, "synchronous disposal releases queue and revokes old follow-on work"); |
| 78 | console.log("pending plan revision lifecycle: source queues, request identity, replacement, ABA and disposal passed"); |
| 79 | } finally { dom.window.close(); } |
| 80 |