返回 DeepSeek-Reasonix
remote-server-panel.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-server-panel.test.tsx
1 // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx
2 //
3 // Tests the Remote SSH Server tab of the right-dock panel: the single
4 // "Open Remote Web" entry, Serve progress states, the workspace home-directory
5 // fallback, and the fixed remote-provider hint.
6
7 import React from "react";
8 import { JSDOM } from "jsdom";
9
10 import type { AppBindings } from "../lib/bridge";
11 import { installDesktopHostStub } from "./desktopHostStub";
12
13 let passed = 0;
14 let failed = 0;
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 console.log("\nRemote SSH Server tab (Open Remote Web)");
26 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
27 pretendToBeVisual: true,
28 url: "http://localhost/",
29 });
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 globalThis.HTMLElement = dom.window.HTMLElement;
35 globalThis.Event = dom.window.Event;
36 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
37 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
38 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
39
40 const [{ createRoot }, { RemotePanel }, { LocaleProvider }, { useRemoteStore }, { __emitMockRemote, onRemoteServer, onRemoteStatus }] = await Promise.all([
41 import("react-dom/client"),
42 import("../components/RemotePanel"),
43 import("../lib/i18n"),
44 import("../store/remote"),
45 import("../lib/bridge"),
46 ]);
47
48 // The production subscription lives in App.tsx; wire the store to the mock
49 // event fan-out here so remote:server progress reaches the panel.
50 onRemoteServer((s) => useRemoteStore.getState().setServer(s));
51 onRemoteStatus((s) => useRemoteStore.getState().applyStatus(s));
52
53 const openCalls: Array<{ hostId: string; workspace: string }> = [];
54 const navigationCalls: string[] = [];
55 const stopCalls: Array<{ hostId: string; workspace: string }> = [];
56 const statusCalls: Array<{ hostId: string; workspace: string }> = [];
57 installDesktopHostStub(({ main: { App: {
58 async RegisterNavigationIntent(token: string) {
59 navigationCalls.push(token);
60 },
61 async RemoteLastWorkspace(hostId: string) {
62 return hostId === "box" ? "/srv/app" : "";
63 },
64 async RemoteServerStatus(hostId: string, workspace: string) {
65 statusCalls.push({ hostId, workspace });
66 // Echo the registered per-workspace view when one exists (the bound call
67 // returns the tracked entry), else a fresh stopped view.
68 return useRemoteStore.getState().servers[hostId]?.[workspace]
69 ?? { hostId, workspace, state: "stopped" };
70 },
71 async RemoteServerLogs() {
72 return "";
73 },
74 async OpenRemoteWorkspace(hostId: string, workspace: string) {
75 openCalls.push({ hostId, workspace });
76 },
77 async StopRemoteServer(hostId: string, workspace: string) {
78 stopCalls.push({ hostId, workspace });
79 },
80 } as Partial<AppBindings> as AppBindings } }).main.App);
81
82 const host = { id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false };
83 useRemoteStore.getState().setHosts([host]);
84 useRemoteStore.getState().openExplorer("box");
85 useRemoteStore.getState().setExplorerTab("server");
86 useRemoteStore.getState().applyStatus({ hostId: "box", state: "connected" });
87
88 const rootElement = document.getElementById("root");
89 if (!rootElement) throw new Error("missing root");
90 const { act } = await import("react");
91 const root = createRoot(rootElement);
92
93 await act(async () => {
94 root.render(
95 <LocaleProvider>
96 <RemotePanel onClose={() => {}} />
97 </LocaleProvider>,
98 );
99 await Promise.resolve();
100 });
101
102 ok(document.body.textContent?.includes("Open Remote Web") === true, "server tab shows the unified Open Remote Web entry");
103 ok(
104 document.body.textContent?.includes("Models, API keys, and sessions are managed by the Reasonix configuration on the remote server.") === true,
105 "server tab states that providers, API keys, and sessions are managed by the remote Reasonix configuration",
106 );
107
108 // Serve progress: remote:server events drive the busy state and label.
109 await act(async () => {
110 __emitMockRemote("server", { hostId: "box", workspace: "/srv/app", state: "starting", message: "detecting platform" });
111 await Promise.resolve();
112 });
113 ok(document.body.textContent?.includes("Starting") === true, "Serve progress renders while starting");
114 const openButton = () => Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.includes("Open Remote Web"));
115 ok(openButton()?.hasAttribute("disabled") === true, "Open Remote Web is disabled while Serve is starting");
116
117 await act(async () => {
118 __emitMockRemote("server", { hostId: "box", workspace: "/srv/app", state: "ready", localUrl: "http://127.0.0.1:54321/" });
119 await Promise.resolve();
120 });
121 ok(document.body.textContent?.includes("Ready") === true, "Serve ready state renders");
122 ok(openButton()?.hasAttribute("disabled") === false, "Open Remote Web is enabled once Serve is ready");
123
124 // Clicking the entry opens (or re-points) the host web window with the
125 // resolved workspace.
126 await act(async () => {
127 openButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
128 await Promise.resolve();
129 });
130 ok(
131 openCalls.length === 1 && openCalls[0].hostId === "box" && openCalls[0].workspace === "/srv/app",
132 `Open Remote Web calls OpenRemoteWorkspace with the last workspace (got ${JSON.stringify(openCalls)})`,
133 );
134 ok(navigationCalls.some((token) => token.startsWith("nav-remote-workspace-")), "Open Remote Web registers navigation before launch");
135
136 // Fresh host: without a configured or last workspace, the SSH login user's
137 // home directory is a safe, enterable zero-configuration fallback.
138 await act(async () => {
139 useRemoteStore.getState().openExplorer("bare");
140 useRemoteStore.getState().setExplorerTab("server");
141 useRemoteStore.getState().setHosts([
142 host,
143 { id: "bare", label: "bare", host: "bare.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
144 ]);
145 useRemoteStore.getState().applyStatus({ hostId: "bare", state: "connected" });
146 await Promise.resolve();
147 });
148 const workspaceInput = document.querySelector<HTMLInputElement>('input[placeholder="~"]');
149 ok(workspaceInput?.value === "~", "fresh host defaults its workspace to the SSH login home");
150 ok(openButton()?.hasAttribute("disabled") === false, "Open Remote Web is enabled for a fresh host");
151
152 await act(async () => {
153 openButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
154 await Promise.resolve();
155 });
156 ok(
157 openCalls.length === 2 && openCalls[1].hostId === "bare" && openCalls[1].workspace === "~",
158 `fresh host opens the SSH login home (got ${JSON.stringify(openCalls)})`,
159 );
160
161 // ── Per-workspace management: two registered serves on one host ──
162 await act(async () => {
163 useRemoteStore.getState().openExplorer("box");
164 useRemoteStore.getState().setExplorerTab("server");
165 await Promise.resolve();
166 });
167 await act(async () => {
168 __emitMockRemote("server", { hostId: "box", workspace: "/srv/app", state: "ready" });
169 __emitMockRemote("server", { hostId: "box", workspace: "/srv/web", state: "ready" });
170 await Promise.resolve();
171 });
172 const stopButton = () => Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.includes("Stop"));
173 ok(statusCalls.some((c) => c.hostId === "box" && c.workspace === "/srv/app"), "status is fetched per workspace (the input's workspace)");
174 await act(async () => {
175 stopButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
176 await Promise.resolve();
177 });
178 ok(
179 stopCalls.length === 1 && stopCalls[0].hostId === "box" && stopCalls[0].workspace === "/srv/app",
180 `stop targets the managed workspace (got ${JSON.stringify(stopCalls)})`,
181 );
182 // Switching the input switches which registered serve the panel manages; the
183 // other workspace's entry is untouched in the store.
184 {
185 const wsInput = document.querySelector<HTMLInputElement>('input[placeholder="~"]');
186 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
187 await act(async () => {
188 setter?.call(wsInput, "/srv/web");
189 wsInput?.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
190 await Promise.resolve();
191 });
192 ok(stopButton()?.hasAttribute("disabled") === false, "switching the workspace keeps the other ready serve manageable");
193 await act(async () => {
194 stopButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
195 await Promise.resolve();
196 });
197 ok(
198 stopCalls.length === 2 && stopCalls[1].workspace === "/srv/web",
199 `stop follows the workspace switch (got ${JSON.stringify(stopCalls)})`,
200 );
201 const servers = useRemoteStore.getState().servers;
202 ok(
203 servers.box?.["/srv/app"]?.state === "ready" && servers.box?.["/srv/web"]?.state === "ready",
204 "both workspaces keep independent entries in the store",
205 );
206 }
207
208 await act(async () => { root.unmount(); });
209 dom.window.close();
210
211 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
212 if (failed > 0) process.exit(1);
213
213 lines Plain Text