返回 DeepSeek-Reasonix
remote-hosts-page.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-hosts-page.test.tsx
1 import { selectSettingsValue } from "./settingsSelectTestUtils";
2 // Run: tsx src/__tests__/remote-hosts-page.test.tsx
3
4 import React from "react";
5 import { JSDOM } from "jsdom";
6 import { act } from "react";
7 import { createRoot } from "react-dom/client";
8
9 import { RemoteHostsPage } from "../components/RemoteHostsPage";
10 import type { AppBindings } from "../lib/bridge";
11 import { LocaleProvider } from "../lib/i18n";
12 import type { RemoteHostInput, RemoteHostView } from "../lib/types";
13 import { installDesktopHostStub } from "./desktopHostStub";
14
15 let passed = 0;
16 let failed = 0;
17
18 function ok(value: boolean, label: string) {
19 if (value) {
20 process.stdout.write(` PASS ${label}\n`);
21 passed += 1;
22 } else {
23 process.stdout.write(` FAIL ${label}\n`);
24 failed += 1;
25 }
26 }
27
28 async function flush() {
29 await new Promise((resolve) => setTimeout(resolve, 20));
30 }
31
32 function button(label: string, root: ParentNode = document): HTMLButtonElement | undefined {
33 return Array.from(root.querySelectorAll<HTMLButtonElement>("button"))
34 .find((candidate) => (candidate.getAttribute("aria-label") || candidate.textContent?.trim()) === label);
35 }
36
37 console.log("\nRemote SSH host settings");
38
39 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
40 pretendToBeVisual: true,
41 url: "http://localhost/",
42 });
43 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
44 globalThis.window = dom.window as unknown as Window & typeof globalThis;
45 globalThis.document = dom.window.document;
46 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
47 globalThis.Node = dom.window.Node;
48 globalThis.Element = dom.window.Element;
49 globalThis.HTMLElement = dom.window.HTMLElement;
50 globalThis.Event = dom.window.Event;
51 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
52 globalThis.MouseEvent = dom.window.MouseEvent;
53 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
54 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
55
56 let hosts: RemoteHostView[] = [{
57 id: "build-box",
58 label: "Build box",
59 host: "ssh.example.test",
60 port: 22,
61 user: "dev",
62 identityFile: "~/.ssh/id_ed25519",
63 proxyJump: "",
64 defaultWorkspace: "/srv/app",
65 serveInstall: "auto",
66 credentialMode: "remote",
67 useSSHConfig: false,
68 passwordSet: true,
69 keyPassphraseSet: true,
70 }];
71 let removeCalls = 0;
72 const recordedUpdates: Array<RemoteHostInput & { id: string }> = [];
73 let legacyView = { mirrorCount: 0, mirrorBytes: 0, trustFile: false };
74 let cleanCalls: string[] = [];
75 const bindings = {
76 async RemoteHosts() { return hosts.slice(); },
77 async RemoteConnectionStatuses() { return []; },
78 async RemoveRemoteHost(id: string) {
79 removeCalls += 1;
80 hosts = hosts.filter((host) => host.id !== id);
81 },
82 async UpdateRemoteHost(id: string, input: RemoteHostInput) {
83 recordedUpdates.push({ id, ...input });
84 return { ...hosts.find((host) => host.id === id)!, ...input, id } as RemoteHostView;
85 },
86 async ScanRemoteLegacyWorkbenchData() { return legacyView; },
87 async CleanRemoteLegacyWorkbenchData(target: "mirrors" | "trust") {
88 cleanCalls.push(target);
89 legacyView = { mirrorCount: 0, mirrorBytes: 0, trustFile: false };
90 },
91 } as unknown as AppBindings;
92 installDesktopHostStub(({ main: { App: bindings } }).main.App);
93
94 window.matchMedia = (() => ({matches: true, addEventListener(){}, removeEventListener(){}})) as any;
95 const rootElement = document.getElementById("root");
96 if (!rootElement) throw new Error("missing root");
97 const root = createRoot(rootElement);
98
99 await act(async () => {
100 root.render(<LocaleProvider><RemoteHostsPage /></LocaleProvider>);
101 await flush();
102 });
103
104 await act(async () => {
105 button("Remove")?.click();
106 await flush();
107 });
108 const firstDialog = document.querySelector<HTMLElement>(".reasonix-confirm-dialog");
109 ok(Boolean(firstDialog), "remove opens the in-app confirmation dialog");
110 ok(firstDialog?.textContent?.includes("Build box") === true, "confirmation identifies the host being removed");
111 ok(removeCalls === 0, "host is not removed before confirmation");
112
113 await act(async () => {
114 button("Cancel", firstDialog ?? document)?.click();
115 await flush();
116 });
117 ok(removeCalls === 0 && document.querySelector(".reasonix-confirm-dialog") === null, "Cancel keeps the host and closes the dialog");
118
119 await act(async () => {
120 button("Edit")?.click();
121 await flush();
122 });
123 const secretInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
124 ok(secretInputs.length === 2, "edit form exposes password and private-key passphrase fields");
125 ok(Array.from(secretInputs).every((input) => input.value === "" && input.placeholder.includes("Saved")), "saved credentials are represented without returning plaintext to the UI");
126
127 await act(async () => {
128 button("Remove saved password")?.click();
129 await flush();
130 });
131 ok(document.body.textContent?.includes("saved password will be removed") === true, "explicit clear action is staged until Save");
132
133 // Credential mode: the host form offers remote | local-proxy and the choice
134 // rides the UpdateRemoteHost payload.
135 {
136 const modeSelect = Array.from(document.querySelectorAll<HTMLButtonElement>("button.settings-select")).find(sel => sel.value === "remote");
137 ok(Boolean(modeSelect), "edit form offers the credential-mode select");
138 if (modeSelect) await selectSettingsValue(modeSelect, "local-proxy");
139 await act(async () => {
140 button("Save")?.click();
141 await flush();
142 });
143 ok(recordedUpdates.length === 1 && recordedUpdates[0].credentialMode === "local-proxy", `save carries the chosen credential mode (got ${JSON.stringify(recordedUpdates.map((u) => u.credentialMode))})`);
144 }
145
146 await act(async () => {
147 button("Cancel")?.click();
148 await flush();
149 button("Remove")?.click();
150 await flush();
151 });
152 const secondDialog = document.querySelector<HTMLElement>(".reasonix-confirm-dialog");
153 await act(async () => {
154 button("Remove", secondDialog ?? document)?.click();
155 await flush();
156 });
157 ok(removeCalls === 1, "confirmed removal invokes the backend exactly once");
158 ok(document.body.textContent?.includes("No remote hosts yet") === true, "host list refreshes after removal");
159
160 // Legacy Remote Workbench data card: hidden when no data, and cleans mirrors
161 // and trust separately after confirmation.
162 ok(document.querySelector(".remote-hosts__legacy") === null, "legacy data card is hidden when no legacy files exist");
163 let legacyRoot = root;
164 await act(async () => {
165 legacyRoot.unmount();
166 legacyView = { mirrorCount: 3, mirrorBytes: 2048, trustFile: true };
167 legacyRoot = createRoot(rootElement);
168 legacyRoot.render(<LocaleProvider><RemoteHostsPage /></LocaleProvider>);
169 await flush();
170 });
171 ok(document.querySelector(".remote-hosts__legacy") !== null, "legacy data card appears when legacy files are detected");
172 ok(document.body.textContent?.includes("3") === true, "legacy card shows the mirror file count");
173 ok(document.body.textContent?.includes("trust") === true, "legacy card names the trust file");
174 await act(async () => {
175 button("Delete mirrors")?.click();
176 await flush();
177 });
178 await act(async () => {
179 button("Delete", document.querySelector(".reasonix-confirm-dialog") ?? document)?.click();
180 await flush();
181 });
182 ok(cleanCalls.join(",") === "mirrors", "confirmed mirror cleanup calls the backend once");
183 ok(document.querySelector(".remote-hosts__legacy") === null, "card hides after both artifacts are cleaned");
184 await act(async () => {
185 legacyRoot.unmount();
186 legacyView = { mirrorCount: 1, mirrorBytes: 512, trustFile: false };
187 legacyRoot = createRoot(rootElement);
188 legacyRoot.render(<LocaleProvider><RemoteHostsPage /></LocaleProvider>);
189 await flush();
190 });
191 await act(async () => {
192 button("Delete mirrors")?.click();
193 await flush();
194 button("Cancel", document.querySelector(".reasonix-confirm-dialog") ?? document)?.click();
195 await flush();
196 });
197 ok(cleanCalls.length === 1, "cancelled cleanup does not call the backend");
198
199 await act(async () => legacyRoot.unmount());
200 dom.window.close();
201
202 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
203 if (failed > 0) process.exit(1);
204
204 lines Plain Text