返回 DeepSeek-Reasonix
remote-session-takeover-hydration.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-session-takeover-hydration.test.tsx
1 // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-takeover-hydration.test.tsx
2 // useRemoteSession's hydration has exactly one legitimate fallback: a session
3 // a local runtime on the serve host took over answers the Follow request
4 // with 409 and is read through the legacy history view. Every other failure
5 // keeps its error, and ownership returning must re-run the real hydration so
6 // the composer can send again.
7 import React, { act } from "react";
8 import { JSDOM } from "jsdom";
9 import type { AppBindings } from "../lib/bridge";
10 import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
11 import type { TabMeta } from "../lib/types";
12 import type { RemoteSessionApi } from "../lib/useRemoteSession";
13 import { installDesktopHostStub } from "./desktopHostStub";
14
15 let passed = 0, failed = 0;
16 function ok(value: boolean, label: string) {
17 process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
18 if (value) passed += 1; else failed += 1;
19 }
20 console.log("\nRemote session take-over hydration");
21 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
22 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
23 globalThis.window = dom.window as unknown as Window & typeof globalThis;
24 globalThis.document = dom.window.document;
25 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
26 globalThis.localStorage = dom.window.localStorage;
27 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
28 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
29 // The spectator reconcile loop is the hook's only status traffic while taken
30 // over; capture its callback so the poll path is driven deterministically.
31 const polls: Array<() => void> = [];
32 const realSetInterval = dom.window.setInterval.bind(dom.window);
33 dom.window.setInterval = ((handler: TimerHandler, timeout?: number) => {
34 if (typeof handler === "function") polls.push(handler as () => void);
35 return realSetInterval(() => {}, timeout ?? 0);
36 }) as typeof dom.window.setInterval;
37
38 const tape: string[] = [];
39 type FollowMode = "ok" | "transient" | "takeover";
40 const followMode = new Map<string, FollowMode>();
41 const takenOver = new Map<string, boolean>();
42 const statusFor = (tabId: string) => ({ running: false, label: "Model", plan: false, toolApprovalMode: "ask", goal: "", takenOver: takenOver.get(tabId) === true });
43 const inline = (id: string, content: string) => ({ messageId: id, position: 0, version: 1, role: "assistant", eventSequence: 1, visibleTurn: 1, preview: "", inline: { id, role: "assistant", content } });
44 function followResponse(tabId: string): TranscriptFollowResponse {
45 return {
46 protocolVersion: 2, subscription: `sub-${tabId}`, changes: [], resetRequired: false,
47 snapshot: {
48 protocolVersion: 1, snapshotId: `cut-${tabId}`, identity: { sessionId: tabId, runtimeEpoch: "epoch", rewriteEpoch: 0, headId: "" },
49 projectionRevision: 10, coveredThroughSeq: 4, durableSeq: 4, records: [], activeRecords: [], activeAttempts: [],
50 runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false, totalRecords: 1, totalTurns: 1, stale: false,
51 },
52 history: { status: "ready", snapshotSequence: 4, coverageSequence: 4, generation: "gen", totalTurns: 1, hasOlder: false, hasNewer: false, messages: [inline("follower-answer", "follower answer")] },
53 };
54 }
55 const desktopStub = installDesktopHostStub({
56 async RegisterNavigationIntent() {},
57 async SetActiveTab() {},
58 async ForkTargetsRemoteTab() { return { targets: [], verifiable: false }; },
59 async RemoteTabStatus(tabId: string) { tape.push(`status:${tabId}`); return statusFor(tabId); },
60 async RemoteTabMetadata(tabId: string) { tape.push(`metadata:${tabId}`); return { status: statusFor(tabId) }; },
61 async RemoteTabSnapshot(tabId: string) { tape.push(`snapshot:${tabId}`); return { history: [{ role: "assistant", content: "legacy history" }], status: statusFor(tabId) }; },
62 async RemoteTranscriptFollowForTab(tabId: string, request: FollowRequest) {
63 if (request.close) return { protocolVersion: 2, subscription: request.subscription ?? "", changes: [], resetRequired: false };
64 if (request.subscription) return new Promise<TranscriptFollowResponse>(() => {});
65 tape.push(`follow:${tabId}`);
66 const mode = followMode.get(tabId) ?? "ok";
67 if (mode === "transient") throw new Error("fetch failed");
68 if (mode === "takeover") throw new Error("remote transcript read failed (HTTP 409)");
69 return followResponse(tabId);
70 },
71 // The prime's legacy window read is unavailable before activation on a
72 // spectator; keep it out of the picture so the cases isolate hydrate().
73 async RemoteSessionHistoryWindowForTab() { return new Promise(() => {}); },
74 } as Partial<AppBindings> as AppBindings);
75
76 const [{ createRoot }, { useRemoteSession }, { setTranscriptBindingIdentity }] = await Promise.all([
77 import("react-dom/client"), import("../lib/useRemoteSession"), import("../lib/canonicalTranscriptBackend"),
78 ]);
79 setTranscriptBindingIdentity(() => "remote");
80
81 async function flush(ticks = 4) {
82 for (let i = 0; i < ticks; i++) await Promise.resolve();
83 await new Promise((resolve) => setTimeout(resolve, 30));
84 }
85 let probe: RemoteSessionApi | undefined;
86 function Probe({ tabId }: { tabId: string }) {
87 probe = useRemoteSession(tabId, "ready", `/${tabId}`);
88 return null;
89 }
90 const root = createRoot(document.getElementById("root")!);
91 const mount = (tabId: string) => act(async () => { root.render(<Probe tabId={tabId} />); await flush(); });
92 const count = (entry: string) => tape.filter((item) => item === entry).length;
93 const texts = () => (probe?.transcript.items ?? []).flatMap((item) => item.kind === "assistant" ? [item.text] : []);
94 const meta = (id: string, spectator: boolean): TabMeta => ({ id, scope: "project", workspaceRoot: "~/app", workspaceName: "app", topicId: "", topicTitle: "app", label: "box",
95 ready: true, running: false, mode: "normal", active: true, cwd: "~/app", sessionGeneration: 1, remote: { hostId: "box", workspace: "~/app" },
96 readOnly: spectator, takenOver: spectator } as TabMeta);
97
98 // 1. A transient failure is surfaced, not swallowed into the legacy view.
99 followMode.set("tab-transient", "transient");
100 await mount("tab-transient");
101 ok(/fetch failed/.test(probe?.error ?? "") && probe?.hydrated === false, "a transient Follow failure surfaces its error and stays unhydrated");
102 ok(count("snapshot:tab-transient") === 0 && texts().length === 0, "a transient failure never installs the legacy history view");
103 ok(count("status:tab-transient") >= 1, "the ownership verdict is read from status before deciding against the fallback");
104
105 // 2. A taken-over session (409) falls back to the legacy history view.
106 followMode.set("tab-spectator", "takeover");
107 takenOver.set("tab-spectator", true);
108 await mount("tab-spectator");
109 ok(probe?.hydrated === true && probe.state === "ready" && probe.error === "", "a 409 take-over hydrates through the legacy fallback");
110 ok(probe?.transcript.transcriptProtocol !== 2 && texts().includes("legacy history") && count("snapshot:tab-spectator") === 1, "the spectator reads the file-backed history view");
111 ok(polls.length >= 1, "a spectator arms the slow status reconcile loop");
112 let submitError = "";
113 await act(async () => { await probe?.submit("hello").catch((error: unknown) => { submitError = String(error); }); await flush(); });
114 ok(/not synchronized/.test(submitError), "a spectator cannot submit over the legacy view, so ownership return must repair it");
115
116 // 3. Ownership returning through the tab meta re-runs hydration.
117 followMode.set("tab-spectator", "ok");
118 takenOver.set("tab-spectator", false);
119 const followsBefore = count("follow:tab-spectator");
120 await act(async () => { desktopStub.emit("remote-tab:updated", meta("tab-spectator", false)); await flush(); });
121 ok(count("follow:tab-spectator") === followsBefore + 1, "the spectator -> owner flip re-attaches the follower");
122 ok(probe?.transcript.transcriptProtocol === 2 && probe.hydrated && texts().includes("follower answer") && !texts().includes("legacy history"),
123 "ownership return replaces the legacy view with the live transcript");
124 await act(async () => { desktopStub.emit("remote-tab:updated", meta("tab-spectator", false)); await flush(); });
125 ok(count("follow:tab-spectator") === followsBefore + 1, "a repeated owner publication does not re-hydrate again");
126
127 // 4. The status verdict alone gates the fallback when the error text is not a 409.
128 followMode.set("tab-status-verdict", "transient");
129 takenOver.set("tab-status-verdict", true);
130 await mount("tab-status-verdict");
131 ok(probe?.hydrated === true && probe.transcript.transcriptProtocol !== 2 && texts().includes("legacy history"),
132 "status.takenOver admits the legacy fallback for a non-409 failure on a spectated session");
133
134 // 5. Ownership returning through a status refresh also re-hydrates.
135 followMode.set("tab-status-verdict", "ok");
136 takenOver.set("tab-status-verdict", false);
137 const verdictFollows = count("follow:tab-status-verdict");
138 const poll = polls[polls.length - 1];
139 await act(async () => { poll(); await flush(); });
140 ok(count("follow:tab-status-verdict") === verdictFollows + 1 && probe?.transcript.transcriptProtocol === 2,
141 "a status poll observing ownership return re-attaches the follower");
142
143 // 6. Switching tabs resets the ownership observation: no spurious re-hydrate.
144 followMode.set("tab-fresh", "ok");
145 await mount("tab-fresh");
146 const freshFollows = count("follow:tab-fresh");
147 await act(async () => { await flush(); });
148 ok(freshFollows === 1 && count("follow:tab-fresh") === 1, "a fresh owner tab mounted after a spectator hydrates exactly once");
149
150 await act(async () => { root.unmount(); });
151 desktopStub.uninstall();
152 if (failed > 0) { console.error(`\n${failed} check(s) failed`); process.exit(1); }
153 console.log(`\n${passed} passed, 0 failed`);
154 process.exit(0);
155
155 lines Plain Text