| 1 | import { RELAY_TUNNEL_RELEASED_MESSAGE, type Discovery } from "@t3tools/client-runtime/relay"; |
| 2 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import { AsyncResult } from "effect/reactivity"; |
| 6 | import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react"; |
| 7 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 8 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 9 | |
| 10 | type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; |
| 11 | |
| 12 | const discovery = vi.hoisted(() => ({ |
| 13 | state: null as Discovery.RelayEnvironmentDiscoveryState | null, |
| 14 | listeners: new Set<() => void>(), |
| 15 | refreshCommand: Symbol("refresh"), |
| 16 | registerCommand: Symbol("register"), |
| 17 | refresh: vi.fn<() => Promise<AtomCommandResult<void, never>>>(), |
| 18 | register: vi.fn(), |
| 19 | listEnvironments: vi.fn<() => Promise<DiscoveredEnvironments>>(), |
| 20 | })); |
| 21 | |
| 22 | vi.mock("~/state/relay", () => ({ |
| 23 | relayEnvironmentDiscovery: { refresh: discovery.refreshCommand }, |
| 24 | })); |
| 25 | vi.mock("~/connection/catalog", () => ({ |
| 26 | environmentCatalog: { register: discovery.registerCommand }, |
| 27 | })); |
| 28 | vi.mock("~/state/use-atom-command", () => ({ |
| 29 | useAtomCommand: (command: unknown) => |
| 30 | command === discovery.refreshCommand ? discovery.refresh : discovery.register, |
| 31 | })); |
| 32 | vi.mock("~/state/environments", async () => { |
| 33 | const { useSyncExternalStore } = await import("react"); |
| 34 | const subscribe = (listener: () => void) => { |
| 35 | discovery.listeners.add(listener); |
| 36 | return () => discovery.listeners.delete(listener); |
| 37 | }; |
| 38 | const read = () => { |
| 39 | if (discovery.state === null) throw new Error("Discovery fixture is not initialized"); |
| 40 | return discovery.state; |
| 41 | }; |
| 42 | return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) }; |
| 43 | }); |
| 44 | vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null })); |
| 45 | vi.mock("../ui/tooltip", () => ({ |
| 46 | Tooltip: ({ children }: { children: ReactNode }) => children, |
| 47 | TooltipTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>, |
| 48 | TooltipPopup: () => null, |
| 49 | })); |
| 50 | vi.mock("../ui/checkbox", () => ({ |
| 51 | Checkbox: (props: { |
| 52 | checked: boolean; |
| 53 | disabled: boolean; |
| 54 | onCheckedChange: (checked: boolean) => void; |
| 55 | }) => ( |
| 56 | <input |
| 57 | type="checkbox" |
| 58 | checked={props.checked} |
| 59 | disabled={props.disabled} |
| 60 | onChange={(event) => props.onCheckedChange(event.target.checked)} |
| 61 | /> |
| 62 | ), |
| 63 | })); |
| 64 | vi.mock("../ui/button", () => ({ |
| 65 | Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => ( |
| 66 | <button {...props}>{children}</button> |
| 67 | ), |
| 68 | })); |
| 69 | vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); |
| 70 | |
| 71 | import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; |
| 72 | |
| 73 | const newMachineId = EnvironmentId.make("new-computer"); |
| 74 | const linkedMachines: DiscoveredEnvironments = new Map([ |
| 75 | [ |
| 76 | newMachineId, |
| 77 | { |
| 78 | environment: { |
| 79 | environmentId: newMachineId, |
| 80 | label: "Work laptop", |
| 81 | endpoint: { |
| 82 | httpBaseUrl: "https://relay.example.test", |
| 83 | wsBaseUrl: "wss://relay.example.test/ws", |
| 84 | providerKind: "manual", |
| 85 | }, |
| 86 | linkedAt: "2026-09-05T12:00:00.000Z", |
| 87 | }, |
| 88 | availability: "online", |
| 89 | status: Option.none(), |
| 90 | error: Option.none(), |
| 91 | }, |
| 92 | ], |
| 93 | ]); |
| 94 | |
| 95 | let renderer: ReactTestRenderer | null; |
| 96 | let page: EventTarget & { visibilityState: DocumentVisibilityState }; |
| 97 | let browserWindow: EventTarget; |
| 98 | |
| 99 | function publish(state: Discovery.RelayEnvironmentDiscoveryState) { |
| 100 | discovery.state = state; |
| 101 | for (const listener of discovery.listeners) listener(); |
| 102 | } |
| 103 | |
| 104 | async function mount(refreshWhileEmpty = true) { |
| 105 | await act(async () => { |
| 106 | renderer = create( |
| 107 | <CloudEnvironmentConnectRows |
| 108 | primaryEnvironmentId={null} |
| 109 | savedEnvironments={[]} |
| 110 | showSavedEnvironments |
| 111 | refreshWhileEmpty={refreshWhileEmpty} |
| 112 | empty={<p>Waiting for your computer to connect.</p>} |
| 113 | />, |
| 114 | ); |
| 115 | }); |
| 116 | } |
| 117 | |
| 118 | async function advance(milliseconds: number) { |
| 119 | await act(async () => { |
| 120 | await vi.advanceTimersByTimeAsync(milliseconds); |
| 121 | }); |
| 122 | } |
| 123 | |
| 124 | beforeEach(() => { |
| 125 | vi.useFakeTimers(); |
| 126 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 127 | page = Object.assign(new EventTarget(), { visibilityState: "visible" as const }); |
| 128 | browserWindow = new EventTarget(); |
| 129 | vi.stubGlobal("document", page); |
| 130 | vi.stubGlobal("window", browserWindow); |
| 131 | renderer = null; |
| 132 | discovery.listeners.clear(); |
| 133 | discovery.state = { |
| 134 | environments: new Map(), |
| 135 | refreshing: false, |
| 136 | offline: false, |
| 137 | error: Option.none(), |
| 138 | }; |
| 139 | discovery.listEnvironments.mockReset().mockResolvedValue(new Map()); |
| 140 | discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined)); |
| 141 | discovery.refresh.mockReset().mockImplementation(async () => { |
| 142 | publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() }); |
| 143 | const environments = await discovery.listEnvironments(); |
| 144 | publish({ environments, refreshing: false, offline: false, error: Option.none() }); |
| 145 | return AsyncResult.success(undefined); |
| 146 | }); |
| 147 | }); |
| 148 | |
| 149 | afterEach(async () => { |
| 150 | await act(async () => renderer?.unmount()); |
| 151 | vi.useRealTimers(); |
| 152 | vi.unstubAllGlobals(); |
| 153 | }); |
| 154 | |
| 155 | describe("cloud environment offline reasons", () => { |
| 156 | it("tells the user to update a computer whose idle tunnel was removed", async () => { |
| 157 | const base = linkedMachines.get(newMachineId)!; |
| 158 | const offlineEntry = (offlineReason?: "tunnel_released") => ({ |
| 159 | ...base, |
| 160 | availability: "offline" as const, |
| 161 | status: Option.some({ |
| 162 | environmentId: newMachineId, |
| 163 | endpoint: base.environment.endpoint, |
| 164 | status: "offline" as const, |
| 165 | checkedAt: "2026-09-15T00:00:00Z", |
| 166 | error: "Managed endpoint health request failed.", |
| 167 | ...(offlineReason ? { offlineReason } : {}), |
| 168 | }), |
| 169 | }); |
| 170 | const rowText = () => |
| 171 | renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children); |
| 172 | |
| 173 | discovery.listEnvironments.mockResolvedValue(new Map([[newMachineId, offlineEntry()]])); |
| 174 | await mount(); |
| 175 | expect(rowText()).toContain("T3 Connect · Not added · Relay offline"); |
| 176 | |
| 177 | await act(async () => { |
| 178 | publish({ |
| 179 | environments: new Map([[newMachineId, offlineEntry("tunnel_released")]]), |
| 180 | refreshing: false, |
| 181 | offline: false, |
| 182 | error: Option.none(), |
| 183 | }); |
| 184 | }); |
| 185 | expect(rowText()).toContain(RELAY_TUNNEL_RELEASED_MESSAGE); |
| 186 | }); |
| 187 | }); |
| 188 | |
| 189 | describe("cloud onboarding discovery", () => { |
| 190 | it("signals that the section can expand after initial discovery settles", async () => { |
| 191 | let finishDiscovery!: (environments: DiscoveredEnvironments) => void; |
| 192 | discovery.listEnvironments.mockReturnValue( |
| 193 | new Promise((resolve) => { |
| 194 | finishDiscovery = resolve; |
| 195 | }), |
| 196 | ); |
| 197 | const onDiscoveryReady = vi.fn(); |
| 198 | await act(async () => { |
| 199 | renderer = create( |
| 200 | <CloudEnvironmentConnectRows |
| 201 | primaryEnvironmentId={null} |
| 202 | savedEnvironments={[]} |
| 203 | onDiscoveryReady={onDiscoveryReady} |
| 204 | />, |
| 205 | ); |
| 206 | }); |
| 207 | expect(onDiscoveryReady).not.toHaveBeenCalled(); |
| 208 | await act(async () => { |
| 209 | finishDiscovery(linkedMachines); |
| 210 | }); |
| 211 | expect(onDiscoveryReady).toHaveBeenCalledTimes(1); |
| 212 | expect(renderer!.root.findByType("button").children).toEqual(["Add"]); |
| 213 | }); |
| 214 | |
| 215 | it("keeps incompatible discoveries unselected until the user enables a compatible server", async () => { |
| 216 | const base = linkedMachines.get(newMachineId)!; |
| 217 | const entry = (protocolVersion: number) => ({ |
| 218 | ...base, |
| 219 | status: Option.some({ |
| 220 | environmentId: newMachineId, |
| 221 | endpoint: base.environment.endpoint, |
| 222 | status: "online" as const, |
| 223 | checkedAt: "2026-09-15T00:00:00Z", |
| 224 | descriptor: { |
| 225 | environmentId: newMachineId, |
| 226 | label: base.environment.label, |
| 227 | platform: { os: "linux" as const, arch: "x64" as const }, |
| 228 | serverVersion: "1.0.0", |
| 229 | orchestrationProtocolVersion: protocolVersion, |
| 230 | capabilities: { repositoryIdentity: true }, |
| 231 | }, |
| 232 | }), |
| 233 | }); |
| 234 | discovery.listEnvironments.mockResolvedValue( |
| 235 | new Map([[newMachineId, entry(ORCHESTRATION_PROTOCOL_VERSION + 1)]]), |
| 236 | ); |
| 237 | const onSelectionChange = vi.fn(); |
| 238 | const autoSelectedComputers = new Set<EnvironmentId>(); |
| 239 | function Setup() { |
| 240 | const [selectedIds, setSelectedIds] = useState<ReadonlySet<EnvironmentId>>( |
| 241 | new Set([newMachineId]), |
| 242 | ); |
| 243 | return ( |
| 244 | <CloudEnvironmentConnectRows |
| 245 | primaryEnvironmentId={null} |
| 246 | savedEnvironments={[]} |
| 247 | showSavedEnvironments |
| 248 | selection={{ |
| 249 | autoSelectedComputers, |
| 250 | selectedIds, |
| 251 | onChange: (id, checked) => { |
| 252 | onSelectionChange(id, checked); |
| 253 | setSelectedIds((current) => { |
| 254 | const next = new Set(current); |
| 255 | if (checked) next.add(id); |
| 256 | else next.delete(id); |
| 257 | return next; |
| 258 | }); |
| 259 | }, |
| 260 | }} |
| 261 | /> |
| 262 | ); |
| 263 | } |
| 264 | await act(async () => { |
| 265 | renderer = create(<Setup />); |
| 266 | }); |
| 267 | expect(discovery.register).not.toHaveBeenCalled(); |
| 268 | expect(onSelectionChange).toHaveBeenCalledWith(newMachineId, false); |
| 269 | expect(renderer!.root.findByType("input").props.checked).toBe(false); |
| 270 | expect(renderer!.root.findByType("input").props.disabled).toBe(true); |
| 271 | expect(renderer!.root.findAllByType("span").flatMap((span) => span.children)).toContain( |
| 272 | "Client not supported", |
| 273 | ); |
| 274 | await act(async () => { |
| 275 | await renderer!.root.findByType("input").props.onChange({ target: { checked: true } }); |
| 276 | }); |
| 277 | expect(discovery.register).not.toHaveBeenCalled(); |
| 278 | await act(async () => |
| 279 | publish({ |
| 280 | ...discovery.state!, |
| 281 | environments: new Map([[newMachineId, entry(ORCHESTRATION_PROTOCOL_VERSION)]]), |
| 282 | }), |
| 283 | ); |
| 284 | expect(discovery.register).not.toHaveBeenCalled(); |
| 285 | expect(renderer!.root.findByType("input").props.checked).toBe(false); |
| 286 | expect(renderer!.root.findByType("input").props.disabled).toBe(false); |
| 287 | await act(async () => { |
| 288 | await renderer!.root.findByType("input").props.onChange({ target: { checked: true } }); |
| 289 | }); |
| 290 | expect(discovery.register).toHaveBeenCalledTimes(1); |
| 291 | }); |
| 292 | |
| 293 | it("connects and selects discovered computers by default without overwriting deselection", async () => { |
| 294 | discovery.listEnvironments.mockResolvedValue(linkedMachines); |
| 295 | const autoSelectedComputers = new Set<EnvironmentId>(); |
| 296 | function Setup() { |
| 297 | const [selectedIds, setSelectedIds] = useState<ReadonlySet<EnvironmentId>>(new Set()); |
| 298 | return ( |
| 299 | <CloudEnvironmentConnectRows |
| 300 | primaryEnvironmentId={null} |
| 301 | savedEnvironments={[]} |
| 302 | showSavedEnvironments |
| 303 | selection={{ |
| 304 | autoSelectedComputers, |
| 305 | selectedIds, |
| 306 | onChange: (id, checked) => |
| 307 | setSelectedIds((current) => { |
| 308 | const next = new Set(current); |
| 309 | if (checked) next.add(id); |
| 310 | else next.delete(id); |
| 311 | return next; |
| 312 | }), |
| 313 | }} |
| 314 | /> |
| 315 | ); |
| 316 | } |
| 317 | await act(async () => { |
| 318 | renderer = create(<Setup />); |
| 319 | }); |
| 320 | |
| 321 | expect(discovery.register).toHaveBeenCalledTimes(1); |
| 322 | expect(renderer!.root.findByType("input").props.checked).toBe(true); |
| 323 | await act(async () => { |
| 324 | await renderer!.root.findByType("input").props.onChange({ target: { checked: false } }); |
| 325 | }); |
| 326 | await act(async () => { |
| 327 | publish({ ...discovery.state!, environments: new Map(linkedMachines) }); |
| 328 | }); |
| 329 | expect(renderer!.root.findByType("input").props.checked).toBe(false); |
| 330 | expect(discovery.register).toHaveBeenCalledTimes(1); |
| 331 | }); |
| 332 | |
| 333 | it("shows a newly linked computer without remounting and stops polling once found", async () => { |
| 334 | discovery.listEnvironments |
| 335 | .mockResolvedValueOnce(new Map()) |
| 336 | .mockResolvedValueOnce(linkedMachines); |
| 337 | await mount(); |
| 338 | expect(renderer!.root.findByType("p").children).toEqual([ |
| 339 | "Waiting for your computer to connect.", |
| 340 | ]); |
| 341 | |
| 342 | await advance(5_000); |
| 343 | |
| 344 | expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ |
| 345 | "Work laptop", |
| 346 | ]); |
| 347 | expect(renderer!.root.findByType("button").children).toEqual(["Add"]); |
| 348 | await advance(30_000); |
| 349 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); |
| 350 | }); |
| 351 | |
| 352 | it("keeps a discovered computer visible when it is added to the browser", async () => { |
| 353 | discovery.listEnvironments.mockResolvedValue(linkedMachines); |
| 354 | await mount(); |
| 355 | expect(renderer!.root.findByType("button").children).toEqual(["Add"]); |
| 356 | await act(async () => { |
| 357 | renderer!.update( |
| 358 | <CloudEnvironmentConnectRows |
| 359 | primaryEnvironmentId={null} |
| 360 | savedEnvironments={[ |
| 361 | { |
| 362 | environmentId: newMachineId, |
| 363 | connection: { phase: "connected", error: null, traceId: null }, |
| 364 | relayManaged: true, |
| 365 | }, |
| 366 | ]} |
| 367 | showSavedEnvironments |
| 368 | refreshWhileEmpty |
| 369 | />, |
| 370 | ); |
| 371 | }); |
| 372 | expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ |
| 373 | "Work laptop", |
| 374 | ]); |
| 375 | expect(renderer!.root.findByType("button").children).toEqual(["Connected"]); |
| 376 | }); |
| 377 | |
| 378 | it("waits while hidden and refreshes immediately when visible again", async () => { |
| 379 | page.visibilityState = "hidden"; |
| 380 | await mount(); |
| 381 | await advance(30_000); |
| 382 | expect(discovery.listEnvironments).not.toHaveBeenCalled(); |
| 383 | |
| 384 | await act(async () => { |
| 385 | page.visibilityState = "visible"; |
| 386 | page.dispatchEvent(new Event("visibilitychange")); |
| 387 | }); |
| 388 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); |
| 389 | |
| 390 | page.visibilityState = "hidden"; |
| 391 | page.dispatchEvent(new Event("visibilitychange")); |
| 392 | browserWindow.dispatchEvent(new Event("focus")); |
| 393 | await advance(30_000); |
| 394 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); |
| 395 | }); |
| 396 | |
| 397 | it("does not overlap a slow refresh or restart polling after unmount", async () => { |
| 398 | let resolveRefresh!: (environments: DiscoveredEnvironments) => void; |
| 399 | const pending = new Promise<DiscoveredEnvironments>((resolve) => { |
| 400 | resolveRefresh = resolve; |
| 401 | }); |
| 402 | discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending); |
| 403 | await mount(); |
| 404 | await advance(5_000); |
| 405 | expect(renderer!.root.findByType("p").children).toEqual([ |
| 406 | "Waiting for your computer to connect.", |
| 407 | ]); |
| 408 | |
| 409 | browserWindow.dispatchEvent(new Event("focus")); |
| 410 | page.dispatchEvent(new Event("visibilitychange")); |
| 411 | await advance(30_000); |
| 412 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); |
| 413 | |
| 414 | await act(async () => renderer!.unmount()); |
| 415 | renderer = null; |
| 416 | await act(async () => resolveRefresh(new Map())); |
| 417 | await advance(30_000); |
| 418 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); |
| 419 | }); |
| 420 | |
| 421 | it("pauses while offline and resumes when discovery is online", async () => { |
| 422 | await mount(); |
| 423 | await act(async () => publish({ ...discovery.state!, offline: true })); |
| 424 | await advance(30_000); |
| 425 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); |
| 426 | |
| 427 | await act(async () => publish({ ...discovery.state!, offline: false })); |
| 428 | await advance(5_000); |
| 429 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); |
| 430 | }); |
| 431 | |
| 432 | it("does not add polling to other cloud lists", async () => { |
| 433 | await mount(false); |
| 434 | await advance(30_000); |
| 435 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); |
| 436 | }); |
| 437 | }); |