1import { EnvironmentId, ProjectId, ThreadId, type DevServerStatus } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/reactivity";
4import { act, create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 runtime: undefined as "server" | undefined,
9 connection: {
10 httpBaseUrl: "http://127.0.0.1:3773",
11 target: { _tag: "PrimaryConnectionTarget" },
12 },
13 phase: "connected" as "available" | "connecting" | "connected" | "reconnecting",
14 supported: true,
15 canRead: true,
16 canStart: true,
17 status: vi.fn(),
18 start: vi.fn(),
19 open: vi.fn(),
20 reveal: vi.fn(),
21 toast: vi.fn(),
22}));
23vi.mock("@effect/atom-react", () => ({
24 useAtomValue: (atom: string) =>
25 atom === "config"
26 ? { devServerStatus: state.supported }
27 : atom === "read"
28 ? state.canRead
29 : state.canStart,
30}));
31vi.mock("~/state/devServer", () => ({
32 devServerEnvironment: {
33 status: { id: "status", permissionAtom: () => "read" },
34 start: { id: "start", permissionAtom: () => "start-permission" },
35 },
36}));
37vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => "config" } }));
38vi.mock("~/state/preview", () => ({ previewEnvironment: { open: { id: "open" } } }));
39vi.mock("~/state/use-atom-command", () => ({
40 useAtomCommand: ({ id }: { id: "status" | "start" | "open" }) => state[id],
41}));
42vi.mock("~/state/environments", () => ({
43 useEnvironment: () => ({ connection: { phase: state.phase } }),
44}));
45vi.mock("~/state/session", () => ({
46 useEnvironmentScope: () => true,
47 readPreparedConnection: () => state.connection,
48}));
49vi.mock("~/env", () => ({ isElectron: true }));
50vi.mock("~/browser/previewRuntime", () => ({ previewRuntimeFor: () => state.runtime }));
51vi.mock("~/rightPanelStore", () => ({
52 useRightPanelStore: { getState: () => ({ openBrowser: state.reveal }) },
53}));
54vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open }));
55vi.mock("../ui/toast", () => ({
56 stackedThreadToast: (input: unknown) => input,
57 toastManager: { add: state.toast },
58}));
59vi.mock("../ui/button", () => ({
60 Button: ({ children, ...props }: React.ComponentProps<"button">) => (
61 <button {...props}>{children}</button>
62 ),
63}));
64
65import { ComposerDevServerStatus } from "./ComposerDevServerStatus";
66
67const threadRef = {
68 environmentId: EnvironmentId.make("host"),
69 threadId: ThreadId.make("thread-a"),
70};
71const target = { _tag: "Thread", threadId: threadRef.threadId } as const;
72const ready: DevServerStatus = {
73 workspaceRoot: "/workspace/a",
74 canStart: true,
75 environment: "local",
76 stopping: false,
77 services: [{ id: "client", status: "ready", url: "http://localhost:3001" }],
78 frontendUrl: "http://localhost:3001/",
79};
80const deferred = <T,>() => {
81 let resolve!: (value: T) => void;
82 const promise = new Promise<T>((complete) => {
83 resolve = complete;
84 });
85 return { promise, resolve };
86};
87let renderer: ReactTestRenderer | undefined;
88const mount = async () => {
89 await act(async () => {
90 renderer = create(<ComposerDevServerStatus threadRef={threadRef} target={target} />);
91 });
92};
93const click = () => renderer!.root.findByType("button").props.onClick();
94
95beforeEach(() => {
96 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
97 vi.stubGlobal("document", { visibilityState: "visible" });
98 vi.useFakeTimers();
99 state.runtime = undefined;
100 state.connection = {
101 httpBaseUrl: "http://127.0.0.1:3773",
102 target: { _tag: "PrimaryConnectionTarget" },
103 };
104 state.phase = "connected";
105 state.supported = true;
106 state.canRead = true;
107 state.canStart = true;
108 for (const mock of [state.status, state.start, state.open, state.reveal, state.toast])
109 mock.mockReset();
110 state.status.mockResolvedValue(AsyncResult.success(ready));
111 state.start.mockResolvedValue(AsyncResult.success(ready));
112 state.open.mockResolvedValue(AsyncResult.success({ tabId: "tab" }));
113});
114afterEach(async () => {
115 await act(async () => renderer?.unmount());
116 renderer = undefined;
117 vi.useRealTimers();
118 vi.unstubAllGlobals();
119});
120
121describe("composer dev server lifecycle", () => {
122 it("does not query old hosts or connections without destination read grants", async () => {
123 state.supported = false;
124 await mount();
125 expect(state.status).not.toHaveBeenCalled();
126 state.supported = true;
127 state.canRead = false;
128 await act(async () =>
129 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
130 );
131 expect(state.status).not.toHaveBeenCalled();
132 });
133
134 it("waits for connection readiness with cached capability and grants, then resumes on reconnect", async () => {
135 state.phase = "available";
136 await mount();
137 await act(async () => {
138 await vi.advanceTimersByTimeAsync(15000);
139 });
140 expect(state.status).not.toHaveBeenCalled();
141 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
142
143 state.phase = "connecting";
144 await act(async () =>
145 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
146 );
147 expect(state.status).not.toHaveBeenCalled();
148
149 state.phase = "connected";
150 await act(async () =>
151 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
152 );
153 expect(state.status).toHaveBeenCalledTimes(1);
154 expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Servers 1/1");
155
156 state.phase = "reconnecting";
157 await act(async () =>
158 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
159 );
160 expect(renderer!.root.findByType("button").props.disabled).toBe(true);
161 await act(async () => {
162 await vi.advanceTimersByTimeAsync(15000);
163 click();
164 });
165 expect(state.status).toHaveBeenCalledTimes(1);
166 expect(state.open).not.toHaveBeenCalled();
167
168 state.phase = "connected";
169 await act(async () =>
170 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
171 );
172 expect(state.status).toHaveBeenCalledTimes(2);
173 expect(renderer!.root.findByType("button").props.disabled).toBe(false);
174 await act(async () => {
175 await vi.advanceTimersByTimeAsync(5000);
176 });
177 expect(state.status).toHaveBeenCalledTimes(3);
178 });
179
180 it("starts the selected draft workspace once across repeated clicks", async () => {
181 const stopped = { ...ready, services: [], frontendUrl: null };
182 state.status.mockResolvedValue(AsyncResult.success(stopped));
183 const launch = deferred<ReturnType<typeof AsyncResult.success<DevServerStatus>>>();
184 state.start.mockReturnValue(launch.promise);
185 const draft = {
186 _tag: "Project",
187 projectId: ProjectId.make("project"),
188 workspacePath: "/workspace/selected",
189 } as const;
190 await act(async () => {
191 renderer = create(<ComposerDevServerStatus threadRef={threadRef} target={draft} />);
192 });
193 await act(async () => {
194 click();
195 click();
196 });
197 expect(state.start).toHaveBeenCalledTimes(1);
198 expect(state.start.mock.calls[0]?.[0]).toEqual({
199 environmentId: threadRef.environmentId,
200 input: draft,
201 });
202 expect(state.open).not.toHaveBeenCalled();
203 await act(async () => launch.resolve(AsyncResult.success(ready)));
204 expect(state.open).toHaveBeenCalledTimes(1);
205 expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab");
206 });
207
208 it("clears status immediately and discards an old launch when switching thread", async () => {
209 state.status.mockResolvedValue(AsyncResult.success({ ...ready, frontendUrl: null }));
210 const launch = deferred<ReturnType<typeof AsyncResult.success<DevServerStatus>>>();
211 state.start.mockReturnValue(launch.promise);
212 await mount();
213 await act(async () => click());
214 const nextRead = deferred<ReturnType<typeof AsyncResult.success<DevServerStatus>>>();
215 state.status.mockReturnValue(nextRead.promise);
216 const next = { ...threadRef, threadId: ThreadId.make("thread-b") };
217 await act(async () =>
218 renderer!.update(
219 <ComposerDevServerStatus
220 threadRef={next}
221 target={{ _tag: "Thread", threadId: next.threadId }}
222 />,
223 ),
224 );
225 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
226 await act(async () => launch.resolve(AsyncResult.success(ready)));
227 expect(state.open).not.toHaveBeenCalled();
228 expect(state.reveal).not.toHaveBeenCalled();
229 await act(async () =>
230 nextRead.resolve(AsyncResult.success({ ...ready, services: [], canStart: false })),
231 );
232 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
233 });
234
235 it("clears pending after a launch failure and allows a new attempt", async () => {
236 state.status.mockResolvedValue(AsyncResult.success({ ...ready, frontendUrl: null }));
237 state.start.mockResolvedValueOnce(
238 AsyncResult.failure(Cause.fail(new Error("Server did not start"))),
239 );
240 await mount();
241 await act(async () => click());
242 expect(state.toast).toHaveBeenCalledTimes(1);
243 expect(state.open).not.toHaveBeenCalled();
244 expect(renderer!.root.findByType("button").props.disabled).toBe(false);
245 await act(async () => click());
246 expect(state.start).toHaveBeenCalledTimes(2);
247 expect(state.open).toHaveBeenCalledTimes(1);
248 expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab");
249 });
250
251 it("maps a remote desktop frontend to the destination environment host", async () => {
252 state.connection = {
253 httpBaseUrl: "http://100.65.180.100:3773",
254 target: { _tag: "RemoteConnectionTarget" },
255 };
256 await mount();
257 await act(async () => click());
258 expect(state.open).toHaveBeenCalledWith(
259 expect.objectContaining({ threadRef, url: "http://100.65.180.100:3001/" }),
260 );
261 state.runtime = "server";
262 await act(async () => click());
263 expect(state.open).toHaveBeenLastCalledWith(
264 expect.objectContaining({ threadRef, url: ready.frontendUrl }),
265 );
266 });
267
268 it("resumes bounded polling after a successful explicit retry and pauses on another error", async () => {
269 state.status.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("unavailable"))));
270 await mount();
271 await act(async () => {
272 await vi.advanceTimersByTimeAsync(15000);
273 });
274 expect(state.status).toHaveBeenCalledTimes(1);
275 state.phase = "reconnecting";
276 await act(async () =>
277 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
278 );
279 state.phase = "connected";
280 await act(async () =>
281 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
282 );
283 await act(async () => {
284 await vi.advanceTimersByTimeAsync(15000);
285 });
286 expect(state.status).toHaveBeenCalledTimes(1);
287 state.status.mockResolvedValue(AsyncResult.success(ready));
288 await act(async () => click());
289 expect(state.open).toHaveBeenCalledTimes(1);
290 expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab");
291 state.status.mockResolvedValue(
292 AsyncResult.success({ ...ready, services: [], frontendUrl: null }),
293 );
294 const reads = state.status.mock.calls.length;
295 await act(async () => {
296 await vi.advanceTimersByTimeAsync(5000);
297 });
298 expect(state.status).toHaveBeenCalledTimes(reads + 1);
299 expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Start frontend");
300 state.status.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("unavailable"))));
301 await act(async () => {
302 await vi.advanceTimersByTimeAsync(15000);
303 });
304 expect(state.status).toHaveBeenCalledTimes(reads + 2);
305 expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Servers unavailable");
306 });
307});