| 1 | import { PlatformServices } from "@/Util/PlatformServices.ts"; |
| 2 | import { describe, expect, it } from "alchemy-test"; |
| 3 | import { nodePath, nodeSupportsDevMode } from "../nodeProbe.ts"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | |
| 10 | const CLI = fileURLToPath(new URL("../../bin/cli.js", import.meta.url)); |
| 11 | |
| 12 | /** |
| 13 | * Run the CLI with no TTY on any stdio and return its exit code. Each |
| 14 | * invocation is a real `bun bin/cli.js` child with `ALCHEMY_HOME` pointed at |
| 15 | * a throwaway directory, so these pin the exit-code contract scripts and |
| 16 | * agents rely on — 0 only when the command completed — without touching the |
| 17 | * real `~/.alchemy`. |
| 18 | */ |
| 19 | const exitCodeOf = ( |
| 20 | args: ReadonlyArray<string>, |
| 21 | env: Record<string, string> = {}, |
| 22 | runtime = "bun", |
| 23 | ) => |
| 24 | Effect.gen(function* () { |
| 25 | const fs = yield* FileSystem.FileSystem; |
| 26 | const home = yield* fs.makeTempDirectoryScoped({ |
| 27 | prefix: "alchemy-exit-codes-", |
| 28 | }); |
| 29 | const handle = yield* ChildProcess.make(runtime, [CLI, ...args], { |
| 30 | env: { ALCHEMY_HOME: home, ...env }, |
| 31 | extendEnv: true, |
| 32 | stdin: "ignore", |
| 33 | stdout: "ignore", |
| 34 | stderr: "ignore", |
| 35 | killSignal: "SIGTERM", |
| 36 | forceKillAfter: "1 second", |
| 37 | }); |
| 38 | return yield* handle.exitCode; |
| 39 | }).pipe(Effect.scoped, Effect.provide(PlatformServices)); |
| 40 | |
| 41 | /** Like {@link exitCodeOf}, but from an empty project directory with stderr captured. */ |
| 42 | const runInEmptyProject = (args: ReadonlyArray<string>, runtime = "bun") => |
| 43 | Effect.gen(function* () { |
| 44 | const fs = yield* FileSystem.FileSystem; |
| 45 | const home = yield* fs.makeTempDirectoryScoped({ |
| 46 | prefix: "alchemy-exit-codes-", |
| 47 | }); |
| 48 | const project = yield* fs.makeTempDirectoryScoped({ |
| 49 | prefix: "alchemy-empty-project-", |
| 50 | }); |
| 51 | const handle = yield* ChildProcess.make(runtime, [CLI, ...args], { |
| 52 | cwd: project, |
| 53 | env: { ALCHEMY_HOME: home }, |
| 54 | extendEnv: true, |
| 55 | stdin: "ignore", |
| 56 | stdout: "ignore", |
| 57 | stderr: "pipe", |
| 58 | killSignal: "SIGTERM", |
| 59 | forceKillAfter: "1 second", |
| 60 | }); |
| 61 | const [stderr, exitCode] = yield* Effect.all( |
| 62 | [handle.stderr.pipe(Stream.decodeText, Stream.mkString), handle.exitCode], |
| 63 | { concurrency: 2 }, |
| 64 | ); |
| 65 | return { stderr, exitCode }; |
| 66 | }).pipe(Effect.scoped, Effect.provide(PlatformServices)); |
| 67 | |
| 68 | describe("CLI exit codes", { tags: ["unit", "local"] }, () => { |
| 69 | it.live("dev without a stack entrypoint reports it and exits 1", () => |
| 70 | Effect.gen(function* () { |
| 71 | const { stderr, exitCode } = yield* runInEmptyProject(["dev"]); |
| 72 | expect(exitCode).toBe(1); |
| 73 | expect(stderr).toContain( |
| 74 | "Stack entrypoint 'alchemy.run.ts' does not exist", |
| 75 | ); |
| 76 | expect(stderr).not.toContain("PlatformError"); |
| 77 | expect(stderr).not.toContain("at Effect.fn"); |
| 78 | }), |
| 79 | ); |
| 80 | |
| 81 | it.live.skipIf(!nodeSupportsDevMode)( |
| 82 | "dev without a stack entrypoint reports it and exits 1 under node", |
| 83 | () => |
| 84 | Effect.gen(function* () { |
| 85 | const { stderr, exitCode } = yield* runInEmptyProject( |
| 86 | ["dev"], |
| 87 | nodePath!, |
| 88 | ); |
| 89 | expect(exitCode).toBe(1); |
| 90 | expect(stderr).toContain( |
| 91 | "Stack entrypoint 'alchemy.run.ts' does not exist", |
| 92 | ); |
| 93 | expect(stderr).not.toContain("PlatformError"); |
| 94 | }), |
| 95 | ); |
| 96 | |
| 97 | it.live("plan accepts --adopt like deploy --dry-run", () => |
| 98 | Effect.gen(function* () { |
| 99 | const { stderr, exitCode } = yield* runInEmptyProject([ |
| 100 | "plan", |
| 101 | "--adopt", |
| 102 | ]); |
| 103 | expect(exitCode).toBe(1); |
| 104 | expect(stderr).not.toContain("Unrecognized flag"); |
| 105 | expect(stderr).toContain( |
| 106 | "Stack entrypoint 'alchemy.run.ts' does not exist", |
| 107 | ); |
| 108 | }), |
| 109 | ); |
| 110 | |
| 111 | it.live("bare `profile` without a terminal prints help and exits 1", () => |
| 112 | Effect.gen(function* () { |
| 113 | expect(yield* exitCodeOf(["profile"])).toBe(1); |
| 114 | }), |
| 115 | ); |
| 116 | |
| 117 | it.live("bare `state` without a terminal prints help and exits 1", () => |
| 118 | Effect.gen(function* () { |
| 119 | expect(yield* exitCodeOf(["state"])).toBe(1); |
| 120 | }), |
| 121 | ); |
| 122 | |
| 123 | it.live("--no-input is accepted and forces a plain, working run", () => |
| 124 | Effect.gen(function* () { |
| 125 | expect(yield* exitCodeOf(["--no-input", "profile", "list"])).toBe(0); |
| 126 | }), |
| 127 | ); |
| 128 | |
| 129 | it.live("--help exits 0", () => |
| 130 | Effect.gen(function* () { |
| 131 | expect(yield* exitCodeOf(["--help"])).toBe(0); |
| 132 | }), |
| 133 | ); |
| 134 | |
| 135 | // Pins buildless node dev: the launcher must run the checkout's .ts/.tsx |
| 136 | // source under plain node (type stripping + the register-dev-mode hooks) |
| 137 | // — --help renders the TSX help view through the full terminal runtime. |
| 138 | // Stderr is captured: this is the one test in the file whose failure |
| 139 | // mode is environmental, so "expected 9 to be 0" alone is useless. |
| 140 | it.live.skipIf(!nodeSupportsDevMode)( |
| 141 | "--help exits 0 under node from source, no build required", |
| 142 | () => |
| 143 | Effect.gen(function* () { |
| 144 | const fs = yield* FileSystem.FileSystem; |
| 145 | const home = yield* fs.makeTempDirectoryScoped({ |
| 146 | prefix: "alchemy-exit-codes-", |
| 147 | }); |
| 148 | const handle = yield* ChildProcess.make(nodePath!, [CLI, "--help"], { |
| 149 | // The test runner is bun and marks every child's env as |
| 150 | // bun-invoked; blank the markers so the launcher takes its |
| 151 | // node path like a real `node bin/cli.js` invocation. |
| 152 | env: { |
| 153 | ALCHEMY_HOME: home, |
| 154 | npm_execpath: "", |
| 155 | npm_config_user_agent: "", |
| 156 | }, |
| 157 | extendEnv: true, |
| 158 | stdin: "ignore", |
| 159 | stdout: "ignore", |
| 160 | stderr: "pipe", |
| 161 | killSignal: "SIGTERM", |
| 162 | forceKillAfter: "1 second", |
| 163 | }); |
| 164 | const [stderr, exitCode] = yield* Effect.all( |
| 165 | [ |
| 166 | handle.stderr.pipe( |
| 167 | Stream.decodeText, |
| 168 | Stream.runCollect, |
| 169 | Effect.map((chunks) => chunks.join("")), |
| 170 | ), |
| 171 | handle.exitCode, |
| 172 | ], |
| 173 | { concurrency: 2 }, |
| 174 | ); |
| 175 | expect({ |
| 176 | exitCode, |
| 177 | stderr: exitCode === 0 ? "" : stderr, |
| 178 | }).toEqual({ exitCode: 0, stderr: "" }); |
| 179 | }).pipe(Effect.scoped, Effect.provide(PlatformServices)), |
| 180 | ); |
| 181 | |
| 182 | it.live("provider check-env exits 1 when a required var is missing", () => |
| 183 | Effect.gen(function* () { |
| 184 | expect( |
| 185 | yield* exitCodeOf(["provider", "check-env", "--provider", "neon"], { |
| 186 | NEON_API_KEY: "", |
| 187 | }), |
| 188 | ).toBe(1); |
| 189 | }), |
| 190 | ); |
| 191 | |
| 192 | it.live("provider check-env exits 0 when the contract is satisfied", () => |
| 193 | Effect.gen(function* () { |
| 194 | expect( |
| 195 | yield* exitCodeOf(["provider", "check-env", "--provider", "neon"], { |
| 196 | NEON_API_KEY: "napi_test_key", |
| 197 | }), |
| 198 | ).toBe(0); |
| 199 | }), |
| 200 | ); |
| 201 | |
| 202 | it.live("provider check-env accepts an explicit profile", () => |
| 203 | Effect.gen(function* () { |
| 204 | expect( |
| 205 | yield* exitCodeOf( |
| 206 | [ |
| 207 | "provider", |
| 208 | "check-env", |
| 209 | "--profile", |
| 210 | "default", |
| 211 | "--provider", |
| 212 | "neon", |
| 213 | ], |
| 214 | { NEON_API_KEY: "napi_test_key" }, |
| 215 | ), |
| 216 | ).toBe(0); |
| 217 | }), |
| 218 | ); |
| 219 | }); |