1import { EnvironmentId } from "@t3tools/contracts";
2import type {
3 RelayClientEnvironmentRecord,
4 RelayEnvironmentStatusResponse,
5} from "@t3tools/contracts/relay";
6import { describe, expect, it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";
13import * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";
15
16import * as ManagedRelay from "./managedRelay.ts";
17import * as ClientCapabilities from "../platform/capabilities.ts";
18import * as Connectivity from "../connection/connectivity.ts";
19import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts";
20import * as ConnectionWakeups from "../connection/wakeups.ts";
21import * as RelayEnvironmentDiscovery from "./discovery.ts";
22import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
23
24const environments = [
25 {
26 environmentId: EnvironmentId.make("environment-1"),
27 label: "Environment One",
28 endpoint: {
29 httpBaseUrl: "https://one.example.test",
30 wsBaseUrl: "wss://one.example.test",
31 providerKind: "cloudflare_tunnel",
32 },
33 linkedAt: "2026-06-01T00:00:00.000Z",
34 },
35 {
36 environmentId: EnvironmentId.make("environment-2"),
37 label: "Environment Two",
38 endpoint: {
39 httpBaseUrl: "https://two.example.test",
40 wsBaseUrl: "wss://two.example.test",
41 providerKind: "cloudflare_tunnel",
42 },
43 linkedAt: "2026-06-01T00:00:00.000Z",
44 },
45] satisfies ReadonlyArray<RelayClientEnvironmentRecord>;
46
47function status(
48 environment: RelayClientEnvironmentRecord,
49 value: "online" | "offline",
50): RelayEnvironmentStatusResponse {
51 return {
52 environmentId: environment.environmentId,
53 endpoint: environment.endpoint,
54 status: value,
55 checkedAt: "2026-06-01T00:00:00.000Z",
56 };
57}
58
59const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
60 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online");
61 const listCalls = yield* Ref.make(0);
62 const listFailure = yield* Ref.make<ManagedRelay.ManagedRelayClientError | null>(null);
63 const secondListCall = yield* Deferred.make<void>();
64 const clerkToken = yield* Ref.make<string | null>("clerk-token");
65 const sessionIdentity = { accountId: "account-1" };
66 const wakeups = yield* SubscriptionRef.make<{
67 readonly sequence: number;
68 readonly reason: "application-active" | "credentials-changed";
69 }>({
70 sequence: 0,
71 reason: "application-active",
72 });
73 const statusRequests = yield* Ref.make(
74 new Map<
75 string,
76 Deferred.Deferred<RelayEnvironmentStatusResponse, ManagedRelay.ManagedRelayClientError>
77 >(),
78 );
79 for (const environment of environments) {
80 const request = yield* Deferred.make<
81 RelayEnvironmentStatusResponse,
82 ManagedRelay.ManagedRelayClientError
83 >();
84 yield* Ref.update(statusRequests, (current) => {
85 const next = new Map(current);
86 next.set(environment.environmentId, request);
87 return next;
88 });
89 }
90
91 const client = ManagedRelay.ManagedRelayClient.of({
92 relayUrl: "https://relay.example.test",
93 listEnvironments: () =>
94 Effect.gen(function* () {
95 const count = yield* Ref.updateAndGet(listCalls, (current) => current + 1);
96 if (count >= 2) {
97 yield* Deferred.succeed(secondListCall, undefined);
98 }
99 const failure = yield* Ref.get(listFailure);
100 if (failure) {
101 return yield* failure;
102 }
103 return environments;
104 }),
105 getEnvironmentStatus: ({ environmentId }) =>
106 Ref.get(statusRequests).pipe(
107 Effect.flatMap((requests) => Deferred.await(requests.get(environmentId)!)),
108 ),
109 listDevices: () => Effect.die("unused"),
110 createEnvironmentLinkChallenge: () => Effect.die("unused"),
111 linkEnvironment: () => Effect.die("unused"),
112 unlinkEnvironment: () => Effect.die("unused"),
113 connectEnvironment: () => Effect.die("unused"),
114 registerDevice: () => Effect.die("unused"),
115 unregisterDevice: () => Effect.die("unused"),
116 registerLiveActivity: () => Effect.die("unused"),
117 getAgentActivitySnapshot: () => Effect.die("unused"),
118 resetTokenCache: Effect.void,
119 } satisfies ManagedRelay.ManagedRelayClient["Service"]);
120 const connectivity = Connectivity.Connectivity.of({
121 status: SubscriptionRef.get(networkStatus),
122 changes: SubscriptionRef.changes(networkStatus),
123 });
124 const layer = RelayEnvironmentDiscovery.layer.pipe(
125 Layer.provide(
126 Layer.mergeAll(
127 Layer.succeed(ManagedRelay.ManagedRelayClient, client),
128 Layer.succeed(
129 ClientCapabilities.CloudSession,
130 ClientCapabilities.CloudSession.of({
131 identity: Ref.get(clerkToken).pipe(
132 Effect.map((token) =>
133 token === null ? Option.none() : Option.some(sessionIdentity),
134 ),
135 ),
136 clerkToken: Ref.get(clerkToken).pipe(
137 Effect.filterOrFail(
138 (token) => token !== null,
139 () =>
140 new ConnectionBlockedError({
141 reason: "authentication",
142 detail: "Signed out.",
143 }),
144 ),
145 ),
146 }),
147 ),
148 Layer.succeed(Connectivity.Connectivity, connectivity),
149 Layer.succeed(
150 ConnectionWakeups.ConnectionWakeups,
151 ConnectionWakeups.ConnectionWakeups.of({
152 changes: SubscriptionRef.changes(wakeups).pipe(
153 Stream.drop(1),
154 Stream.map((event) => event.reason),
155 ),
156 }),
157 ),
158 ),
159 ),
160 );
161
162 return {
163 layer,
164 listCalls,
165 listFailure,
166 clerkToken,
167 networkStatus,
168 secondListCall,
169 statusRequests,
170 wake: (reason: "application-active" | "credentials-changed") =>
171 SubscriptionRef.update(wakeups, (event) => ({
172 sequence: event.sequence + 1,
173 reason,
174 })),
175 };
176});
177
178describe("RelayEnvironmentDiscovery", () => {
179 it.effect("publishes each environment status as soon as that lookup completes", () =>
180 Effect.gen(function* () {
181 const harness = yield* makeHarness();
182 yield* Effect.gen(function* () {
183 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
184 const refreshFiber = yield* Effect.forkChild(discovery.refresh);
185
186 const checking = yield* SubscriptionRef.changes(discovery.state).pipe(
187 Stream.filter((state) => state.environments.size === 2),
188 Stream.runHead,
189 Effect.map(Option.getOrThrow),
190 );
191 expect(
192 [...checking.environments.values()].every((entry) => entry.availability === "checking"),
193 ).toBe(true);
194
195 const requests = yield* Ref.get(harness.statusRequests);
196 yield* Deferred.succeed(
197 requests.get(environments[1]!.environmentId)!,
198 status(environments[1]!, "online"),
199 );
200
201 const partiallyResolved = yield* SubscriptionRef.changes(discovery.state).pipe(
202 Stream.filter(
203 (state) =>
204 state.environments.get(environments[1]!.environmentId)?.availability === "online",
205 ),
206 Stream.runHead,
207 Effect.map(Option.getOrThrow),
208 );
209 expect(
210 partiallyResolved.environments.get(environments[0]!.environmentId)?.availability,
211 ).toBe("checking");
212
213 yield* Deferred.succeed(
214 requests.get(environments[0]!.environmentId)!,
215 status(environments[0]!, "offline"),
216 );
217 yield* Fiber.join(refreshFiber);
218
219 const complete = yield* SubscriptionRef.get(discovery.state);
220 expect(complete.environments.get(environments[0]!.environmentId)?.availability).toBe(
221 "offline",
222 );
223 expect(complete.refreshing).toBe(false);
224 }).pipe(Effect.provide(harness.layer));
225 }),
226 );
227
228 it.effect(
229 "preserves discovered rows while offline and refreshes after connectivity returns",
230 () =>
231 Effect.gen(function* () {
232 const harness = yield* makeHarness();
233 yield* Effect.gen(function* () {
234 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
235 const requests = yield* Ref.get(harness.statusRequests);
236 for (const environment of environments) {
237 yield* Deferred.succeed(
238 requests.get(environment.environmentId)!,
239 status(environment, "online"),
240 );
241 }
242 yield* discovery.refresh;
243
244 const offlineFiber = yield* SubscriptionRef.changes(discovery.state).pipe(
245 Stream.filter((state) => state.offline),
246 Stream.runHead,
247 Effect.forkChild,
248 );
249 yield* SubscriptionRef.set(harness.networkStatus, "offline");
250 yield* Fiber.join(offlineFiber);
251 expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2);
252
253 yield* SubscriptionRef.set(harness.networkStatus, "online");
254 yield* Deferred.await(harness.secondListCall);
255 expect(yield* Ref.get(harness.listCalls)).toBe(2);
256 }).pipe(Effect.provide(harness.layer));
257 }),
258 );
259
260 it.effect("publishes listing failures without rejecting the refresh command", () =>
261 Effect.gen(function* () {
262 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online");
263 const client = ManagedRelay.ManagedRelayClient.of({
264 relayUrl: "https://relay.example.test",
265 listEnvironments: () =>
266 Effect.fail(
267 new ManagedRelay.ManagedRelayRequestTimeoutError({
268 activity: "Relay environment listing",
269 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
270 traceId: null,
271 }),
272 ),
273 getEnvironmentStatus: () => Effect.die("unused"),
274 listDevices: () => Effect.die("unused"),
275 createEnvironmentLinkChallenge: () => Effect.die("unused"),
276 linkEnvironment: () => Effect.die("unused"),
277 unlinkEnvironment: () => Effect.die("unused"),
278 connectEnvironment: () => Effect.die("unused"),
279 registerDevice: () => Effect.die("unused"),
280 unregisterDevice: () => Effect.die("unused"),
281 registerLiveActivity: () => Effect.die("unused"),
282 getAgentActivitySnapshot: () => Effect.die("unused"),
283 resetTokenCache: Effect.void,
284 } satisfies ManagedRelay.ManagedRelayClient["Service"]);
285 const layer = RelayEnvironmentDiscovery.layer.pipe(
286 Layer.provide(
287 Layer.mergeAll(
288 Layer.succeed(ManagedRelay.ManagedRelayClient, client),
289 Layer.succeed(ClientCapabilities.CloudSession, {
290 identity: Effect.succeedSome({ accountId: "account-1" }),
291 clerkToken: Effect.succeed("clerk-token"),
292 }),
293 Layer.succeed(Connectivity.Connectivity, {
294 status: SubscriptionRef.get(networkStatus),
295 changes: SubscriptionRef.changes(networkStatus),
296 }),
297 Layer.succeed(
298 ConnectionWakeups.ConnectionWakeups,
299 ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }),
300 ),
301 ),
302 ),
303 );
304
305 yield* Effect.gen(function* () {
306 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
307 yield* discovery.refresh;
308
309 const state = yield* SubscriptionRef.get(discovery.state);
310 expect(state.refreshing).toBe(false);
311 expect(Option.getOrThrow(state.error)).toMatchObject({
312 _tag: "ConnectionTransientError",
313 reason: "timeout",
314 message: `Relay environment listing timed out. ${NETWORK_BLOCKING_HINT}`,
315 });
316 }).pipe(Effect.provide(layer));
317 }),
318 );
319
320 it.effect("clears previously discovered rows when a refresh fails", () =>
321 Effect.gen(function* () {
322 const harness = yield* makeHarness();
323 yield* Effect.gen(function* () {
324 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
325 const requests = yield* Ref.get(harness.statusRequests);
326 for (const environment of environments) {
327 yield* Deferred.succeed(
328 requests.get(environment.environmentId)!,
329 status(environment, "online"),
330 );
331 }
332 yield* discovery.refresh;
333 expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2);
334
335 yield* Ref.set(
336 harness.listFailure,
337 new ManagedRelay.ManagedRelayRequestFailedError({
338 action: "list relay-managed environments",
339 cause: new Error("Relay request failed."),
340 }),
341 );
342 yield* discovery.refresh;
343
344 const failed = yield* SubscriptionRef.get(discovery.state);
345 expect(failed.environments.size).toBe(0);
346 expect(Option.isSome(failed.error)).toBe(true);
347 }).pipe(Effect.provide(harness.layer));
348 }),
349 );
350
351 it.effect("refreshes proactively when credentials change before any manual refresh", () =>
352 Effect.gen(function* () {
353 const harness = yield* makeHarness();
354 yield* Effect.gen(function* () {
355 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
356 const requests = yield* Ref.get(harness.statusRequests);
357 for (const environment of environments) {
358 yield* Deferred.succeed(
359 requests.get(environment.environmentId)!,
360 status(environment, "online"),
361 );
362 }
363
364 // Let the scoped wakeup subscription start before emitting, mirroring
365 // a real sign-in which always happens long after service start.
366 yield* Effect.yieldNow;
367
368 // Sign-in activates the session and emits credentials-changed; the
369 // list must populate without any screen having asked for a refresh.
370 yield* harness.wake("credentials-changed");
371 const populated = yield* SubscriptionRef.changes(discovery.state).pipe(
372 Stream.filter(
373 (state) => state.environments.size === environments.length && !state.refreshing,
374 ),
375 Stream.runHead,
376 Effect.map(Option.getOrThrow),
377 );
378 expect(Option.isNone(populated.error)).toBe(true);
379 expect(yield* Ref.get(harness.listCalls)).toBe(1);
380 }).pipe(Effect.provide(harness.layer), Effect.scoped);
381 }),
382 );
383
384 it.effect("settles to a clean empty state when refreshed while signed out", () =>
385 Effect.gen(function* () {
386 const harness = yield* makeHarness();
387 yield* Effect.gen(function* () {
388 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
389 yield* Ref.set(harness.clerkToken, null);
390 yield* discovery.refresh;
391
392 const state = yield* SubscriptionRef.get(discovery.state);
393 expect(state.environments.size).toBe(0);
394 expect(state.refreshing).toBe(false);
395 expect(Option.isNone(state.error)).toBe(true);
396 }).pipe(Effect.provide(harness.layer));
397 }),
398 );
399
400 it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () =>
401 Effect.gen(function* () {
402 const harness = yield* makeHarness();
403 yield* Effect.gen(function* () {
404 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
405 const refreshFiber = yield* Effect.forkChild(discovery.refresh);
406 yield* SubscriptionRef.changes(discovery.state).pipe(
407 Stream.filter((state) => state.environments.size === environments.length),
408 Stream.runHead,
409 );
410
411 yield* Ref.set(harness.clerkToken, null);
412 yield* harness.wake("credentials-changed");
413 yield* SubscriptionRef.changes(discovery.state).pipe(
414 Stream.filter((state) => state.environments.size === 0),
415 Stream.runHead,
416 );
417
418 const requests = yield* Ref.get(harness.statusRequests);
419 for (const environment of environments) {
420 yield* Deferred.succeed(
421 requests.get(environment.environmentId)!,
422 status(environment, "online"),
423 );
424 }
425 yield* Fiber.join(refreshFiber);
426 yield* Effect.yieldNow;
427
428 expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(0);
429 }).pipe(Effect.provide(harness.layer), Effect.scoped);
430 }),
431 );
432});