返回 DeepSeek-Reasonix
remote-connect-wizard.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-connect-wizard.test.tsx
1 // Run: tsx src/__tests__/remote-connect-wizard.test.tsx
2
3 import React from "react";
4 import { RemoteNavigationHarness } from "./helpers/RemoteNavigationHarness";
5 import { JSDOM } from "jsdom";
6 import { act } from "react";
7
8 import { readFileSync } from "node:fs";
9 import { dirname, resolve } from "node:path";
10 import { fileURLToPath } from "node:url";
11
12 import type { AppBindings } from "../lib/bridge";
13 import type { RemoteDirEntry, RemoteHostView } from "../lib/types";
14 import { installDesktopHostStub } from "./desktopHostStub";
15
16 let passed = 0;
17 let failed = 0;
18 let mergedWorkspace = "";
19 function ok(value: boolean, label: string) {
20 if (value) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}\n`);
25 failed += 1;
26 }
27 }
28
29 console.log("\nRemote connect wizard (three steps)");
30 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
31 pretendToBeVisual: true,
32 url: "http://localhost/",
33 });
34 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
35 globalThis.window = dom.window as unknown as Window & typeof globalThis;
36 globalThis.document = dom.window.document;
37 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
38 globalThis.HTMLElement = dom.window.HTMLElement;
39 globalThis.Event = dom.window.Event;
40 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
41 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
42 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
43
44 const [{ createRoot }, { RemoteConnectWizard }, { LocaleProvider }, { useRemoteStore }] = await Promise.all([
45 import("react-dom/client"),
46 import("../components/RemoteConnectWizard"),
47 import("../lib/i18n"),
48 import("../store/remote"),
49 ]);
50
51 // Bridge call tape: every remote method appends "<name>:<detail>".
52 const tape: string[] = [];
53 const savedHosts: RemoteHostView[] = [
54 { id: "gpu-box", label: "gpu-box", host: "192.168.1.10", port: 22, user: "dev", identityFile: "~/.ssh/id_ed25519", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
55 { id: "pw-box", label: "pw-box", host: "10.0.0.8", port: 22, user: "ops", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false, passwordSet: true },
56 ];
57 let hostCount = 0;
58
59 function setInput(input: HTMLInputElement, value: string) {
60 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
61 setter?.call(input, value);
62 input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
63 input.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
64 }
65
66 function buttonByText(text: string): HTMLButtonElement | undefined {
67 return [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === text);
68 }
69
70 async function flush(ticks = 20) {
71 for (let i = 0; i < ticks; i++) await Promise.resolve();
72 }
73
74 // Real-time wait: the ConnectRemoteHost mock holds the connecting step for a
75 // 20ms macrotask so the log panel renders; microtask flushes cannot cover it.
76 function delay(ms: number): Promise<void> {
77 return new Promise<void>((resolve) => setTimeout(resolve, ms));
78 }
79
80 const dirs: Record<string, Array<{ name: string; path: string; isDir: boolean }>> = {
81 "/home/dev": [
82 { name: "projects", path: "/home/dev/projects", isDir: true },
83 { name: "notes.txt", path: "/home/dev/notes.txt", isDir: false },
84 ],
85 "/home/dev/projects": [{ name: "app", path: "/home/dev/projects/app", isDir: true }],
86 "/home/dev/projects/web": [],
87 };
88 const slowDirectory = Promise.withResolvers<RemoteDirEntry[]>();
89
90 // Connection attempt counter: the first attempt fails (the wizard stays on
91 // the connecting step so its log panel stays observable); the retry succeeds.
92 let connectAttempts = 0;
93 // Platform-gate attempt counter: the first check models a Windows SSH host
94 // (uname reports MINGW64); later checks pass.
95 let platformAttempts = 0;
96 // Last AddRemoteHost payload, for credential-mode assertions.
97 let lastAddInput: RemoteHostInput | undefined;
98 installDesktopHostStub(({ main: { App: {
99 async RegisterNavigationIntent(token: string) {
100 tape.push(`RegisterNavigationIntent:${token}`);
101 },
102 async RemoteHosts() {
103 tape.push("RemoteHosts");
104 return savedHosts.slice();
105 },
106 async AddRemoteHost(input: RemoteHostView & { label?: string; host?: string }) {
107 lastAddInput = input as unknown as RemoteHostInput;
108 tape.push(`AddRemoteHost:${input.label}:${input.host}:${(input as RemoteHostInput).credentialMode ?? ""}`);
109 hostCount += 1;
110 const view = { id: `new-${hostCount}`, label: String(input.label), host: String(input.host), port: 22, user: "", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "npm", credentialMode: "remote", useSSHConfig: false } as RemoteHostView;
111 savedHosts.push(view);
112 return view;
113 },
114 async UpdateRemoteHost(id: string, input: RemoteHostView & { label?: string; host?: string }) {
115 tape.push(`UpdateRemoteHost:${id}:${input.host}`);
116 return savedHosts.find((h) => h.id === id) ?? savedHosts[0] as RemoteHostView;
117 },
118 async RemoteLastWorkspace() {
119 tape.push("RemoteLastWorkspace");
120 return "/home/dev";
121 },
122 async ListRemoteDir(_hostId: string, path: string) {
123 tape.push(`ListRemoteDir:${path}`);
124 if (path === "/slow") return slowDirectory.promise;
125 if (path === "/fast") {
126 return [{ name: "latest", path: "/fast/latest", isDir: true, size: 0, mtimeUnix: 0, symlink: false }];
127 }
128 return (dirs[path] ?? []).map((entry) => ({ ...entry, size: 0, mtimeUnix: 0, symlink: false }));
129 },
130 async MkdirRemote(_hostId: string, path: string) {
131 tape.push(`MkdirRemote:${path}`);
132 return undefined;
133 },
134 async ConnectRemoteHost(hostId: string) {
135 tape.push(`ConnectRemoteHost:${hostId}`);
136 // Hold 60ms so the connecting step mounts, then emit the kernel status
137 // per attempt: first stopped+error → waitForRemoteConnection rejects
138 // immediately and the wizard stays on the connecting step; later
139 // attempts connected → the flow proceeds to the platform check.
140 await new Promise<void>((resolve) => setTimeout(resolve, 60));
141 connectAttempts += 1;
142 if (connectAttempts === 1) {
143 useRemoteStore.getState().applyStatus({ hostId, state: "stopped", error: "ssh: handshake failed" });
144 return undefined;
145 }
146 useRemoteStore.getState().applyStatus({ hostId, state: "connected" });
147 return undefined;
148 },
149 // Platform gate: the first check models a Windows SSH host (uname reports
150 // MINGW64), later checks pass.
151 async CheckRemotePlatform(hostId: string) {
152 tape.push(`CheckRemotePlatform:${hostId}`);
153 platformAttempts += 1;
154 if (platformAttempts === 1) {
155 throw new Error('remote host platform check failed: unsupported remote OS "MINGW64_NT-10.0-19045" (V1 supports Linux and macOS)');
156 }
157 return undefined;
158 },
159 async PickRemoteIdentityFile() {
160 tape.push("PickRemoteIdentityFile");
161 return "/home/dev/.ssh/id_wizard";
162 },
163 async OpenRemoteWorkspace(hostId: string, workspace: string) {
164 tape.push(`OpenRemoteWorkspace:${hostId}:${workspace}`);
165 },
166 async OpenRemoteProjectTab(hostId: string, workspace: string, opts?: { newSession?: boolean }) {
167 tape.push(`OpenRemoteProjectTab:${hostId}:${workspace}:${opts?.newSession === true}`);
168 },
169 async AddRemoteProject(hostId: string, workspace: string) {
170 tape.push(`AddRemoteProject:${hostId}:${workspace}`);
171 return { hostId, workspace: mergedWorkspace || workspace, merged: Boolean(mergedWorkspace) };
172 },
173 } as Partial<AppBindings> as AppBindings } }).main.App);
174
175 function WizardHarness() {
176 return (
177 <LocaleProvider>
178 <RemoteNavigationHarness>
179 <RemoteConnectWizard
180 onRefresh={async () => { tape.push("refresh"); }}
181 onClose={() => {
182 tape.push("close");
183 }}
184 />
185 </RemoteNavigationHarness>
186 </LocaleProvider>
187 );
188 }
189
190 const rootElement = document.getElementById("root");
191 if (!rootElement) throw new Error("missing root");
192 const root = createRoot(rootElement);
193 await act(async () => {
194 root.render(<WizardHarness />);
195 });
196 await act(async () => flush());
197
198 // ── Step ① initial state: stepper rail, config form ──
199 const railItems = () => [...document.querySelectorAll(".remote-wizard__rail-item")];
200 ok(railItems().length === 3, "stepper rail lists all three steps");
201 ok(railItems()[0]?.className.includes("--current") === true, "step 1 is current on open");
202 ok(railItems().every((item) => !item.className.includes("--done")), "no step is done on open");
203 ok(document.querySelectorAll(".remote-wizard__seg").length === 3, "auth, download, and credential mode use segmented sliders");
204 const hostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
205 ok(Boolean(hostInput), "config step shows the host input");
206 ok(
207 hostInput?.closest("label") === null &&
208 hostInput?.labels?.length === 1 &&
209 hostInput.labels[0]?.textContent?.trim() === "Host",
210 "host input uses an exact explicit label",
211 );
212 ok(document.activeElement === hostInput, "opening the dialog focuses the first field");
213 await act(async () => {
214 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
215 await Promise.resolve();
216 });
217 ok(document.activeElement === buttonByText("Next"), "Shift+Tab from the first field wraps to the last action");
218 await act(async () => {
219 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
220 await Promise.resolve();
221 });
222 ok(document.activeElement === hostInput, "Tab from the last action wraps to the first field");
223
224 // ── Empty host+user: footer alert names both missing fields ──
225 {
226 const userInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
227 await act(async () => {
228 if (hostInput) setInput(hostInput, "");
229 if (userInput) setInput(userInput, "");
230 await Promise.resolve();
231 });
232 await act(async () => {
233 buttonByText("Next")?.click();
234 await Promise.resolve();
235 });
236 const alert = document.querySelector(".remote-wizard__footer [role='alert']");
237 const text = alert?.textContent ?? "";
238 ok(text.includes("Host") && text.includes("username") && text.includes("Password"), "empty form reports host, username, and password");
239 ok(text.includes("⚠"), "footer alert uses the warning mark");
240 await act(async () => {
241 if (userInput) setInput(userInput, "root");
242 await Promise.resolve();
243 });
244 }
245 // ── Saved-host suggestion: arrow toggle → dropdown → prefill ──
246 const toggleArrow = () => document.querySelector<HTMLButtonElement>(".remote-wizard__suggest-toggle");
247 ok(Boolean(toggleArrow()), "host field exposes the saved-connections arrow");
248 ok(toggleArrow()?.closest("label") === null, "saved-connections arrow stays outside the host label");
249 ok(toggleArrow()?.getAttribute("aria-haspopup") === "menu", "arrow advertises its saved-host menu");
250 ok(toggleArrow()?.getAttribute("aria-expanded") === "false", "arrow starts collapsed");
251 await act(async () => {
252 hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
253 hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
254 await Promise.resolve();
255 });
256 ok(!document.querySelector(".remote-wizard__suggest-list"), "focusing the host input alone no longer opens the list");
257 await act(async () => {
258 toggleArrow()?.click();
259 await Promise.resolve();
260 });
261 ok(Boolean(document.querySelector(".remote-wizard__suggest-list")), "clicking the arrow lists saved SSH connections");
262 ok(toggleArrow()?.getAttribute("aria-expanded") === "true", "arrow reflects the expanded state");
263 ok((document.querySelector(".remote-wizard__suggest-head")?.textContent ?? "").toLowerCase().includes("ssh"), "dropdown leads with the saved-connections caption");
264 ok(document.querySelector(".remote-wizard__suggest-list")?.getAttribute("role") === "menu", "saved hosts use menu semantics");
265 const menuItems = [...document.querySelectorAll<HTMLButtonElement>('.remote-wizard__suggest-list [role="menuitem"]')];
266 ok(menuItems.length === savedHosts.length, "every saved host is exposed as a menu item");
267 ok(document.activeElement === menuItems[0], "opening the menu moves focus to the first saved host");
268 await act(async () => {
269 menuItems[0]?.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
270 await Promise.resolve();
271 });
272 ok(document.activeElement === menuItems[1], "ArrowDown moves focus to the next saved host");
273 await act(async () => {
274 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
275 await Promise.resolve();
276 });
277 ok(!document.querySelector(".remote-wizard__suggest-list"), "Escape closes the keyboard-opened menu");
278 ok(document.activeElement === toggleArrow(), "Escape restores focus to the saved-host arrow");
279 await act(async () => {
280 toggleArrow()?.click();
281 await Promise.resolve();
282 });
283 const suggestion = document.querySelector<HTMLButtonElement>('.remote-wizard__suggest-list [role="menuitem"]');
284 await act(async () => {
285 suggestion?.click();
286 await Promise.resolve();
287 });
288 ok(hostInput?.value === "192.168.1.10", "picking a suggestion prefills the host");
289 ok(!document.querySelector(".remote-wizard__suggest-list"), "picking a suggestion closes the list");
290 ok(document.activeElement === hostInput, "picking a suggestion restores focus to the host input");
291 const keyInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.value.includes("id_ed25519"));
292 ok(Boolean(keyInput), "saved key auth switches the form to key mode with the identity file");
293 await act(async () => {
294 document.querySelector<HTMLButtonElement>(".remote-wizard__pick-btn")?.click();
295 await flush();
296 });
297 ok(tape.includes("PickRemoteIdentityFile"), "identity-file action uses the native desktop picker");
298 ok(keyInput?.value === "/home/dev/.ssh/id_wizard", "native picker returns the absolute identity-file path");
299
300 {
301 const listButtons = () => [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")];
302 await act(async () => {
303 if (hostInput) setInput(hostInput, "");
304 await Promise.resolve();
305 });
306 await act(async () => {
307 toggleArrow()?.click();
308 await Promise.resolve();
309 });
310 const listed = listButtons().find((b) => b.textContent?.includes("pw-box"));
311 await act(async () => {
312 listed?.click();
313 await Promise.resolve();
314 });
315 const passwordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
316 ok((passwordInput?.placeholder ?? "").toLowerCase().includes("saved") || (passwordInput?.placeholder ?? "").includes("已保存"), "saved password host keeps a keep-existing placeholder");
317 // Typed text no longer filters the list: reopen with a filled host field
318 // and every saved connection must still be offered.
319 await act(async () => {
320 if (hostInput) setInput(hostInput, "10.0.0.8");
321 await Promise.resolve();
322 });
323 await act(async () => {
324 toggleArrow()?.click();
325 await Promise.resolve();
326 });
327 {
328 const labels = listButtons().map((b) => b.textContent ?? "");
329 ok(labels.some((l) => l.includes("gpu-box")) && labels.some((l) => l.includes("pw-box")), "typing in the host field does not filter the dropdown");
330 }
331 await act(async () => {
332 hostInput?.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
333 await Promise.resolve();
334 });
335 ok(Boolean(document.querySelector(".remote-wizard__suggest-list")), "a pointer press on the host field keeps the list open");
336 await act(async () => {
337 document.body.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
338 await Promise.resolve();
339 });
340 ok(!document.querySelector(".remote-wizard__suggest-list"), "a pointer press outside the field closes the list");
341 await act(async () => {
342 toggleArrow()?.click();
343 await Promise.resolve();
344 });
345 await act(async () => {
346 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
347 await Promise.resolve();
348 });
349 ok(!document.querySelector(".remote-wizard__suggest-list"), "Escape closes the open list");
350 ok(!tape.includes("close"), "Escape with the list open keeps the wizard open");
351 ok(document.activeElement === toggleArrow(), "Escape from a saved host restores focus to the arrow");
352 await act(async () => {
353 toggleArrow()?.click();
354 await Promise.resolve();
355 });
356 await act(async () => {
357 toggleArrow()?.click();
358 await Promise.resolve();
359 });
360 ok(!document.querySelector(".remote-wizard__suggest-list"), "clicking the open arrow toggles the list shut");
361 // No saved hosts at all: no arrow, no list.
362 {
363 const restored = useRemoteStore.getState().hosts.slice();
364 await act(async () => {
365 useRemoteStore.getState().setHosts([]);
366 await Promise.resolve();
367 });
368 ok(!document.querySelector(".remote-wizard__suggest-toggle"), "no saved hosts hides the arrow entirely");
369 await act(async () => {
370 useRemoteStore.getState().setHosts(restored);
371 await Promise.resolve();
372 });
373 ok(Boolean(document.querySelector(".remote-wizard__suggest-toggle")), "the arrow returns once hosts exist again");
374 }
375 await act(async () => {
376 toggleArrow()?.click();
377 await Promise.resolve();
378 });
379 const gpuSuggestion = listButtons().find((b) => b.textContent?.includes("gpu-box"));
380 await act(async () => {
381 gpuSuggestion?.click();
382 await Promise.resolve();
383 });
384 ok(hostInput?.value === "192.168.1.10", "picking gpu-box from the reopened list restores its host");
385 }
386 // ── Next: first connect fails; the wizard stays on the connecting step ──
387 await act(async () => {
388 buttonByText("Next")?.click();
389 await delay(120);
390 await flush();
391 });
392 ok(tape.includes("UpdateRemoteHost:gpu-box:192.168.1.10"), "next on a picked host updates it instead of adding a duplicate");
393 ok(!tape.some((entry) => entry.startsWith("AddRemoteHost:")), "no AddRemoteHost for a saved host");
394 // The failure path keeps the connecting step on screen (act has ended and
395 // the DOM is committed), so counting log lines here is reliable: at least
396 // two — connecting + failed.
397 {
398 const logCount = document.querySelectorAll(".remote-wizard__log-line").length;
399 ok(logCount >= 2, "connecting step streams the deployment log");
400 const connectError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
401 ok(Boolean(connectError?.textContent?.includes("ssh: handshake failed")), "failed connect surfaces the kernel error");
402 ok(Boolean(buttonByText("Retry")), "retry action is offered after a failed connect");
403 await act(async () => {
404 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
405 await Promise.resolve();
406 });
407 ok(document.activeElement === buttonByText("Back to edit"), "Tab after a step transition returns focus to the dialog");
408 }
409 // ── Retry #1: SSH connects, but the platform check rejects the host ──
410 await act(async () => {
411 buttonByText("Retry")?.click();
412 await flush();
413 });
414 ok(buttonByText("Cancel")?.disabled === true, "retry keeps the wizard busy while the connection is pending");
415 await act(async () => {
416 await delay(120);
417 await flush();
418 });
419 ok(tape.includes("CheckRemotePlatform:gpu-box"), "a connected host runs the platform check before the workspace step");
420 ok(railItems()[1]?.className.includes("--current") === true, "an unsupported OS keeps the wizard on the connecting step");
421 {
422 const platformError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
423 ok(Boolean(platformError?.textContent?.includes("unsupported remote OS")), "the platform failure surfaces the kernel error");
424 ok(!tape.some((entry) => entry.startsWith("ListRemoteDir")), "directory browsing never starts for an unsupported host");
425 ok(Boolean(buttonByText("Retry")), "retry stays available after a platform rejection");
426 }
427 // ── Retry #2: the platform check passes and lands on step ③ ──
428 await act(async () => {
429 buttonByText("Retry")?.click();
430 await delay(120);
431 await flush();
432 });
433 ok(railItems()[0]?.className.includes("--done") === true, "step 1 turns done (green check) after advancing");
434 ok(railItems()[2]?.className.includes("--current") === true, "step 3 is current after connecting");
435 ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "workspace picker starts at RemoteLastWorkspace path");
436 ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "projects")), "directory entries render");
437 ok(Boolean([...document.querySelectorAll(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"))), "files render in the tree next to folders");
438 {
439 const fileRow = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"));
440 await act(async () => {
441 fileRow?.click();
442 await Promise.resolve();
443 });
444 ok(fileRow?.className.includes("--selected") === true, "clicking a file highlights the row");
445 ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "clicking a file selects its parent directory as the workspace");
446 }
447
448 await act(async () => {
449 [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__dir")].find((b) => b.textContent === "projects")?.click();
450 await flush();
451 });
452 ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "app")), "drilling into a directory lists its children");
453 ok(!document.querySelector(".remote-wizard__mkdir"), "workspace step has no create-folder controls");
454
455 // ── Directory race: an older slow response cannot replace the newer path ──
456 {
457 const pathInput = document.querySelector<HTMLInputElement>(".remote-wizard__path-input");
458 await act(async () => {
459 if (pathInput) setInput(pathInput, "/slow");
460 await Promise.resolve();
461 });
462 await act(async () => {
463 buttonByText("Go")?.click();
464 await flush();
465 });
466 await act(async () => {
467 if (pathInput) setInput(pathInput, "/fast");
468 await Promise.resolve();
469 });
470 await act(async () => {
471 buttonByText("Go")?.click();
472 await flush();
473 });
474 ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "latest")), "newer directory response renders first");
475 await act(async () => {
476 slowDirectory.resolve([{ name: "stale", path: "/slow/stale", isDir: true, size: 0, mtimeUnix: 0, symlink: false }]);
477 await flush();
478 });
479 ok(![...document.querySelectorAll(".remote-wizard__dir")].some((b) => b.textContent === "stale"), "stale directory response cannot overwrite the latest path");
480 await act(async () => {
481 if (pathInput) setInput(pathInput, "/home/dev/projects");
482 await Promise.resolve();
483 });
484 }
485
486 // ── Finish: pin, open an in-app session tab, then refresh the tree ──
487 await act(async () => {
488 buttonByText("Connect and open")?.click();
489 await flush();
490 });
491 ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish opens the selected workspace in a new remote session tab");
492 const navigationRegistration = tape.findIndex((entry) => entry.startsWith("RegisterNavigationIntent:nav-"));
493 ok(navigationRegistration >= 0 && navigationRegistration < tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish registers navigation before opening the remote tab");
494 ok(tape.includes("AddRemoteProject:gpu-box:/home/dev/projects"), "finish pins the selected remote workspace");
495 ok(tape.indexOf("AddRemoteProject:gpu-box:/home/dev/projects") < tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "the workspace is pinned before its session tab opens");
496 ok(tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true") < tape.indexOf("refresh"), "the project tree refreshes after the session tab opens");
497 ok(tape.includes("close"), "wizard closes after a successful finish");
498
499 mergedWorkspace = "/home/dev";
500 await act(async () => { buttonByText("Connect and open")?.click(); await flush(); });
501 ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev:true"), "a merged finish opens the canonical workspace through the navigation owner");
502 mergedWorkspace = "";
503
504 await act(async () => root.unmount());
505
506 // ── Second harness: brand-new host goes through AddRemoteHost ──
507 const secondRootEl = document.createElement("div");
508 document.body.appendChild(secondRootEl);
509 const secondRoot = createRoot(secondRootEl);
510 await act(async () => {
511 secondRoot.render(<WizardHarness />);
512 });
513 await act(async () => flush());
514 const newHostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
515 const newUserInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
516 const newPasswordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
517 await act(async () => {
518 if (newHostInput) setInput(newHostInput, "10.9.8.7");
519 if (newUserInput) setInput(newUserInput, "root");
520 await Promise.resolve();
521 });
522 await act(async () => {
523 if (newPasswordInput) setInput(newPasswordInput, "s3cret");
524 await Promise.resolve();
525 });
526 {
527 // Credential mode: a segmented control mirroring the download method;
528 // picking local-proxy must ride the AddRemoteHost payload.
529 const segButtons = Array.from(document.querySelectorAll<HTMLButtonElement>(".remote-wizard__field .provider-add-segmented__item"));
530 const localProxy = segButtons.find((b) => b.textContent?.includes("本机") || b.textContent?.includes("this computer"));
531 ok(Boolean(localProxy), "wizard host form offers the credential-mode segmented control");
532 await act(async () => {
533 localProxy?.click();
534 await Promise.resolve();
535 });
536 ok(localProxy?.className.includes("--active") === true, "local-proxy segment highlights when selected");
537 }
538 await act(async () => {
539 buttonByText("Next")?.click();
540 await flush();
541 });
542 ok(tape.some((entry) => entry.startsWith("AddRemoteHost:10.9.8.7:10.9.8.7")), "a new host is added (label defaults to the host)");
543 ok(lastAddInput?.credentialMode === "local-proxy", `AddRemoteHost carries the chosen credential mode (got ${lastAddInput?.credentialMode})`);
544
545 await act(async () => secondRoot.unmount());
546
547 // ── Merged finish: source contract for overlapping workspaces ──
548 const here = dirname(fileURLToPath(import.meta.url));
549 const wizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8");
550 ok(
551 /if \(!project\.merged\) \{[\s\S]*?RemoveRemoteProject\(hostId, target\)/.test(wizardSource),
552 "rollback only removes a pin the wizard actually added (a merge owns none)",
553 );
554 ok(
555 /onMerged\?\.\(t\("remoteWizard\.mergedProject"/.test(wizardSource),
556 "a merged finish notifies through onMerged",
557 );
558
559 dom.window.close();
560 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
561 if (failed > 0) process.exit(1);
562
562 lines Plain Text