| 1 | import { EnvironmentId, ProjectId, ThreadId, type DevServerStatus } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import { AsyncResult } from "effect/reactivity"; |
| 4 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | const 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 | })); |
| 23 | vi.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 | })); |
| 31 | vi.mock("~/state/devServer", () => ({ |
| 32 | devServerEnvironment: { |
| 33 | status: { id: "status", permissionAtom: () => "read" }, |
| 34 | start: { id: "start", permissionAtom: () => "start-permission" }, |
| 35 | }, |
| 36 | })); |
| 37 | vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => "config" } })); |
| 38 | vi.mock("~/state/preview", () => ({ previewEnvironment: { open: { id: "open" } } })); |
| 39 | vi.mock("~/state/use-atom-command", () => ({ |
| 40 | useAtomCommand: ({ id }: { id: "status" | "start" | "open" }) => state[id], |
| 41 | })); |
| 42 | vi.mock("~/state/environments", () => ({ |
| 43 | useEnvironment: () => ({ connection: { phase: state.phase } }), |
| 44 | })); |
| 45 | vi.mock("~/state/session", () => ({ |
| 46 | useEnvironmentScope: () => true, |
| 47 | readPreparedConnection: () => state.connection, |
| 48 | })); |
| 49 | vi.mock("~/env", () => ({ isElectron: true })); |
| 50 | vi.mock("~/browser/previewRuntime", () => ({ previewRuntimeFor: () => state.runtime })); |
| 51 | vi.mock("~/rightPanelStore", () => ({ |
| 52 | useRightPanelStore: { getState: () => ({ openBrowser: state.reveal }) }, |
| 53 | })); |
| 54 | vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); |
| 55 | vi.mock("../ui/toast", () => ({ |
| 56 | stackedThreadToast: (input: unknown) => input, |
| 57 | toastManager: { add: state.toast }, |
| 58 | })); |
| 59 | vi.mock("../ui/button", () => ({ |
| 60 | Button: ({ children, ...props }: React.ComponentProps<"button">) => ( |
| 61 | <button {...props}>{children}</button> |
| 62 | ), |
| 63 | })); |
| 64 | |
| 65 | import { ComposerDevServerStatus } from "./ComposerDevServerStatus"; |
| 66 | |
| 67 | const threadRef = { |
| 68 | environmentId: EnvironmentId.make("host"), |
| 69 | threadId: ThreadId.make("thread-a"), |
| 70 | }; |
| 71 | const target = { _tag: "Thread", threadId: threadRef.threadId } as const; |
| 72 | const 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 | }; |
| 80 | const deferred = <T,>() => { |
| 81 | let resolve!: (value: T) => void; |
| 82 | const promise = new Promise<T>((complete) => { |
| 83 | resolve = complete; |
| 84 | }); |
| 85 | return { promise, resolve }; |
| 86 | }; |
| 87 | let renderer: ReactTestRenderer | undefined; |
| 88 | const mount = async () => { |
| 89 | await act(async () => { |
| 90 | renderer = create(<ComposerDevServerStatus threadRef={threadRef} target={target} />); |
| 91 | }); |
| 92 | }; |
| 93 | const click = () => renderer!.root.findByType("button").props.onClick(); |
| 94 | |
| 95 | beforeEach(() => { |
| 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 | }); |
| 114 | afterEach(async () => { |
| 115 | await act(async () => renderer?.unmount()); |
| 116 | renderer = undefined; |
| 117 | vi.useRealTimers(); |
| 118 | vi.unstubAllGlobals(); |
| 119 | }); |
| 120 | |
| 121 | describe("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 | }); |