返回 DeepSeek-Reasonix
remote-switch-from-local.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-switch-from-local.test.tsx
1 // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-switch-from-local.test.tsx
2 //
3 // Local → remote activation must not run HistorySliceForTab and must not
4 // revert to the previous local tab when that local hydrate fails.
5
6 import { JSDOM } from "jsdom";
7 import React, { act } from "react";
8 import { createRoot } from "react-dom/client";
9 import type { AppBindings } from "../lib/bridge";
10 import { useController } from "../lib/useController";
11 import type { HistorySlice, HistorySliceRequest, TabMeta } from "../lib/types";
12 import { installDesktopHostStub } from "./desktopHostStub";
13
14 let passed = 0;
15 let failed = 0;
16 function ok(value: boolean, label: string) {
17 if (value) {
18 process.stdout.write(` PASS ${label}\n`);
19 passed += 1;
20 } else {
21 process.stdout.write(` FAIL ${label}\n`);
22 failed += 1;
23 }
24 }
25 function eq(actual: unknown, expected: unknown, label: string) {
26 if (actual === expected) ok(true, label);
27 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
28 }
29 function flushPromises(): Promise<void> {
30 return new Promise((resolve) => setTimeout(resolve, 0));
31 }
32 async function waitFor(label: string, predicate: () => boolean) {
33 for (let attempt = 0; attempt < 40; attempt += 1) {
34 await act(async () => { await flushPromises(); });
35 if (predicate()) return;
36 }
37 throw new Error(`timed out waiting for ${label}`);
38 }
39
40 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
41 pretendToBeVisual: true,
42 url: "http://localhost/",
43 });
44 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
45 globalThis.window = dom.window as unknown as Window & typeof globalThis;
46 globalThis.document = dom.window.document;
47 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
48 globalThis.HTMLElement = dom.window.HTMLElement;
49 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
50 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
51
52 const localTab: TabMeta = {
53 id: "tab-local",
54 scope: "project",
55 workspaceRoot: "/repo/local",
56 workspaceName: "local",
57 workspacePath: "/repo/local",
58 topicId: "topic-local",
59 topicTitle: "local",
60 sessionPath: "/repo/local/sessions/local.jsonl",
61 label: "local-model",
62 ready: true,
63 running: false,
64 mode: "normal",
65 toolApprovalMode: "ask",
66 tokenMode: "full",
67 active: true,
68 cwd: "/repo/local",
69 };
70 const remoteTab: TabMeta = {
71 id: "tab-remote",
72 scope: "project",
73 workspaceRoot: "~/app",
74 workspaceName: "app",
75 topicId: "",
76 topicTitle: "remote session",
77 label: "gpu-box",
78 ready: true,
79 running: false,
80 mode: "normal",
81 toolApprovalMode: "ask",
82 tokenMode: "full",
83 active: false,
84 cwd: "~/app",
85 remote: { hostId: "gpu-box", workspace: "~/app" },
86 remoteState: "ready",
87 };
88
89 let backendActiveId = "tab-local";
90 let historySliceCalls: string[] = [];
91 let setActiveCalls: string[] = [];
92
93 installDesktopHostStub(({
94 main: {
95 App: {
96 RegisterNavigationIntent: async () => {},
97 ListTabs: async () => {
98 const tabs = [localTab, remoteTab].map((tab) => ({ ...tab, active: tab.id === backendActiveId }));
99 return tabs;
100 },
101 SetActiveTab: async (tabID: string) => {
102 setActiveCalls.push(tabID);
103 backendActiveId = tabID;
104 },
105 HistorySliceForTab: async (tabID: string, _req: HistorySliceRequest): Promise<HistorySlice> => {
106 historySliceCalls.push(tabID);
107 if (tabID === "tab-remote") {
108 return {
109 messages: [],
110 startTurn: 0,
111 endTurn: 0,
112 hasOlder: false,
113 hasNewer: false,
114 error: "session path unavailable before controller ready",
115 };
116 }
117 return {
118 messages: [{ role: "user", content: "local hello" }],
119 startTurn: 1,
120 endTurn: 1,
121 hasOlder: false,
122 hasNewer: false,
123 };
124 },
125 MetaForTab: async () => ({ label: "local-model", ready: true, eventChannel: "agent:event", cwd: "/repo/local" }),
126 ContextUsageForTab: async () => ({ used: 0, window: 0 }),
127 EffortForTab: async () => ({ level: "high", supported: ["high"] }),
128 BalanceForTab: async () => ({ available: false, display: "" }),
129 JobsForTab: async () => [],
130 CheckpointsForTab: async () => [],
131 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
132 ReplayPendingPrompts: async () => {},
133 ReportUIReady: async () => {},
134 } as Partial<AppBindings> as AppBindings,
135 },
136 }).main.App);
137
138 type ControllerApi = {
139 activeTabId?: string;
140 switchTab: (tabId: string, optimisticTab?: TabMeta) => Promise<unknown>;
141 };
142
143 let controller: ControllerApi | undefined;
144 function Probe() {
145 controller = useController() as unknown as ControllerApi;
146 return null;
147 }
148 const root = createRoot(document.getElementById("root")!);
149 await act(async () => { root.render(<Probe />); });
150 await waitFor("local active", () => controller?.activeTabId === "tab-local");
151
152 historySliceCalls = [];
153 setActiveCalls = [];
154 await act(async () => {
155 await controller?.switchTab(remoteTab.id, remoteTab);
156 await flushPromises();
157 });
158 await waitFor("remote stays active", () => controller?.activeTabId === "tab-remote");
159
160 eq(controller?.activeTabId, "tab-remote", "switching to a remote tab keeps the remote tab active");
161 ok(!historySliceCalls.includes("tab-remote"), "remote switch does not request local HistorySliceForTab");
162 ok(!setActiveCalls.includes("tab-local"), "failed local hydrate must not revert SetActiveTab back to the local tab");
163 eq(setActiveCalls[0], "tab-remote", "SetActiveTab is called for the remote tab");
164
165 await act(async () => { root.unmount(); });
166 dom.window.close();
167 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
168 if (failed > 0) process.exit(1);
169
169 lines Plain Text