diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 9712e56950..81a7e16ba2 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -9,6 +9,7 @@ "!packages/api/**", "!packages/config/**", "!packages/stack/**", + "!apps/cli-e2e/**", "!apps/cli/**", "!apps/docs/**", "!packages/cli-test-helpers/**", diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index 7cd57a5f91..0b75112979 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -61,20 +61,30 @@ the running tests exercise. The implications: ### `testBehaviour` — the default for all success and error tests ```typescript -import { testBehaviour } from "./test-context.ts"; - -testBehaviour("does the thing", async ({ run, projectRef, orgId, apiUrl, workspace }) => { - const result = await run(["command", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Expected output"); -}); +import { Effect } from "effect"; +import { runE2e, testBehaviour } from "./test-context.ts"; + +testBehaviour("does the thing", ({ run, projectRef, orgId, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["command", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Expected output"); + }), + signal, + ), +); ``` The `workspace` fixture is what triggers scenario setup — if you destructure `workspace` or `run` (which depends on `workspace`), the scenario is automatically started before the test and cleaned up after. +Always return `runE2e(...)` from the body: Vitest does not run a returned Effect, so the harness fails a test that requests `run` or `runNoProjectId` and passes without executing it. A custom runner added with `testBehaviour.extend` gets the same check by wrapping its CLI Effect in `runGuard.track(...)`, as `stackRun` does. Pass the test's `signal` so a Vitest timeout interrupts the body. + +This workspace is under the Effect lint (`.oxlintrc.effect.json`, warnings are errors), which rejects `async` test bodies, global `fetch`, `process.env` and `JSON.parse` in Effect code. Decode JSON stdout with `decodeJsonOutput(schema)(result.stdout)`, which reports a stdout excerpt on failure. + Available fixtures: -- `run(cmd)` — executes the CLI via the relay server +- `run(cmd)` — returns an Effect that executes the CLI via the relay server - `projectRef` — 20-char project ref (real in record mode, default in replay) - `orgId` — org slug (real in record mode, default in replay) - `apiUrl` — the relay server base URL, for direct `/_ctrl/` calls @@ -85,15 +95,23 @@ Available fixtures: Inject errors via `/_ctrl/error-all` before the `run` call: ```typescript -testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["command", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); -}); +import { Effect } from "effect"; +import { PROJECT_REF } from "./env.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; + +testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["command", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), +); ``` Error injection tests can use `PROJECT_REF` directly (not `projectRef`) because no real API call reaches staging — the error is injected before the relay server checks the fixture. Their scenario fixture is always `[]` (empty). @@ -103,15 +121,21 @@ Error injection tests can use `PROJECT_REF` directly (not `projectRef`) because If a test depends on timing (e.g. deleting a project that was just created won't work because it's not yet provisioned), use `skipIf(isRecording)` and write a static fixture instead: ```typescript +import { Effect } from "effect"; import { isRecording } from "./env.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; testBehaviour.skipIf(isRecording)( "deletes project with --yes flag", - async ({ run, projectRef }) => { - const result = await run(["projects", "delete", projectRef, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted project"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "delete", projectRef, "--yes"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted project"); + }), + signal, + ), ); ``` diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index d5caeef214..616559a701 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -12,7 +12,10 @@ "types:check": "tsc --noEmit" }, "dependencies": { - "@supabase/cli-test-helpers": "workspace:*" + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "@supabase/cli-test-helpers": "workspace:*", + "effect": "catalog:" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/apps/cli-e2e/scripts/replay-serve.ts b/apps/cli-e2e/scripts/replay-serve.ts index f1ea4a6e88..441236a9e2 100644 --- a/apps/cli-e2e/scripts/replay-serve.ts +++ b/apps/cli-e2e/scripts/replay-serve.ts @@ -1,36 +1,55 @@ +import { Config, Console, Data, Effect, Fiber, Schema } from "effect"; import { startReplayServer } from "../src/server/replay-server.ts"; const FIXTURES_DIR = new URL("../fixtures", import.meta.url).pathname; -function parsePort(argv: readonly string[]): number { +class ReplayServeError extends Data.TaggedError("ReplayServeError")<{ readonly message: string }> {} + +const parsePort = (argv: readonly string[]) => { const index = argv.indexOf("--port"); - if (index === -1) return 0; + if (index === -1) return Effect.succeed(0); const port = Number(argv[index + 1]); if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new Error(`--port expects an integer between 0 and 65535, got "${argv[index + 1]}"`); + return Effect.fail( + new ReplayServeError({ + message: `--port expects an integer between 0 and 65535, got "${argv[index + 1]}"`, + }), + ); } - return port; -} - -// Recording mode wipes the committed fixtures, so a standalone server never runs in it. -if (process.env["RECORD"] === "true") { - throw new Error("replay-serve does not support RECORD; unset it to serve the fixtures"); -} - -const server = await startReplayServer({ - fixturesDir: FIXTURES_DIR, - hostname: "127.0.0.1", - port: parsePort(process.argv.slice(2)), + return Effect.succeed(port); +}; + +const stopSignal = Effect.callback((resume) => { + const stop = () => resume(Effect.void); + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + return Effect.sync(() => { + process.off("SIGTERM", stop); + process.off("SIGINT", stop); + }); }); -let stopping = false; -const shutdown = async () => { - if (stopping) return; - stopping = true; - await server.stop(); - process.exit(0); -}; -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); +const main = Effect.gen(function* () { + // Recording mode wipes the committed fixtures, so a standalone server never runs in it. + if ((yield* Config.String("RECORD").pipe(Config.withDefault(""))) === "true") { + return yield* new ReplayServeError({ + message: "replay-serve does not support RECORD; unset it to serve the fixtures", + }); + } + + const server = yield* startReplayServer({ + fixturesDir: FIXTURES_DIR, + hostname: "127.0.0.1", + port: yield* parsePort(process.argv.slice(2)), + record: false, + stagingUrl: undefined, + }); + + const stopped = yield* stopSignal.pipe(Effect.forkChild({ startImmediately: true })); + yield* Console.log( + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ url: server.url }), + ); + yield* Fiber.join(stopped); +}); -console.log(JSON.stringify({ url: server.url })); +await Effect.runPromise(Effect.scoped(main)); diff --git a/apps/cli-e2e/src/server/fixture-loader.ts b/apps/cli-e2e/src/server/fixture-loader.ts index 50da310d26..7fe1a82eb7 100644 --- a/apps/cli-e2e/src/server/fixture-loader.ts +++ b/apps/cli-e2e/src/server/fixture-loader.ts @@ -1,59 +1,86 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -export interface FixtureRequest { - method: string; - path: string; - query: Record; - headers: Record; - body: unknown; -} +import { Data, Effect, FileSystem, Formatter, Path, Schema } from "effect"; -export interface FixtureResponse { - status: number; - headers: Record; - body: unknown; -} +/* Hand-authored fixtures may omit headers (served as none) and the response body (served empty). */ +const FixtureHeaders = Schema.Record(Schema.String, Schema.String).pipe( + Schema.withDecodingDefaultKey(Effect.sync(() => ({}))), +); -export interface FixtureEntry { - request: FixtureRequest; - response: FixtureResponse; -} +const FixtureRequest = Schema.Struct({ + method: Schema.String, + path: Schema.mutableKey(Schema.String), + query: Schema.Record(Schema.String, Schema.String), + headers: FixtureHeaders, + body: Schema.Unknown, +}); + +const FixtureResponse = Schema.Struct({ + status: Schema.Finite, + headers: FixtureHeaders, + body: Schema.optionalKey(Schema.Unknown), +}); + +const FixtureEntry = Schema.Struct({ request: FixtureRequest, response: FixtureResponse }); + +export type FixtureRequest = typeof FixtureRequest.Type; +export type FixtureResponse = typeof FixtureResponse.Type; +export type FixtureEntry = typeof FixtureEntry.Type; /** All fixtures indexed by their key (e.g. "GET_v1_projects"), each holding * an ordered queue of entries (for sequential calls to the same endpoint). */ export type FixtureStore = Map; +/* Mirrors `existsSync`: any failure to reach the path counts as absent. */ +export const pathExists = (fs: FileSystem.FileSystem, path: string) => + fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + +/** A fixture file is missing or does not hold valid JSON. */ +class FixtureFileError extends Data.Error<{ readonly message: string; readonly cause?: unknown }> {} + /** Load an ordered scenario from scenarios//interactions.json. * Returns null if the scenario file does not exist — caller decides whether * to fail loudly or fall back to per-endpoint fixtures. */ -export function loadScenario(scenariosDir: string, name: string): FixtureEntry[] | null { - const scenarioFile = join(scenariosDir, name, "interactions.json"); - if (!existsSync(scenarioFile)) return null; - return parseFixtureFile(scenarioFile); -} +export const loadScenario = Effect.fn("FixtureLoader.loadScenario")(function* ( + scenariosDir: string, + name: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scenarioFile = path.join(scenariosDir, name, "interactions.json"); + if (!(yield* pathExists(fs, scenarioFile))) return null; + const entries: FixtureEntry[] = yield* parseFixtureFile( + scenarioFile, + Schema.mutable(Schema.Array(FixtureEntry)), + ); + return entries; +}); /** Load all fixture pairs from the recorded/ directory into a FixtureStore. * Fails fast with a descriptive error if any fixture file is malformed. */ -export function loadFixtures(fixturesDir: string): FixtureStore { - const recordedDir = join(fixturesDir, "recorded"); +export const loadFixtures = Effect.fn("FixtureLoader.loadFixtures")(function* ( + fixturesDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const recordedDir = path.join(fixturesDir, "recorded"); const store: FixtureStore = new Map(); - if (!existsSync(recordedDir)) { + if (!(yield* pathExists(fs, recordedDir))) { return store; } - const keys = readdirSync(recordedDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); + const keys: string[] = []; + for (const name of yield* fs.readDirectory(recordedDir)) { + const info = yield* fs.stat(path.join(recordedDir, name)); + if (info.type === "Directory") keys.push(name); + } for (const key of keys) { - const keyDir = join(recordedDir, key); + const keyDir = path.join(recordedDir, key); const entries: FixtureEntry[] = []; // Collect numbered pairs: 1.request.json/1.response.json, 2.request.json, ... // Also accept "default" as an alias for "1". - const files = readdirSync(keyDir).sort(); + const files = (yield* fs.readDirectory(keyDir)).sort(); const indices = new Set(); for (const file of files) { @@ -62,11 +89,11 @@ export function loadFixtures(fixturesDir: string): FixtureStore { } for (const index of [...indices].sort(compareIndices)) { - const reqFile = join(keyDir, `${index}.request.json`); - const resFile = join(keyDir, `${index}.response.json`); + const reqFile = path.join(keyDir, `${index}.request.json`); + const resFile = path.join(keyDir, `${index}.response.json`); - const request = parseFixtureFile(reqFile); - const response = parseFixtureFile(resFile); + const request = yield* parseFixtureFile(reqFile, FixtureRequest); + const response = yield* parseFixtureFile(resFile, FixtureResponse); entries.push({ request, response }); } @@ -75,19 +102,31 @@ export function loadFixtures(fixturesDir: string): FixtureStore { } } + yield* Effect.annotateCurrentSpan("fixture.key_count", store.size); return store; -} - -function parseFixtureFile(filePath: string): T { - if (!existsSync(filePath)) { - throw new Error(`Missing fixture file: ${filePath}`); - } - try { - return JSON.parse(readFileSync(filePath, "utf8")) as T; - } catch (cause) { - throw new Error(`Malformed fixture file: ${filePath}`, { cause }); +}); + +const parseFixtureFile = Effect.fnUntraced(function* ( + filePath: string, + schema: S, +) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* pathExists(fs, filePath))) { + return yield* new FixtureFileError({ message: `Missing fixture file: ${filePath}` }); } -} + const malformed = (cause: unknown) => + new FixtureFileError({ message: `Malformed fixture file: ${filePath}`, cause }); + const bytes = yield* fs.readFile(filePath).pipe(Effect.mapError(malformed)); + const text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); + return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(text).pipe( + Effect.mapError( + (error) => + new FixtureFileError({ + message: `Malformed fixture file: ${filePath}\n ${error.message}: ${Formatter.format(text.slice(0, 200))}`, + }), + ), + ); +}); /** Sort indices so "default" comes first, then numerically. */ function compareIndices(a: string, b: string): number { diff --git a/apps/cli-e2e/src/server/pg-mock.ts b/apps/cli-e2e/src/server/pg-mock.ts index f187f3cfed..931fb8d8c4 100644 --- a/apps/cli-e2e/src/server/pg-mock.ts +++ b/apps/cli-e2e/src/server/pg-mock.ts @@ -22,6 +22,8 @@ * - Terminate ('X') → closes connection gracefully */ +import { Console, Effect } from "effect"; + export interface PgFixture { /** Lowercase column names, matching the field names the inspect commands map query results onto. */ columns: string[]; @@ -440,13 +442,13 @@ export function startPgMock(): PgMockHandle { try { processMessages(socket, () => state); } catch (err) { - console.error("[pg-mock] error processing message:", err); + Effect.runSync(Console.error("[pg-mock] error processing message:", err)); socket.end(); } }, close(_socket) {}, error(_socket, err) { - console.error("[pg-mock] socket error:", err); + Effect.runSync(Console.error("[pg-mock] socket error:", err)); }, }, }); diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 4d85198cac..b7f9e90813 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -1,14 +1,43 @@ -import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { request as httpRequest } from "node:http"; -import { join } from "node:path"; import { URL } from "node:url"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { NodeHttpClient } from "@effect/platform-node"; +import { + Clock, + Console, + Context, + Data, + DateTime, + Deferred, + Duration, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + LayerMap, + Option, + Path, + Schedule, + Schema, + Scope, + Semaphore, + Stream, +} from "effect"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpMethod, +} from "effect/http"; import type { FixtureEntry, FixtureRequest, FixtureResponse, FixtureStore, } from "./fixture-loader.ts"; -import { loadFixtures, loadScenario } from "./fixture-loader.ts"; +import { loadFixtures, loadScenario, pathExists } from "./fixture-loader.ts"; import { applyPlaceholders, fixtureKey, normalizeUrlPath } from "./placeholder.ts"; import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts"; import type { PgFixture, PgMockHandle } from "./pg-mock.ts"; @@ -63,6 +92,44 @@ interface RawBody { base64: string; } +/** The replay server could not start: record mode without a staging URL, or no listener. */ +class ReplayServerStartError extends Data.TaggedError("ReplayServerStartError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** An incoming request or control payload body could not be read. */ +class RequestBodyError extends Data.TaggedError("RequestBodyError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** The record-mode proxy to staging failed before a response arrived. */ +class StagingProxyError extends Data.TaggedError("StagingProxyError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** A recorded interaction could not be serialized into its fixture files. */ +class FixtureWriteError extends Data.TaggedError("FixtureWriteError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** The record-mode Docker socket exchange failed; messages are the `node:http` errors. */ +class DockerSocketError extends Data.TaggedError("DockerSocketError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const causeMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); + +/* `JSON.stringify`/`JSON.parse` through Schema; fixture files keep their 2-space indentation. */ +const JsonText = Schema.fromJsonString(Schema.Unknown); +const encodeJson = Schema.encodeEffect(JsonText); +const decodeJson = Schema.decodeEffect(JsonText); +const encodeFixtureJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + function isMultipartBody(body: unknown): body is MultipartBody { return ( typeof body === "object" && @@ -125,7 +192,6 @@ function buildMultipartResponse( interface ReplayServerHandle { readonly url: string; readonly port: number; - stop(): Promise; /** Return all requests received since the last clearRequests() call. */ getRequests(): RecordedRequest[]; /** Clear the recorded request log and reset fixture sequence counters. */ @@ -157,25 +223,60 @@ interface ReplayServerOptions { hostname?: string; /** Optional Postgres mock server to control via /_ctrl/pg-* endpoints. */ pgMock?: PgMockHandle; + /** Proxy to staging and record fixtures instead of replaying them. */ + record: boolean; + /** Staging Management API base URL; required when recording. */ + stagingUrl: string | undefined; } -export async function startReplayServer(options: ReplayServerOptions): Promise { - const isRecord = process.env["RECORD"] === "true"; - const stagingUrl = process.env["SUPABASE_STAGING_URL"]; +/* The Docker leg uses node:http because Bun's fetch sorts response headers, while fixtures record them in arrival order. */ +class DockerClients extends LayerMap.Service()("cli-e2e/DockerClients", { + lookup: (socketPath: string) => { + /* AgentOptions does not declare socketPath, but the pinned Bun's node:http reads it from + the agent options; Bun 1.3 ignores it and connects to localhost:80. */ + const agentOptions = { keepAlive: true, socketPath }; + return Layer.fresh(NodeHttpClient.layerNodeHttpNoAgent).pipe( + Layer.provide(NodeHttpClient.layerAgentOptions(agentOptions)), + ); + }, + idleTimeToLive: Duration.infinity, +}) {} + +const serverLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + DockerClients.layer, +); + +/** Runs the replay server until the caller's scope closes, which stops it and releases its layer. */ +export const startReplayServer = (options: ReplayServerOptions) => + Layer.build(serverLayer).pipe( + Effect.flatMap((context) => makeReplayServer(options).pipe(Effect.provideContext(context))), + ); + +const makeReplayServer = Effect.fn("ReplayServer.start")(function* (options: ReplayServerOptions) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const isRecord = options.record; + const stagingUrl = options.stagingUrl; if (isRecord && !stagingUrl) { - throw new Error("RECORD=true requires SUPABASE_STAGING_URL to be set"); + return yield* new ReplayServerStartError({ + message: "RECORD=true requires SUPABASE_STAGING_URL to be set", + }); } // In record mode, wipe both fixture stores before serving any traffic, so the // recording session repopulates only what the running tests exercise and any // orphan from a prior session is dropped. if (isRecord) { - rmSync(join(options.fixturesDir, "recorded"), { recursive: true, force: true }); - rmSync(join(options.fixturesDir, "scenarios"), { recursive: true, force: true }); + yield* fs.remove(path.join(options.fixturesDir, "recorded"), { recursive: true, force: true }); + yield* fs.remove(path.join(options.fixturesDir, "scenarios"), { recursive: true, force: true }); } - const store: FixtureStore = isRecord ? new Map() : loadFixtures(options.fixturesDir); + const store: FixtureStore = isRecord ? new Map() : yield* loadFixtures(options.fixturesDir); const counters: SequenceCounters = new Map(); const requestLog: RecordedRequest[] = []; @@ -188,125 +289,147 @@ export async function startReplayServer(options: ReplayServerOptions): Promise(), + Scope.Scope, + serverScope, + ); function overrideKey(method: string, path: string): string { return `${method.toUpperCase()} ${path}`; } - const server = Bun.serve({ - port: options.port ?? 0, - ...(options.hostname === undefined ? {} : { hostname: options.hostname }), - async fetch(req: Request) { - const url = new URL(req.url); - - // Control plane — not forwarded to CLI or staging - if (url.pathname.startsWith("/_ctrl/")) { - return handleControl(req, url, { - requestLog, - counters, - errorOverrides, - rateLimitOverrides, - scenario, - globalErrorRef, - isRecord, - fixturesDir: options.fixturesDir, - pgMock: options.pgMock, - }); - } + const handleRequest = Effect.fnUntraced(function* (req: Request) { + const url = new URL(req.url); + + // Control plane — not forwarded to CLI or staging + if (url.pathname.startsWith("/_ctrl/")) { + return yield* handleControl(req, url, { + requestLog, + counters, + errorOverrides, + rateLimitOverrides, + scenario, + globalErrorRef, + isRecord, + fixturesDir: options.fixturesDir, + pgMock: options.pgMock, + recordLock, + }); + } - const method = req.method; - const pathname = url.pathname; - const query = Object.fromEntries(url.searchParams.entries()); - const requestHeaders = Object.fromEntries(req.headers.entries()); - - let requestBody: unknown = null; - let rawBody: ReadableStream | null = null; - const contentType = req.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - try { - requestBody = await req.json(); - } catch { - // not JSON — leave as null - } - } else { - rawBody = req.body; - } + const method = req.method; + const pathname = url.pathname; + const query = Object.fromEntries(url.searchParams.entries()); + const requestHeaders = Object.fromEntries(req.headers.entries()); + + let requestBody: unknown = null; + let rawBody: ReadableStream | null = null; + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + // not JSON — leave as null + requestBody = yield* readJson(req).pipe(Effect.orElseSucceed(() => null)); + } else { + rawBody = req.body; + } - requestLog.push({ - method, - pathname, - query, - headers: requestHeaders, - body: requestBody, - timestamp: new Date().toISOString(), - }); + requestLog.push({ + method, + pathname, + query, + headers: requestHeaders, + body: requestBody, + timestamp: DateTime.formatIso(yield* DateTime.now), + }); - // Global error override — returned for all API requests regardless of endpoint. - if (globalErrorRef.value) { - return injectedErrorResponse(globalErrorRef.value); - } + // Global error override — returned for all API requests regardless of endpoint. + if (globalErrorRef.value) { + return injectedErrorResponse(globalErrorRef.value); + } - // Per-endpoint error overrides - const errKey = overrideKey(method, pathname); - const errorOverride = errorOverrides.get(errKey); - if (errorOverride) { - return injectedErrorResponse(errorOverride); - } + // Per-endpoint error overrides + const errKey = overrideKey(method, pathname); + const errorOverride = errorOverrides.get(errKey); + if (errorOverride) { + return injectedErrorResponse(errorOverride); + } - const rateLimitOverride = rateLimitOverrides.get(pathname); - if (rateLimitOverride) { - return new Response(JSON.stringify({ message: "Too Many Requests" }), { + const rateLimitOverride = rateLimitOverrides.get(pathname); + if (rateLimitOverride) { + return Response.json( + { message: "Too Many Requests" }, + { status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(rateLimitOverride.retryAfterSeconds), }, - }); - } + }, + ); + } - if (isRecord) { - return proxyAndRecord( - method, - pathname, - query, - requestHeaders, - requestBody, - rawBody, - stagingUrl!, - options.fixturesDir, - recordedKeys, - scenario, - storageProxyUrl, - storageProxyAuth, - dockerProxySocketPath, - ); - } + if (isRecord) { + return yield* proxyAndRecord( + method, + pathname, + query, + requestHeaders, + requestBody, + rawBody, + stagingUrl!, + options.fixturesDir, + recordedKeys, + scenario, + recordLock, + storageProxyUrl, + storageProxyAuth, + dockerProxySocketPath, + ); + } - // Replay mode: scenario takes priority for matching requests; out-of-band - // requests (e.g. post-command telemetry calls) fall through to the - // per-endpoint fixture store. - if (scenario.name !== null) { - const expected = scenario.queue[scenario.index]; - if ( - expected !== undefined && - expected.request.method.toUpperCase() === method.toUpperCase() && - expected.request.path === normalizeUrlPath(pathname) - ) { - return serveFromScenario(scenario, method, pathname, { query, body: requestBody }); - } + // Replay mode: scenario takes priority for matching requests; out-of-band + // requests (e.g. post-command telemetry calls) fall through to the + // per-endpoint fixture store. + if (scenario.name !== null) { + const expected = scenario.queue[scenario.index]; + if ( + expected !== undefined && + expected.request.method.toUpperCase() === method.toUpperCase() && + expected.request.path === normalizeUrlPath(pathname) + ) { + return serveFromScenario(scenario, method, pathname, { query, body: requestBody }); } + } - return serveFromFixtures(store, counters, method, pathname, { query, body: requestBody }); - }, + return serveFromFixtures(store, counters, method, pathname, { query, body: requestBody }); }); + /* Bun's graceful stop drains in-flight responses before serverScope closes. */ + const server = yield* Effect.acquireRelease( + Effect.try({ + try: () => + Bun.serve({ + port: options.port ?? 0, + ...(options.hostname === undefined ? {} : { hostname: options.hostname }), + fetch: (req: Request) => Effect.runPromiseWith(services)(handleRequest(req)), + }), + catch: (cause) => new ReplayServerStartError({ message: causeMessage(cause), cause }), + }), + (server) => Effect.promise(() => server.stop()), + ); + const port = server.port ?? 0; const serverUrl = `http://127.0.0.1:${port}`; - return { + const handle: ReplayServerHandle = { url: serverUrl, port, - stop: () => server.stop(), getRequests: () => [...requestLog], clearRequests: () => { requestLog.length = 0; @@ -333,7 +456,8 @@ export async function startReplayServer(options: ReplayServerOptions): Promise): Record { + const responseHeaders: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; + } + return responseHeaders; +} + +const proxyAndRecord = Effect.fn("ReplayServer.proxyAndRecord")(function* ( method: string, pathname: string, query: Record, @@ -372,10 +504,11 @@ async function proxyAndRecord( fixturesDir: string, recordedKeys: Set, scenario: ScenarioState, + recordLock: Semaphore.Semaphore, storageProxyUrl?: string, storageProxyAuth?: string, dockerProxySocketPath?: string, -): Promise { +) { const isStoragePath = pathname.startsWith("/storage/v1/"); // Docker versioned API paths start with /v1. (decimal), not /v1/ (slash, the // management API). /_ping is the Docker health-check endpoint (no version prefix). @@ -388,7 +521,11 @@ async function proxyAndRecord( } if (isDockerPath && dockerProxySocketPath) { - const dockerResult = await proxyToDockerSocket( + // Stream the response back to the caller immediately. Recording happens + // asynchronously after the body has fully drained — long-running streaming + // endpoints (image pull progress, container logs) are not blocked on it. + // The stream ends for the caller only once the recording is written. + const dockerResult = yield* proxyToDockerSocket( dockerProxySocketPath, method, pathname, @@ -396,32 +533,26 @@ async function proxyAndRecord( upstreamHeaders, requestBody, rawBody, + (responseStatus, headers, body) => + recordDockerInteraction({ + body, + method, + pathname, + query, + requestHeaders, + requestBody, + responseStatus, + responseHeaders: stripResponseHeaders(headers), + fixturesDir, + recordedKeys, + scenario, + recordLock, + }), ); - const responseHeaders: Record = {}; - for (const [k, v] of Object.entries(dockerResult.headers)) { - if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; - } - - // Stream the response back to the caller immediately. Recording happens - // asynchronously after the body has fully drained — long-running streaming - // endpoints (image pull progress, container logs) are not blocked on it. - void recordDockerInteraction({ - bodyPromise: dockerResult.bodyPromise, - method, - pathname, - query, - requestHeaders, - requestBody, - responseStatus: dockerResult.status, - responseHeaders, - fixturesDir, - recordedKeys, - scenario, - }); return new Response(dockerResult.stream, { status: dockerResult.status, - headers: responseHeaders, + headers: stripResponseHeaders(dockerResult.headers), }); } @@ -434,29 +565,39 @@ async function proxyAndRecord( upstreamHeaders["authorization"] = `Bearer ${storageProxyAuth}`; } - const upstreamRes = await fetch(targetUrl.toString(), { - method, - headers: upstreamHeaders, - body: - method !== "GET" && method !== "HEAD" - ? requestBody != null - ? JSON.stringify(requestBody) - : (rawBody ?? undefined) - : undefined, - }); - - const responseBody = await upstreamRes - .clone() - .json() - .catch(() => null); - const responseHeaders: Record = {}; - for (const [k, v] of upstreamRes.headers.entries()) { - if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; + if (!HttpMethod.isHttpMethod(method)) { + return yield* new StagingProxyError({ message: `Unsupported HTTP method: ${method}` }); } + const upstreamBody = + method !== "GET" && method !== "HEAD" + ? requestBody != null + ? yield* encodeJson(requestBody).pipe( + Effect.mapError((cause) => new StagingProxyError({ message: cause.message, cause })), + ) + : (rawBody ?? undefined) + : undefined; + const upstreamRes = yield* HttpClient.execute( + HttpClientRequest.make(method)(targetUrl.toString(), { + headers: upstreamHeaders, + body: + upstreamBody === undefined + ? undefined + : HttpBody.raw(upstreamBody, { contentType: upstreamHeaders["content-type"] }), + }), + ).pipe( + Effect.mapError((error) => { + const cause = "cause" in error.reason ? error.reason.cause : error; + return new StagingProxyError({ message: causeMessage(cause), cause }); + }), + ); + + const responseBody = yield* upstreamRes.json.pipe(Effect.orElseSucceed(() => null)); + const responseHeaders = stripResponseHeaders(upstreamRes.headers); const upstreamStatus = upstreamRes.status; - const responseContentType = upstreamRes.headers.get("content-type") ?? "application/json"; + yield* Effect.annotateCurrentSpan("http.response.status_code", upstreamStatus); + const responseContentType = upstreamRes.headers["content-type"] ?? "application/json"; - recordFixture({ + yield* recordFixture({ method, pathname, query, @@ -468,19 +609,20 @@ async function proxyAndRecord( fixturesDir, recordedKeys, scenario, + recordLock, }); return buildApiResponse(responseBody, upstreamStatus, { ...responseHeaders, "content-type": responseContentType, }); -} +}); /** Record a Docker interaction once its streamed body has fully drained. Errors * are logged but do not surface — recording is best-effort and must not affect * the response the caller already received. */ -async function recordDockerInteraction(params: { - bodyPromise: Promise; +const recordDockerInteraction = Effect.fnUntraced(function* (params: { + body: Effect.Effect; method: string; pathname: string; query: Record; @@ -491,33 +633,33 @@ async function recordDockerInteraction(params: { fixturesDir: string; recordedKeys: Set; scenario: ScenarioState; -}): Promise { - let body: Buffer; - try { - body = await params.bodyPromise; - } catch (err) { - console.error( - `[replay-server] failed to capture Docker body for ${params.method} ${params.pathname}:`, - err, - ); - return; - } + recordLock: Semaphore.Semaphore; +}) { + const captured = yield* params.body.pipe( + Effect.tapError((err) => + Console.error( + `[replay-server] failed to capture Docker body for ${params.method} ${params.pathname}:`, + err, + ), + ), + Effect.option, + ); + if (Option.isNone(captured)) return; + const body = captured.value; let responseBody: unknown; if (body.length === 0) { responseBody = null; } else { - try { - responseBody = JSON.parse(body.toString("utf8")); - } catch { - // Non-JSON or chunked NDJSON (image pull progress, container log frames, - // event streams) — preserve as a base64 envelope so replay can return the - // bytes verbatim instead of silently dropping them. - responseBody = { __type: "raw", base64: body.toString("base64") }; - } + // Non-JSON or chunked NDJSON (image pull progress, container log frames, + // event streams) — preserve as a base64 envelope so replay can return the + // bytes verbatim instead of silently dropping them. + responseBody = yield* decodeJson(body.toString("utf8")).pipe( + Effect.orElseSucceed(() => ({ __type: "raw", base64: body.toString("base64") })), + ); } - recordFixture({ + yield* recordFixture({ method: params.method, pathname: params.pathname, query: params.query, @@ -529,10 +671,19 @@ async function recordDockerInteraction(params: { fixturesDir: params.fixturesDir, recordedKeys: params.recordedKeys, scenario: params.scenario, - }); -} + recordLock: params.recordLock, + }).pipe( + Effect.catch((err) => + Console.error( + `[replay-server] failed to record Docker interaction for ${params.method} ${params.pathname}:`, + err, + ), + ), + ); +}); -function recordFixture(params: { +/* Once the record lock is held, the write finishes even if the server stops mid-recording. */ +const recordFixture = (params: { method: string; pathname: string; query: Record; @@ -544,8 +695,28 @@ function recordFixture(params: { fixturesDir: string; recordedKeys: Set; scenario: ScenarioState; -}): void { - const rawPair = JSON.stringify({ + recordLock: Semaphore.Semaphore; +}) => params.recordLock.withPermit(writeFixture(params).pipe(Effect.uninterruptible)); + +const fixtureWriteError = (cause: Schema.SchemaError) => + new FixtureWriteError({ message: cause.message, cause }); + +const writeFixture = Effect.fn("ReplayServer.writeFixture")(function* (params: { + method: string; + pathname: string; + query: Record; + requestHeaders: Record; + requestBody: unknown; + responseStatus: number; + responseHeaders: Record; + responseBody: unknown; + fixturesDir: string; + recordedKeys: Set; + scenario: ScenarioState; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rawPair = yield* encodeJson({ request: { method: params.method, path: params.pathname, @@ -558,9 +729,9 @@ function recordFixture(params: { headers: params.responseHeaders, body: params.responseBody, }, - }); + }).pipe(Effect.mapError(fixtureWriteError)); const { output } = applyPlaceholders(rawPair); - const normalized = JSON.parse(output) as { + const normalized = (yield* decodeJson(output).pipe(Effect.mapError(fixtureWriteError))) as { request: FixtureRequest; response: FixtureResponse; }; @@ -568,39 +739,39 @@ function recordFixture(params: { normalized.request.path = normalizeUrlPath(params.pathname); const key = fixtureKey(params.method, params.pathname); - const keyDir = join(params.fixturesDir, "recorded", key); + const keyDir = path.join(params.fixturesDir, "recorded", key); if (!params.recordedKeys.has(key)) { params.recordedKeys.add(key); - if (existsSync(keyDir)) { - for (const file of readdirSync(keyDir)) { - unlinkSync(join(keyDir, file)); + if (yield* pathExists(fs, keyDir)) { + for (const file of yield* fs.readDirectory(keyDir)) { + yield* fs.remove(path.join(keyDir, file)); } } } - mkdirSync(keyDir, { recursive: true }); + yield* fs.makeDirectory(keyDir, { recursive: true }); - const nextIndex = nextFixtureIndex(keyDir); + const nextIndex = yield* nextFixtureIndex(keyDir); // Beyond the cap, the proxied response is still returned but no longer persisted. if (nextIndex <= MAX_FIXTURE_ENTRIES) { const indexStr = nextIndex === 1 ? "default" : String(nextIndex); - writeFileSync( - join(keyDir, `${indexStr}.request.json`), - JSON.stringify(normalized.request, null, 2), + yield* fs.writeFileString( + path.join(keyDir, `${indexStr}.request.json`), + yield* encodeFixtureJson(normalized.request).pipe(Effect.mapError(fixtureWriteError)), ); - writeFileSync( - join(keyDir, `${indexStr}.response.json`), - JSON.stringify(normalized.response, null, 2), + yield* fs.writeFileString( + path.join(keyDir, `${indexStr}.response.json`), + yield* encodeFixtureJson(normalized.response).pipe(Effect.mapError(fixtureWriteError)), ); } if (params.scenario.name !== null) { params.scenario.log.push({ request: normalized.request, response: normalized.response }); - writeScenarioInteractions(params.fixturesDir, params.scenario.name, params.scenario.log); + yield* writeScenarioInteractions(params.fixturesDir, params.scenario.name, params.scenario.log); } -} +}); interface DockerProxyResult { status: number; @@ -609,18 +780,32 @@ interface DockerProxyResult { * long-running streaming endpoints (image pull progress, container logs, * events) are not blocked on the full upstream body. */ stream: ReadableStream; - /** Resolves with the full concatenated body once the upstream stream ends. - * Used by `proxyAndRecord` to write the fixture *after* the response has - * flushed to the caller. */ - bodyPromise: Promise; } +/** Receives the full concatenated body (or its failure) once the upstream + * stream ends. Used by `proxyAndRecord` to write the fixture *after* the + * body has drained to the caller and before the caller's stream ends. */ +type DockerBodyHandler = ( + status: number, + headers: Record, + body: Exit.Exit, +) => Effect.Effect; + /** Idle timeout: abort if the upstream socket goes silent for this long. Only * kills truly stuck connections — a slow but still-active operation (e.g. a * first-time image pull) stays alive as long as it keeps emitting progress. */ const DOCKER_SOCKET_IDLE_TIMEOUT_MS = 60_000; -async function proxyToDockerSocket( +const dockerFailure = (error: HttpClientError.HttpClientError) => { + const cause = "cause" in error.reason ? error.reason.cause : error; + return new DockerSocketError({ message: causeMessage(cause), cause }); +}; + +/* The error `node:http` raised on a response body cut short by an idle timeout or the + caller going away. */ +const abortedError = () => new DockerSocketError({ message: "aborted" }); + +const proxyToDockerSocket = Effect.fn("ReplayServer.proxyToDockerSocket")(function* ( socketPath: string, method: string, pathname: string, @@ -628,15 +813,22 @@ async function proxyToDockerSocket( headers: Record, requestBody: unknown, rawBody: ReadableStream | null, -): Promise { + onBody: DockerBodyHandler, +) { const qStr = new URLSearchParams(query).toString(); const path = qStr ? `${pathname}?${qStr}` : pathname; let bodyBuf: Buffer | undefined; if (requestBody != null) { - bodyBuf = Buffer.from(JSON.stringify(requestBody), "utf8"); + const json = yield* encodeJson(requestBody).pipe( + Effect.mapError((cause) => new DockerSocketError({ message: cause.message, cause })), + ); + bodyBuf = Buffer.from(json, "utf8"); } else if (rawBody != null) { - const ab = await new Response(rawBody).arrayBuffer(); + const ab = yield* Effect.tryPromise({ + try: () => new Response(rawBody).arrayBuffer(), + catch: (cause) => new RequestBodyError({ message: causeMessage(cause), cause }), + }); bodyBuf = Buffer.from(ab); } @@ -648,91 +840,106 @@ async function proxyToDockerSocket( "keep-alive", "content-length", ]); - const reqHeaders: Record = {}; + const reqHeaders: Record = {}; for (const [k, v] of Object.entries(headers)) { if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders[k] = v; } - if (bodyBuf) { - reqHeaders["Content-Length"] = bodyBuf.length; - } - - return new Promise((resolve, reject) => { - const req = httpRequest({ socketPath, method, path, headers: reqHeaders }, (res) => { - if (res.statusCode == null) { - reject(new Error("Docker socket returned response with no status code")); - return; - } - const resHeaders: Record = {}; - for (const [k, v] of Object.entries(res.headers)) { - if (typeof v === "string") resHeaders[k] = v; - else if (Array.isArray(v)) resHeaders[k] = v.join(", "); - } + if (!HttpMethod.isHttpMethod(method)) { + return yield* new DockerSocketError({ message: `Unsupported HTTP method: ${method}` }); + } + const response = yield* HttpClient.execute( + HttpClientRequest.make(method)(`http://localhost${path}`, { + headers: reqHeaders, + body: + bodyBuf === undefined + ? undefined + : HttpBody.raw(bodyBuf, { + contentType: reqHeaders["content-type"], + contentLength: bodyBuf.length, + }), + }), + ).pipe(Effect.provide(DockerClients.get(socketPath)), Effect.mapError(dockerFailure)); + yield* Effect.annotateCurrentSpan("http.response.status_code", response.status); - const chunks: Buffer[] = []; - let bodyResolve: (buf: Buffer) => void = () => {}; - let bodyReject: (err: Error) => void = () => {}; - const bodyPromise = new Promise((res2, rej2) => { - bodyResolve = res2; - bodyReject = rej2; - }); + const chunks: Uint8Array[] = []; + let lastActivity = yield* Clock.currentTimeMillis; + const settled = yield* Deferred.make(); + let cancelled = false; + let controller: ReadableStreamDefaultController | undefined; + const stream = new ReadableStream({ + start(c) { + controller = c; + }, + cancel() { + cancelled = true; + Deferred.doneUnsafe(settled, Exit.fail(abortedError())); + }, + }); + const emit = Effect.fnUntraced(function* (chunk: Uint8Array) { + lastActivity = yield* Clock.currentTimeMillis; + chunks.push(chunk); + if (!cancelled) controller?.enqueue(new Uint8Array(chunk)); + }); - let lastActivity = Date.now(); - const idleTimer = setInterval(() => { - if (Date.now() - lastActivity > DOCKER_SOCKET_IDLE_TIMEOUT_MS) { - clearInterval(idleTimer); - req.destroy(new Error(`Docker socket idle for ${DOCKER_SOCKET_IDLE_TIMEOUT_MS / 1000}s`)); - } - }, 5_000); - - const stream = new ReadableStream({ - start(controller) { - res.on("data", (chunk: Buffer) => { - lastActivity = Date.now(); - chunks.push(chunk); - controller.enqueue(new Uint8Array(chunk)); - }); - res.on("end", () => { - clearInterval(idleTimer); - controller.close(); - bodyResolve(Buffer.concat(chunks)); - }); - res.on("error", (err) => { - clearInterval(idleTimer); - controller.error(err); - bodyReject(err); - }); - }, - cancel(reason) { - clearInterval(idleTimer); - req.destroy(reason instanceof Error ? reason : undefined); - }, - }); + const relay = Effect.gen(function* () { + yield* Stream.runForEach(response.stream, emit).pipe( + Effect.mapError(dockerFailure), + Effect.exit, + Effect.flatMap((exit) => Deferred.done(settled, exit)), + Effect.forkScoped, + ); + yield* Clock.currentTimeMillis.pipe( + Effect.map((now) => now - lastActivity > DOCKER_SOCKET_IDLE_TIMEOUT_MS), + Effect.repeat({ schedule: Schedule.fixed("5 seconds"), until: (idle) => idle }), + Effect.andThen(Deferred.fail(settled, abortedError())), + Effect.forkScoped, + ); + yield* Deferred.await(settled).pipe( + Effect.matchEffect({ + onSuccess: () => + onBody(response.status, response.headers, Exit.succeed(Buffer.concat(chunks))).pipe( + Effect.andThen( + Effect.sync(() => { + if (!cancelled) controller?.close(); + }), + ), + ), + onFailure: (error) => + Effect.sync(() => controller?.error(error)).pipe( + Effect.andThen(onBody(response.status, response.headers, Exit.fail(error))), + ), + }), + ); + }).pipe(Effect.scoped); + const relayFiber = yield* Effect.forkScoped(relay); + /* node:http ends a bodiless response before the caller sees it, so it is recorded and closed first. */ + if (method === "HEAD" || response.status === 204 || response.status === 304) { + yield* Fiber.await(relayFiber); + } - resolve({ status: res.statusCode, headers: resHeaders, stream, bodyPromise }); - }); - req.on("error", (err) => { - req.destroy(); - reject(err); - }); - if (bodyBuf) req.write(bodyBuf); - req.end(); - }); -} + return { status: response.status, headers: response.headers, stream } satisfies DockerProxyResult; +}); -function writeScenarioInteractions( +const writeScenarioInteractions = Effect.fn("ReplayServer.writeScenarioInteractions")(function* ( fixturesDir: string, scenarioName: string, interactions: Array<{ request: FixtureRequest; response: FixtureResponse }>, -): void { - const scenarioDir = join(fixturesDir, "scenarios", scenarioName); - mkdirSync(scenarioDir, { recursive: true }); - writeFileSync(join(scenarioDir, "interactions.json"), JSON.stringify(interactions, null, 2)); -} +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scenarioDir = path.join(fixturesDir, "scenarios", scenarioName); + yield* fs.makeDirectory(scenarioDir, { recursive: true }); + yield* fs.writeFileString( + path.join(scenarioDir, "interactions.json"), + yield* encodeFixtureJson(interactions).pipe(Effect.mapError(fixtureWriteError)), + ); +}); -function nextFixtureIndex(keyDir: string): number { - if (!existsSync(keyDir)) return 1; - const files = readdirSync(keyDir); +const nextFixtureIndex = Effect.fnUntraced(function* (keyDir: string) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* pathExists(fs, keyDir))) return 1; + const files = yield* fs.readDirectory(keyDir); let max = 0; for (const file of files) { const match = file.match(/^(\d+)\.(request|response)\.json$/); @@ -743,7 +950,7 @@ function nextFixtureIndex(keyDir: string): number { if (file.startsWith("default.")) max = Math.max(max, 1); } return max + 1; -} +}); /** Injected 429/5xx responses ask the typed client to retry at once so tests skip its backoff. */ function injectedErrorResponse(error: { status: number; body: unknown }): Response { @@ -902,9 +1109,17 @@ interface ControlContext { isRecord: boolean; fixturesDir: string; pgMock?: PgMockHandle; + recordLock: Semaphore.Semaphore; } -async function handleControl(req: Request, url: URL, ctx: ControlContext): Promise { +const readJson = (req: Request) => + Effect.tryPromise({ + try: () => req.json(), + catch: (cause) => new RequestBodyError({ message: causeMessage(cause), cause }), + }); + +const handleControl = Effect.fnUntraced(function* (req: Request, url: URL, ctx: ControlContext) { + const path = yield* Path.Path; const subpath = url.pathname.slice("/_ctrl".length); if (subpath === "/requests") { @@ -920,45 +1135,51 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi if (subpath === "/scenario") { if (req.method === "POST") { - const { name } = (await req.json()) as { name: string }; - - if (!ctx.isRecord) { - const interactions = loadScenario(join(ctx.fixturesDir, "scenarios"), name); - if (!interactions) { - return new Response( - JSON.stringify({ - message: `Missing scenario: "${name}" — re-record with RECORD=true`, - }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ); - } - ctx.scenario.queue = interactions; - } else { - ctx.scenario.queue = []; - ctx.scenario.log = []; - } - - ctx.scenario.name = name; - ctx.scenario.index = 0; - return new Response(null, { status: 204 }); + const { name } = (yield* readJson(req)) as { name: string }; + + return yield* ctx.recordLock.withPermit( + Effect.gen(function* () { + if (!ctx.isRecord) { + const interactions = yield* loadScenario(path.join(ctx.fixturesDir, "scenarios"), name); + if (!interactions) { + return Response.json( + { message: `Missing scenario: "${name}" — re-record with RECORD=true` }, + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + ctx.scenario.queue = interactions; + } else { + ctx.scenario.queue = []; + ctx.scenario.log = []; + } + + ctx.scenario.name = name; + ctx.scenario.index = 0; + return new Response(null, { status: 204 }); + }), + ); } if (req.method === "DELETE") { - // Flush interactions.json even when empty, so a test that hits a global - // error before any API call still gets a scenario file. - if (ctx.isRecord && ctx.scenario.name !== null) { - writeScenarioInteractions(ctx.fixturesDir, ctx.scenario.name, ctx.scenario.log); - } - ctx.scenario.name = null; - ctx.scenario.queue = []; - ctx.scenario.index = 0; - ctx.scenario.log = []; - return new Response(null, { status: 204 }); + return yield* ctx.recordLock.withPermit( + Effect.gen(function* () { + // Flush interactions.json even when empty, so a test that hits a global + // error before any API call still gets a scenario file. + if (ctx.isRecord && ctx.scenario.name !== null) { + yield* writeScenarioInteractions(ctx.fixturesDir, ctx.scenario.name, ctx.scenario.log); + } + ctx.scenario.name = null; + ctx.scenario.queue = []; + ctx.scenario.index = 0; + ctx.scenario.log = []; + return new Response(null, { status: 204 }); + }), + ); } } if (subpath === "/error" && req.method === "POST") { - const body = (await req.json()) as { + const body = (yield* readJson(req)) as { method: string; path: string; status: number; @@ -972,7 +1193,7 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi } if (subpath === "/error-all" && req.method === "POST") { - const body = (await req.json()) as { status: number; body?: unknown }; + const body = (yield* readJson(req)) as { status: number; body?: unknown }; ctx.globalErrorRef.value = { status: body.status, body: body.body ?? { message: "Error" }, @@ -981,7 +1202,7 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi } if (subpath === "/rate-limit" && req.method === "POST") { - const body = (await req.json()) as { + const body = (yield* readJson(req)) as { path: string; retryAfterSeconds: number; }; @@ -999,37 +1220,37 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi if (subpath === "/pg-fixture" && req.method === "POST") { if (!ctx.pgMock) { - return new Response(JSON.stringify({ message: "No PG mock configured" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); + return Response.json( + { message: "No PG mock configured" }, + { status: 503, headers: { "Content-Type": "application/json" } }, + ); } - const { key } = (await req.json()) as { key: string }; - const fixturePath = join(ctx.fixturesDir, "pg", `${key}.json`); - let fixture: unknown; - try { - fixture = await Bun.file(fixturePath).json(); - } catch { - return new Response(JSON.stringify({ message: `PG fixture not found: ${key}` }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + const { key } = (yield* readJson(req)) as { key: string }; + const fixturePath = path.join(ctx.fixturesDir, "pg", `${key}.json`); + const fixture = yield* Effect.tryPromise(() => Bun.file(fixturePath).json()).pipe( + Effect.option, + ); + if (Option.isNone(fixture)) { + return Response.json( + { message: `PG fixture not found: ${key}` }, + { status: 404, headers: { "Content-Type": "application/json" } }, + ); } - ctx.pgMock.setState({ type: "fixture", fixture: fixture as PgFixture }); + ctx.pgMock.setState({ type: "fixture", fixture: fixture.value as PgFixture }); return new Response(null, { status: 204 }); } if (subpath === "/pg-error" && req.method === "POST") { if (!ctx.pgMock) { - return new Response(JSON.stringify({ message: "No PG mock configured" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); + return Response.json( + { message: "No PG mock configured" }, + { status: 503, headers: { "Content-Type": "application/json" } }, + ); } - const error = (await req.json()) as { code: string; message: string; severity?: string }; + const error = (yield* readJson(req)) as { code: string; message: string; severity?: string }; ctx.pgMock.setState({ type: "error", error }); return new Response(null, { status: 204 }); } return new Response("Not Found", { status: 404 }); -} +}); diff --git a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts index b3a9e76493..eecc95b949 100644 --- a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts +++ b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts @@ -1,1360 +1,1771 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { BACKUP_TIMESTAMP, PROJECT_REF, SNIPPET_ID, isRecording } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("postgres-config", () => { describe("postgres-config:get", () => { - testBehaviour("renders config overrides", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("renders config overrides", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); testBehaviour( "returns config overrides as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).not.toBeNull(); - expect(typeof parsed).toBe("object"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--debug", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).not.toBeNull(); + expect(typeof parsed).toBe("object"); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("postgres-config:update", () => { - testBehaviour("sets single config override", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("sets multiple config overrides", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--config", - "shared_buffers=256MB", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("sets single config override", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("sets multiple config overrides", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--config", + "shared_buffers=256MB", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); testBehaviour( "--replace-existing-overrides replaces all overrides", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--replace-existing-overrides", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--replace-existing-overrides", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), ); testBehaviour( "--no-restart applies config without restarting postgres", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--no-restart", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 422 unrecognized config key", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "unrecognized config key: invalid_key" }, - }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "invalid_key=value", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("unrecognized config key"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--no-restart", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 unrecognized config key", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "unrecognized config key: invalid_key" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "invalid_key=value", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("unrecognized config key"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("postgres-config:delete", () => { - testBehaviour("removes config override", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("removes multiple config keys", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--config", - "shared_buffers", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("removes config override", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("removes multiple config keys", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--config", + "shared_buffers", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); testBehaviour( "--no-restart removes config without restarting postgres", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--no-restart", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--no-restart", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("vanity-subdomains", () => { describe("vanity-subdomains:get", () => { - testBehaviour("renders vanity subdomain and status", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("renders vanity subdomain and status", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); testBehaviour( "returns subdomain config as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("status"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--debug", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toHaveProperty("status"); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("vanity-subdomains:check-availability", () => { - testBehaviour("reports availability for desired subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("reports availability for desired subdomain", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); testBehaviour( "returns availability as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("available"); - }, - ); - - testBehaviour("exits non-zero without --desired-subdomain flag", async ({ run }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 409 subdomain taken", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Subdomain already taken" }, - }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "taken", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("already taken"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toHaveProperty("available"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero without --desired-subdomain flag", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 subdomain taken", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Subdomain already taken" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "taken", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("already taken"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); }); describe("vanity-subdomains:activate", () => { - testBehaviour("activates desired vanity subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Activated vanity subdomain"); - }); - - testBehaviour("exits non-zero on 409 subdomain already taken", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Subdomain already taken" }, - }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "taken", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("already taken"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("activates desired vanity subdomain", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Activated vanity subdomain"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 subdomain already taken", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Subdomain already taken" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "taken", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("already taken"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("vanity-subdomains:delete", () => { - testBehaviour("removes vanity subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Deleted vanity subdomain"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("removes vanity subdomain", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Deleted vanity subdomain"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("encryption", () => { describe("encryption:get-root-key", () => { - testBehaviour("renders root encryption key", async ({ run, projectRef }) => { - const result = await run(["encryption", "get-root-key", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); - - testBehaviour("returns root_key as JSON with --output json", async ({ run, projectRef }) => { - const result = await run([ - "encryption", - "get-root-key", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["encryption", "get-root-key", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders root encryption key", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["encryption", "get-root-key", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); + + testBehaviour("returns root_key as JSON with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "encryption", + "get-root-key", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "encryption", + "get-root-key", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("encryption:update-root-key", () => { - testBehaviour.skipIf(isRecording)("rotates the vault root key", async ({ run, projectRef }) => { - const result = await run([ - "encryption", - "update-root-key", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "encryption", - "update-root-key", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "encryption", - "update-root-key", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "encryption", - "update-root-key", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "encryption", - "update-root-key", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "encryption", - "update-root-key", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("rotates the vault root key", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("backups", () => { describe("backups:list", () => { - testBehaviour("renders backup table with REGION column", async ({ run, projectRef }) => { - const result = await run(["backups", "list", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("REGION"); - }); + testBehaviour("renders backup table with REGION column", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["backups", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("REGION"); + }), + signal, + ), + ); testBehaviour( "returns backup response as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--output", "json", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("region"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "backups", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toHaveProperty("region"); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["backups", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("backups:restore", () => { testBehaviour.skipIf(isRecording)( "initiates PITR restore with -t timestamp", - async ({ run, projectRef }) => { - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 422 out-of-range timestamp", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "recovery time target is out of range" }, - }), - }); - const result = await run([ - "backups", - "restore", - "-t", - "0", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("out of range"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 out-of-range timestamp", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "recovery time target is out of range" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + "0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("out of range"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("snippets", () => { describe("snippets:list", () => { - testBehaviour("renders snippet table with ID and NAME columns", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ID"); - expect(result.stdout).toContain("NAME"); - }); + testBehaviour("renders snippet table with ID and NAME columns", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["snippets", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ID"); + expect(result.stdout).toContain("NAME"); + }), + signal, + ), + ); testBehaviour( "returns snippet list as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "snippets", - "list", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed.data)).toBe(true); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "snippets", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Record(Schema.String, Schema.Unknown))( + result.stdout, + ); + expect(Array.isArray(parsed.data)).toBe(true); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["snippets", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("snippets:download", () => { testBehaviour.skipIf(isRecording)( "prints SQL content to stdout", - async ({ run, projectRef }) => { - const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }, - ); - - testBehaviour("exits non-zero on 404 snippet not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Snippet not found" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 snippet not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Snippet not found" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/branches.e2e.test.ts b/apps/cli-e2e/src/tests/branches.e2e.test.ts index f979199dba..5d317f4308 100644 --- a/apps/cli-e2e/src/tests/branches.e2e.test.ts +++ b/apps/cli-e2e/src/tests/branches.e2e.test.ts @@ -1,500 +1,642 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const BRANCH_NAME = "my-branch"; describe("branches", () => { describe("branches:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["branches", "list", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("STATUS"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("STATUS"); + }), + signal, + ), + ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "list", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }, - ); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["branches", "list", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("branches:create", () => { - testBehaviour.skipIf(isRecording)("creates ephemeral branch", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour.skipIf(isRecording)("creates persistent branch", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--persistent", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour.skipIf(isRecording)("creates branch with data", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--with-data", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Branch name already in use" } }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch name already in use"); - }); - - testBehaviour("exits non-zero on 422 branching not enabled", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Preview branching is not enabled" }, + testBehaviour.skipIf(isRecording)("creates ephemeral branch", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("creates persistent branch", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--persistent", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("creates branch with data", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--with-data", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Branch name already in use" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch name already in use"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 branching not enabled", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Preview branching is not enabled" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Preview branching is not enabled"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 invalid region", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Invalid region" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--region", + "invalid-region", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--region"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Preview branching is not enabled"); - }); - - testBehaviour("exits non-zero on 422 invalid region", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 422, body: { message: "Invalid region" } }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--region", - "invalid-region", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--region"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + signal, + ), + ); }); describe("branches:get", () => { testBehaviour.skipIf(isRecording)( "returns single branch details", - async ({ run, projectRef }) => { - const result = await run(["branches", "get", BRANCH_NAME, `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(BRANCH_NAME); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "get", BRANCH_NAME, `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(BRANCH_NAME); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "get", - BRANCH_NAME, - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as Record; - expect(parsed).toMatchObject({ SUPABASE_JWT_SECRET: expect.any(String) }); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run(["branches", "get", "nonexistent", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "get", BRANCH_NAME, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "get", + BRANCH_NAME, + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toMatchObject({ SUPABASE_JWT_SECRET: expect.any(String) }); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run(["branches", "get", "nonexistent", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["branches", "get", BRANCH_NAME, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:update", () => { - testBehaviour.skipIf(isRecording)("renames branch with --name", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--name", - "renamed-branch", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Updated preview branch:"); - }); + testBehaviour.skipIf(isRecording)("renames branch with --name", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "update", + BRANCH_NAME, + "--name", + "renamed-branch", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Updated preview branch:"); + }), + signal, + ), + ); testBehaviour.skipIf(isRecording)( "changes git branch with --git-branch", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--git-branch", - "feature/new", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Updated preview branch:"); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run([ - "branches", - "update", - "nonexistent", - "--name", - "new-name", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--name", - "new-name", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "update", + BRANCH_NAME, + "--git-branch", + "feature/new", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Updated preview branch:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run([ + "branches", + "update", + "nonexistent", + "--name", + "new-name", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "update", + BRANCH_NAME, + "--name", + "new-name", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:pause", () => { - testBehaviour.skipIf(isRecording)("pauses branch successfully", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "pause", - BRANCH_NAME, - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run([ - "branches", - "pause", - "nonexistent", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "branches", - "pause", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("pauses branch successfully", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "pause", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run([ + "branches", + "pause", + "nonexistent", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "pause", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:unpause", () => { testBehaviour.skipIf(isRecording)( "unpauses branch successfully", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "unpause", - BRANCH_NAME, - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "branches", - "unpause", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "unpause", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "unpause", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:delete", () => { testBehaviour.skipIf(isRecording)( "deletes branch successfully", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "delete", - BRANCH_NAME, - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Deleted preview branch:"); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run([ - "branches", - "delete", - "nonexistent", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "branches", - "delete", - BRANCH_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "delete", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Deleted preview branch:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run([ + "branches", + "delete", + "nonexistent", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "delete", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:disable", () => { - testBehaviour.skipIf(isRecording)("disables preview branching", async ({ run, projectRef }) => { - const result = await run(["branches", "disable", "--yes", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Disabled preview branching for project:"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "disable", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("disables preview branching", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "disable", "--yes", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Disabled preview branching for project:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["branches", "disable", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/config-push.e2e.test.ts b/apps/cli-e2e/src/tests/config-push.e2e.test.ts index 3be38add6e..10604b33bf 100644 --- a/apps/cli-e2e/src/tests/config-push.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-push.e2e.test.ts @@ -1,18 +1,19 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; /** * Writes a supabase/config.toml covering every section `config push` * reconciles, each with a small diff against the recorded test project — a * section without a diff sends no PATCH/POST to the management API. */ -function writeConfigToml(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const writeConfigToml = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), ` project_id = "test-project" @@ -53,150 +54,179 @@ file_size_limit = "50MiB" enabled = true `.trimStart(), ); -} +}); // `--yes` skips the per-section y/n prompt, since the test process runs with stdin closed. describe("config push", () => { - testBehaviour("reconciles every section against the remote", async ({ run, workspace }) => { - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("emits HTTP trace with --debug", async ({ run, workspace }) => { - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--debug", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401 with token guidance", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 401, - body: { message: "Invalid token" }, + testBehaviour("reconciles every section against the remote", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("emits HTTP trace with --debug", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* writeConfigToml(workspace.path); + const result = yield* run([ + "config", + "push", + "--yes", + "--debug", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); }), - }); + signal, + ), + ); - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("401"); - }); + testBehaviour("exits non-zero on 401 with token guidance", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("401"); + }), + signal, + ), + ); testBehaviour( "exits non-zero on 403 with resource context", - async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 403, - body: { - message: `Forbidden: you do not have access to project ${PROJECT_REF}`, - }, + ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { + message: `Forbidden: you do not have access to project ${PROJECT_REF}`, + }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("403"); }), - }); + signal, + ), + ); + + testBehaviour("exits non-zero on 404", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("404"); + }), + signal, + ), + ); - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("403"); - }, + testBehaviour("exits non-zero on 409", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Conflict" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("409"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 404", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 404, - body: { message: "Project not found" }, + testBehaviour("exits non-zero on 422 with field detail", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { + message: "Invalid config: max_rows must be a positive integer", + }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("422"); + expect(result.stderr).toContain("max_rows"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("404"); - }); - - testBehaviour("exits non-zero on 409", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Conflict" } }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("409"); - }); - - testBehaviour("exits non-zero on 422 with field detail", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { - message: "Invalid config: max_rows must be a positive integer", - }, + signal, + ), + ); + + testBehaviour("exits non-zero on 429 after retrying", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("429"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("422"); - expect(result.stderr).toContain("max_rows"); - }); - - testBehaviour("exits non-zero on 429 after retrying", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 429, - body: { message: "Too Many Requests" }, + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("500"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("429"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 500, - body: { message: "Internal Server Error" }, + signal, + ), + ); + + testBehaviour("exits non-zero on 502", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 502, + body: { message: "Bad Gateway" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("502"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("500"); - }); - - testBehaviour("exits non-zero on 502", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 502, body: { message: "Bad Gateway" } }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("502"); - }); + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts index 5275c838b8..121dc418aa 100644 --- a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -1,8 +1,7 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; // Regression guard: a non-string field written as `env(VAR)` (e.g. // a port) must not crash config decoding before env resolution runs. @@ -10,10 +9,12 @@ import { testBehaviour } from "./test-context.ts"; // A 401 is injected so the test doesn't need a real API fixture; it only // asserts that the CLI got past config decode. -function writeConfigWithEnvPorts(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const writeConfigWithEnvPorts = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), [ 'project_id = "with-env-ports"', "", @@ -28,7 +29,7 @@ function writeConfigWithEnvPorts(dir: string): void { "", ].join("\n"), ); -} +}); const ENV_PORTS = { SUPABASE_API_PORT: "54321", @@ -37,21 +38,25 @@ const ENV_PORTS = { }; describe("env-in-config-toml", () => { - testBehaviour("does not crash on numeric fields", async ({ run, workspace, apiUrl }) => { - writeConfigWithEnvPorts(workspace.path); + testBehaviour("does not crash on numeric fields", ({ run, workspace, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* writeConfigWithEnvPorts(workspace.path); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); - const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`], { - env: ENV_PORTS, - }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`], { + env: ENV_PORTS, + }); - const output = `${result.stdout}\n${result.stderr}`; - expect(output).not.toContain("CliConfigParseError"); - expect(output).not.toMatch(/Expected number.*env\(SUPABASE_/); - }); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).not.toContain("CliConfigParseError"); + expect(output).not.toMatch(/Expected number.*env\(SUPABASE_/); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index 1a4f575c59..11c385925c 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -1,115 +1,154 @@ -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { describe, expect } from "vitest"; import { linkProject } from "./link-project.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const TEST_MIGRATION_SQL = "CREATE TABLE IF NOT EXISTS e2e_test_table (id bigint generated always as identity primary key);"; /** Create a single migration file in supabase/migrations/ and return the SQL. */ -function seedMigration(dir: string): void { - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_e2e_test.sql"), TEST_MIGRATION_SQL); -} +const seedMigration = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101000000_e2e_test.sql"), + TEST_MIGRATION_SQL, + ); +}); describe("db advisors", () => { describe("db advisors:security", () => { - testBehaviour("returns security advisors", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--type", "security", "--remote"]); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("No issues found"); - }); + testBehaviour("returns security advisors", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("No issues found"); + }), + signal, + ), + ); testBehaviour( "exits zero when --fail-on error and no error-level advisors found", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run([ - "db", - "advisors", - "--type", - "security", - "--fail-on", - "error", - "--remote", - ]); - expect(result.exitCode).toBe(0); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run([ + "db", + "advisors", + "--type", + "security", + "--fail-on", + "error", + "--remote", + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), ); - testBehaviour("exits non-zero on 401", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["db", "advisors", "--type", "security", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["db", "advisors", "--type", "security", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["db", "advisors", "--type", "security", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["db", "advisors", "--type", "security", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("db advisors:performance", () => { - testBehaviour("returns performance advisors", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--type", "performance", "--remote"]); - expect(result.exitCode).toBe(0); - if (result.stdout.trim()) { - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - } else { - expect(result.stderr).toContain("No issues found"); - } - }); + testBehaviour("returns performance advisors", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "advisors", "--type", "performance", "--remote"]); + expect(result.exitCode).toBe(0); + if (result.stdout.trim()) { + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + } else { + expect(result.stderr).toContain("No issues found"); + } + }), + signal, + ), + ); }); describe("db advisors:all", () => { - testBehaviour("returns advisors with --type all", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--type", "all", "--remote"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("returns advisors with --type all", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "advisors", "--type", "all", "--remote"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); }); @@ -117,57 +156,74 @@ describe("db query", () => { describe("db query:remote", () => { testBehaviour( "returns SELECT 1 result in table format", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "query", "SELECT 1", "--read-only", "--remote"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("1"); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "query", "SELECT 1", "--read-only", "--remote"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + }), + signal, + ), ); - testBehaviour("returns JSON with --output json", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run([ - "db", - "query", - "--output", - "json", - "SELECT 1", - "--read-only", - "--remote", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown; - // In agent mode (CLAUDECODE env set) the output is wrapped in {warning, boundary, rows}. - // In normal mode it's a plain array. - const rows = Array.isArray(parsed) ? parsed : (parsed as { rows: unknown[] }).rows; - expect(Array.isArray(rows)).toBe(true); - expect(rows).toHaveLength(1); - }); + testBehaviour("returns JSON with --output json", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run([ + "db", + "query", + "--output", + "json", + "SELECT 1", + "--read-only", + "--remote", + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + // In agent mode (CLAUDECODE env set) the output is wrapped in {warning, boundary, rows}. + // In normal mode it's a plain array. + const rows = Array.isArray(parsed) ? parsed : (parsed as { rows: unknown[] }).rows; + expect(Array.isArray(rows)).toBe(true); + expect(rows).toHaveLength(1); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["db", "query", "SELECT 1", "--read-only", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["db", "query", "SELECT 1", "--read-only", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["db", "query", "SELECT 1", "--read-only", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["db", "query", "SELECT 1", "--read-only", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); }); }); @@ -175,96 +231,151 @@ describe("db push", () => { describe("db push:dry-run", () => { testBehaviour( "exits non-zero on connection refused with --dry-run", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - seedMigration(workspace.path); - const result = await run(["db", "push", "--dry-run", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* seedMigration(workspace.path); + const result = yield* run(["db", "push", "--dry-run", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), ); }); describe("db push:local", () => { - testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { - const result = await run(["db", "push"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "push"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); describe("db push:remote", () => { testBehaviour( "exits non-zero on connection refused with --remote", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "push", "--remote"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "push", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), ); }); }); describe("db pull", () => { - testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { - const result = await run(["db", "pull"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "pull"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); describe("db lint", () => { - testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { - const result = await run(["db", "lint"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "lint"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); describe("db dump", () => { - testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { - const result = await run(["db", "dump"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "dump"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); - testBehaviour("exits non-zero when --role-only and --data-only are both set", async ({ run }) => { - const result = await run(["db", "dump", "--role-only", "--data-only"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr.toLowerCase()).toMatch(/role.only|data.only|mutually exclusive/); - }); + testBehaviour("exits non-zero when --role-only and --data-only are both set", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "dump", "--role-only", "--data-only"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toLowerCase()).toMatch(/role.only|data.only|mutually exclusive/); + }), + signal, + ), + ); }); describe("db reset", () => { - testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { - const result = await run(["db", "reset"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toMatch(/connect|not running/i); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "reset"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/connect|not running/i); + }), + signal, + ), + ); }); describe("test new", () => { - testBehaviour("creates a pgTAP test file", async ({ run, workspace }) => { - const result = await run(["test", "new", "my_test"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/my_test/); - const files = readdirSync(join(workspace.path, "supabase", "tests")).filter((f) => - f.endsWith("my_test_test.sql"), - ); - expect(files.length).toBe(1); - }); + testBehaviour("creates a pgTAP test file", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["test", "new", "my_test"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/my_test/); + const files = (yield* fs.readDirectory( + path.join(workspace.path, "supabase", "tests"), + )).filter((f) => f.endsWith("my_test_test.sql")); + expect(files.length).toBe(1); + }), + signal, + ), + ); - testBehaviour("exits non-zero when name argument is missing", async ({ run }) => { - const result = await run(["test", "new"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero when name argument is missing", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["test", "new"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("test db", () => { - testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { - const result = await run(["test", "db"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["test", "db"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/domains.e2e.test.ts b/apps/cli-e2e/src/tests/domains.e2e.test.ts index e2244691e6..30cc4b6384 100644 --- a/apps/cli-e2e/src/tests/domains.e2e.test.ts +++ b/apps/cli-e2e/src/tests/domains.e2e.test.ts @@ -1,6 +1,7 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; -import { testBehaviour } from "./test-context"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context"; import { isRecording, PROJECT_REF } from "./env"; const CONFIGURED_CNAME = "www.urgsimurksi.xyz"; @@ -9,258 +10,355 @@ describe("domains", () => { describe.todo("domains:create — requires mocking of 1.1.1.1 for DNS queries"); describe("domains:get", () => { - testBehaviour("custom domain disabled", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 400, - body: { message: "Please enable custom domains first" }, + testBehaviour("custom domain disabled", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 400, + body: { message: "Please enable custom domains first" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); }), - }); - - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", - ); - }); - - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - `www.urgsimurksi.xyz CNAME -> ${REPLAY_PROJECT_REF}.supabase.red`, - ); - }); - - testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run(["domains", "get", "--output", "json", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).toBe(0); - - expect(JSON.parse(result.stdout)).toEqual({ - custom_hostname: "www.urgsimurksi.xyz", - data: { - errors: [], - messages: [], - result: { - custom_origin_server: `${REPLAY_PROJECT_REF}.supabase.red`, - hostname: "www.urgsimurksi.xyz", - id: "00000000-0000-0000-0000-000000000000", - ownership_verification: { - name: "", - type: "", - value: "", - }, - ssl: { - status: "active", - validation_records: [], + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", + ); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("verification completed", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + `www.urgsimurksi.xyz CNAME -> ${REPLAY_PROJECT_REF}.supabase.red`, + ); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("domain activated", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "domains", + "get", + "--output", + "json", + `--remote=${PROJECT_REF}`, + ]); + + expect(result.exitCode).toBe(0); + + expect(yield* decodeJsonOutput(Schema.Unknown)(result.stdout)).toEqual({ + custom_hostname: "www.urgsimurksi.xyz", + data: { + errors: [], + messages: [], + result: { + custom_origin_server: `${REPLAY_PROJECT_REF}.supabase.red`, + hostname: "www.urgsimurksi.xyz", + id: "00000000-0000-0000-0000-000000000000", + ownership_verification: { + name: "", + type: "", + value: "", + }, + ssl: { + status: "active", + validation_records: [], + }, + status: "active", + }, + success: true, }, - status: "active", - }, - success: true, - }, - status: "5_services_reconfigured", - }); - }); - - testBehaviour("exists non-zero on 403", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 403, - body: { message: "Unauthorized" }, + status: "5_services_reconfigured", + }); + }), + signal, + ), + ); + + testBehaviour("exists non-zero on 403", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Unauthorized" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("403"); }), - }); - - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("403"); - }); - - testBehaviour("exists non-zero on 401", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 401, - body: { message: "Unauthorized" }, + signal, + ), + ); + + testBehaviour("exists non-zero on 401", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Unauthorized" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("401"); }), - }); - - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("401"); - }); - - testBehaviour("exists non-zero on 429", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 429, - body: { message: "Too Many Requests" }, + signal, + ), + ); + + testBehaviour("exists non-zero on 429", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("429"); }), - }); - - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("429"); - }); - - testBehaviour("exists non-zero on 500", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 500, - body: { message: "Internal Server Error" }, + signal, + ), + ); + + testBehaviour("exists non-zero on 500", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("500"); }), - }); + signal, + ), + ); + + testBehaviour("exists non-zero on 502", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 502, + body: { message: "Bad Gateway" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("502"); + }), + signal, + ), + ); + }); - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); + describe("domains:reverify", () => { + testBehaviour("custom domain disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("500"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour("exists non-zero on 502", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 502, - body: { message: "Bad Gateway" }, + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", + ); }), - }); + signal, + ), + ); - const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); + testBehaviour.skipIf(isRecording)("verification completed", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("502"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); }); + describe("domains:activate", () => { + testBehaviour("custom domain disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - describe("domains:reverify", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", - ); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification in debug mode", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "domains", + "activate", + "--debug", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/HTTP.*POST:/); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("verification completed", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`completed`); + expect(result.stderr).toContain(`at ${CONFIGURED_CNAME}`); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); + testBehaviour.skipIf(isRecording)("domain activated", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - }); - describe("domains:activate", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification in debug mode", async ({ run }) => { - const result = await run([ - "domains", - "activate", - "--debug", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toMatch(/HTTP.*POST:/); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain(`completed`); - expect(result.stderr).toContain(`at ${CONFIGURED_CNAME}`); - }); - - testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); }); describe("domains:delete", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); + testBehaviour("custom domain disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - }); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 9bd1c8f850..29e211d96b 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -1,64 +1,60 @@ +import { Config, ConfigProvider, Effect, Option } from "effect"; import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; -type CliE2eMode = "replay" | "record"; +/** Reads an environment variable through `Config`, keeping empty values as set. */ +const readEnv = (name: string): string | undefined => + Effect.runSync( + Config.option(Config.String(name)) + .parse(ConfigProvider.fromEnv({ preserveEmptyStrings: true })) + .pipe(Effect.map(Option.getOrUndefined)), + ); // Runtime mode. `replay` (default) serves recorded fixtures; `record` proxies to // staging and captures fixtures. // Back-compat: RECORD=true still maps to `record`. -const MODE: CliE2eMode = - (process.env["CLI_E2E_MODE"] as CliE2eMode | undefined) ?? - (process.env["RECORD"] === "true" ? "record" : "replay"); +const MODE = readEnv("CLI_E2E_MODE") ?? (readEnv("RECORD") === "true" ? "record" : "replay"); export const isRecording = MODE === "record"; -// The replay server + tests/setup.ts key recording off RECORD directly, so keep -// it in sync with MODE in both directions: an explicit CLI_E2E_MODE=replay must -// not record and wipe fixtures just because RECORD=true lingers in the shell. -if (isRecording) { - process.env["RECORD"] = "true"; -} else { - delete process.env["RECORD"]; -} - -// startReplayServer + tests/setup.ts read SUPABASE_STAGING_URL directly as the -// record proxy target. Normalise it from CLI_E2E_API_URL so +// The record proxy target. Normalised from CLI_E2E_API_URL so // `CLI_E2E_MODE=record CLI_E2E_API_URL=…` works without also setting the legacy var. -if (isRecording && !process.env["SUPABASE_STAGING_URL"] && process.env["CLI_E2E_API_URL"]) { - process.env["SUPABASE_STAGING_URL"] = process.env["CLI_E2E_API_URL"]; -} +const LEGACY_STAGING_URL = readEnv("SUPABASE_STAGING_URL"); +const API_URL = readEnv("CLI_E2E_API_URL"); +export const STAGING_URL = + isRecording && !LEGACY_STAGING_URL && API_URL ? API_URL : LEGACY_STAGING_URL; // In replay mode the token never reaches a real API, but the CLI validates the // format before making any request (must match sbp_[a-f0-9]{40}). In record mode // it must be a valid token for the staging API. export const ACCESS_TOKEN = - process.env["SUPABASE_ACCESS_TOKEN"] ?? "sbp_0000000000000000000000000000000000000000"; + readEnv("SUPABASE_ACCESS_TOKEN") ?? "sbp_0000000000000000000000000000000000000000"; // Region for the fresh recording project. -export const REGION = process.env["CLI_E2E_REGION"] ?? "us-east-1"; +export const REGION = readEnv("CLI_E2E_REGION") ?? "us-east-1"; // Recorded fixtures store every project ref and org slug as REPLAY_PROJECT_REF, so replay // requests use it too. In record mode supply a real project ref via env. -export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? REPLAY_PROJECT_REF; +export const PROJECT_REF = readEnv("SUPABASE_TEST_PROJECT_REF") ?? REPLAY_PROJECT_REF; // In record mode supply a real org slug via env, or let the resolver derive it. -export const ORG_ID = process.env["SUPABASE_TEST_ORG_ID"] ?? REPLAY_PROJECT_REF; +export const ORG_ID = readEnv("SUPABASE_TEST_ORG_ID") ?? REPLAY_PROJECT_REF; // UUID of an existing SAML provider on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real provider ID via env. export const PROVIDER_ID = - process.env["SUPABASE_TEST_PROVIDER_ID"] ?? "00000000-0000-0000-0000-000000000000"; + readEnv("SUPABASE_TEST_PROVIDER_ID") ?? "00000000-0000-0000-0000-000000000000"; // UUID of an existing SQL snippet on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real snippet UUID via env. export const SNIPPET_ID = - process.env["SUPABASE_TEST_SNIPPET_ID"] ?? "00000000-0000-4000-8000-000000000001"; + readEnv("SUPABASE_TEST_SNIPPET_ID") ?? "00000000-0000-4000-8000-000000000001"; // Unix epoch seconds for a PITR restore timestamp within the staging project's backup window. // In replay mode the replay server serves responses in order regardless of the request body value. // In record mode supply a real timestamp (within the backup window) via env. export const BACKUP_TIMESTAMP = parseInt( - process.env["SUPABASE_TEST_BACKUP_TIMESTAMP"] ?? "1707407047", + readEnv("SUPABASE_TEST_BACKUP_TIMESTAMP") ?? "1707407047", 10, ); diff --git a/apps/cli-e2e/src/tests/functions.e2e.test.ts b/apps/cli-e2e/src/tests/functions.e2e.test.ts index af2a08db8e..fc2d40390d 100644 --- a/apps/cli-e2e/src/tests/functions.e2e.test.ts +++ b/apps/cli-e2e/src/tests/functions.e2e.test.ts @@ -1,351 +1,453 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const FUNCTION_NAME = "hello-world"; describe("functions", () => { describe("functions:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["functions", "list", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("STATUS"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["functions", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("STATUS"); + }), + signal, + ), + ); - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--output", "json", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }); + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "functions", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }), + signal, + ), + ); - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["functions", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:deploy", () => { // Deploy bundles function files locally before any API call, so error injection tests // pre-create the function with `functions new` first. - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/rate-limit`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path: `/v1/projects/${PROJECT_REF}/functions/deploy`, - retryAfterSeconds: 0, + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/rate-limit`, { + path: `/v1/projects/${PROJECT_REF}/functions/deploy`, + retryAfterSeconds: 0, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:delete", () => { testBehaviour.skipIf(isRecording)( "deletes function successfully", - async ({ run, projectRef }) => { - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted Function"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted Function"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 404 function not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Function not found" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("does not exist"); - }); + testBehaviour("exits non-zero on 404 function not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Function not found" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("does not exist"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:download", () => { testBehaviour.skipIf(isRecording)( "downloads function successfully", - async ({ run, projectRef }) => { - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Downloaded Function"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Downloaded Function"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 404 function not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Function not found" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Function not found"); - }); + testBehaviour("exits non-zero on 404 function not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Function not found" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Function not found"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:new", () => { - testBehaviour("successfully creates a new function", async ({ run }) => { - const result = await run(["functions", "new", "testFunction"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created new Function at supabase/functions/testFunction"); - }); + testBehaviour("successfully creates a new function", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["functions", "new", "testFunction"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Created new Function at supabase/functions/testFunction", + ); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/gen.e2e.test.ts b/apps/cli-e2e/src/tests/gen.e2e.test.ts index 95a24b5eec..f6a1bd7bc3 100644 --- a/apps/cli-e2e/src/tests/gen.e2e.test.ts +++ b/apps/cli-e2e/src/tests/gen.e2e.test.ts @@ -1,6 +1,7 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; function decodeJwtPart(part: string): Record { const padded = part + "=".repeat((4 - (part.length % 4)) % 4); @@ -11,147 +12,204 @@ describe("gen", () => { describe("gen:types", () => { testBehaviour.skipIf(isRecording)( "generates typescript types from project", - async ({ run, projectRef }) => { - // #5212 — with CLAUDECODE=1, piped/redirected output must not include the - // plugin hint (would break `gen types > file.ts` and similar captures). - const result = await run(["gen", "types", `--remote=${projectRef}`], { - env: { CLAUDECODE: "1" }, - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("export type Json"); - expect(result.stdout).toContain("export type Database"); - expect(result.stdout).not.toMatch(/claude-code-hint/); - expect(result.stderr).not.toMatch(/claude-code-hint/); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + // #5212 — with CLAUDECODE=1, piped/redirected output must not include the + // plugin hint (would break `gen types > file.ts` and similar captures). + const result = yield* run(["gen", "types", `--remote=${projectRef}`], { + env: { CLAUDECODE: "1" }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("export type Json"); + expect(result.stdout).toContain("export type Database"); + expect(result.stdout).not.toMatch(/claude-code-hint/); + expect(result.stderr).not.toMatch(/claude-code-hint/); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "includes debug output with --debug", - async ({ run, projectRef }) => { - const result = await run(["gen", "types", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "types", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["gen", "types", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["gen", "types", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["gen", "types", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["gen", "types", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["gen", "types", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["gen", "types", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); testBehaviour( "exits non-zero when the local stack is not running", - async ({ runNoProjectId }) => { - const result = await runNoProjectId(["gen", "types"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Target: local stack"); - expect(result.stderr).toContain("supabase start is not running"); - }, + ({ runNoProjectId, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* runNoProjectId(["gen", "types"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Target: local stack"); + expect(result.stderr).toContain("supabase start is not running"); + }), + signal, + ), ); }); describe("gen:signing-key", () => { - testBehaviour("generates ES256 signing key by default", async ({ run }) => { - const result = await run(["gen", "signing-key"]); - expect(result.exitCode).toBe(0); - const key = JSON.parse(result.stdout) as Record; - expect(key["kty"]).toBe("EC"); - expect(key["alg"]).toBe("ES256"); - expect(key["crv"]).toBe("P-256"); - expect(key["use"]).toBe("sig"); - expect(typeof key["d"]).toBe("string"); - expect((key["d"] as string).length).toBeGreaterThan(0); - }); + testBehaviour("generates ES256 signing key by default", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "signing-key"]); + expect(result.exitCode).toBe(0); + const key = yield* decodeJsonOutput(Schema.Record(Schema.String, Schema.Unknown))( + result.stdout, + ); + expect(key["kty"]).toBe("EC"); + expect(key["alg"]).toBe("ES256"); + expect(key["crv"]).toBe("P-256"); + expect(key["use"]).toBe("sig"); + expect(typeof key["d"]).toBe("string"); + expect((key["d"] as string).length).toBeGreaterThan(0); + }), + signal, + ), + ); - testBehaviour("generates RS256 signing key with --algorithm RS256", async ({ run }) => { - const result = await run(["gen", "signing-key", "--algorithm", "RS256"]); - expect(result.exitCode).toBe(0); - const key = JSON.parse(result.stdout) as Record; - expect(key["kty"]).toBe("RSA"); - expect(key["alg"]).toBe("RS256"); - expect(key["use"]).toBe("sig"); - expect(typeof key["n"]).toBe("string"); - expect((key["n"] as string).length).toBeGreaterThan(0); - }); + testBehaviour("generates RS256 signing key with --algorithm RS256", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "signing-key", "--algorithm", "RS256"]); + expect(result.exitCode).toBe(0); + const key = yield* decodeJsonOutput(Schema.Record(Schema.String, Schema.Unknown))( + result.stdout, + ); + expect(key["kty"]).toBe("RSA"); + expect(key["alg"]).toBe("RS256"); + expect(key["use"]).toBe("sig"); + expect(typeof key["n"]).toBe("string"); + expect((key["n"] as string).length).toBeGreaterThan(0); + }), + signal, + ), + ); }); describe("gen:bearer-jwt", () => { - testBehaviour("exits non-zero without --role", async ({ run }) => { - const result = await run(["gen", "bearer-jwt"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain('"role"'); - }); + testBehaviour("exits non-zero without --role", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "bearer-jwt"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('"role"'); + }), + signal, + ), + ); - testBehaviour("generates bearer jwt for anon role", async ({ run }) => { - const result = await run(["gen", "bearer-jwt", "--role", "anon"]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const header = decodeJwtPart(parts[0]!); - expect(header["alg"]).toBe("ES256"); - expect(typeof header["kid"]).toBe("string"); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("anon"); - expect(typeof payload["exp"]).toBe("number"); - expect(typeof payload["iat"]).toBe("number"); - }); + testBehaviour("generates bearer jwt for anon role", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "bearer-jwt", "--role", "anon"]); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const header = decodeJwtPart(parts[0]!); + expect(header["alg"]).toBe("ES256"); + expect(typeof header["kid"]).toBe("string"); + const payload = decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("anon"); + expect(typeof payload["exp"]).toBe("number"); + expect(typeof payload["iat"]).toBe("number"); + }), + signal, + ), + ); - testBehaviour("generates bearer jwt with custom validity", async ({ run }) => { - const result = await run(["gen", "bearer-jwt", "--role", "anon", "--valid-for", "1h"]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("anon"); - expect((payload["exp"] as number) - (payload["iat"] as number)).toBe(3600); - }); + testBehaviour("generates bearer jwt with custom validity", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "bearer-jwt", "--role", "anon", "--valid-for", "1h"]); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const payload = decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("anon"); + expect((payload["exp"] as number) - (payload["iat"] as number)).toBe(3600); + }), + signal, + ), + ); testBehaviour( "generates bearer jwt for authenticated role with custom sub", - async ({ run }) => { - const result = await run([ - "gen", - "bearer-jwt", - "--role", - "authenticated", - "--sub", - "user-123", - ]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("authenticated"); - expect(payload["sub"]).toBe("user-123"); - }, + ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "gen", + "bearer-jwt", + "--role", + "authenticated", + "--sub", + "user-123", + ]); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const payload = decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("authenticated"); + expect(payload["sub"]).toBe("user-123"); + }), + signal, + ), ); }); }); diff --git a/apps/cli-e2e/src/tests/inspect.e2e.test.ts b/apps/cli-e2e/src/tests/inspect.e2e.test.ts index bdb9bd5311..12f3e4d9d2 100644 --- a/apps/cli-e2e/src/tests/inspect.e2e.test.ts +++ b/apps/cli-e2e/src/tests/inspect.e2e.test.ts @@ -1,24 +1,19 @@ -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; -function setupInspectWorkspace(dir: string, pgPort: number): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const setupInspectWorkspace = Effect.fnUntraced(function* (dir: string, pgPort: number) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), ['project_id = "test-project"', "", "[db]", `port = ${pgPort}`].join("\n"), ); -} +}); -async function setPgFixture(apiUrl: string, key: string): Promise { - const res = await fetch(`${apiUrl}/_ctrl/pg-fixture`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }); - if (!res.ok) throw new Error(`Failed to set PG fixture "${key}": ${await res.text()}`); -} +const setPgFixture = (apiUrl: string, key: string) => + postControl(`${apiUrl}/_ctrl/pg-fixture`, { key }); const SUBCOMMANDS = [ { name: "db-stats", fixtureKey: "db-stats", assertValue: "42 MB" }, @@ -37,70 +32,96 @@ const SUBCOMMANDS = [ ] as const; describe("inspect:flags", () => { - testBehaviour("rejects --remote with --local", async ({ run, workspace, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - const result = await run([ - "inspect", - "db", - "db-stats", - "--local", - "--remote=postgresql://postgres:postgres@localhost:5432/postgres", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain( - "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", - ); - }); + testBehaviour("rejects --remote with --local", ({ run, workspace, pgMockPort, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupInspectWorkspace(workspace.path, pgMockPort); + const result = yield* run([ + "inspect", + "db", + "db-stats", + "--local", + "--remote=postgresql://postgres:postgres@localhost:5432/postgres", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", + ); + }), + signal, + ), + ); }); for (const { name, fixtureKey, assertValue } of SUBCOMMANDS) { describe(`inspect:db:${name}`, () => { testBehaviour( "renders query results as a table", - async ({ run, workspace, apiUrl, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - await setPgFixture(apiUrl, fixtureKey); - const result = await run(["inspect", "db", name]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(assertValue); - }, + ({ run, workspace, apiUrl, pgMockPort, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupInspectWorkspace(workspace.path, pgMockPort); + yield* setPgFixture(apiUrl, fixtureKey); + const result = yield* run(["inspect", "db", name]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(assertValue); + }), + signal, + ), ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "inspect", - "db", - name, - "--remote=postgresql://postgres:postgres@localhost:1/postgres", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "inspect", + "db", + name, + "--remote=postgresql://postgres:postgres@localhost:1/postgres", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + signal, + ), + ); }); } describe("inspect:report", () => { - testBehaviour("saves CSV files on success", async ({ run, workspace, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - const outDir = join(workspace.path, "report-out"); - const result = await run(["inspect", "report", "--output-dir", outDir]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Reports saved to"); - const subdirs = readdirSync(outDir); - expect(subdirs.length).toBe(1); - const dateDir = subdirs[0]!; - const csvFiles = readdirSync(join(outDir, dateDir)); - expect(csvFiles.length).toBeGreaterThan(0); - expect(csvFiles.every((f) => f.endsWith(".csv"))).toBe(true); - }); + testBehaviour("saves CSV files on success", ({ run, workspace, pgMockPort, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* setupInspectWorkspace(workspace.path, pgMockPort); + const outDir = path.join(workspace.path, "report-out"); + const result = yield* run(["inspect", "report", "--output-dir", outDir]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Reports saved to"); + const subdirs = yield* fs.readDirectory(outDir); + expect(subdirs.length).toBe(1); + const dateDir = subdirs[0]!; + const csvFiles = yield* fs.readDirectory(path.join(outDir, dateDir)); + expect(csvFiles.length).toBeGreaterThan(0); + expect(csvFiles.every((f) => f.endsWith(".csv"))).toBe(true); + }), + signal, + ), + ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "inspect", - "report", - "--remote=postgresql://postgres:postgres@localhost:1/postgres", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "inspect", + "report", + "--remote=postgresql://postgres:postgres@localhost:1/postgres", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/link-project.ts b/apps/cli-e2e/src/tests/link-project.ts index 513658c3c7..c405530943 100644 --- a/apps/cli-e2e/src/tests/link-project.ts +++ b/apps/cli-e2e/src/tests/link-project.ts @@ -1,13 +1,14 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; /** Writes `project-ref` and a stub `pooler-url` so `--remote` skips the live Postgres probe. */ -export function linkProject(dir: string, ref: string): void { - const tempDir = join(dir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), ref); - writeFileSync( - join(tempDir, "pooler-url"), +export const linkProject = Effect.fnUntraced(function* (dir: string, ref: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(dir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), ref); + yield* fs.writeFileString( + path.join(tempDir, "pooler-url"), `postgresql://postgres.${ref}:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres`, ); -} +}); diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 3180c38ae3..a18e0bb383 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -1,91 +1,155 @@ -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; const MIGRATION_NAME = "my_change"; describe("migrations", () => { describe("migration:new", () => { - testBehaviour("creates timestamped sql file", async ({ run, workspace }) => { - const result = await run(["migration", "new", MIGRATION_NAME]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created new migration at"); - const files = readdirSync(join(workspace.path, "supabase", "migrations")); - expect(files.some((f) => f.endsWith(`_${MIGRATION_NAME}.sql`))).toBe(true); - }); + testBehaviour("creates timestamped sql file", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["migration", "new", MIGRATION_NAME]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created new migration at"); + const files = yield* fs.readDirectory( + path.join(workspace.path, "supabase", "migrations"), + ); + expect(files.some((f) => f.endsWith(`_${MIGRATION_NAME}.sql`))).toBe(true); + }), + signal, + ), + ); - testBehaviour("exits non-zero without name argument", async ({ run }) => { - const result = await run(["migration", "new"]); - expect(result.exitCode).not.toBe(0); - // A missing positional argument's usage block prints to stderr only. - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("migration name"); - }); + testBehaviour("exits non-zero without name argument", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "new"]); + expect(result.exitCode).not.toBe(0); + // A missing positional argument's usage block prints to stderr only. + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("migration name"); + }), + signal, + ), + ); }); describe("migration:list", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:up", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "up"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "up"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:down", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "down"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "down"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on connection refused with --last 2", async ({ run }) => { - const result = await run(["migration", "down", "--last", "2"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused with --last 2", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "down", "--last", "2"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:repair", () => { - testBehaviour("exits non-zero when --status flag is missing", async ({ run }) => { - const result = await run(["migration", "repair", "20230101000000"]); - expect(result.exitCode).not.toBe(0); - // A missing required flag's error prints once, without a duplicate - // usage dump, and spells the flag name without its `--` prefix. - expect(result.stderr).toContain('"status" not set'); - }); + testBehaviour("exits non-zero when --status flag is missing", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "repair", "20230101000000"]); + expect(result.exitCode).not.toBe(0); + // A missing required flag's error prints once, without a duplicate + // usage dump, and spells the flag name without its `--` prefix. + expect(result.stderr).toContain('"status" not set'); + }), + signal, + ), + ); - testBehaviour("exits non-zero on connection refused", async ({ run, workspace }) => { - const migrationsDir = join(workspace.path, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20230101000000_x.sql"), "select 1;\n"); - const result = await run(["migration", "repair", "--status", "applied", "20230101000000"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(workspace.path, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20230101000000_x.sql"), + "select 1;\n", + ); + const result = yield* run([ + "migration", + "repair", + "--status", + "applied", + "20230101000000", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:squash", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "squash"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "squash"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + signal, + ), + ); }); describe("migration:fetch", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "fetch"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "fetch"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/network-security.e2e.test.ts b/apps/cli-e2e/src/tests/network-security.e2e.test.ts index 5e19ae3803..8e80199da4 100644 --- a/apps/cli-e2e/src/tests/network-security.e2e.test.ts +++ b/apps/cli-e2e/src/tests/network-security.e2e.test.ts @@ -1,683 +1,883 @@ +import { Effect } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("network-bans", () => { describe("network-bans:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["network-bans", "get", "--experimental", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--debug", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "get", + "--experimental", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("network-bans:remove", () => { - testBehaviour("removes IP from ban list", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("removes multiple IPs from ban list", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--db-unban-ip", - "5.6.7.8", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with invalid IP address", async ({ run }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "invalid-ip", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("invalid IP address"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); + testBehaviour("removes IP from ban list", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("removes multiple IPs from ban list", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--db-unban-ip", + "5.6.7.8", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with invalid IP address", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "invalid-ip", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("invalid IP address"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); }); }); describe("network-restrictions", () => { describe("network-restrictions:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "get", - "--experimental", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("network-restrictions:update", () => { - testBehaviour("sets CIDR allowlist", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("appends to existing restrictions", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--append", - "--db-allow-cidr", - "8.8.8.0/24", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("bypasses CIDR validation checks", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--bypass-cidr-checks", - "--db-allow-cidr", - "0.0.0.0/0", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with invalid CIDR format", async ({ run }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "not-a-cidr", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to parse IP"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("sets CIDR allowlist", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("appends to existing restrictions", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--append", + "--db-allow-cidr", + "8.8.8.0/24", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("bypasses CIDR validation checks", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--bypass-cidr-checks", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with invalid CIDR format", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "not-a-cidr", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to parse IP"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("ssl-enforcement", () => { describe("ssl-enforcement:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("ssl-enforcement:update", () => { - testBehaviour("enables SSL enforcement", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("disables SSL enforcement", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--disable-db-ssl-enforcement", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with mutually exclusive flags", async ({ run }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--disable-db-ssl-enforcement", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero with no flags provided", async ({ run }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("enables SSL enforcement", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("disables SSL enforcement", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--disable-db-ssl-enforcement", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with mutually exclusive flags", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--disable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with no flags provided", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/orgs.e2e.test.ts b/apps/cli-e2e/src/tests/orgs.e2e.test.ts index 74964819b3..4f362f6507 100644 --- a/apps/cli-e2e/src/tests/orgs.e2e.test.ts +++ b/apps/cli-e2e/src/tests/orgs.e2e.test.ts @@ -1,157 +1,222 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const ORG_NAME = "My Test Org"; describe("orgs", () => { describe("orgs:list", () => { - testBehaviour("renders org data", async ({ run }) => { - const result = await run(["orgs", "list"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ID"); - expect(result.stdout).toMatch(/[a-z]{20}/); - }); - - testBehaviour("returns json output with --output json", async ({ run }) => { - const result = await run(["orgs", "list", "--output", "json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0]).toMatchObject({ id: expect.any(String), name: expect.any(String) }); - }); - - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["orgs", "list", "--debug"]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders org data", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ID"); + expect(result.stdout).toMatch(/[a-z]{20}/); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "list", "--output", "json"]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + /* Narrows the type only: the assertion above already fails a non-array. */ + if (!Array.isArray(parsed)) return; + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0]).toMatchObject({ id: expect.any(String), name: expect.any(String) }); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "list", "--debug"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("orgs:create", () => { - testBehaviour.skipIf(isRecording)("creates organization with name", async ({ run }) => { - const result = await run(["orgs", "create", ORG_NAME, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created organization:"); - expect(result.stdout).toMatch(/[a-z]{20}/); - }); - - testBehaviour("exits non-zero without name in non-TTY", async ({ run }) => { - const result = await run(["orgs", "create", "--yes"]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Organization name already in use" }, + testBehaviour.skipIf(isRecording)("creates organization with name", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created organization:"); + expect(result.stdout).toMatch(/[a-z]{20}/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero without name in non-TTY", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "create", "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Organization name already in use" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Organization name already in use"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403 plan limit reached", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Organization limit reached" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Organization limit reached"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 validation error", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Invalid organization name" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid organization name"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); }), - }); - const result = await run(["orgs", "create", ORG_NAME, "--yes"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Organization name already in use"); - }); - - testBehaviour("exits non-zero on 403 plan limit reached", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Organization limit reached" } }), - }); - const result = await run(["orgs", "create", ORG_NAME, "--yes"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Organization limit reached"); - }); - - testBehaviour("exits non-zero on 422 validation error", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 422, body: { message: "Invalid organization name" } }), - }); - const result = await run(["orgs", "create", ORG_NAME, "--yes"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid organization name"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["orgs", "create", ORG_NAME, "--yes"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["orgs", "create", ORG_NAME, "--yes"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["orgs", "create", ORG_NAME, "--yes"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts index 9c68da3322..e3e3bf27df 100644 --- a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts +++ b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts @@ -1,63 +1,107 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect, inject, test } from "vitest"; -import { createHarness, exec, makeTempDir } from "@supabase/cli-test-helpers"; +import { createHarness, makeTempDir } from "@supabase/cli-test-helpers"; import { ACCESS_TOKEN, isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { execCli, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("init", () => { - testBehaviour("creates supabase/config.toml and exits zero", async ({ run, workspace }) => { - const result = await run(["init"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - expect(existsSync(join(workspace.path, "supabase", "config.toml"))).toBe(true); - }); + testBehaviour("creates supabase/config.toml and exits zero", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["init"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + expect(yield* fs.exists(path.join(workspace.path, "supabase", "config.toml"))).toBe(true); + }), + signal, + ), + ); testBehaviour( "exits non-zero if config.toml already exists without --force", - async ({ run, workspace }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync(join(workspace.path, "supabase", "config.toml"), "# existing config\n"); - const result = await run(["init"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("force"); - }, + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspace.path, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace.path, "supabase", "config.toml"), + "# existing config\n", + ); + const result = yield* run(["init"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("force"); + }), + signal, + ), ); - testBehaviour("exits zero with --force when config.toml exists", async ({ run, workspace }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync(join(workspace.path, "supabase", "config.toml"), "# existing config\n"); - const result = await run(["init", "--force"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - expect(existsSync(join(workspace.path, "supabase", "config.toml"))).toBe(true); - }); + testBehaviour("exits zero with --force when config.toml exists", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspace.path, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace.path, "supabase", "config.toml"), + "# existing config\n", + ); + const result = yield* run(["init", "--force"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + expect(yield* fs.exists(path.join(workspace.path, "supabase", "config.toml"))).toBe(true); + }), + signal, + ), + ); testBehaviour( "creates VS Code settings with --with-vscode-settings", - async ({ run, workspace }) => { - const result = await run(["init", "--with-vscode-settings"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Generated VS Code settings"); - expect(existsSync(join(workspace.path, ".vscode", "settings.json"))).toBe(true); - }, + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["init", "--with-vscode-settings"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Generated VS Code settings"); + expect(yield* fs.exists(path.join(workspace.path, ".vscode", "settings.json"))).toBe( + true, + ); + }), + signal, + ), ); testBehaviour( "creates IntelliJ settings with --with-intellij-settings", - async ({ run, workspace }) => { - const result = await run(["init", "--with-intellij-settings"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Generated IntelliJ settings"); - expect(existsSync(join(workspace.path, ".idea", "deno.xml"))).toBe(true); - }, + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["init", "--with-intellij-settings"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Generated IntelliJ settings"); + expect(yield* fs.exists(path.join(workspace.path, ".idea", "deno.xml"))).toBe(true); + }), + signal, + ), ); - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["init", "--debug"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - }); + testBehaviour("includes debug output with --debug", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["init", "--debug"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + }), + signal, + ), + ); }); describe("link", () => { @@ -65,92 +109,142 @@ describe("link", () => { // SUPABASE_PROJECT_ID, which the CLI accepts as a substitute for // --project-ref in non-TTY mode. A raw test omits it so the CLI correctly // requires the --project-ref flag. - test("exits non-zero without --project-ref in non-TTY", async () => { + test("exits non-zero without --project-ref in non-TTY", ({ signal }) => { const serverUrl = inject("replayServerUrl") as string; - const dir = await makeTempDir("cli-e2e-link-no-ref-"); - await using _ = dir; - const harness = createHarness({ - apiUrl: serverUrl, - accessToken: ACCESS_TOKEN, - cwd: dir.path, - }); - const result = await exec(harness, ["link"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("project-ref"); + return runE2e( + Effect.acquireUseRelease( + Effect.promise(() => makeTempDir("cli-e2e-link-no-ref-")), + (dir) => + Effect.gen(function* () { + const harness = createHarness({ + apiUrl: serverUrl, + accessToken: ACCESS_TOKEN, + cwd: dir.path, + }); + const result = yield* execCli(harness, ["link"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("project-ref"); + }), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ), + signal, + ); }); // The testBehaviour run fixture always injects SUPABASE_PROJECT_ID, which the // CLI accepts in place of --project-ref, so link proceeds to the API and succeeds. - testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", async ({ run }) => { - const result = await run(["link"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["link"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["link", "--project-ref", PROJECT_REF]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["link", "--project-ref", PROJECT_REF]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["link", "--project-ref", PROJECT_REF]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); // link's concurrent service calls (after project-status and api-keys) fail // silently, so only the first two sequential calls need fixture entries. testBehaviour.skipIf(isRecording)( "links project successfully", - async ({ run, projectRef, workspace }) => { - const result = await run(["link", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase link"); - expect(existsSync(join(workspace.path, "supabase", ".temp", "project-ref"))).toBe(true); - expect( - readFileSync(join(workspace.path, "supabase", ".temp", "storage-migration"), "utf8"), - ).toBe("operation-ergonomics"); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["link", "--project-ref", projectRef]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect( + yield* fs.exists(path.join(workspace.path, "supabase", ".temp", "project-ref")), + ).toBe(true); + expect( + new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile( + path.join(workspace.path, "supabase", ".temp", "storage-migration"), + ), + ), + ).toBe("operation-ergonomics"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "--skip-pooler uses direct connection", - async ({ run, projectRef, workspace }) => { - const result = await run(["link", "--project-ref", projectRef, "--skip-pooler"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase link"); - expect(existsSync(join(workspace.path, "supabase", ".temp", "project-ref"))).toBe(true); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["link", "--project-ref", projectRef, "--skip-pooler"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect( + yield* fs.exists(path.join(workspace.path, "supabase", ".temp", "project-ref")), + ).toBe(true); + }), + signal, + ), ); }); describe("unlink", () => { - testBehaviour("exits non-zero when project not linked", async ({ run }) => { - const result = await run(["unlink"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("supabase link"); - }); + testBehaviour("exits non-zero when project not linked", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["unlink"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("supabase link"); + }), + signal, + ), + ); // The success path is omitted: unlink deletes a keyring entry on success, and // Linux CI (no D-Bus session bus) makes that call exit 1. See diff --git a/apps/cli-e2e/src/tests/projects.e2e.test.ts b/apps/cli-e2e/src/tests/projects.e2e.test.ts index 3dd0f694ae..858a03ace2 100644 --- a/apps/cli-e2e/src/tests/projects.e2e.test.ts +++ b/apps/cli-e2e/src/tests/projects.e2e.test.ts @@ -1,317 +1,418 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("projects", () => { describe("projects:list", () => { - testBehaviour("renders project list", async ({ run }) => { - const result = await run(["projects", "list"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/[a-z]{20}/); - expect(result.stdout).toContain("REFERENCE ID"); - }); + testBehaviour("renders project list", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "list"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/[a-z]{20}/); + expect(result.stdout).toContain("REFERENCE ID"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("returns json output with --output json", async ({ run }) => { - const result = await run(["projects", "list", "--output", "json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0]).toMatchObject({ name: expect.any(String), ref: expect.any(String) }); - }); + testBehaviour("returns json output with --output json", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "list", "--output", "json"]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + /* Narrows the type only: the assertion above already fails a non-array. */ + if (!Array.isArray(parsed)) return; + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0]).toMatchObject({ name: expect.any(String), ref: expect.any(String) }); + }), + signal, + ), + ); - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["projects", "list", "--debug"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("includes debug output with --debug", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "list", "--debug"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("projects:api-keys", () => { - testBehaviour("shows default and anon keys", async ({ run, projectRef }) => { - const result = await run(["projects", "api-keys", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("KEY VALUE"); - expect(result.stdout).toContain("anon"); - expect(result.stdout).toContain("default"); - }); + testBehaviour("shows default and anon keys", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "api-keys", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("KEY VALUE"); + expect(result.stdout).toContain("anon"); + expect(result.stdout).toContain("default"); + }), + signal, + ), + ); - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "projects", - "api-keys", - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual(expect.arrayContaining([expect.objectContaining({ name: "anon" })])); - }); + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "projects", + "api-keys", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "anon" })]), + ); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("projects:create", () => { - testBehaviour("creates project with required flags", async ({ run, orgId }) => { - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - orgId, - "--db-password", - "password123", - "--region", - "us-east-1", - "--size", - "micro", - "--yes", - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("REFERENCE ID"); - }); + testBehaviour("creates project with required flags", ({ run, orgId, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + orgId, + "--db-password", + "password123", + "--region", + "us-east-1", + "--size", + "micro", + "--yes", + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("REFERENCE ID"); + }), + signal, + ), + ); - testBehaviour("exits non-zero without required flags in non-TTY", async ({ run }) => { - const result = await run(["projects", "create", "--org-id", "test-org-id", "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero without required flags in non-TTY", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "create", "--org-id", "test-org-id", "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Project name already in use" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - "--yes", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Project name already in use" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 422 validation error", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { - message: "Validation failed", - errors: [{ field: "region", message: "Invalid region" }], - }, + testBehaviour("exits non-zero on 422 validation error", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { + message: "Validation failed", + errors: [{ field: "region", message: "Invalid region" }], + }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - "--yes", - ]); - expect(result.exitCode).not.toBe(0); - }); + signal, + ), + ); - testBehaviour("exits non-zero on 403 no org access", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - "--yes", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403 no org access", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - "--yes", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - "--yes", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("projects:delete", () => { testBehaviour.skipIf(isRecording)( "returns 400 when project not ready for deletion", - async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 400, - body: { message: "Project not ready for deletion." }, + ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 400, + body: { message: "Project not ready for deletion." }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }, + signal, + ), ); testBehaviour.skipIf(isRecording)( "deletes project with --yes flag", - async ({ run, projectRef }) => { - const result = await run(["projects", "delete", projectRef, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted project"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "delete", projectRef, "--yes"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted project"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/request-log.ts b/apps/cli-e2e/src/tests/request-log.ts index 4b62c26abb..dc172c1bff 100644 --- a/apps/cli-e2e/src/tests/request-log.ts +++ b/apps/cli-e2e/src/tests/request-log.ts @@ -1,12 +1,15 @@ -export interface RequestEntry { - method: string; - pathname: string; - headers: Record; - body: unknown; -} +import { Effect, Schema } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +const RequestEntry = Schema.Struct({ + method: Schema.String, + pathname: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Unknown, +}); /** The requests the replay server received since the last test cleared its log. */ -export async function getRequestLog(apiUrl: string): Promise { - const res = await fetch(`${apiUrl}/_ctrl/requests`); - return res.json() as Promise; -} +export const getRequestLog = Effect.fnUntraced(function* (apiUrl: string) { + const res = yield* HttpClient.get(`${apiUrl}/_ctrl/requests`); + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(RequestEntry))(res); +}); diff --git a/apps/cli-e2e/src/tests/secrets.e2e.test.ts b/apps/cli-e2e/src/tests/secrets.e2e.test.ts index a3d2924351..28e8b5a61d 100644 --- a/apps/cli-e2e/src/tests/secrets.e2e.test.ts +++ b/apps/cli-e2e/src/tests/secrets.e2e.test.ts @@ -1,206 +1,325 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("secrets", () => { describe("secrets:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("DIGEST"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--output", "json", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("DIGEST"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); describe("secrets:set", () => { - testBehaviour("sets a single secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "set", "FOO=bar", "--yes", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("sets multiple secrets", async ({ run, projectRef }) => { - const result = await run([ - "secrets", - "set", - "FOO=bar", - "BAZ=qux", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("sets secrets from env file", async ({ run, workspace, projectRef }) => { - writeFileSync(join(workspace.path, ".env.local"), "FOO=bar\n"); - const result = await run([ - "secrets", - "set", - "--env-file", - ".env.local", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("exits non-zero on 422 invalid name", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, - }), - }); - const result = await run(["secrets", "set", "FOO=bar", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero when env file not found", async ({ run }) => { - const result = await run([ - "secrets", - "set", - "--env-file", - "nonexistent.env", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "set", "FOO=bar", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("sets a single secret", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("sets multiple secrets", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "BAZ=qux", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("sets secrets from env file", ({ run, workspace, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(workspace.path, ".env.local"), "FOO=bar\n"); + const result = yield* run([ + "secrets", + "set", + "--env-file", + ".env.local", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 invalid name", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, + }); + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero when env file not found", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "set", + "--env-file", + "nonexistent.env", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("secrets:unset", () => { - testBehaviour("removes a secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "unset", "FOO", "--yes", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("removes multiple secrets", async ({ run, projectRef }) => { - await run(["secrets", "set", "FOO=bar", "BAR=baz", "--yes", `--remote=${projectRef}`]); - const result = await run([ - "secrets", - "unset", - "FOO", - "BAR", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("exits non-zero on 404", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Secret not found" } }), - }); - const result = await run([ - "secrets", - "unset", - "NONEXISTENT", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "unset", "FOO", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["secrets", "unset", "FOO", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("removes a secret", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "unset", "FOO", "--yes", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("removes multiple secrets", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["secrets", "set", "FOO=bar", "BAR=baz", "--yes", `--remote=${projectRef}`]); + const result = yield* run([ + "secrets", + "unset", + "FOO", + "BAR", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Secret not found" }, + }); + const result = yield* run([ + "secrets", + "unset", + "NONEXISTENT", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "secrets", + "unset", + "FOO", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "secrets", + "unset", + "FOO", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/sso.e2e.test.ts b/apps/cli-e2e/src/tests/sso.e2e.test.ts index af6a369fc4..f117212d3b 100644 --- a/apps/cli-e2e/src/tests/sso.e2e.test.ts +++ b/apps/cli-e2e/src/tests/sso.e2e.test.ts @@ -1,8 +1,7 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF, PROVIDER_ID } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const MINIMAL_SAML_XML = ` @@ -13,94 +12,141 @@ const MINIMAL_SAML_XML = ` describe("sso", () => { describe("sso:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "list", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("IDENTITY PROVIDER ID"); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--output", "json", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ providers: [] }); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--debug", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("SAML 2.0 support is not enabled"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("IDENTITY PROVIDER ID"); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "list", "--output", "json", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(yield* decodeJsonOutput(Schema.Unknown)(result.stdout)).toMatchObject({ + providers: [], + }); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("SAML 2.0 support is not enabled"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:info", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "info", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("supabase.co/auth/v1/sso/saml/acs"); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "info", "--output", "json", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - acs_url: expect.stringContaining("supabase.co/auth/v1/sso/saml/acs"), - }); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "info", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("supabase.co/auth/v1/sso/saml/acs"); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "info", "--output", "json", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(yield* decodeJsonOutput(Schema.Unknown)(result.stdout)).toMatchObject({ + acs_url: expect.stringContaining("supabase.co/auth/v1/sso/saml/acs"), + }); + }), + signal, + ), + ); // sso info makes no API calls — no error injection tests needed }); @@ -108,531 +154,716 @@ describe("sso", () => { describe("sso:show", () => { testBehaviour.skipIf(isRecording)( "renders fixture data in output", - async ({ run, projectRef }) => { - const result = await run(["sso", "show", PROVIDER_ID, `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "show", - PROVIDER_ID, - "--output", - "json", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "show", + PROVIDER_ID, + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "shows raw SAML metadata XML with --metadata", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "show", - PROVIDER_ID, - "--metadata", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("EntityDescriptor"); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "show", + PROVIDER_ID, + "--metadata", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("EntityDescriptor"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "SSO Identity Provider not found" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:add", () => { testBehaviour( "adds SAML provider via metadata file", - async ({ run, projectRef, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--domains", - "example.com", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, - ); - - testBehaviour("exits non-zero without --type", async ({ run }) => { - const result = await run([ - "sso", - "add", - "--metadata-url", - "https://example.com/saml/metadata", - "--skip-url-validation", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain('"type"'); - }); + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--domains", + "example.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero without --type", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "add", + "--metadata-url", + "https://example.com/saml/metadata", + "--skip-url-validation", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('"type"'); + }), + signal, + ), + ); testBehaviour( "exits non-zero with both --metadata-url and --metadata-file", - async ({ run, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-url", - "https://example.com/saml/metadata", - "--metadata-file", - metadataPath, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("metadata"); - }, - ); - - testBehaviour("exits non-zero with unreachable --metadata-url", async ({ run }) => { - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-url", - "http://localhost:19999/saml.xml", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("HTTPS"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 422 invalid metadata", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Invalid SAML metadata" }, - }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid SAML metadata"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-url", + "https://example.com/saml/metadata", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("metadata"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with unreachable --metadata-url", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-url", + "http://localhost:19999/saml.xml", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("HTTPS"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 invalid metadata", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Invalid SAML metadata" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid SAML metadata"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:update", () => { testBehaviour.skipIf(isRecording)( "appends domain with --add-domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "replaces domains with --domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--domains", - "new.com", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("new.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--domains", + "new.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("new.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "removes domain with --remove-domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--remove-domains", - "example.com", - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("IDENTITY PROVIDER ID"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--remove-domains", + "example.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("IDENTITY PROVIDER ID"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "updates metadata via metadata file", - async ({ run, projectRef, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--metadata-file", - metadataPath, - "--yes", - `--remote=${projectRef}`, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("EntityDescriptor"); - }, - ); - - testBehaviour("exits non-zero with --domains and --add-domains", async ({ run }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--domains", - "a.com", - "--add-domains", - "b.com", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("domains"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--yes", - `--remote=${PROJECT_REF}`, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--metadata-file", + metadataPath, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("EntityDescriptor"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with --domains and --add-domains", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--domains", + "a.com", + "--add-domains", + "b.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("domains"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "SSO Identity Provider not found" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:remove", () => { - testBehaviour.skipIf(isRecording)("removes a provider", async ({ run }) => { - const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("removes a provider", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "SSO Identity Provider not found" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/stack.e2e.test.ts b/apps/cli-e2e/src/tests/stack.e2e.test.ts index d36ea2ae23..ec0b7b41a4 100644 --- a/apps/cli-e2e/src/tests/stack.e2e.test.ts +++ b/apps/cli-e2e/src/tests/stack.e2e.test.ts @@ -1,9 +1,8 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect, inject, test } from "vitest"; -import { createHarness, exec, type CLIResult } from "@supabase/cli-test-helpers"; +import { createHarness, type CLIResult } from "@supabase/cli-test-helpers"; import { getRequestLog } from "./request-log.ts"; -import { testBehaviour } from "./test-context.ts"; +import { type CliExecError, execCli, runE2e, testBehaviour } from "./test-context.ts"; import { ACCESS_TOKEN } from "./env.ts"; // A guaranteed-unreachable TCP address — connection is refused immediately. @@ -11,10 +10,15 @@ import { ACCESS_TOKEN } from "./env.ts"; const UNREACHABLE_DOCKER_HOST = "tcp://localhost:1"; // Minimal config.toml required by start/stop/status. -function setupStackWorkspace(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "config.toml"), 'project_id = "test-project"\n'); -} +const setupStackWorkspace = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + 'project_id = "test-project"\n', + ); +}); // Extends testBehaviour with a `stackRun` fixture that automatically passes // DOCKER_HOST to the CLI subprocess, pointing it at the relay server. @@ -23,11 +27,14 @@ function setupStackWorkspace(dir: string): void { // The optional `dockerHost` override lets individual tests substitute an // unreachable host to simulate Docker being unavailable. interface StackFixtures { - stackRun: (cmd: string[], opts?: { dockerHost?: string }) => Promise; + stackRun: ( + cmd: string[], + opts?: { dockerHost?: string }, + ) => Effect.Effect; } const testStack = testBehaviour.extend({ - stackRun: async ({ workspace }, use) => { + stackRun: ({ runGuard, workspace }, use) => { const serverUrl = inject("replayServerUrl") as string; const dockerHostUrl = inject("dockerHostUrl") as string; const harness = createHarness({ @@ -35,65 +42,90 @@ const testStack = testBehaviour.extend({ accessToken: ACCESS_TOKEN, cwd: workspace.path, }); - await use((cmd, opts) => - exec(harness, cmd, { env: { DOCKER_HOST: opts?.dockerHost ?? dockerHostUrl } }), + return use((cmd, opts) => + runGuard.track( + execCli(harness, cmd, { env: { DOCKER_HOST: opts?.dockerHost ?? dockerHostUrl } }), + ), ); }, }); // `services` prints a static service matrix, so DOCKER_HOST is not needed. describe("services", () => { - testBehaviour("lists known service images", async ({ run }) => { - const result = await run(["services"]); - expect(result.exitCode).toBe(0); - // Output is a pipe-separated markdown table; verify well-known image names appear. - expect(result.stdout).toContain("postgres"); - expect(result.stdout).toContain("gotrue"); - expect(result.stdout).toContain("storage"); - }); + testBehaviour("lists known service images", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["services"]); + expect(result.exitCode).toBe(0); + // Output is a pipe-separated markdown table; verify well-known image names appear. + expect(result.stdout).toContain("postgres"); + expect(result.stdout).toContain("gotrue"); + expect(result.stdout).toContain("storage"); + }), + signal, + ), + ); }); // `status` resolves and prints the current linked project/branch on stdout // before any Docker/daemon work runs, in every output mode. describe("status", () => { - testStack("exits 1 when stack is not running", async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["status"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/no such container/i); - }); + testStack("exits 1 when stack is not running", ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* stackRun(["status"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/no such container/i); + }), + signal, + ), + ); }); describe("stop", () => { - testStack("succeeds when stack is not running", async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["stop"]); - expect(result.exitCode).toBe(0); - }); + testStack("succeeds when stack is not running", ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* stackRun(["stop"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); // Flag validation happens before the command runs, so no Docker or API calls are made. testStack( "exits 1 with mutual-exclusion error for --project-id and --all", - async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["stop", "--project-id", "test-project", "--all"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/mutually exclusive|if any flags in the group.*are set/i); - }, + ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* stackRun(["stop", "--project-id", "test-project", "--all"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/mutually exclusive|if any flags in the group.*are set/i); + }), + signal, + ), ); }); describe("start", () => { testStack( "exits 1 with Docker error when Docker is unavailable", - async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - // Use an unreachable host so connection fails immediately without waiting - // for a timeout. The relay has no special handling for this case. - const result = await stackRun(["start"], { dockerHost: UNREACHABLE_DOCKER_HOST }); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - }, + ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + // Use an unreachable host so connection fails immediately without waiting + // for a timeout. The relay has no special handling for this case. + const result = yield* stackRun(["start"], { dockerHost: UNREACHABLE_DOCKER_HOST }); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + }), + signal, + ), ); // Must run in sequence in a shared workspace so status/stop see the stack start brought up. @@ -105,27 +137,34 @@ describe("start", () => { // `seed buckets` makes storage HTTP calls (not Docker), so plain testBehaviour // with `run` is correct. describe("seed buckets", () => { - testBehaviour("creates buckets defined in config", async ({ workspace, run, apiUrl }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync( - join(workspace.path, "supabase", "config.toml"), - [ - 'project_id = "test-project"', - "", - "[api]", - // Point the local stack API at the relay server so bucket creation - // calls are captured. - `port = ${new URL(apiUrl).port}`, - "", - "[storage.buckets.my-bucket]", - "public = false", - ].join("\n"), - ); - const result = await run(["seed", "buckets"]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect(requests.some((r) => r.method === "POST" && r.pathname === "/storage/v1/bucket")).toBe( - true, - ); - }); + testBehaviour("creates buckets defined in config", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspace.path, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace.path, "supabase", "config.toml"), + [ + 'project_id = "test-project"', + "", + "[api]", + // Point the local stack API at the relay server so bucket creation + // calls are captured. + `port = ${new URL(apiUrl).port}`, + "", + "[storage.buckets.my-bucket]", + "public = false", + ].join("\n"), + ); + const result = yield* run(["seed", "buckets"]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some((r) => r.method === "POST" && r.pathname === "/storage/v1/bucket"), + ).toBe(true); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/storage.e2e.test.ts b/apps/cli-e2e/src/tests/storage.e2e.test.ts index 21f1144fb8..e0b9173a1a 100644 --- a/apps/cli-e2e/src/tests/storage.e2e.test.ts +++ b/apps/cli-e2e/src/tests/storage.e2e.test.ts @@ -1,438 +1,583 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; +import { HttpClient } from "effect/http"; import { describe, expect } from "vitest"; import { getRequestLog } from "./request-log.ts"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; const BUCKET = "cli-e2e-bucket"; const EXPERIMENTAL_FLAGS = ["--experimental"]; -function setupStorageWorkspace(dir: string, relayUrl: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const setupStorageWorkspace = Effect.fnUntraced(function* (dir: string, relayUrl: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), ['project_id = "test-project"', "", "[api]", `external_url = "${relayUrl}"`].join("\n"), ); - writeFileSync(join(dir, "upload.txt"), "test upload content"); -} + yield* fs.writeFileString(path.join(dir, "upload.txt"), "test upload content"); +}); -async function injectGlobalError(apiUrl: string, status: number, message: string): Promise { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status, body: { message } }), - }); -} +const injectGlobalError = (apiUrl: string, status: number, message: string) => + postControl(`${apiUrl}/_ctrl/error-all`, { status, body: { message } }); -async function clearOverrides(apiUrl: string): Promise { - await fetch(`${apiUrl}/_ctrl/overrides`, { method: "DELETE" }); -} +const clearOverrides = (apiUrl: string) => HttpClient.del(`${apiUrl}/_ctrl/overrides`); describe("storage ls", () => { - testBehaviour("lists objects in bucket", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("hello.txt"); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, - ), - ).toBe(true); - }); + testBehaviour("lists objects in bucket", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hello.txt"); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, + ), + ).toBe(true); + }), + signal, + ), + ); - testBehaviour("lists objects recursively", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, "-r", `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, - ), - ).toBe(true); - }); + testBehaviour("lists objects recursively", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "ls", + ...EXPERIMENTAL_FLAGS, + "-r", + `ss:///${BUCKET}/`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, + ), + ).toBe(true); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); }); describe("storage cp", () => { - testBehaviour("uploads local file to storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/${BUCKET}/upload.txt`, - ), - ).toBe(true); - }); + testBehaviour("uploads local file to storage", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/${BUCKET}/upload.txt`, + ), + ).toBe(true); + }), + signal, + ), + ); - testBehaviour("passes --cache-control header on upload", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "--cache-control", - "no-cache", - "upload.txt", - `ss:///${BUCKET}/cached.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const uploadReq = requests.find( - (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), - ); - expect(uploadReq).toBeDefined(); - expect(uploadReq?.headers["cache-control"]).toBe("no-cache"); - }); + testBehaviour("passes --cache-control header on upload", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "--cache-control", + "no-cache", + "upload.txt", + `ss:///${BUCKET}/cached.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const uploadReq = requests.find( + (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), + ); + expect(uploadReq).toBeDefined(); + expect(uploadReq?.headers["cache-control"]).toBe("no-cache"); + }), + signal, + ), + ); - testBehaviour("passes --content-type header on upload", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "--content-type", - "application/json", - "upload.txt", - `ss:///${BUCKET}/typed.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const uploadReq = requests.find( - (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), - ); - expect(uploadReq).toBeDefined(); - expect(uploadReq?.headers["content-type"]).toContain("application/json"); - }); + testBehaviour("passes --content-type header on upload", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "--content-type", + "application/json", + "upload.txt", + `ss:///${BUCKET}/typed.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const uploadReq = requests.find( + (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), + ); + expect(uploadReq).toBeDefined(); + expect(uploadReq?.headers["content-type"]).toContain("application/json"); + }), + signal, + ), + ); - testBehaviour("exits 1 when source file not found", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "nonexistent.txt", - `ss:///${BUCKET}/x.txt`, - ]); - expect(result.exitCode).toBe(1); - }); + testBehaviour("exits 1 when source file not found", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "nonexistent.txt", + `ss:///${BUCKET}/x.txt`, + ]); + expect(result.exitCode).toBe(1); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("downloads file from storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/hello.txt`, - "hello-download.txt", - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("downloads file from storage", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/hello.txt`, + "hello-download.txt", + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); describe("storage mv", () => { - testBehaviour("moves file within bucket", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - // Upload source file so the move has something to move in staging. - await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/mv-source.txt`, - ]); - const result = await run([ - "storage", - "mv", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/mv-source.txt`, - `ss:///${BUCKET}/mv-dest.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const moveReq = requests.find( - (r) => r.method === "POST" && r.pathname === "/storage/v1/object/move", - ); - expect(moveReq).toBeDefined(); - expect(moveReq?.body).toMatchObject({ - bucketId: BUCKET, - sourceKey: "mv-source.txt", - destinationKey: "mv-dest.txt", - }); - }); + testBehaviour("moves file within bucket", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + // Upload source file so the move has something to move in staging. + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/mv-source.txt`, + ]); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/mv-source.txt`, + `ss:///${BUCKET}/mv-dest.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const moveReq = requests.find( + (r) => r.method === "POST" && r.pathname === "/storage/v1/object/move", + ); + expect(moveReq).toBeDefined(); + expect(moveReq?.body).toMatchObject({ + bucketId: BUCKET, + sourceKey: "mv-source.txt", + destinationKey: "mv-dest.txt", + }); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "mv", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "mv", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "mv", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "mv", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); }); describe("storage rm", () => { - testBehaviour("removes a file from storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - // Upload the file to remove. - await run([ - "storage", - "cp", - ...EXPERIMENTAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/rm-target.txt`, - ]); - const result = await run([ - "storage", - "rm", - "--yes", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/rm-target.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const rmReq = requests.find( - (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, - ); - expect(rmReq).toBeDefined(); - expect(rmReq?.body).toMatchObject({ prefixes: ["rm-target.txt"] }); - }); + testBehaviour("removes a file from storage", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + // Upload the file to remove. + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/rm-target.txt`, + ]); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/rm-target.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const rmReq = requests.find( + (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, + ); + expect(rmReq).toBeDefined(); + expect(rmReq?.body).toMatchObject({ prefixes: ["rm-target.txt"] }); + }), + signal, + ), + ); - testBehaviour("removes multiple files", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - writeFileSync(join(workspace.path, "file2.txt"), "second file"); - await run(["storage", "cp", ...EXPERIMENTAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-a.txt`]); - await run(["storage", "cp", ...EXPERIMENTAL_FLAGS, "file2.txt", `ss:///${BUCKET}/rm-b.txt`]); - const result = await run([ - "storage", - "rm", - "--yes", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/rm-a.txt`, - `ss:///${BUCKET}/rm-b.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const rmReq = requests.find( - (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, - ); - expect(rmReq).toBeDefined(); - expect(rmReq?.body).toMatchObject({ prefixes: ["rm-a.txt", "rm-b.txt"] }); - }); + testBehaviour("removes multiple files", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* fs.writeFileString(path.join(workspace.path, "file2.txt"), "second file"); + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/rm-a.txt`, + ]); + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "file2.txt", + `ss:///${BUCKET}/rm-b.txt`, + ]); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/rm-a.txt`, + `ss:///${BUCKET}/rm-b.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const rmReq = requests.find( + (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, + ); + expect(rmReq).toBeDefined(); + expect(rmReq?.body).toMatchObject({ prefixes: ["rm-a.txt", "rm-b.txt"] }); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "rm", - "--yes", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "rm", - "--yes", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "rm", - "--yes", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "rm", - "--yes", - ...EXPERIMENTAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index 183a5f691d..c43305a73b 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -1,29 +1,46 @@ +import { Effect } from "effect"; import { describe, expect } from "vitest"; import { linkProject } from "./link-project.ts"; import { getRequestLog } from "./request-log.ts"; -import { testBehaviour } from "./test-context.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; const DEAD_DB_URL = "postgresql://postgres:postgres@localhost:1/postgres"; describe("remote target selection", () => { testBehaviour( "reads a space-separated --remote as --remote=", - async ({ run, projectRef, workspace, apiUrl }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--type", "security", "--remote", projectRef]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect(requests.map((r) => `${r.method} ${r.pathname}`)).toContain( - `GET /v1/projects/${projectRef}/advisors/security`, - ); - }, + ({ run, projectRef, workspace, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run([ + "db", + "advisors", + "--type", + "security", + "--remote", + projectRef, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect(requests.map((r) => `${r.method} ${r.pathname}`)).toContain( + `GET /v1/projects/${projectRef}/advisors/security`, + ); + }), + signal, + ), ); - testBehaviour("rejects --remote= on a hosted command", async ({ run, apiUrl }) => { - const result = await run(["secrets", "list", `--remote=${DEAD_DB_URL}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--remote takes a project ref or a branch name."); - const requests = await getRequestLog(apiUrl); - expect(requests.filter((r) => r.pathname.endsWith("/secrets"))).toEqual([]); - }); + testBehaviour("rejects --remote= on a hosted command", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "list", `--remote=${DEAD_DB_URL}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--remote takes a project ref or a branch name."); + const requests = yield* getRequestLog(apiUrl); + expect(requests.filter((r) => r.pathname.endsWith("/secrets"))).toEqual([]); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts index 9d6bc4e942..6470256ac2 100644 --- a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts +++ b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts @@ -1,63 +1,105 @@ -import { chmodSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; describe("telemetry", () => { describe("telemetry:enable", () => { - testBehaviour("enables telemetry", async ({ run }) => { - const result = await run(["telemetry", "enable"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - }); + testBehaviour("enables telemetry", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["telemetry", "enable"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + }), + signal, + ), + ); - testBehaviour("exits non-zero on unwritable config dir", async ({ run, workspace }) => { - chmodSync(workspace.path, 0o555); - try { - const result = await run(["telemetry", "enable"]); - expect(result.exitCode).not.toBe(0); - } finally { - chmodSync(workspace.path, 0o755); - } - }); + testBehaviour("exits non-zero on unwritable config dir", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Effect.acquireUseRelease( + fs.chmod(workspace.path, 0o555), + () => + Effect.gen(function* () { + const result = yield* run(["telemetry", "enable"]); + expect(result.exitCode).not.toBe(0); + }), + () => fs.chmod(workspace.path, 0o755), + ); + }), + signal, + ), + ); }); describe("telemetry:disable", () => { - testBehaviour("disables telemetry", async ({ run }) => { - const result = await run(["telemetry", "disable"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is disabled."); - }); + testBehaviour("disables telemetry", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["telemetry", "disable"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is disabled."); + }), + signal, + ), + ); }); describe("telemetry:status", () => { - testBehaviour("shows current telemetry state", async ({ run }) => { - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); - }); + testBehaviour("shows current telemetry state", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); + }), + signal, + ), + ); - testBehaviour("round-trip: enable then status shows enabled", async ({ run }) => { - await run(["telemetry", "enable"]); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - }); + testBehaviour("round-trip: enable then status shows enabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["telemetry", "enable"]); + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + }), + signal, + ), + ); - testBehaviour("round-trip: disable then status shows disabled", async ({ run }) => { - await run(["telemetry", "disable"]); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is disabled."); - }); + testBehaviour("round-trip: disable then status shows disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["telemetry", "disable"]); + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is disabled."); + }), + signal, + ), + ); - testBehaviour("handles corrupted config gracefully", async ({ run, workspace }) => { - const telemetryPath = join(workspace.path, "telemetry.json"); - writeFileSync(telemetryPath, "{{not valid json}}"); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow(); - }); + testBehaviour("handles corrupted config gracefully", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const telemetryPath = path.join(workspace.path, "telemetry.json"); + yield* fs.writeFileString(telemetryPath, "{{not valid json}}"); + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + const telemetry = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(telemetryPath), + ); + expect(() => JSON.parse(telemetry)).not.toThrow(); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/test-context.ts b/apps/cli-e2e/src/tests/test-context.ts index 4a856f97d6..92e22b1d64 100644 --- a/apps/cli-e2e/src/tests/test-context.ts +++ b/apps/cli-e2e/src/tests/test-context.ts @@ -1,8 +1,12 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Data, Effect, Formatter, Layer, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { inject, test } from "vitest"; import { createHarness, exec, makeTempDir, + type CLIHarness, type CLIResult, type TempDir, } from "@supabase/cli-test-helpers"; @@ -22,12 +26,79 @@ function scenarioSlug(task: { name: string; suite?: { name: string } | null }): type ExecOptions = NonNullable[2]>; +/** The CLI subprocess could not be spawned or awaited. */ +export class CliExecError extends Data.TaggedError("CliExecError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** The replay server refused to load the test's scenario. */ +class ScenarioLoadError extends Data.Error<{ readonly message: string }> {} + +/** CLI stdout did not decode as the expected JSON. */ +class JsonOutputError extends Data.Error<{ readonly message: string }> {} + +/** A test body passed without executing its CLI runner. */ +class RunNotExecutedError extends Data.Error<{ readonly message: string }> {} + +/** Runs the compiled CLI against a harness target. */ +export const execCli = (harness: CLIHarness, cmd: string[], execOpts?: ExecOptions) => + Effect.tryPromise({ + try: () => exec(harness, cmd, execOpts), + catch: (cause) => + new CliExecError({ message: cause instanceof Error ? cause.message : String(cause), cause }), + }); + +const sendControl = (url: string, body: unknown) => + HttpClientRequest.post(url).pipe( + HttpClientRequest.bodyJson(body), + Effect.flatMap(HttpClient.execute), + ); + +/** POSTs a JSON body to a replay server control endpoint; a non-2xx answer fails. */ +export const postControl = (url: string, body: unknown) => + sendControl(url, body).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + +const httpLayer = Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), +); + +const e2eLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer, httpLayer); + +/** Runs a test body at the Vitest edge with the file system, path, and HTTP services. */ +export const runE2e = ( + body: Effect.Effect>, + signal?: AbortSignal, +): Promise => Effect.runPromise(body.pipe(Effect.provide(e2eLayer)), { signal }); + +/** Decodes CLI stdout as JSON; a failure keeps the schema message plus a bounded stdout excerpt. */ +export const decodeJsonOutput = + (schema: S) => + (stdout: string) => + Schema.decodeEffect(Schema.fromJsonString(schema))(stdout).pipe( + Effect.mapError( + (error) => + new JsonOutputError({ + message: `${error.message}\n stdout: ${Formatter.format(stdout.slice(0, 200))}`, + }), + ), + ); + +const ScenarioFailure = Schema.Struct({ message: Schema.String }); + +/** Marks a CLI run as executed for the current test. */ +interface RunGuard { + readonly track: (effect: Effect.Effect) => Effect.Effect; +} + interface BehaviourFixtures { + runGuard: RunGuard; projectRef: string; orgId: string; workspace: TempDir; - run: (cmd: string[], execOpts?: ExecOptions) => Promise; - runNoProjectId: (cmd: string[]) => Promise; + run: (cmd: string[], execOpts?: ExecOptions) => Effect.Effect; + runNoProjectId: (cmd: string[]) => Effect.Effect; apiUrl: string; storageBucket: string; pgMockPort: number; @@ -47,42 +118,63 @@ interface BehaviourFixtures { * server knows which ordered interaction sequence to serve. Auto-clears the * request log, error overrides, and active scenario after every test. */ export const testBehaviour = test.extend({ - projectRef: async ({ task: _task }, use) => { - await use(inject("projectRef") as string); + /* Fails a test that passed without executing a CLI run, e.g. because its body returned the + Effect without `runE2e`. Checked once the test finished, after every fixture cleaned up. */ + runGuard: ({ task, onTestFinished }, use) => { + let executed = false; + onTestFinished(() => { + if (!executed && task.result?.state === "pass") { + return Effect.runPromise( + new RunNotExecutedError({ + message: `"${task.name}" passed without executing its run Effect; return runE2e(Effect.gen(...), signal) from the test body`, + }), + ); + } + }); + return use({ + track: (effect) => + Effect.suspend(() => { + executed = true; + return effect; + }), + }); }, - orgId: async ({ task: _task }, use) => { - await use(inject("orgId") as string); - }, + projectRef: ({ task: _task }, use) => use(inject("projectRef")), + + orgId: ({ task: _task }, use) => use(inject("orgId")), - workspace: async ({ task }, use) => { + workspace: ({ task }, use) => { const serverUrl = inject("replayServerUrl"); const slug = scenarioSlug(task); - // Truncate to 40 chars to keep temp dir names manageable. - const dir = await makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`); + return Effect.runPromise( + Effect.gen(function* () { + // Truncate to 40 chars to keep temp dir names manageable. + const dir = yield* Effect.promise(() => makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`)); - const res = await fetch(`${serverUrl}/_ctrl/scenario`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: slug }), - }); - if (!res.ok) { - await dir[Symbol.asyncDispose](); - const { message } = (await res.json()) as { message: string }; - throw new Error(message); - } - - await use(dir); - - await dir[Symbol.asyncDispose](); - await Promise.all([ - fetch(`${serverUrl}/_ctrl/requests`, { method: "DELETE" }), - fetch(`${serverUrl}/_ctrl/overrides`, { method: "DELETE" }), - fetch(`${serverUrl}/_ctrl/scenario`, { method: "DELETE" }), - ]); + const res = yield* sendControl(`${serverUrl}/_ctrl/scenario`, { name: slug }); + if (res.status < 200 || res.status >= 300) { + yield* Effect.promise(() => dir[Symbol.asyncDispose]()); + const { message } = yield* HttpClientResponse.schemaBodyJson(ScenarioFailure)(res); + return yield* new ScenarioLoadError({ message }); + } + + yield* Effect.promise(() => use(dir)); + + yield* Effect.promise(() => dir[Symbol.asyncDispose]()); + yield* Effect.all( + [ + HttpClient.del(`${serverUrl}/_ctrl/requests`), + HttpClient.del(`${serverUrl}/_ctrl/overrides`), + HttpClient.del(`${serverUrl}/_ctrl/scenario`), + ], + { concurrency: "unbounded", discard: true }, + ); + }).pipe(Effect.provide(httpLayer)), + ); }, - run: async ({ workspace }, use) => { + run: ({ runGuard, workspace }, use) => { const serverUrl = inject("replayServerUrl"); const harness = createHarness({ apiUrl: serverUrl, @@ -90,28 +182,22 @@ export const testBehaviour = test.extend({ cwd: workspace.path, projectId: inject("projectRef") as string, }); - await use((cmd, execOpts) => exec(harness, cmd, execOpts)); + return use((cmd, execOpts) => runGuard.track(execCli(harness, cmd, execOpts))); }, - runNoProjectId: async ({ workspace }, use) => { + runNoProjectId: ({ runGuard, workspace }, use) => { const serverUrl = inject("replayServerUrl"); const harness = createHarness({ apiUrl: serverUrl, accessToken: ACCESS_TOKEN, cwd: workspace.path, }); - await use((cmd) => exec(harness, cmd)); + return use((cmd) => runGuard.track(execCli(harness, cmd))); }, - storageBucket: async ({ task: _task }, use) => { - await use(inject("storageBucket") as string); - }, + storageBucket: ({ task: _task }, use) => use(inject("storageBucket")), - apiUrl: async ({ task: _task }, use) => { - await use(inject("replayServerUrl")); - }, + apiUrl: ({ task: _task }, use) => use(inject("replayServerUrl")), - pgMockPort: async ({ task: _task }, use) => { - await use(inject("pgMockPort") as number); - }, + pgMockPort: ({ task: _task }, use) => use(inject("pgMockPort")), }); diff --git a/apps/cli-e2e/tests/setup.ts b/apps/cli-e2e/tests/setup.ts index ae9ea9a70f..df8a49c454 100644 --- a/apps/cli-e2e/tests/setup.ts +++ b/apps/cli-e2e/tests/setup.ts @@ -1,34 +1,73 @@ +import { + Config, + ConfigProvider, + Data, + Effect, + Exit, + Layer, + Option, + Predicate, + Schedule, + Schema, + Scope, +} from "effect"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/http"; import type { ProvidedContext } from "vitest"; import { startPgMock } from "../src/server/pg-mock.ts"; import { startReplayServer } from "../src/server/replay-server.ts"; -import { ACCESS_TOKEN, isRecording, ORG_ID, PROJECT_REF } from "../src/tests/env.ts"; +import { ACCESS_TOKEN, isRecording, ORG_ID, PROJECT_REF, STAGING_URL } from "../src/tests/env.ts"; import { cleanupProjectsByName, createTestProject, deleteTestProject, generateDbPassword, resolveOrgId, + StagingProjectError, waitForProjectReady, } from "./staging-project.ts"; import "./provided-context.ts"; // centralized `inject()` key augmentation const FIXTURES_DIR = new URL("../fixtures", import.meta.url).pathname; -function resolveDockerSocket(): string { - const dockerHost = process.env["DOCKER_HOST"]; +const resolveDockerSocket = Effect.gen(function* () { + const dockerHost = Option.getOrUndefined(yield* Config.option(Config.String("DOCKER_HOST"))); if (dockerHost?.startsWith("unix://")) return dockerHost.slice("unix://".length); return "/var/run/docker.sock"; -} +}); + +/** The api-keys endpoint answered with a non-2xx status. */ +class ApiKeysNotReady extends Data.TaggedError("ApiKeysNotReady")<{ + readonly response: HttpClientResponse.HttpClientResponse; +}> {} + +const ApiKeys = Schema.Array( + Schema.Struct({ name: Schema.String, api_key: Schema.optionalKey(Schema.NullOr(Schema.String)) }), +); -export async function setup({ - provide, -}: { - provide: (key: K, value: ProvidedContext[K]) => void; -}) { +const setupLayer = Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + ConfigProvider.layer(Effect.sync(() => ConfigProvider.fromEnv({ preserveEmptyStrings: true }))), +); + +type Provide = (key: K, value: ProvidedContext[K]) => void; + +const globalSetup = Effect.fn("E2eSetup.globalSetup")(function* (provide: Provide) { const pgMock = startPgMock(); provide("pgMockPort", pgMock.port); - const server = await startReplayServer({ fixturesDir: FIXTURES_DIR, pgMock }); + const server = yield* startReplayServer({ + fixturesDir: FIXTURES_DIR, + pgMock, + record: isRecording, + stagingUrl: STAGING_URL, + }); provide("replayServerUrl", server.url); // Docker host URL: relay server in TCP form so DOCKER_HOST env can point at it. @@ -37,94 +76,112 @@ export async function setup({ const dockerHostUrl = server.url.replace(/^http:\/\//, "tcp://"); provide("dockerHostUrl", dockerHostUrl); + /* The replay server stops after this, when its own finalizers run. */ + const stopPgMock = Effect.sync(() => pgMock.stop()); + if (!isRecording) { // Replay mode — no real API calls; PROJECT_REF and ORG_ID default to the ref // recorded fixtures use. provide("projectRef", PROJECT_REF); provide("orgId", ORG_ID); provide("storageBucket", "cli-e2e-bucket"); - return async () => { - pgMock.stop(); - await server.stop(); - }; + yield* Effect.addFinalizer(() => stopPgMock); + return; } // Record mode — wire up Docker proxy so Docker SDK calls (via DOCKER_HOST) are // intercepted by the relay server and forwarded to the real Docker socket. - server.setDockerProxyUrl(resolveDockerSocket()); + server.setDockerProxyUrl(yield* resolveDockerSocket); // Record mode — resolve org, then wipe any projects left over from previous // failed recording runs before creating a fresh dedicated test project. - const orgId = await resolveOrgId(server.url); + const orgId = yield* resolveOrgId(server.url); // Delete any orphaned projects whose names would conflict with what the tests // are about to create. Runs before any scenario is loaded so these API calls // go straight to staging and are not captured in any scenario fixture. - await cleanupProjectsByName(server.url, ["cli-e2e-test", "my-project", "to-delete"]); + yield* cleanupProjectsByName(server.url, ["cli-e2e-test", "my-project", "to-delete"]); // Create a fresh project for this recording run. Its ref is used by branches, // functions, secrets, and api-keys tests. - const projectRef = await createTestProject( + const projectRef = yield* createTestProject( server.url, orgId, "cli-e2e-test", - generateDbPassword(), + yield* generateDbPassword, ); provide("projectRef", projectRef); provide("orgId", orgId); // Wire storage proxy so /storage/v1/ calls from --local mode reach staging. - const stagingApiUrl = process.env["SUPABASE_STAGING_URL"]!; + const stagingApiUrl = STAGING_URL!; // Wait for the project to be fully initialised before fetching api-keys — the // api-keys endpoint is unavailable while the project is in COMING_SOON/BUILDING state. - await waitForProjectReady(stagingApiUrl, projectRef); + yield* waitForProjectReady(stagingApiUrl, projectRef); // Retry api-keys fetch: even after ACTIVE_HEALTHY, the endpoint may briefly return 4xx. - let serviceRoleKey = ""; - for (let attempt = 1; attempt <= 12; attempt++) { - const keysRes = await fetch(`${stagingApiUrl}/v1/projects/${projectRef}/api-keys`, { + const fetchServiceRoleKey = Effect.gen(function* () { + const keysRes = yield* HttpClient.get(`${stagingApiUrl}/v1/projects/${projectRef}/api-keys`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, }); - if (keysRes.ok) { - const keys = (await keysRes.json()) as Array<{ name: string; api_key: string }>; - serviceRoleKey = keys.find((k) => k.name === "service_role")?.api_key ?? ""; - break; + if (keysRes.status < 200 || keysRes.status >= 300) { + return yield* new ApiKeysNotReady({ response: keysRes }); } - if (attempt === 12) { - throw new Error(`Failed to fetch api-keys after 12 attempts: ${await keysRes.text()}`); - } - await new Promise((r) => setTimeout(r, 10_000)); - } + const keys = yield* HttpClientResponse.schemaBodyJson(ApiKeys)(keysRes); + return keys.find((k) => k.name === "service_role")?.api_key ?? ""; + }); + const serviceRoleKey = yield* fetchServiceRoleKey.pipe( + Effect.retry({ + schedule: Schedule.spaced("10 seconds"), + times: 11, + while: (error) => Predicate.isTagged(error, "ApiKeysNotReady"), + }), + Effect.catchTag("ApiKeysNotReady", ({ response }) => + Effect.gen(function* () { + return yield* new StagingProjectError({ + message: `Failed to fetch api-keys after 12 attempts: ${yield* response.text}`, + }); + }), + ), + ); const storageBaseUrl = `https://${projectRef}.supabase.red`; server.setStorageProxyUrl(storageBaseUrl); server.setStorageProxyAuth(serviceRoleKey); // Create test bucket and seed a file — direct calls to staging, not via relay. - await fetch(`${storageBaseUrl}/storage/v1/bucket`, { - method: "POST", - headers: { - Authorization: `Bearer ${serviceRoleKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ id: "cli-e2e-bucket", name: "cli-e2e-bucket", public: false }), - }); - await fetch(`${storageBaseUrl}/storage/v1/object/cli-e2e-bucket/hello.txt`, { - method: "POST", - headers: { - Authorization: `Bearer ${serviceRoleKey}`, - "Content-Type": "text/plain", - }, - body: "hello world", + yield* HttpClientRequest.post(`${storageBaseUrl}/storage/v1/bucket`, { + headers: { Authorization: `Bearer ${serviceRoleKey}` }, + }).pipe( + HttpClientRequest.bodyJson({ id: "cli-e2e-bucket", name: "cli-e2e-bucket", public: false }), + Effect.flatMap(HttpClient.execute), + ); + yield* HttpClient.post(`${storageBaseUrl}/storage/v1/object/cli-e2e-bucket/hello.txt`, { + headers: { Authorization: `Bearer ${serviceRoleKey}` }, + body: HttpBody.text("hello world", "text/plain"), }); provide("storageBucket", "cli-e2e-bucket"); - return async () => { - // The projects:delete test is self-contained (it creates and deletes its own - // "to-delete" project). The projects:create test creates "my-project" but - // does not delete it, so we clean it up here. - await cleanupProjectsByName(server.url, ["my-project"]); - await deleteTestProject(server.url, projectRef); - pgMock.stop(); - await server.stop(); - }; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + // The projects:delete test is self-contained (it creates and deletes its own + // "to-delete" project). The projects:create test creates "my-project" but + // does not delete it, so we clean it up here. + yield* cleanupProjectsByName(server.url, ["my-project"]); + yield* deleteTestProject(server.url, projectRef); + yield* stopPgMock; + }).pipe(Effect.orDie), + ); +}); + +export function setup({ provide }: { provide: Provide }) { + return Effect.runPromise( + Effect.gen(function* () { + const scope = yield* Scope.make(); + yield* globalSetup(provide).pipe(Scope.provide(scope)); + return scope; + }).pipe( + Effect.map((scope) => () => Effect.runPromise(Scope.close(scope, Exit.void))), + Effect.provide(setupLayer), + ), + ); } diff --git a/apps/cli-e2e/tests/staging-project.ts b/apps/cli-e2e/tests/staging-project.ts index 57ae218d49..1f7c3151dc 100644 --- a/apps/cli-e2e/tests/staging-project.ts +++ b/apps/cli-e2e/tests/staging-project.ts @@ -1,4 +1,17 @@ import { randomBytes } from "node:crypto"; +import { + Clock, + Config, + Console, + Data, + Effect, + Formatter, + Option, + Predicate, + Schedule, + Schema, +} from "effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { createHarness, exec } from "@supabase/cli-test-helpers"; import { ACCESS_TOKEN, REGION } from "../src/tests/env.ts"; @@ -10,31 +23,84 @@ function harness(apiUrl: string) { return createHarness({ apiUrl, accessToken: ACCESS_TOKEN }); } +/** A record-setup step against the staging project failed. */ +export class StagingProjectError extends Data.Error<{ readonly message: string }> {} + +/** The CLI subprocess for a record-setup step could not be spawned or awaited. */ +class StagingCliError extends Data.TaggedError("StagingCliError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const runCli = (apiUrl: string, args: string[]) => + Effect.tryPromise({ + try: () => exec(harness(apiUrl), args), + catch: (cause) => + new StagingCliError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + const PROJECT_REF_RE = /^[a-z]{20}$/; const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); +const ProjectStatus = Schema.Struct({ status: Schema.optionalKey(Schema.String) }); +const Orgs = Schema.fromJsonString(Schema.Array(Schema.Struct({ id: Schema.String }))); +const CreatedProject = Schema.fromJsonString( + Schema.Struct({ id: Schema.optionalKey(Schema.String), ref: Schema.optionalKey(Schema.String) }), +); +const ListedProjects = Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + id: Schema.String, + ref: Schema.optionalKey(Schema.String), + name: Schema.String, + }), + ), +); + +/* Schema's JSON errors drop the parser detail, so the failure keeps a bounded stdout excerpt. */ +const decodeStdout = (schema: Schema.Codec, stdout: string) => + Schema.decodeEffect(schema)(stdout).pipe( + Effect.mapError( + (error) => + new StagingProjectError({ + message: `${error.message}\n stdout: ${Formatter.format(stdout.slice(0, 200))}`, + }), + ), + ); /** A DB password for a throwaway recording project. Randomised per call * (overridable via CLI_E2E_DB_PASSWORD) so no static credential is committed — * the project is deleted on teardown anyway. */ -export function generateDbPassword(): string { - return process.env["CLI_E2E_DB_PASSWORD"] ?? `cli-e2e-${randomBytes(12).toString("hex")}`; -} +export const generateDbPassword = Effect.gen(function* () { + const override = Option.getOrUndefined( + yield* Config.option(Config.String("CLI_E2E_DB_PASSWORD")), + ); + return override ?? `cli-e2e-${randomBytes(12).toString("hex")}`; +}); -export async function resolveOrgId(apiUrl: string): Promise { - const result = await exec(harness(apiUrl), ["orgs", "list", "--output", "json"]); - if (result.exitCode !== 0) throw new Error(`orgs list failed: ${result.stderr}`); - const first = (JSON.parse(result.stdout) as Array<{ id: string }>)[0]?.id; - if (!first) throw new Error("No orgs found — cannot create test project"); +export const resolveOrgId = Effect.fn("StagingProject.resolveOrgId")(function* (apiUrl: string) { + const result = yield* runCli(apiUrl, ["orgs", "list", "--output", "json"]); + if (result.exitCode !== 0) { + return yield* new StagingProjectError({ message: `orgs list failed: ${result.stderr}` }); + } + const first = (yield* decodeStdout(Orgs, result.stdout))[0]?.id; + if (!first) { + return yield* new StagingProjectError({ + message: "No orgs found — cannot create test project", + }); + } return first; -} +}); -export async function createTestProject( +export const createTestProject = Effect.fn("StagingProject.createTestProject")(function* ( apiUrl: string, orgId: string, name: string, password: string, -): Promise { - const result = await exec(harness(apiUrl), [ +) { + const result = yield* runCli(apiUrl, [ "projects", "create", name, @@ -47,74 +113,96 @@ export async function createTestProject( "--output", "json", ]); - if (result.exitCode !== 0) throw new Error(`projects create failed: ${result.stderr}`); - const project = JSON.parse(result.stdout) as { id?: string; ref?: string }; + if (result.exitCode !== 0) { + return yield* new StagingProjectError({ message: `projects create failed: ${result.stderr}` }); + } + const project = yield* decodeStdout(CreatedProject, result.stdout); const ref = project.ref ?? project.id; if (!ref || !PROJECT_REF_RE.test(ref)) { - throw new Error(`Unexpected project ref from create: ${result.stdout}`); + return yield* new StagingProjectError({ + message: `Unexpected project ref from create: ${result.stdout}`, + }); } return ref; -} +}); // `throwOnError` surfaces deletion failures when a caller needs to fail loudly; // record setup keeps the lenient default. -export async function deleteTestProject( +export const deleteTestProject = Effect.fn("StagingProject.deleteTestProject")(function* ( apiUrl: string, projectRef: string, opts: { throwOnError?: boolean } = {}, -): Promise { - try { - const result = await exec(harness(apiUrl), ["projects", "delete", projectRef, "--yes"]); +) { + yield* Effect.gen(function* () { + const result = yield* runCli(apiUrl, ["projects", "delete", projectRef, "--yes"]); if (result.exitCode !== 0) { - throw new Error(`projects delete exited ${result.exitCode}: ${result.stderr}`); + return yield* new StagingProjectError({ + message: `projects delete exited ${result.exitCode}: ${result.stderr}`, + }); } - } catch (err) { - if (opts.throwOnError) throw err; - console.error(`Warning: failed to delete test project ${projectRef}:`, err); - } -} + }).pipe( + Effect.catch((err) => + opts.throwOnError + ? Effect.fail(err) + : Console.error( + `Warning: failed to delete test project ${projectRef}:`, + /* A Data.Error logs as a bare { message } object; log it as an Error. */ + Predicate.isTagged(err, "StagingCliError") ? err.cause : new Error(err.message), + ), + ), + ); +}); -export async function cleanupProjectsByName(apiUrl: string, names: string[]): Promise { - const listResult = await exec(harness(apiUrl), ["projects", "list", "--output", "json"]); +export const cleanupProjectsByName = Effect.fn("StagingProject.cleanupProjectsByName")(function* ( + apiUrl: string, + names: string[], +) { + const listResult = yield* runCli(apiUrl, ["projects", "list", "--output", "json"]); if (listResult.exitCode !== 0) return; - const projects = JSON.parse(listResult.stdout) as Array<{ - id: string; - ref?: string; - name: string; - }>; + const projects = yield* decodeStdout(ListedProjects, listResult.stdout); for (const project of projects.filter((p) => names.includes(p.name))) { const ref = project.ref ?? project.id; if (ref && PROJECT_REF_RE.test(ref)) { - await exec(harness(apiUrl), ["projects", "delete", ref, "--yes"]); + yield* runCli(apiUrl, ["projects", "delete", ref, "--yes"]); } } -} +}); /** Poll the Management API until the recording project is ACTIVE_HEALTHY. */ -export async function waitForProjectReady( +export const waitForProjectReady = Effect.fn("StagingProject.waitForProjectReady")(function* ( apiBaseUrl: string, projectRef: string, timeoutMs = 300_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const project = (await res.json()) as { status?: string }; - if (project.status === "ACTIVE_HEALTHY") return; - if (project.status && TERMINAL_BAD_STATUSES.has(project.status)) { - throw new Error( - `Project ${projectRef} entered terminal status ${project.status} during provisioning`, - ); - } - } else { - await res.body?.cancel(); +) { + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); + const deadline = (yield* Clock.currentTimeMillis) + timeoutMs; + const poll = Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) >= deadline) { + return yield* new StagingProjectError({ + message: `Project ${projectRef} did not become ACTIVE_HEALTHY within ${timeoutMs}ms`, + }); } - await new Promise((r) => setTimeout(r, 5_000)); - } - throw new Error(`Project ${projectRef} did not become ACTIVE_HEALTHY within ${timeoutMs}ms`); -} + return yield* Effect.scoped( + Effect.gen(function* () { + const res = yield* client.get(`${apiBaseUrl}/v1/projects/${projectRef}`, { + headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, + }); + /* A failed poll's body is cancelled when this scope closes. */ + if (res.status < 200 || res.status >= 300) return false; + const project = yield* HttpClientResponse.schemaBodyJson(ProjectStatus)(res); + if (project.status === "ACTIVE_HEALTHY") return true; + if (project.status && TERMINAL_BAD_STATUSES.has(project.status)) { + return yield* new StagingProjectError({ + message: `Project ${projectRef} entered terminal status ${project.status} during provisioning`, + }); + } + return false; + }), + ); + }); + yield* poll.pipe( + Effect.repeat({ schedule: Schedule.spaced("5 seconds"), until: (ready) => ready }), + ); +}); diff --git a/apps/cli-e2e/vitest.config.ts b/apps/cli-e2e/vitest.config.ts index bb87f884aa..7a876e3529 100644 --- a/apps/cli-e2e/vitest.config.ts +++ b/apps/cli-e2e/vitest.config.ts @@ -13,8 +13,8 @@ export default defineConfig({ hookTimeout: 30_000, sequence: { sequencer: class extends BaseSequencer { - override async sort(files: TestSpecification[]) { - return [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)); + override sort(files: TestSpecification[]) { + return Promise.resolve([...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId))); } }, }, diff --git a/apps/cli/scripts/bench-cli.integration.test.ts b/apps/cli/scripts/bench-cli.integration.test.ts index 6caeed1fcb..c3a1ebb411 100644 --- a/apps/cli/scripts/bench-cli.integration.test.ts +++ b/apps/cli/scripts/bench-cli.integration.test.ts @@ -28,10 +28,11 @@ describe("runWorkloadBench", () => { Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const server = yield* Effect.acquireRelease( - Effect.promise(() => startReplayServer({ fixturesDir })), - (handle) => Effect.promise(() => handle.stop()), - ); + const server = yield* startReplayServer({ + fixturesDir, + record: false, + stagingUrl: undefined, + }); const dir = yield* fs.makeTempDirectoryScoped({ prefix: "bench-fake-cli-" }); const binary = `${dir}/supabase`; yield* fs.writeFileString(binary, FAKE_CLI); diff --git a/apps/cli/scripts/replay-control.integration.test.ts b/apps/cli/scripts/replay-control.integration.test.ts index ee393ead1e..17149e5485 100644 --- a/apps/cli/scripts/replay-control.integration.test.ts +++ b/apps/cli/scripts/replay-control.integration.test.ts @@ -8,10 +8,7 @@ import { readRequestLogCount, resetReplay } from "./replay-control.ts"; const fixturesDir = new URL("../../cli-e2e/fixtures", import.meta.url).pathname; const SCENARIO = "projects-list-renders-project-list"; -const replayServer = Effect.acquireRelease( - Effect.promise(() => startReplayServer({ fixturesDir })), - (server) => Effect.promise(() => server.stop()), -); +const replayServer = startReplayServer({ fixturesDir, record: false, stagingUrl: undefined }); const callApi = (url: string, path: string) => HttpClient.HttpClient.use((client) => client.get(new URL(path, url).toString())); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e437ab91b..a65f64d186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,9 +439,18 @@ importers: apps/cli-e2e: dependencies: + '@effect/platform-bun': + specifier: 'catalog:' + version: 4.0.2(effect@4.0.2) + '@effect/platform-node': + specifier: 'catalog:' + version: 4.0.2(effect@4.0.2)(redis@6.2.1) '@supabase/cli-test-helpers': specifier: workspace:* version: link:../../packages/cli-test-helpers + effect: + specifier: 'catalog:' + version: 4.0.2 devDependencies: '@tsconfig/bun': specifier: 'catalog:'