diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 9712e56950..9c2cecafc9 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -9,12 +9,17 @@ "!packages/api/**", "!packages/config/**", "!packages/stack/**", + "!apps/cli-e2e/src/server/**", + "!apps/cli-e2e/src/tests/env.ts", + "!apps/cli-e2e/tests/**", + "!apps/cli-e2e/vitest.config.ts", "!apps/cli/**", "!apps/docs/**", "!packages/cli-test-helpers/**", "!tools/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. + "apps/cli-e2e/src/server/replay-server.ts", "apps/cli/src/shared/cli/bin.ts", "apps/cli/src/shared/compute/stacks/**", "apps/cli/src/shared/feedback/database.types.ts", diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index d5caeef214..5e40d919fc 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -12,7 +12,9 @@ "types:check": "tsc --noEmit" }, "dependencies": { - "@supabase/cli-test-helpers": "workspace:*" + "@effect/platform-bun": "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..11e426d78f 100644 --- a/apps/cli-e2e/scripts/replay-serve.ts +++ b/apps/cli-e2e/scripts/replay-serve.ts @@ -21,6 +21,8 @@ const server = await startReplayServer({ fixturesDir: FIXTURES_DIR, hostname: "127.0.0.1", port: parsePort(process.argv.slice(2)), + record: false, + stagingUrl: undefined, }); let stopping = false; diff --git a/apps/cli-e2e/src/server/fixture-loader.ts b/apps/cli-e2e/src/server/fixture-loader.ts index 50da310d26..da38ad565c 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. */ +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..32308cce6f 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } 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 { Effect, Layer } from "effect"; import type { FixtureEntry, FixtureRequest, @@ -157,11 +159,17 @@ 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; } +const fixtureFileLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); + export async function startReplayServer(options: ReplayServerOptions): Promise { - const isRecord = process.env["RECORD"] === "true"; - const stagingUrl = process.env["SUPABASE_STAGING_URL"]; + const isRecord = options.record; + const stagingUrl = options.stagingUrl; if (isRecord && !stagingUrl) { throw new Error("RECORD=true requires SUPABASE_STAGING_URL to be set"); @@ -175,7 +183,11 @@ export async function startReplayServer(options: ReplayServerOptions): Promise + 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/tests/setup.ts b/apps/cli-e2e/tests/setup.ts index ae9ea9a70f..5a41e617d5 100644 --- a/apps/cli-e2e/tests/setup.ts +++ b/apps/cli-e2e/tests/setup.ts @@ -1,34 +1,86 @@ +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 replay server could not be started or stopped. */ +class ReplayServerError extends Data.TaggedError("ReplayServerError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const replayServerError = (cause: unknown) => + new ReplayServerError({ message: cause instanceof Error ? cause.message : String(cause), cause }); + +/** The api-keys endpoint answered with a non-2xx status. */ +class ApiKeysNotReady extends Data.TaggedError("ApiKeysNotReady")<{ + readonly response: HttpClientResponse.HttpClientResponse; +}> {} -export async function setup({ - provide, -}: { - provide: (key: K, value: ProvidedContext[K]) => void; -}) { +const ApiKeys = Schema.Array( + Schema.Struct({ name: Schema.String, api_key: Schema.optionalKey(Schema.NullOr(Schema.String)) }), +); + +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* Effect.tryPromise({ + try: () => + startReplayServer({ + fixturesDir: FIXTURES_DIR, + pgMock, + record: isRecording, + stagingUrl: STAGING_URL, + }), + catch: replayServerError, + }); provide("replayServerUrl", server.url); // Docker host URL: relay server in TCP form so DOCKER_HOST env can point at it. @@ -37,94 +89,117 @@ export async function setup({ const dockerHostUrl = server.url.replace(/^http:\/\//, "tcp://"); provide("dockerHostUrl", dockerHostUrl); + const stopServers = Effect.gen(function* () { + pgMock.stop(); + yield* Effect.tryPromise({ + try: () => server.stop(), + catch: replayServerError, + }); + }); + 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(() => stopServers.pipe(Effect.orDie)); + 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 (attempt === 12) { - throw new Error(`Failed to fetch api-keys after 12 attempts: ${await keysRes.text()}`); + if (keysRes.status < 200 || keysRes.status >= 300) { + return yield* new ApiKeysNotReady({ response: keysRes }); } - 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* stopServers; + }).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..8eba13d152 100644 --- a/apps/cli/scripts/bench-cli.integration.test.ts +++ b/apps/cli/scripts/bench-cli.integration.test.ts @@ -29,7 +29,9 @@ describe("runWorkloadBench", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const server = yield* Effect.acquireRelease( - Effect.promise(() => startReplayServer({ fixturesDir })), + Effect.promise(() => + startReplayServer({ fixturesDir, record: false, stagingUrl: undefined }), + ), (handle) => Effect.promise(() => handle.stop()), ); const dir = yield* fs.makeTempDirectoryScoped({ prefix: "bench-fake-cli-" }); diff --git a/apps/cli/scripts/replay-control.integration.test.ts b/apps/cli/scripts/replay-control.integration.test.ts index ee393ead1e..9b8b4f0ee3 100644 --- a/apps/cli/scripts/replay-control.integration.test.ts +++ b/apps/cli/scripts/replay-control.integration.test.ts @@ -9,7 +9,7 @@ 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 })), + Effect.promise(() => startReplayServer({ fixturesDir, record: false, stagingUrl: undefined })), (server) => Effect.promise(() => server.stop()), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e437ab91b..6ec2ccbf74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,9 +439,15 @@ importers: apps/cli-e2e: dependencies: + '@effect/platform-bun': + specifier: 'catalog:' + version: 4.0.2(effect@4.0.2) '@supabase/cli-test-helpers': specifier: workspace:* version: link:../../packages/cli-test-helpers + effect: + specifier: 'catalog:' + version: 4.0.2 devDependencies: '@tsconfig/bun': specifier: 'catalog:'