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