返回 DeepSeek-Reasonix
remote-fork-targets.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-fork-targets.test.tsx
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
147 lines Plain Text