| 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 |