1import { describe, expect, it } from "@effect/vitest";
2import {
3 PrimaryConnectionTarget,
4 type EnvironmentPresentation,
5 type NetworkStatus,
6} from "@t3tools/client-runtime/connection";
7import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections";
8import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell";
9import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
10import * as Option from "effect/Option";
11import { Atom, AtomRegistry } from "effect/reactivity";
12
13import { createWorkspaceConnectionAtoms } from "./workspace-connection-atoms";
14import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel";
15
16const ID = EnvironmentId.make("environment-1");
17function presentation(): EnvironmentPresentation {
18 return {
19 entry: {
20 target: new PrimaryConnectionTarget({
21 environmentId: ID,
22 label: "Mac",
23 httpBaseUrl: "https://example.test",
24 wsBaseUrl: "wss://example.test",
25 }),
26 enabled: true,
27 profile: Option.none(),
28 },
29 connection: { phase: "connected", error: null, traceId: null },
30 serverConfig: null,
31 };
32}
33function harness() {
34 const source = Atom.make<EnvironmentPresentation | null>(presentation());
35 const catalog = Atom.make<EnvironmentCatalogState>({
36 isReady: true,
37 entries: new Map([[ID, presentation().entry]]),
38 });
39 const network = Atom.make<NetworkStatus>("online");
40 const atoms = createWorkspaceConnectionAtoms({
41 catalogValueAtom: catalog,
42 networkStatusValueAtom: network,
43 presentationAtom: () => source,
44 });
45 const registry = AtomRegistry.make();
46 return { source, catalog, network, registry, ...atoms };
47}
48
49describe("workspace connection subscriptions", () => {
50 it("keeps connection consumers stable across shell and provider updates", () => {
51 const h = harness();
52 const shell = Atom.make<EnvironmentShellSummary>({
53 hasSnapshot: true,
54 hasSynchronizingShell: false,
55 hasCachedShell: false,
56 hasLiveShell: true,
57 firstError: null,
58 });
59 const broadState = Atom.make((get) =>
60 projectWorkspaceState({
61 isReady: get(h.catalog).isReady,
62 networkStatus: get(h.network),
63 environments: [projectWorkspaceEnvironment(ID, get(h.source)!)],
64 shellSummary: get(shell),
65 }),
66 );
67 const initial = h.registry.get(h.stateAtom);
68 const initialEnvironments = h.registry.get(h.environmentsAtom);
69 let broadChanges = 0;
70 let connectionChanges = 0;
71 const stopBroad = h.registry.subscribe(broadState, () => broadChanges++);
72 const stopNarrow = h.registry.subscribe(h.stateAtom, () => connectionChanges++);
73 try {
74 for (let index = 0; index < 20; index++) {
75 h.registry.set(shell, {
76 ...h.registry.get(shell),
77 hasSynchronizingShell: index % 2 === 0,
78 });
79 h.registry.get(broadState);
80 h.registry.get(h.stateAtom);
81 }
82 expect(broadChanges).toBe(20);
83 expect(connectionChanges).toBe(0);
84 for (let index = 0; index < 4; index++) {
85 // Full config still advances for consumers such as Settings.
86 const config = { cwd: `/workspace-${index}` } as ServerConfig;
87 h.registry.set(h.source, { ...presentation(), serverConfig: config });
88 expect(h.registry.get(h.source)?.serverConfig).toBe(config);
89 expect(h.registry.get(h.stateAtom)).toBe(initial);
90 expect(h.registry.get(h.environmentsAtom)).toBe(initialEnvironments);
91 }
92 expect(connectionChanges).toBe(0);
93 } finally {
94 stopBroad();
95 stopNarrow();
96 h.registry.dispose();
97 }
98 });
99
100 it("propagates errors, offline state, disabling and reconnection", () => {
101 const h = harness();
102 try {
103 expect(h.registry.get(h.stateAtom).connectionState).toBe("connected");
104 const failed = {
105 ...presentation(),
106 connection: { phase: "error" as const, error: "Connection failed", traceId: "trace-1" },
107 };
108 h.registry.set(h.source, failed);
109 expect(h.registry.get(h.stateAtom)).toMatchObject({
110 connectionState: "error",
111 connectionError: "Connection failed",
112 hasReadyEnvironment: false,
113 });
114 expect(h.registry.get(h.environmentsAtom)[0]?.connectionErrorTraceId).toBe("trace-1");
115 h.registry.set(h.source, { ...failed, entry: { ...failed.entry, enabled: false } });
116 expect(h.registry.get(h.stateAtom)).toMatchObject({
117 hasConnections: true,
118 connectionState: "available",
119 connectionError: null,
120 });
121 h.registry.set(h.source, {
122 ...presentation(),
123 connection: { phase: "reconnecting", error: null, traceId: null },
124 });
125 expect(h.registry.get(h.stateAtom)).toMatchObject({
126 hasConnectingEnvironment: true,
127 connectionState: "reconnecting",
128 });
129 h.registry.set(h.source, presentation());
130 expect(h.registry.get(h.stateAtom)).toMatchObject({
131 hasReadyEnvironment: true,
132 hasConnectingEnvironment: false,
133 });
134 h.registry.set(h.network, "offline");
135 expect(h.registry.get(h.stateAtom)).toMatchObject({
136 connectionState: "offline",
137 hasReadyEnvironment: false,
138 });
139 h.registry.set(h.network, "online");
140 expect(h.registry.get(h.stateAtom).connectionState).toBe("connected");
141 } finally {
142 h.registry.dispose();
143 }
144 });
145
146 it("updates labels and endpoints, removes environments, and finishes catalog loading", () => {
147 const h = harness();
148 try {
149 h.registry.set(h.catalog, { isReady: false, entries: new Map() });
150 expect(h.registry.get(h.stateAtom)).toMatchObject({
151 isLoadingConnections: true,
152 hasConnections: false,
153 });
154 h.registry.set(h.source, {
155 ...presentation(),
156 entry: {
157 ...presentation().entry,
158 target: new PrimaryConnectionTarget({
159 environmentId: ID,
160 label: "Renamed",
161 httpBaseUrl: "https://new.example.test",
162 wsBaseUrl: "wss://new.example.test",
163 }),
164 },
165 });
166 h.registry.set(h.catalog, { isReady: true, entries: new Map([[ID, presentation().entry]]) });
167 expect(h.registry.get(h.environmentsAtom)[0]).toMatchObject({
168 environmentLabel: "Renamed",
169 displayUrl: "https://new.example.test",
170 });
171 expect(h.registry.get(h.stateAtom).isLoadingConnections).toBe(false);
172 h.registry.set(h.catalog, { isReady: true, entries: new Map() });
173 expect(h.registry.get(h.stateAtom)).toMatchObject({
174 hasConnections: false,
175 connectionState: "available",
176 hasReadyEnvironment: false,
177 });
178 expect(h.registry.get(h.environmentsAtom)).toEqual([]);
179 } finally {
180 h.registry.dispose();
181 }
182 });
183});