1import { RELAY_TUNNEL_RELEASED_MESSAGE, type Discovery } from "@t3tools/client-runtime/relay";
2import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { AsyncResult } from "effect/reactivity";
6import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
7import { create, type ReactTestRenderer } from "react-test-renderer";
8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"];
11
12const 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
22vi.mock("~/state/relay", () => ({
23 relayEnvironmentDiscovery: { refresh: discovery.refreshCommand },
24}));
25vi.mock("~/connection/catalog", () => ({
26 environmentCatalog: { register: discovery.registerCommand },
27}));
28vi.mock("~/state/use-atom-command", () => ({
29 useAtomCommand: (command: unknown) =>
30 command === discovery.refreshCommand ? discovery.refresh : discovery.register,
31}));
32vi.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});
44vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null }));
45vi.mock("../ui/tooltip", () => ({
46 Tooltip: ({ children }: { children: ReactNode }) => children,
47 TooltipTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>,
48 TooltipPopup: () => null,
49}));
50vi.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}));
64vi.mock("../ui/button", () => ({
65 Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
66 <button {...props}>{children}</button>
67 ),
68}));
69vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
70
71import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
72
73const newMachineId = EnvironmentId.make("new-computer");
74const 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
95let renderer: ReactTestRenderer | null;
96let page: EventTarget & { visibilityState: DocumentVisibilityState };
97let browserWindow: EventTarget;
98
99function publish(state: Discovery.RelayEnvironmentDiscoveryState) {
100 discovery.state = state;
101 for (const listener of discovery.listeners) listener();
102}
103
104async 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
118async function advance(milliseconds: number) {
119 await act(async () => {
120 await vi.advanceTimersByTimeAsync(milliseconds);
121 });
122}
123
124beforeEach(() => {
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
149afterEach(async () => {
150 await act(async () => renderer?.unmount());
151 vi.useRealTimers();
152 vi.unstubAllGlobals();
153});
154
155describe("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
189describe("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});