| 1 | // Run: tsx src/__tests__/remote-fork-targets.test.tsx |
| 2 | // A remote fork reads the serve's persisted turn records and creates the child |
| 3 | // through the create-only command. A serve that cannot create one is refused on |
| 4 | // its advertised capability: the switching /fork route must never run, because |
| 5 | // it rebinds the parent session the user is still reading. |
| 6 | import { register } from "node:module"; |
| 7 | // The surface module graph imports an SVG asset; register the stub loader |
| 8 | // before any dynamic import of the remote surface runs. |
| 9 | register(new URL("../../scripts/svg-loader.mjs", import.meta.url)); |
| 10 | import { readFileSync } from "node:fs"; |
| 11 | import { dirname, resolve } from "node:path"; |
| 12 | import { fileURLToPath } from "node:url"; |
| 13 | import { JSDOM } from "jsdom"; |
| 14 | import React, { act } from "react"; |
| 15 | import { createRoot } from "react-dom/client"; |
| 16 | import { app } from "../lib/bridge"; |
| 17 | import { useRemoteSession, type RemoteSessionApi } from "../lib/useRemoteSession"; |
| 18 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 19 | |
| 20 | let passed = 0; |
| 21 | let failed = 0; |
| 22 | function ok(value: unknown, label: string) { |
| 23 | if (value) { passed += 1; process.stdout.write(` PASS ${label}\n`); } |
| 24 | else { failed += 1; process.stdout.write(` FAIL ${label}\n`); } |
| 25 | } |
| 26 | |
| 27 | console.log("\nremote fork targets"); |
| 28 | |
| 29 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" }); |
| 30 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 31 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 32 | globalThis.document = dom.window.document; |
| 33 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 34 | |
| 35 | // The capability is the tab's own advertised field, and the surface must read it |
| 36 | // rather than inferring support from how many targets the read returned. |
| 37 | const surfaceSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../components/RemoteSessionSurface.tsx"), "utf8"); |
| 38 | const hookSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../lib/useRemoteSession.ts"), "utf8"); |
| 39 | ok(/forkBlocked=\{tab\.forkTargetsSupported \? null : "unsupported"\}/.test(surfaceSource), |
| 40 | "the serve's advertised capability decides the unsupported reason"); |
| 41 | ok(/onFork=\{tab\.forkTargetsSupported \?/.test(surfaceSource), |
| 42 | "an unsupported serve offers no fork entry at all"); |
| 43 | |
| 44 | const TAB = "remote-fork-1"; |
| 45 | const calls: string[] = []; |
| 46 | let targetView: unknown = { targets: [], verifiable: false }; |
| 47 | let createView: unknown = { opened: true, sessionId: "child-1", operationId: "operation-1" }; |
| 48 | const forkTarget = (turnId: string) => ({ sourceSessionId: "parent-1", sessionGeneration: 1, turnId, |
| 49 | boundarySequence: 9, turnNumber: 1, status: "committed", available: true }); |
| 50 | |
| 51 | const commands = { |
| 52 | RemoteTabSnapshot: async (tabId: string) => { calls.push(`snapshot:${tabId}`); return { history: [] }; }, |
| 53 | RemoteTabMetadata: async () => ({ history: [] }), |
| 54 | RemoteTabStatus: async (tabId: string) => { calls.push(`status:${tabId}`); return { plan: false, toolApprovalMode: "ask", goal: "", label: "m", running: false }; }, |
| 55 | ForkTargetsRemoteTab: async (tabId: string) => { calls.push(`targets:${tabId}`); return targetView; }, |
| 56 | CreateForkRemoteTab: async (tabId: string, target: ReturnType<typeof forkTarget>) => { |
| 57 | calls.push(`create:${tabId}:${target.turnId}:${target.sourceSessionId}:${target.boundarySequence}`); |
| 58 | return createView; |
| 59 | }, |
| 60 | AcknowledgeForkOperation: async (tabId: string, operationId: string) => { calls.push(`ack:${tabId}:${operationId}`); }, |
| 61 | ForkRemoteTab: async (tabId: string, turn: number) => { calls.push(`switching:${tabId}:${turn}`); }, |
| 62 | }; |
| 63 | |
| 64 | const desktopStub = installDesktopHostStub(commands as unknown as typeof app); |
| 65 | const PARENT_SESSION = "/sessions/parent.jsonl"; |
| 66 | let sessionPath: string | undefined = PARENT_SESSION; |
| 67 | let probe: RemoteSessionApi | undefined; |
| 68 | function Harness() { probe = useRemoteSession(TAB, undefined, sessionPath); return null; } |
| 69 | const root = createRoot(document.getElementById("root")!); |
| 70 | const settle = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 40)); }); }; |
| 71 | const fork = async (target: ReturnType<typeof forkTarget>) => { |
| 72 | let result: Awaited<ReturnType<RemoteSessionApi["forkTurn"]>> = undefined; |
| 73 | await act(async () => { result = await probe!.forkTurn(target); }); |
| 74 | return result; |
| 75 | }; |
| 76 | |
| 77 | try { |
| 78 | await act(async () => { root.render(<Harness />); }); |
| 79 | // The serve publishes readiness; hydration starts from that transition. |
| 80 | await act(async () => { desktopStub.emit(`remote-tab:${TAB}:state`, { state: "ready" }); }); |
| 81 | await settle(); |
| 82 | ok(probe?.hydrated === true, "a ready serve hydrates the transcript"); |
| 83 | ok(calls.includes(`targets:${TAB}`), "hydration reads the serve's fork targets through the same command the local path uses"); |
| 84 | targetView = { sourceSessionId: "parent-1", sessionGeneration: 1, |
| 85 | targets: [{ ...forkTarget("turn-1"), messageId: "msg-1" }], verifiable: true }; |
| 86 | await act(async () => { await probe!.retryHydration(); }); |
| 87 | await settle(); |
| 88 | ok(probe?.transcript.forkTargets?.targets[0]?.messageId === "msg-1", "the serve's target identity reaches the transcript"); |
| 89 | // The identical command the local path uses, so both surfaces carry the |
| 90 | // serve's own target set into the shared reducer. |
| 91 | ok(!/targets.length > 0 \|\| .*verifiable/.test(hookSource), "support is never inferred from the target list"); |
| 92 | ok((hookSource.match(/forkTargetsRefreshRef\.current\?\.\(\)/g) ?? []).length >= 2, |
| 93 | "the read refreshes once hydration lands and once a turn finishes"); |
| 94 | |
| 95 | calls.length = 0; |
| 96 | ok((await fork(forkTarget("turn-1")))?.sessionId === "child-1", "a created child returns the serve's session identity"); |
| 97 | const operations = calls.filter((call) => call.startsWith("create:")).map((call) => call.split(":")[3]); |
| 98 | const turnIds = calls.filter((call) => call.startsWith("create:")).map((call) => call.split(":")[2]); |
| 99 | ok(operations.length === 1 && operations[0] === "parent-1", "the create carries the observed source identity"); |
| 100 | ok(turnIds[0] === "turn-1", "the create carries the turn identity, not a display index"); |
| 101 | ok(!calls.some((call) => call.startsWith("switching:")), "the switching route is never reached"); |
| 102 | |
| 103 | // A refusal keeps the serve's reason, localized, and creates no second child. |
| 104 | createView = { opened: false, code: "fork_unavailable", reason: "turn_open", error: "turn is open" }; |
| 105 | calls.length = 0; |
| 106 | ok((await fork(forkTarget("turn-2"))) === undefined, "a refused turn opens nothing"); |
| 107 | await settle(); |
| 108 | ok(probe!.promptError.includes("not finished yet"), "the refusal reason reaches the user through the surface's own alert"); |
| 109 | ok(!probe!.promptError.includes("turn_open"), "the reason token itself is not shown"); |
| 110 | // The serve keeps "this boundary cannot be proven" and "this boundary is |
| 111 | // proven but unsafe" apart, so the surface must not report the second as the |
| 112 | // first: only one of them is an absent boundary. |
| 113 | createView = { opened: false, code: "fork_unavailable", reason: "active_authority", error: "authority remains active" }; |
| 114 | ok((await fork(forkTarget("turn-2"))) === undefined, "a proven but unsafe boundary starts no child"); |
| 115 | await settle(); |
| 116 | ok(probe!.promptError.includes("question or approval"), "an unsafe boundary keeps its own reason"); |
| 117 | ok(!probe!.promptError.includes("no verifiable branch boundary"), "a proven boundary is not reported as unverifiable"); |
| 118 | createView = { opened: false, code: "fork_unavailable", reason: "stale_source", error: "source changed" }; |
| 119 | ok((await fork(forkTarget("turn-2"))) === undefined, "a stale source starts no child"); |
| 120 | await settle(); |
| 121 | ok(probe!.promptError.includes("session changed"), "stale_source uses its localized explanation"); |
| 122 | // A child the serve published comes back even when its surface did not open, |
| 123 | // so the caller can open it; the child it could not open is remembered. |
| 124 | createView = { opened: false, sessionId: "child-9", operationId: "operation-9", error: "conversation fork was created but could not be opened" }; |
| 125 | ok((await fork(forkTarget("turn-3")))?.sessionId === "child-9", "a created child is returned so its surface can be opened"); |
| 126 | await settle(); |
| 127 | ok(probe!.promptError === "", "a published child is not a failure of the create"); |
| 128 | ok(!calls.some((call) => call.startsWith("ack:")), "an unopened child remains unacknowledged for host recovery"); |
| 129 | |
| 130 | // A remote fork navigates this same tab to its child. Its next anchored create |
| 131 | // still goes through Desktop; no renderer child cache can answer it. |
| 132 | calls.length = 0; |
| 133 | createView = { opened: true, sessionId: "child-10", operationId: "operation-10" }; |
| 134 | sessionPath = "/sessions/child-9.jsonl"; |
| 135 | await act(async () => { root.render(<Harness />); }); |
| 136 | await settle(); |
| 137 | ok((await fork(forkTarget("turn-3")))?.sessionId === "child-10", "the current source anchor creates its own child"); |
| 138 | ok(calls.some((call) => call.startsWith("create:")), "the fork creates its own child in the session the tab shows"); |
| 139 | } finally { |
| 140 | await act(async () => { root.unmount(); }); |
| 141 | desktopStub.uninstall(); |
| 142 | dom.window.close(); |
| 143 | } |
| 144 | |
| 145 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 146 | if (failed > 0) process.exit(1); |
| 147 |