| 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 { useSessionClearCommands, type SessionClearCommandsInput } from "../app-runtime/useSessionClearCommands"; |
| 6 | import type { Translator } from "../lib/i18n"; |
| 7 | |
| 8 | const dom = new JSDOM("<div id='root'></div>"); |
| 9 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 10 | const root = createRoot(document.getElementById("root")!); |
| 11 | |
| 12 | const t = ((key: string) => key) as Translator; |
| 13 | const authority = { checkpoint() {}, ownsUI: () => true }; |
| 14 | |
| 15 | const operationCalls: { target: unknown; channel: string; input: unknown }[] = []; |
| 16 | const portCalls: string[] = []; |
| 17 | const notices: { text: string; level?: string }[] = []; |
| 18 | let dockRefreshes = 0; |
| 19 | let clearError: Error | null = null; |
| 20 | |
| 21 | const operations: SessionClearCommandsInput["operations"] = async (target, channel, input, execute) => { |
| 22 | operationCalls.push({ target, channel, input }); |
| 23 | try { |
| 24 | const value = await execute(input, authority); |
| 25 | return { status: "completed", value }; |
| 26 | } catch (error) { |
| 27 | return { status: "failed", error }; |
| 28 | } |
| 29 | }; |
| 30 | |
| 31 | let states!: ReturnType<typeof useSessionClearCommands>; |
| 32 | function Probe({ activeTabId, remote = false }: { activeTabId?: string; remote?: boolean }) { |
| 33 | states = useSessionClearCommands({ |
| 34 | activeTabId, |
| 35 | activeSessionIdentity: "A:1", |
| 36 | remote, |
| 37 | t, |
| 38 | notice: (text, level) => { notices.push({ text, level }); }, |
| 39 | operations, |
| 40 | refreshDock: () => { dockRefreshes += 1; }, |
| 41 | ports: { |
| 42 | clearSession: async () => { |
| 43 | portCalls.push("local"); |
| 44 | if (clearError) throw clearError; |
| 45 | }, |
| 46 | clearRemoteSession: async (tabId) => { portCalls.push(`remote:${tabId}`); }, |
| 47 | retryRemoteHydration: async () => { portCalls.push("hydrate"); }, |
| 48 | }, |
| 49 | }); |
| 50 | return null; |
| 51 | } |
| 52 | const paint = (props?: { activeTabId?: string; remote?: boolean }) => |
| 53 | act(async () => root.render(<Probe activeTabId="A" {...props} />)); |
| 54 | |
| 55 | try { |
| 56 | await paint(); |
| 57 | assert.equal(states.clearContextPending, false, "clear confirmation starts closed"); |
| 58 | await act(async () => { states.setClearContextPending(true); }); |
| 59 | assert.equal(states.clearContextPending, true, "requesting clear opens the confirmation"); |
| 60 | await act(async () => { states.cancelClearContext(); }); |
| 61 | assert.equal(states.clearContextPending, false, "cancel closes the confirmation"); |
| 62 | |
| 63 | await act(async () => { states.setClearContextPending(true); }); |
| 64 | await act(async () => { await states.confirmClearContext(); }); |
| 65 | assert.equal(states.clearContextPending, false, "confirm closes the confirmation before executing"); |
| 66 | assert.deepEqual(operationCalls, [{ target: { tabId: "A", sessionKey: "A:1" }, channel: "clear-context", input: { remote: false } }], |
| 67 | "confirm captures the committed tab and session identity at click time"); |
| 68 | assert.deepEqual(portCalls, ["local"], "local confirm clears through the controller port"); |
| 69 | assert.equal(dockRefreshes, 1, "a completed clear refreshes the dock"); |
| 70 | assert.deepEqual(notices, [{ text: "clearContext.done", level: undefined }], "a completed clear notices success"); |
| 71 | |
| 72 | operationCalls.length = 0; |
| 73 | portCalls.length = 0; |
| 74 | notices.length = 0; |
| 75 | dockRefreshes = 0; |
| 76 | await paint({ remote: true }); |
| 77 | await act(async () => { await states.confirmClearContext(); }); |
| 78 | assert.deepEqual(operationCalls[0]?.input, { remote: true }, "remote surfaces route the remote flag into the operation"); |
| 79 | assert.deepEqual(portCalls, ["remote:A", "hydrate"], "remote confirm clears the remote tab and retries hydration"); |
| 80 | assert.equal(dockRefreshes, 1, "remote completion still refreshes the dock"); |
| 81 | |
| 82 | operationCalls.length = 0; |
| 83 | portCalls.length = 0; |
| 84 | notices.length = 0; |
| 85 | dockRefreshes = 0; |
| 86 | await paint(); |
| 87 | clearError = new Error("boom"); |
| 88 | await act(async () => { await states.confirmClearContext(); }); |
| 89 | assert.deepEqual(notices, [{ text: "boom", level: "warn" }], "a failed clear surfaces the error as a warning"); |
| 90 | assert.equal(dockRefreshes, 0, "a failed clear does not refresh the dock"); |
| 91 | |
| 92 | notices.length = 0; |
| 93 | clearError = new Error(""); |
| 94 | await act(async () => { await states.confirmClearContext(); }); |
| 95 | assert.deepEqual(notices, [{ text: "clearContext.failed", level: "warn" }], "an empty error falls back to the localized failure notice"); |
| 96 | clearError = null; |
| 97 | |
| 98 | operationCalls.length = 0; |
| 99 | await paint({ activeTabId: undefined }); |
| 100 | await act(async () => { states.setClearContextPending(true); }); |
| 101 | await act(async () => { await states.confirmClearContext(); }); |
| 102 | assert.equal(operationCalls.length, 0, "confirm without an active tab runs no operation"); |
| 103 | assert.equal(states.clearContextPending, true, "confirm without a target leaves the confirmation untouched"); |
| 104 | |
| 105 | console.log("session clear commands: pending lifecycle, local/remote chains, failure notices and empty-target gate passed"); |
| 106 | } finally { dom.window.close(); } |
| 107 |