| 1 | // @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures. |
| 2 | import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test"; |
| 3 | import { it } from "@effect/vitest"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { ProjectId } from "@t3tools/contracts"; |
| 6 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 10 | import * as NodeFSP from "node:fs/promises"; |
| 11 | import * as NodeOS from "node:os"; |
| 12 | import * as NodePath from "node:path"; |
| 13 | |
| 14 | import * as ServerConfig from "../config.ts"; |
| 15 | import * as ProcessRunner from "../processRunner.ts"; |
| 16 | import * as ManagedWorkspaces from "./ManagedWorkspaces.ts"; |
| 17 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 18 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 19 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 20 | |
| 21 | let home: string; |
| 22 | let repository: string; |
| 23 | let platform: NodeJS.Platform; |
| 24 | let sourceRegistered: boolean; |
| 25 | const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>(); |
| 26 | const created = { |
| 27 | workspacePath: "/test/workspaces/1", |
| 28 | projectId: "new-project", |
| 29 | threadId: "new-thread", |
| 30 | environmentId: "current-host", |
| 31 | }; |
| 32 | const output = (stdout = JSON.stringify(created), code = 0) => ({ |
| 33 | stdout, |
| 34 | stderr: "", |
| 35 | code: ChildProcessSpawner.ExitCode(code), |
| 36 | timedOut: false, |
| 37 | stdoutTruncated: false, |
| 38 | stderrTruncated: false, |
| 39 | stdoutInvalidUtf8: false, |
| 40 | stderrInvalidUtf8: false, |
| 41 | }); |
| 42 | |
| 43 | beforeEach(async () => { |
| 44 | home = await NodeFSP.realpath( |
| 45 | await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-ws-")), |
| 46 | ); |
| 47 | repository = NodePath.join(home, "source"); |
| 48 | platform = "darwin"; |
| 49 | sourceRegistered = true; |
| 50 | await NodeFSP.mkdir(repository, { recursive: true }); |
| 51 | await NodeFSP.mkdir(NodePath.join(home, ".config")); |
| 52 | await NodeFSP.mkdir(NodePath.join(home, "bin")); |
| 53 | await NodeFSP.writeFile( |
| 54 | NodePath.join(home, ".config/workspaces.json"), |
| 55 | JSON.stringify({ repository }), |
| 56 | ); |
| 57 | await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); |
| 58 | launches.mockReset(); |
| 59 | launches.mockImplementation(() => Effect.succeed(output())); |
| 60 | }); |
| 61 | |
| 62 | afterEach(async () => { |
| 63 | await NodeFSP.rm(home, { recursive: true, force: true }); |
| 64 | }); |
| 65 | |
| 66 | const layer = () => |
| 67 | ManagedWorkspaces.layer.pipe( |
| 68 | Layer.provide( |
| 69 | Layer.mergeAll( |
| 70 | NodeServices.layer, |
| 71 | Layer.succeed(HostProcess.HomeDirectory, home), |
| 72 | Layer.succeed(HostProcess.Platform, platform), |
| 73 | Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }), |
| 74 | ServerConfig.layerTest(repository, NodePath.join(home, "isolated-t3")).pipe( |
| 75 | Layer.provide(NodeServices.layer), |
| 76 | ), |
| 77 | Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }), |
| 78 | Layer.mock(Orchestrator.OrchestratorV2)({}), |
| 79 | Layer.mock(ProjectStore.ProjectStoreV2)({ |
| 80 | listShells: () => |
| 81 | Effect.succeed( |
| 82 | sourceRegistered |
| 83 | ? [ |
| 84 | { |
| 85 | id: ProjectId.make("source-project"), |
| 86 | title: "my work", |
| 87 | workspaceRoot: repository, |
| 88 | defaultModelSelection: null, |
| 89 | scripts: [], |
| 90 | createdAt: "2026-10-13T12:00:00.000Z", |
| 91 | updatedAt: "2026-10-13T12:00:00.000Z", |
| 92 | }, |
| 93 | ] |
| 94 | : [], |
| 95 | ), |
| 96 | }), |
| 97 | Layer.mock(PullRequestService.PullRequestService)({}), |
| 98 | ), |
| 99 | ), |
| 100 | ); |
| 101 | |
| 102 | const use = <A, E>(effect: Effect.Effect<A, E, ManagedWorkspaces.ManagedWorkspaces>) => |
| 103 | effect.pipe(Effect.provide(layer())); |
| 104 | |
| 105 | describe("managed workspace creation", () => { |
| 106 | it.effect( |
| 107 | "reuses the installed workflow and targets this server without opening a host window", |
| 108 | () => |
| 109 | use( |
| 110 | Effect.gen(function* () { |
| 111 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; |
| 112 | expect(yield* service.status()).toEqual({ projectId: ProjectId.make("source-project") }); |
| 113 | expect(launches).not.toHaveBeenCalled(); |
| 114 | expect(yield* service.create()).toEqual(created); |
| 115 | expect(launches).toHaveBeenCalledOnce(); |
| 116 | expect(launches.mock.calls[0]?.[0]).toMatchObject({ |
| 117 | command: NodePath.join(home, "bin/ws"), |
| 118 | args: ["new", "--background", "--no-open", "--json"], |
| 119 | cwd: repository, |
| 120 | env: { T3CODE_HOME: NodePath.join(home, "isolated-t3") }, |
| 121 | }); |
| 122 | }), |
| 123 | ), |
| 124 | ); |
| 125 | |
| 126 | it.effect.each([ |
| 127 | "missing tool", |
| 128 | "missing configuration", |
| 129 | "missing repository", |
| 130 | "unregistered source", |
| 131 | "Windows", |
| 132 | ] as const)("does not launch when %s makes creation unavailable", (reason) => { |
| 133 | if (reason === "Windows") platform = "win32"; |
| 134 | if (reason === "unregistered source") sourceRegistered = false; |
| 135 | return use( |
| 136 | Effect.gen(function* () { |
| 137 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; |
| 138 | if (reason === "missing tool") |
| 139 | yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, "bin/ws"))); |
| 140 | if (reason === "missing configuration") |
| 141 | yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, ".config/workspaces.json"))); |
| 142 | if (reason === "missing repository") |
| 143 | yield* Effect.promise(() => NodeFSP.rm(repository, { recursive: true })); |
| 144 | expect(yield* service.status()).toEqual({ projectId: null }); |
| 145 | const error = yield* service.create().pipe(Effect.flip); |
| 146 | expect(error.reason).toBe("unavailable"); |
| 147 | expect(launches).not.toHaveBeenCalled(); |
| 148 | }), |
| 149 | ); |
| 150 | }); |
| 151 | |
| 152 | it.effect("rejects unreadable and incomplete CLI replies", () => |
| 153 | use( |
| 154 | Effect.gen(function* () { |
| 155 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; |
| 156 | for (const stdout of ["not JSON", JSON.stringify({ workspacePath: "/created" })]) { |
| 157 | launches.mockImplementation(() => Effect.succeed(output(stdout))); |
| 158 | expect((yield* service.create().pipe(Effect.flip)).reason).toBe("reply"); |
| 159 | } |
| 160 | }), |
| 161 | ), |
| 162 | ); |
| 163 | |
| 164 | it.effect("propagates a stopped creation instead of navigating to an incomplete workspace", () => |
| 165 | use( |
| 166 | Effect.gen(function* () { |
| 167 | launches.mockImplementation(() => Effect.succeed(output("", 1))); |
| 168 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; |
| 169 | expect((yield* service.create().pipe(Effect.flip)).reason).toBe("create"); |
| 170 | }), |
| 171 | ), |
| 172 | ); |
| 173 | }); |