返回 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 for (const group of document.querySelectorAll<HTMLElement>(".remote-wizard__seg")) {
205 const choices = [...group.querySelectorAll<HTMLButtonElement>("button")];
206 const selected = choices.filter(button => button.getAttribute("aria-pressed") === "true");
207 ok(selected.length === 1 && selected[0].dataset.selected === "true", "each initial choice has accessible and styled selection");
208 ok(group.classList.contains("settings-options"), "wizard uses the shared selection stylesheet");
209 }
210 const initialFocus = document.activeElement as HTMLElement;
211 const authChoices = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__seg") [0].querySelectorAll("button")];
212 await act(async () => {
213 authChoices[0].focus();
214 authChoices[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
215 });
216 ok(authChoices[1].getAttribute("aria-pressed") === "true" && authChoices[1].dataset.selected === "true", "keyboard selection updates the auth highlight");
217 ok(authChoices[0].dataset.selected === "false", "previous auth choice loses its highlight");
218 await act(async () => { authChoices[0].click(); });
219 initialFocus.focus();
220
221 const hostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
222 ok(Boolean(hostInput), "config step shows the host input");
223 ok(
224 hostInput?.closest("label") === null &&
225 hostInput?.labels?.length === 1 &&
226 hostInput.labels[0]?.textContent?.trim() === "Host",
227 "host input uses an exact explicit label",
228 );
229 ok(document.activeElement === hostInput, "opening the dialog focuses the first field");
230 await act(async () => {
231 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
232 await Promise.resolve();
233 });
234 ok(document.activeElement === buttonByText("Next"), "Shift+Tab from the first field wraps to the last action");
235 await act(async () => {
236 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
237 await Promise.resolve();
238 });
239 ok(document.activeElement === hostInput, "Tab from the last action wraps to the first field");
240
241 // ── Empty host+user: footer alert names both missing fields ──
242 {
243 const userInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
244 await act(async () => {
245 if (hostInput) setInput(hostInput, "");
246 if (userInput) setInput(userInput, "");
247 await Promise.resolve();
248 });
249 await act(async () => {
250 buttonByText("Next")?.click();
251 await Promise.resolve();
252 });
253 const alert = document.querySelector(".remote-wizard__footer [role='alert']");
254 const text = alert?.textContent ?? "";
255 ok(text.includes("Host") && text.includes("username") && text.includes("Password"), "empty form reports host, username, and password");
256 ok(text.includes("⚠"), "footer alert uses the warning mark");
257 await act(async () => {
258 if (userInput) setInput(userInput, "root");
259 await Promise.resolve();
260 });
261 }
262 // ── Saved-host suggestion: arrow toggle → dropdown → prefill ──
263 const toggleArrow = () => document.querySelector<HTMLButtonElement>(".remote-wizard__suggest-toggle");
264 ok(Boolean(toggleArrow()), "host field exposes the saved-connections arrow");
265 ok(toggleArrow()?.closest("label") === null, "saved-connections arrow stays outside the host label");
266 ok(toggleArrow()?.getAttribute("aria-haspopup") === "menu", "arrow advertises its saved-host menu");
267 ok(toggleArrow()?.getAttribute("aria-expanded") === "false", "arrow starts collapsed");
268 await act(async () => {
269 hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
270 hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
271 await Promise.resolve();
272 });
273 ok(!document.querySelector(".remote-wizard__suggest-list"), "focusing the host input alone no longer opens the list");
274 await act(async () => {
275 toggleArrow()?.click();
276 await Promise.resolve();
277 });
278 ok(Boolean(document.querySelector(".remote-wizard__suggest-list")), "clicking the arrow lists saved SSH connections");
279 ok(toggleArrow()?.getAttribute("aria-expanded") === "true", "arrow reflects the expanded state");
280 ok((document.querySelector(".remote-wizard__suggest-head")?.textContent ?? "").toLowerCase().includes("ssh"), "dropdown leads with the saved-connections caption");
281 ok(document.querySelector(".remote-wizard__suggest-list")?.getAttribute("role") === "menu", "saved hosts use menu semantics");
282 const menuItems = [...document.querySelectorAll<HTMLButtonElement>('.remote-wizard__suggest-list [role="menuitem"]')];
283 ok(menuItems.length === savedHosts.length, "every saved host is exposed as a menu item");
284 ok(document.activeElement === menuItems[0], "opening the menu moves focus to the first saved host");
285 await act(async () => {
286 menuItems[0]?.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
287 await Promise.resolve();
288 });
289 ok(document.activeElement === menuItems[1], "ArrowDown moves focus to the next saved host");
290 await act(async () => {
291 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
292 await Promise.resolve();
293 });
294 ok(!document.querySelector(".remote-wizard__suggest-list"), "Escape closes the keyboard-opened menu");
295 ok(document.activeElement === toggleArrow(), "Escape restores focus to the saved-host arrow");
296 await act(async () => {
297 toggleArrow()?.click();
298 await Promise.resolve();
299 });
300 const suggestion = document.querySelector<HTMLButtonElement>('.remote-wizard__suggest-list [role="menuitem"]');
301 await act(async () => {
302 suggestion?.click();
303 await Promise.resolve();
304 });
305 ok(hostInput?.value === "192.168.1.10", "picking a suggestion prefills the host");
306 ok(!document.querySelector(".remote-wizard__suggest-list"), "picking a suggestion closes the list");
307 ok(document.activeElement === hostInput, "picking a suggestion restores focus to the host input");
308 const keyInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.value.includes("id_ed25519"));
309 ok(Boolean(keyInput), "saved key auth switches the form to key mode with the identity file");
310 const installChoice = () => document.querySelector<HTMLButtonElement>('.remote-wizard__install [aria-pressed="true"]');
311 ok(installChoice()?.textContent?.trim() === "Automatic", "saved auto install policy remains selected");
312 await act(async () => { buttonByText("Use installed CLI")?.click(); });
313 ok(installChoice()?.textContent?.trim() === "Use installed CLI", "never policy is visible and selectable");
314 await act(async () => { buttonByText("Automatic")?.click(); });
315
316 await act(async () => {
317 document.querySelector<HTMLButtonElement>(".remote-wizard__pick-btn")?.click();
318 await flush();
319 });
320 ok(tape.includes("PickRemoteIdentityFile"), "identity-file action uses the native desktop picker");
321 ok(keyInput?.value === "/home/dev/.ssh/id_wizard", "native picker returns the absolute identity-file path");
322
323 {
324 const listButtons = () => [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")];
325 await act(async () => {
326 if (hostInput) setInput(hostInput, "");
327 await Promise.resolve();
328 });
329 await act(async () => {
330 toggleArrow()?.click();
331 await Promise.resolve();
332 });
333 const listed = listButtons().find((b) => b.textContent?.includes("pw-box"));
334 await act(async () => {
335 listed?.click();
336 await Promise.resolve();
337 });
338 const passwordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
339 ok((passwordInput?.placeholder ?? "").toLowerCase().includes("saved") || (passwordInput?.placeholder ?? "").includes("已保存"), "saved password host keeps a keep-existing placeholder");
340 // Typed text no longer filters the list: reopen with a filled host field
341 // and every saved connection must still be offered.
342 await act(async () => {
343 if (hostInput) setInput(hostInput, "10.0.0.8");
344 await Promise.resolve();
345 });
346 await act(async () => {
347 toggleArrow()?.click();
348 await Promise.resolve();
349 });
350 {
351 const labels = listButtons().map((b) => b.textContent ?? "");
352 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");
353 }
354 await act(async () => {
355 hostInput?.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
356 await Promise.resolve();
357 });
358 ok(Boolean(document.querySelector(".remote-wizard__suggest-list")), "a pointer press on the host field keeps the list open");
359 await act(async () => {
360 document.body.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
361 await Promise.resolve();
362 });
363 ok(!document.querySelector(".remote-wizard__suggest-list"), "a pointer press outside the field closes the list");
364 await act(async () => {
365 toggleArrow()?.click();
366 await Promise.resolve();
367 });
368 await act(async () => {
369 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
370 await Promise.resolve();
371 });
372 ok(!document.querySelector(".remote-wizard__suggest-list"), "Escape closes the open list");
373 ok(!tape.includes("close"), "Escape with the list open keeps the wizard open");
374 ok(document.activeElement === toggleArrow(), "Escape from a saved host restores focus to the arrow");
375 await act(async () => {
376 toggleArrow()?.click();
377 await Promise.resolve();
378 });
379 await act(async () => {
380 toggleArrow()?.click();
381 await Promise.resolve();
382 });
383 ok(!document.querySelector(".remote-wizard__suggest-list"), "clicking the open arrow toggles the list shut");
384 // No saved hosts at all: no arrow, no list.
385 {
386 const restored = useRemoteStore.getState().hosts.slice();
387 await act(async () => {
388 useRemoteStore.getState().setHosts([]);
389 await Promise.resolve();
390 });
391 ok(!document.querySelector(".remote-wizard__suggest-toggle"), "no saved hosts hides the arrow entirely");
392 await act(async () => {
393 useRemoteStore.getState().setHosts(restored);
394 await Promise.resolve();
395 });
396 ok(Boolean(document.querySelector(".remote-wizard__suggest-toggle")), "the arrow returns once hosts exist again");
397 }
398 await act(async () => {
399 toggleArrow()?.click();
400 await Promise.resolve();
401 });
402 const gpuSuggestion = listButtons().find((b) => b.textContent?.includes("gpu-box"));
403 await act(async () => {
404 gpuSuggestion?.click();
405 await Promise.resolve();
406 });
407 ok(hostInput?.value === "192.168.1.10", "picking gpu-box from the reopened list restores its host");
408 }
409 // ── Next: first connect fails; the wizard stays on the connecting step ──
410 await act(async () => {
411 buttonByText("Next")?.click();
412 await delay(120);
413 await flush();
414 });
415 ok(tape.includes("UpdateRemoteHost:gpu-box:192.168.1.10"), "next on a picked host updates it instead of adding a duplicate");
416 ok(!tape.some((entry) => entry.startsWith("AddRemoteHost:")), "no AddRemoteHost for a saved host");
417 // The failure path keeps the connecting step on screen (act has ended and
418 // the DOM is committed), so counting log lines here is reliable: at least
419 // two — connecting + failed.
420 {
421 const logCount = document.querySelectorAll(".remote-wizard__log-line").length;
422 ok(logCount >= 2, "connecting step streams the deployment log");
423 const connectError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
424 ok(Boolean(connectError?.textContent?.includes("ssh: handshake failed")), "failed connect surfaces the kernel error");
425 ok(Boolean(buttonByText("Retry")), "retry action is offered after a failed connect");
426 await act(async () => {
427 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
428 await Promise.resolve();
429 });
430 ok(document.activeElement === buttonByText("Back to edit"), "Tab after a step transition returns focus to the dialog");
431 }
432 // ── Retry #1: SSH connects, but the platform check rejects the host ──
433 await act(async () => {
434 buttonByText("Retry")?.click();
435 await flush();
436 });
437 ok(buttonByText("Cancel")?.disabled === true, "retry keeps the wizard busy while the connection is pending");
438 await act(async () => {
439 await delay(120);
440 await flush();
441 });
442 ok(tape.includes("CheckRemotePlatform:gpu-box"), "a connected host runs the platform check before the workspace step");
443 ok(railItems()[1]?.className.includes("--current") === true, "an unsupported OS keeps the wizard on the connecting step");
444 {
445 const platformError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
446 ok(Boolean(platformError?.textContent?.includes("unsupported remote OS")), "the platform failure surfaces the kernel error");
447 ok(!tape.some((entry) => entry.startsWith("ListRemoteDir")), "directory browsing never starts for an unsupported host");
448 ok(Boolean(buttonByText("Retry")), "retry stays available after a platform rejection");
449 }
450 // ── Retry #2: the platform check passes and lands on step ③ ──
451 await act(async () => {
452 buttonByText("Retry")?.click();
453 await delay(120);
454 await flush();
455 });
456 ok(railItems()[0]?.className.includes("--done") === true, "step 1 turns done (green check) after advancing");
457 ok(railItems()[2]?.className.includes("--current") === true, "step 3 is current after connecting");
458 ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "workspace picker starts at RemoteLastWorkspace path");
459 ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "projects")), "directory entries render");
460 ok(Boolean([...document.querySelectorAll(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"))), "files render in the tree next to folders");
461 {
462 const fileRow = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"));
463 await act(async () => {
464 fileRow?.click();
465 await Promise.resolve();
466 });
467 ok(fileRow?.className.includes("--selected") === true, "clicking a file highlights the row");
468 ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "clicking a file selects its parent directory as the workspace");
469 }
470
471 await act(async () => {
472 [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__dir")].find((b) => b.textContent === "projects")?.click();
473 await flush();
474 });
475 ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "app")), "drilling into a directory lists its children");
476 ok(!document.querySelector(".remote-wizard__mkdir"), "workspace step has no create-folder controls");
477
478 // ── Directory race: an older slow response cannot replace the newer path ──
479 {
480 const pathInput = document.querySelector<HTMLInputElement>(".remote-wizard__path-input");
481 await act(async () => {
482 if (pathInput) setInput(pathInput, "/slow");
483 await Promise.resolve();
484 });
485 await act(async () => {
486 buttonByText("Go")?.click();
487 await flush();
488 });
489 await act(async () => {
490 if (pathInput) setInput(pathInput, "/fast");
491 await Promise.resolve();
492 });
493 await act(async () => {
494 buttonByText("Go")?.click();
495 await flush();
496 });
497 ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "latest")), "newer directory response renders first");
498 await act(async () => {
499 slowDirectory.resolve([{ name: "stale", path: "/slow/stale", isDir: true, size: 0, mtimeUnix: 0, symlink: false }]);
500 await flush();
501 });
502 ok(![...document.querySelectorAll(".remote-wizard__dir")].some((b) => b.textContent === "stale"), "stale directory response cannot overwrite the latest path");
503 await act(async () => {
504 if (pathInput) setInput(pathInput, "/home/dev/projects");
505 await Promise.resolve();
506 });
507 }
508
509 // ── Finish: pin, open an in-app session tab, then refresh the tree ──
510 await act(async () => {
511 buttonByText("Connect and open")?.click();
512 await flush();
513 });
514 ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish opens the selected workspace in a new remote session tab");
515 const navigationRegistration = tape.findIndex((entry) => entry.startsWith("RegisterNavigationIntent:nav-"));
516 ok(navigationRegistration >= 0 && navigationRegistration < tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish registers navigation before opening the remote tab");
517 ok(tape.includes("AddRemoteProject:gpu-box:/home/dev/projects"), "finish pins the selected remote workspace");
518 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");
519 ok(tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true") < tape.indexOf("refresh"), "the project tree refreshes after the session tab opens");
520 ok(tape.includes("close"), "wizard closes after a successful finish");
521
522 mergedWorkspace = "/home/dev";
523 await act(async () => { buttonByText("Connect and open")?.click(); await flush(); });
524 ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev:true"), "a merged finish opens the canonical workspace through the navigation owner");
525 mergedWorkspace = "";
526
527 await act(async () => root.unmount());
528
529 // ── Second harness: brand-new host goes through AddRemoteHost ──
530 const secondRootEl = document.createElement("div");
531 document.body.appendChild(secondRootEl);
532 const secondRoot = createRoot(secondRootEl);
533 await act(async () => {
534 secondRoot.render(<WizardHarness />);
535 });
536 await act(async () => flush());
537 const newHostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
538 const newUserInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
539 const newPasswordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
540 await act(async () => {
541 if (newHostInput) setInput(newHostInput, "10.9.8.7");
542 if (newUserInput) setInput(newUserInput, "root");
543 await Promise.resolve();
544 });
545 await act(async () => {
546 if (newPasswordInput) setInput(newPasswordInput, "s3cret");
547 await Promise.resolve();
548 });
549 {
550 // Credential mode: a segmented control mirroring the download method;
551 // picking local-proxy must ride the AddRemoteHost payload.
552 const segButtons = Array.from(document.querySelectorAll<HTMLButtonElement>(".remote-wizard__field .provider-add-segmented__item"));
553 const localProxy = segButtons.find((b) => b.textContent?.includes("本机") || b.textContent?.includes("this computer"));
554 ok(Boolean(localProxy), "wizard host form offers the credential-mode segmented control");
555 await act(async () => {
556 localProxy?.click();
557 await Promise.resolve();
558 });
559 ok(localProxy?.getAttribute("aria-pressed") === "true" && localProxy?.dataset.selected === "true", "local-proxy segment highlights when selected");
560 }
561 await act(async () => {
562 buttonByText("Next")?.click();
563 await flush();
564 });
565 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)");
566 ok(lastAddInput?.credentialMode === "local-proxy", `AddRemoteHost carries the chosen credential mode (got ${lastAddInput?.credentialMode})`);
567
568 await act(async () => secondRoot.unmount());
569
570 // ── Merged finish: source contract for overlapping workspaces ──
571 const here = dirname(fileURLToPath(import.meta.url));
572 const wizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8");
573 ok(
574 /if \(!project\.merged\) \{[\s\S]*?RemoveRemoteProject\(hostId, target\)/.test(wizardSource),
575 "rollback only removes a pin the wizard actually added (a merge owns none)",
576 );
577 ok(
578 /onMerged\?\.\(t\("remoteWizard\.mergedProject"/.test(wizardSource),
579 "a merged finish notifies through onMerged",
580 );
581
582 dom.window.close();
583 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
584 if (failed > 0) process.exit(1);
585
585 lines Plain Text