diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index dd639f8829..d7582143a6 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -10,6 +10,8 @@ "!packages/config/**", "!packages/stack/**", "!apps/cli/src/**", + "!apps/cli/tests/**", + "!apps/cli/vitest.config.ts", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/cli/bin.ts", @@ -17,13 +19,6 @@ "apps/cli/src/shared/feedback/database.types.ts", "apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts", "apps/cli/src/command-internal/db-bootstrap/local-db-running.ts", - "packages/api/src/generated/contracts.ts", - "!apps/cli/tests/helpers/branches-live.ts", - "!apps/cli/tests/helpers/compute.ts", - "!apps/cli/tests/helpers/migration-live.ts", - "!apps/cli/tests/helpers/notebooks.ts", - "!apps/cli/tests/helpers/postgres-config-live.ts", - "!apps/cli/tests/helpers/secrets-live.ts", - "!apps/cli/tests/helpers/storage-live.ts" + "packages/api/src/generated/contracts.ts" ] } diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts index 135cd87894..bbf6a06113 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts @@ -19,7 +19,7 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem, Option, Path } from "effect"; import { afterEach, describe, expect, test } from "vitest"; -import { makeTempHome, runSupabase } from "../../../tests/helpers/cli.ts"; +import { createTempHome, runSupabase, type TempHome } from "../../../tests/helpers/cli.ts"; const CLEANUP_TIMEOUT_MS = 120_000; @@ -97,7 +97,7 @@ const mtimeMs = Effect.fnUntraced(function* (filePath: string) { describe("shadow baseline cache (e2e, local Docker stack)", () => { let projectDir: string | undefined; - let home: ReturnType | undefined; + let home: TempHome | undefined; afterEach(() => Effect.runPromise( @@ -116,7 +116,7 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { yield* fs.remove(projectDir, { recursive: true, force: true }).pipe(Effect.ignore); } // Disposes the temp `SUPABASE_HOME`, and with it the ~90MB baseline tar this suite published. - home?.[Symbol.dispose](); + if (home !== undefined) yield* home.dispose; projectDir = undefined; home = undefined; }).pipe(Effect.provide(BunServices.layer)), @@ -140,7 +140,7 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { projectDir = yield* fs.makeTempDirectory({ prefix: "sb-shadow-cache-e2e-" }); // One temp `SUPABASE_HOME` per test run so both `db diff` processes share the same cache // directory; `runSupabase` otherwise mints a fresh home per invocation. - home = makeTempHome(); + home = yield* createTempHome; const homeDir = home.dir; const cacheDir = path.join(home.dir, "cache", "shadow-baseline"); diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts index 572df0bea5..1ac039779d 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts @@ -214,7 +214,7 @@ describe("edgeRuntimeScriptLayer sentinel handling", () => { ), ), ); - seedLegacyStateDecoys(workdir); + yield* seedLegacyStateDecoys(workdir); const { layer, docker } = setup( { exitCode: 1, stdout: "", stderr: "main worker has been destroyed\n" }, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index 3694703aa9..53e079c419 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -249,7 +249,7 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { dbConnectionLayer, loginApi.layer, loginCrypto.layer, - mockBrowser(), + mockBrowser, mockStdin(opts.stdinIsTty ?? true), Layer.succeed(OutputFlag, Option.none()), Layer.succeed(WorkdirFlag, opts.workdir ?? Option.some(tempRoot.current)), diff --git a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts index 5a5518c27c..c9dd4fbe27 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -92,7 +92,7 @@ function ambientStubs() { isolatedHomeLayer(tempRoot.current, { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), mockTty(), mockProcessControl().layer, - mockBrowser(), + mockBrowser, mockStdin(false), analytics.layer, mockTelemetryRuntime(), diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 04b3cb7bb0..f6d2154140 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -236,7 +236,7 @@ describe("bootstrap linked-project cache location", () => { dbConnectionLayer, mockLoginApi({ gotrueId: "gotrue-user" }).layer, mockLoginCrypto().layer, - mockBrowser(), + mockBrowser, mockStdin(true), flagsLayer, debugLogger, diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index fe1c2f9435..2c6a66b79c 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -1774,14 +1774,14 @@ describe("db dump target default warning", () => { { label: "--project-ref", over: { projectRef: Option.some("flagflagflagflagflag") } }, ] as const; - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tmp.current, linked); - const { layer, out } = setup({ conn: REMOTE_CONN, isLocal: false, workdir: tmp.current }); - return dbDump(flags({ dryRun: true, ...selector?.over })).pipe( - Effect.provide(layer), - Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), - ); - }; + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tmp.current, linked), () => { + const { layer, out } = setup({ conn: REMOTE_CONN, isLocal: false, workdir: tmp.current }); + return dbDump(flags({ dryRun: true, ...selector?.over })).pipe( + Effect.provide(layer), + Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), + ); + }); it.effect( "warns on a bare run in a linked workdir and stays silent with every selector or when unlinked", diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index 249c6eb24f..3b900a166b 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -2540,23 +2540,23 @@ describe("db pull target default warning", () => { { label: "--project-ref", over: { projectRef: Option.some("flagflagflagflagflag") } }, ] as const; - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tmp.current, linked); - const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: "", - projectId: Option.none(), + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tmp.current, linked), () => { + const s = setup(tmp.current, { + migrations: ["20240101000000"], + remoteVersions: ["20240101000000"], + edgeStdout: "", + projectId: Option.none(), + }); + return dbPull(flags(selector?.over)).pipe( + Effect.exit, + Effect.provide(s.layer), + Effect.map(() => ({ + stdout: streamText(s.out, "stdout"), + stderr: streamText(s.out, "stderr"), + })), + ); }); - return dbPull(flags(selector?.over)).pipe( - Effect.exit, - Effect.provide(s.layer), - Effect.map(() => ({ - stdout: streamText(s.out, "stdout"), - stderr: streamText(s.out, "stderr"), - })), - ); - }; it.effect( "warns on a bare run in a linked workdir and stays silent with every selector or when unlinked", diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 8401cbb6d6..035a77c398 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -1416,19 +1416,19 @@ describe("db push target default warning", () => { }, ] as const; - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tmp.current, linked); - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "push", ...(selector?.args ?? [])], - isLocal: false, - noProjectId: true, + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tmp.current, linked), () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", ...(selector?.args ?? [])], + isLocal: false, + noProjectId: true, + }); + return dbPush({ ...DEFAULT_FLAGS, ...selector?.over }).pipe( + Effect.provide(layer), + Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), + ); }); - return dbPush({ ...DEFAULT_FLAGS, ...selector?.over }).pipe( - Effect.provide(layer), - Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), - ); - }; it.effect( "warns on a bare run in a linked workdir and stays silent with every selector or when unlinked", diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 112f460b41..bf82f8350d 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -1309,7 +1309,6 @@ describe("db reset", () => { }); it.effect("a stack reset never reads or writes supabase/.temp", () => { - seedLegacyStateDecoys(tmp.current); const { layer, accessed } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1317,6 +1316,7 @@ describe("db reset", () => { stackBackend: true, }); return Effect.gen(function* () { + yield* seedLegacyStateDecoys(tmp.current); yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(accessed.some((entry) => entry.endsWith("config.toml"))).toBe(true); expect(legacyStateAccesses(accessed)).toEqual([]); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 80da2461ef..582eef96a2 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -1087,21 +1087,25 @@ describe("db schema declarative sync integration", () => { stackBackend: true, applyFails: true, }); - seedLegacyStateDecoys(tmp.current); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* seedDeclarative(tmp.current); - const error = yield* dbSchemaDeclarativeSync(flags({ apply: Option.some(true) })).pipe( - Effect.flip, - ); - expect(error._tag).toBe("DeclarativeApplyError"); - const pgdeltaRoot = path.join(tmp.current, ".supabase", "tmp", "pgdelta"); - const projectRoots = yield* fs.readDirectory(pgdeltaRoot); - expect(projectRoots).toHaveLength(1); - expect(yield* fs.exists(path.join(pgdeltaRoot, projectRoots[0] ?? "", "debug"))).toBe(true); - expect(legacyStateAccesses(s.accessed)).toEqual([]); - }).pipe(Effect.provide(s.layer)); + return Effect.andThen( + seedLegacyStateDecoys(tmp.current), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* seedDeclarative(tmp.current); + const error = yield* dbSchemaDeclarativeSync(flags({ apply: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error._tag).toBe("DeclarativeApplyError"); + const pgdeltaRoot = path.join(tmp.current, ".supabase", "tmp", "pgdelta"); + const projectRoots = yield* fs.readDirectory(pgdeltaRoot); + expect(projectRoots).toHaveLength(1); + expect(yield* fs.exists(path.join(pgdeltaRoot, projectRoots[0] ?? "", "debug"))).toBe( + true, + ); + expect(legacyStateAccesses(s.accessed)).toEqual([]); + }).pipe(Effect.provide(s.layer)), + ); }, ); diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts index c58f26240c..04aabe8cdd 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -16,9 +16,11 @@ import { ChildProcessSpawner } from "effect/process"; import { parse as parseDotenv } from "dotenv"; import { afterAll, afterEach, describe, expect, test } from "vitest"; import { - makeTempHome, + createTempHome, runSupabaseEffect, + type SpawnedSupabase, spawnSupabase, + type TempHome, } from "../../../../../tests/helpers/cli.ts"; const START_TIMEOUT_MS = 15 * 60_000; @@ -162,22 +164,19 @@ const destroyStack = (home: string, stackId: string) => }), ); -const waitForOutput = ( - spawned: ReturnType, - pattern: RegExp, - timeoutMs: number, -) => - Effect.tryPromise({ - try: () => spawned.waitForOutput(pattern, timeoutMs), - catch: (cause) => - new StartE2eProcessError({ - message: "stack logs follower did not produce live output", - cause, - }), - }); +const waitForOutput = (spawned: SpawnedSupabase, pattern: RegExp, timeoutMs: number) => + spawned.waitForOutput(pattern, timeoutMs).pipe( + Effect.mapError( + (cause) => + new StartE2eProcessError({ + message: "stack logs follower did not produce live output", + cause, + }), + ), + ); describe("stack start (compiled e2e)", () => { - let home: ReturnType | undefined; + let home: TempHome | undefined; let projectDir: string | undefined; let stackId: string | undefined; let stackDestroyed = false; @@ -214,11 +213,14 @@ describe("stack start (compiled e2e)", () => { if (projectDir !== undefined) { yield* remove(projectDir, { recursive: true, force: true }); } - yield* Effect.try({ - try: () => home?.[Symbol.dispose](), - catch: (cause) => - new StartE2eProcessError({ message: "Failed to dispose temporary CLI home", cause }), - }); + if (home !== undefined) { + yield* home.dispose.pipe( + Effect.mapError( + (cause) => + new StartE2eProcessError({ message: "Failed to dispose temporary CLI home", cause }), + ), + ); + } home = undefined; projectDir = undefined; stackId = undefined; @@ -233,7 +235,7 @@ describe("stack start (compiled e2e)", () => { () => runNode( Effect.gen(function* () { - home = makeTempHome(); + home = yield* createTempHome; projectDir = yield* makeTempDirectory("/tmp/supabase-compiled-stack-start-e2e-"); yield* makeDirectory(join(projectDir, "supabase"), { recursive: true }); yield* writeText(join(projectDir, "supabase", "config.toml"), minimalConfig); @@ -339,44 +341,30 @@ describe("stack start (compiled e2e)", () => { const followResult = yield* Effect.scoped( Effect.gen(function* () { - const followed = yield* Effect.acquireRelease( - Effect.try({ - try: () => - spawnSupabase( - [ - "stack", - "logs", - "--follow", - "--stack-id", - idText, - "--service", - "database", - "--output-format", - "stream-json", - ], - { - cwd: projectRoot, - home: homeDir.dir, - env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, - exitTimeoutMs: CLEANUP_TIMEOUT_MS, - }, - ), - catch: (cause) => + /* The scope owns the follower: closing it SIGKILLs the group and awaits its exit. */ + const followed = yield* spawnSupabase( + [ + "stack", + "logs", + "--follow", + "--stack-id", + idText, + "--service", + "database", + "--output-format", + "stream-json", + ], + { + cwd: projectRoot, + home: homeDir.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }, + ).pipe( + Effect.mapError( + (cause) => new StartE2eProcessError({ message: "failed to spawn logs follower", cause }), - }), - (spawned) => - Effect.sync(() => spawned.releaseOwned({ successOptOut: false })).pipe( - Effect.flatMap(() => spawned.exitEffect(CLEANUP_TIMEOUT_MS)), - Effect.asVoid, - Effect.catchTag("CliHomeDisposeError", (cause) => - Effect.die( - new StartE2eProcessError({ - message: "Follower reused the shared CLI home; disposal must not fail", - cause, - }), - ), - ), - ), + ), ); // History precedes the follow, whose start is the last printed record. yield* waitForOutput( @@ -402,8 +390,8 @@ describe("stack start (compiled e2e)", () => { /"type":"log-entry".*"source":"live"/u, START_TIMEOUT_MS, ); - yield* Effect.sync(() => followed.kill("SIGINT")); - return yield* followed.exitEffect(CLEANUP_TIMEOUT_MS); + yield* followed.kill("SIGINT"); + return yield* followed.waitForExit(CLEANUP_TIMEOUT_MS); }), ); expect( diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index ca3c828c75..4dc30ea621 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1003,7 +1003,7 @@ describe("experimental stack start", () => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-uploads-" }); yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "start-uploads"\n'); - seedLegacyStateDecoys(root); + yield* seedLegacyStateDecoys(root); const excluded = ["auth", "realtime", "functions", "mail", "analytics", "pooler"]; const fixture = fakeStack(); const recorder = recordingFileSystem(); diff --git a/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts index 013d848af1..316914b449 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Exit, FileSystem, Path, Schema } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import { + createTempHome, makeTempCliProject, - makeTempHome, runSupabaseEffect, } from "../../../../../tests/helpers/cli.ts"; import { stackArtifactCacheRoot } from "../../../../../tests/helpers/stack-artifacts.ts"; @@ -87,7 +87,8 @@ describe("stack start Storage behind the API gateway (compiled e2e)", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const http = yield* HttpClient.HttpClient; - const home = makeTempHome(); + /* Left to the e2e cleanup drain, which disposes it with the stack project it serves. */ + const home = yield* createTempHome; const project = yield* Effect.promise(() => makeTempCliProject(`stack-storage-${runtime}-e2e-`), ); diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 2e47137c5f..a1bb1fe224 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -2345,7 +2345,7 @@ describe("functions deploy", () => { ), ), ); - seedLegacyStateDecoys(tempRoot.current); + yield* seedLegacyStateDecoys(tempRoot.current); yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); @@ -2404,7 +2404,7 @@ describe("functions deploy", () => { ), ), ); - seedLegacyStateDecoys(tempRoot.current); + yield* seedLegacyStateDecoys(tempRoot.current); yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); diff --git a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts index b58f21eb08..b39e89ed84 100644 --- a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts @@ -76,8 +76,7 @@ describe("functions new (stack e2e)", () => { : {}), prefix: `functions-new-stack-${runtime}-`, }); - const home = makeTempHome(); - yield* Effect.addFinalizer(() => Effect.sync(() => home[Symbol.dispose]())); + const home = yield* makeTempHome; yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); yield* fs.writeFileString( path.join(projectDir, "supabase", "config.toml"), diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index c4445956b9..44b834ca01 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -22,7 +22,7 @@ import { import { FetchHttpClient, HttpClient } from "effect/http"; import { homedir } from "node:os"; -import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; +import { type SpawnedSupabase, spawnSupabase } from "../../../../tests/helpers/cli.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; import { stackArtifactCacheRoot } from "../../../../tests/helpers/stack-artifacts.ts"; @@ -155,31 +155,27 @@ const serve = Effect.fn("FunctionsServeE2e.serve")(function* ( home: string, flags: ReadonlyArray = [], ) { - const child = yield* Effect.acquireRelease( - Effect.try({ - try: () => - spawnSupabase(["functions", "serve", ...flags], { - cwd: root, - home, - env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, - exitTimeoutMs: 120_000, - }), - catch: (cause) => new FunctionsServeE2eError({ message: "CLI spawn failed", cause }), - }), - (process) => Effect.sync(() => process.releaseOwned({ successOptOut: false })), + const child = yield* spawnSupabase(["functions", "serve", ...flags], { + cwd: root, + home, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: 120_000, + }).pipe( + Effect.mapError((cause) => new FunctionsServeE2eError({ message: "CLI spawn failed", cause })), ); - yield* Effect.tryPromise({ - try: () => child.waitForOutput(/Serving Functions at /, 120_000), - catch: (cause) => new FunctionsServeE2eError({ message: "serve did not become ready", cause }), - }); + yield* child + .waitForOutput(/Serving Functions at /, 120_000) + .pipe( + Effect.mapError( + (cause) => new FunctionsServeE2eError({ message: "serve did not become ready", cause }), + ), + ); return child; }); -const interrupt = Effect.fn("FunctionsServeE2e.interrupt")(function* ( - child: ReturnType, -) { - yield* Effect.sync(() => child.kill("SIGINT")); - const exit = yield* child.exitEffect(120_000); +const interrupt = Effect.fn("FunctionsServeE2e.interrupt")(function* (child: SpawnedSupabase) { + yield* child.kill("SIGINT"); + const exit = yield* child.waitForExit(120_000); expect(exit.exitCode, `stdout:\n${child.stdout()}\nstderr:\n${child.stderr()}`).toBe(0); }); diff --git a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts index 00570c44e2..fcad75174f 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts @@ -443,7 +443,7 @@ describe("experimental Stack Functions serve", () => { `${root}/supabase/config.toml`, 'project_id = "functions-no-legacy-state"\n\n[edge_runtime]\nenabled = true\n', ); - seedLegacyStateDecoys(root); + yield* seedLegacyStateDecoys(root); const state = yield* fixture({ standaloneProjectRoot: root }); const run = yield* functionsServeStack(flags()).pipe( Effect.provide(state.layer), diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 78671fba60..96b4ca2a0d 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -2744,7 +2744,7 @@ describe("gen types", () => { }), ), }); - seedLegacyStateDecoys(workdir); + yield* seedLegacyStateDecoys(workdir); yield* genTypes(defaultFlags({ local: true })).pipe( Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"))), diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index bfbd69ffb3..13df57e318 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -677,19 +677,19 @@ describe("inspect db target default warning", () => { }, ] as const; - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tempRoot.current, linked); - const { layer, out } = setup({ - rows: [DB_STATS_ROW], - cliArgs: selector?.args ?? [], - workdir: tempRoot.current, - noProjectId: true, + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tempRoot.current, linked), () => { + const { layer, out } = setup({ + rows: [DB_STATS_ROW], + cliArgs: selector?.args ?? [], + workdir: tempRoot.current, + noProjectId: true, + }); + return inspectDbDbStats(flags(selector?.over)).pipe( + Effect.provide(layer), + Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), + ); }); - return inspectDbDbStats(flags(selector?.over)).pipe( - Effect.provide(layer), - Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), - ); - }; it.effect( "warns on a bare run in a linked workdir and stays silent with every selector or when unlinked", diff --git a/apps/cli/src/commands/inspect/report/report.integration.test.ts b/apps/cli/src/commands/inspect/report/report.integration.test.ts index 0f0b34d817..b2ff393657 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -802,19 +802,19 @@ describe("inspect report target default warning", () => { () => Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tempRoot.current, linked); - const { layer, out } = setupReport({ - csvs: DEFAULT_RULE_CSVS, - cliArgs: selector?.args ?? [], - workdir: tempRoot.current, - noProjectId: true, + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tempRoot.current, linked), () => { + const { layer, out } = setupReport({ + csvs: DEFAULT_RULE_CSVS, + cliArgs: selector?.args ?? [], + workdir: tempRoot.current, + noProjectId: true, + }); + return inspectReport(flags({ outputDir: base, ...selector?.over })).pipe( + Effect.provide(layer), + Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), + ); }); - return inspectReport(flags({ outputDir: base, ...selector?.over })).pipe( - Effect.provide(layer), - Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), - ); - }; yield* assertTargetDefaultWarning({ command: "inspect report", sameTarget: selectors[0], diff --git a/apps/cli/src/commands/link/link.stack-mode.integration.test.ts b/apps/cli/src/commands/link/link.stack-mode.integration.test.ts index b3e9cdd585..fa54c70354 100644 --- a/apps/cli/src/commands/link/link.stack-mode.integration.test.ts +++ b/apps/cli/src/commands/link/link.stack-mode.integration.test.ts @@ -177,15 +177,17 @@ describe("link integration in stack mode", () => { it.effect("never reads or writes supabase/.temp and ignores a legacy link left there", () => { const { layer, workdir, accessed } = setup(); - seedLegacyStateDecoys(workdir); - return Effect.gen(function* () { - yield* link(flags()); - - const document = yield* readProjectLink(workdir); - expect(Option.map(document, (value) => value.project.ref)).toEqual(Option.some(VALID_REF)); - expect(accessed.some((entry) => entry.includes(".link.json"))).toBe(true); - expect(legacyStateAccesses(accessed)).toEqual([]); - }).pipe(Effect.provide(layer)); + return Effect.andThen( + seedLegacyStateDecoys(workdir), + Effect.gen(function* () { + yield* link(flags()); + + const document = yield* readProjectLink(workdir); + expect(Option.map(document, (value) => value.project.ref)).toEqual(Option.some(VALID_REF)); + expect(accessed.some((entry) => entry.includes(".link.json"))).toBe(true); + expect(legacyStateAccesses(accessed)).toEqual([]); + }).pipe(Effect.provide(layer)), + ); }); it.effect("keeps the link, its staged writes, and the stack lease out of version control", () => { diff --git a/apps/cli/src/commands/login/login.integration.test.ts b/apps/cli/src/commands/login/login.integration.test.ts index 135a60f33a..ae370fd2de 100644 --- a/apps/cli/src/commands/login/login.integration.test.ts +++ b/apps/cli/src/commands/login/login.integration.test.ts @@ -122,7 +122,7 @@ function setupLogin(opts: SetupOpts = {}) { ? loginApi.layer : loginApiLayer.pipe(Layer.provide(httpApi.httpClientLayer), Layer.provide(cliSettings)), mockStdin(isTTY, opts.pipedStdin), - mockBrowser(), + mockBrowser, Layer.succeed(ProfileFlag, opts.profileFlag ?? "supabase"), ...(opts.argv !== undefined ? [Layer.succeed(CliArgs, { args: opts.argv })] : []), ); diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index 5b8feb2bda..48b9c4e8de 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -577,18 +577,18 @@ describe("migration fetch target default warning", () => { }, ] as const; - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tmp.current, linked); - const { layer, out } = setup(tmp.current, { - cliArgs: selector?.args ?? [], - rows: [], - noProjectId: true, + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tmp.current, linked), () => { + const { layer, out } = setup(tmp.current, { + cliArgs: selector?.args ?? [], + rows: [], + noProjectId: true, + }); + return migrationFetch(flags(selector?.over)).pipe( + Effect.provide(layer), + Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), + ); }); - return migrationFetch(flags(selector?.over)).pipe( - Effect.provide(layer), - Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), - ); - }; it.effect( "warns on a bare run in a linked workdir and stays silent with every selector or when unlinked", diff --git a/apps/cli/src/commands/migration/list/list.integration.test.ts b/apps/cli/src/commands/migration/list/list.integration.test.ts index 8c5e5f3d02..1b9da24ac6 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -455,18 +455,18 @@ describe("migration list target default warning", () => { }, ] as const; - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tmp.current, linked); - const { layer, out } = setup(tmp.current, { - args: selector?.args ?? [], - remote: ["20240101000000"], - noProjectId: true, + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tmp.current, linked), () => { + const { layer, out } = setup(tmp.current, { + args: selector?.args ?? [], + remote: ["20240101000000"], + noProjectId: true, + }); + return migrationList(flags(selector?.over)).pipe( + Effect.provide(layer), + Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), + ); }); - return migrationList(flags(selector?.over)).pipe( - Effect.provide(layer), - Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), - ); - }; it.effect( "warns on a bare run in a linked workdir and stays silent with every selector or when unlinked", diff --git a/apps/cli/src/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/commands/migration/repair/repair.integration.test.ts index f63bccd905..33f600d040 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -618,19 +618,19 @@ describe("migration repair target default warning", () => { }, ] as const; - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tmp.current, linked); - const { layer, out } = setup(tmp.current, { - args: selector?.args ?? [], - noProjectId: true, + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tmp.current, linked), () => { + const { layer, out } = setup(tmp.current, { + args: selector?.args ?? [], + noProjectId: true, + }); + return migrationRepair( + input({ versions: ["20240101000000"], status: "reverted", ...selector?.over }), + ).pipe( + Effect.provide(layer), + Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), + ); }); - return migrationRepair( - input({ versions: ["20240101000000"], status: "reverted", ...selector?.over }), - ).pipe( - Effect.provide(layer), - Effect.map(() => ({ stdout: out.stdoutText, stderr: out.stderrText })), - ); - }; it.effect( "warns on a bare run in a linked workdir and stays silent with every selector or when unlinked", diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 6cf44c990f..856dc7443b 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -3075,7 +3075,7 @@ describe("stack backend", () => { { method: "POST", match: "/storage/v1/object/", body: {} }, ], }); - seedLegacyStateDecoys(tmp.current); + yield* seedLegacyStateDecoys(tmp.current); yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/status/status.integration.test.ts b/apps/cli/src/commands/status/status.integration.test.ts index 8af2abe9b5..c84f8b79e7 100644 --- a/apps/cli/src/commands/status/status.integration.test.ts +++ b/apps/cli/src/commands/status/status.integration.test.ts @@ -1314,15 +1314,17 @@ content_path = "./supabase/templates/password_changed_notification.html" "stack mode: never reads or writes supabase/.temp and ignores a legacy link there", () => { const { layer, out, workdir, accessed } = setup({ projectStateMode: "stack" }); - seedLegacyStateDecoys(workdir); - return Effect.gen(function* () { - yield* writeConfig(); - yield* status(flags()); - expect(out.stdoutText).not.toContain("Linked Project"); - expect(out.stdoutText).not.toContain("decoy"); - expect(accessed.some((entry) => entry.endsWith("config.toml"))).toBe(true); - expect(legacyStateAccesses(accessed)).toEqual([]); - }).pipe(Effect.provide(layer)); + return Effect.andThen( + seedLegacyStateDecoys(workdir), + Effect.gen(function* () { + yield* writeConfig(); + yield* status(flags()); + expect(out.stdoutText).not.toContain("Linked Project"); + expect(out.stdoutText).not.toContain("decoy"); + expect(accessed.some((entry) => entry.endsWith("config.toml"))).toBe(true); + expect(legacyStateAccesses(accessed)).toEqual([]); + }).pipe(Effect.provide(layer)), + ); }, ); diff --git a/apps/cli/src/commands/storage/storage.target-default-warning.integration.test.ts b/apps/cli/src/commands/storage/storage.target-default-warning.integration.test.ts index f74e0f2ffe..6227bbc97e 100644 --- a/apps/cli/src/commands/storage/storage.target-default-warning.integration.test.ts +++ b/apps/cli/src/commands/storage/storage.target-default-warning.integration.test.ts @@ -70,20 +70,20 @@ describe("storage target default warning", () => { ]; for (const { name, args } of leaves) { - const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => { - setLinkedRefFile(tempRoot.current, linked); - const argv = [...args, "--experimental", ...(selector?.args ?? [])]; - const { layer, out } = setup(argv); - return Command.runWith(testRoot, { version: "0.0.0-test" })(argv).pipe( - Effect.exit, - Effect.provide(layer), - Effect.map((exit) => ({ - stdout: out.stdoutText, - stderr: out.stderrText, - failure: describeFailure(exit), - })), - ); - }; + const runWith = (linked: boolean) => (selector: (typeof selectors)[number] | undefined) => + Effect.andThen(setLinkedRefFile(tempRoot.current, linked), () => { + const argv = [...args, "--experimental", ...(selector?.args ?? [])]; + const { layer, out } = setup(argv); + return Command.runWith(testRoot, { version: "0.0.0-test" })(argv).pipe( + Effect.exit, + Effect.provide(layer), + Effect.map((exit) => ({ + stdout: out.stdoutText, + stderr: out.stderrText, + failure: describeFailure(exit), + })), + ); + }); it.effect( `${name} warns on a bare run in a linked workdir and stays silent with every selector or when unlinked`, diff --git a/apps/cli/src/commands/unlink/unlink.integration.test.ts b/apps/cli/src/commands/unlink/unlink.integration.test.ts index ddf73cc1f8..a5f1180663 100644 --- a/apps/cli/src/commands/unlink/unlink.integration.test.ts +++ b/apps/cli/src/commands/unlink/unlink.integration.test.ts @@ -388,14 +388,16 @@ describe("unlink integration", () => { it.effect("leaves the legacy temp dir alone without reading or writing it", () => { const { layer, workdir, accessed } = setup({ stack: true }); - seedLegacyStateDecoys(workdir); - return Effect.gen(function* () { - yield* seedLinkDocument(workdir, VALID_REF); - yield* unlink(); - expect(accessed.some((entry) => entry.includes(".link.json"))).toBe(true); - expect(legacyStateAccesses(accessed)).toEqual([]); - expect(yield* tempDirExists(workdir)).toBe(true); - }).pipe(Effect.provide(layer)); + return Effect.andThen( + seedLegacyStateDecoys(workdir), + Effect.gen(function* () { + yield* seedLinkDocument(workdir, VALID_REF); + yield* unlink(); + expect(accessed.some((entry) => entry.includes(".link.json"))).toBe(true); + expect(legacyStateAccesses(accessed)).toEqual([]); + expect(yield* tempDirExists(workdir)).toBe(true); + }).pipe(Effect.provide(layer)), + ); }); it.effect("fails with ProjectRefNotLinkedError when only legacy state exists", () => { diff --git a/apps/cli/src/commands/whoami/whoami.e2e.test.ts b/apps/cli/src/commands/whoami/whoami.e2e.test.ts index 314e9b990a..7867871633 100644 --- a/apps/cli/src/commands/whoami/whoami.e2e.test.ts +++ b/apps/cli/src/commands/whoami/whoami.e2e.test.ts @@ -51,12 +51,8 @@ it.live("shows the authenticated profile through the CLI", () => }), (running) => Effect.promise(() => running.stop(true)), ); - const home = yield* Effect.acquireRelease( - Effect.try({ - try: () => makeTempHome(), - catch: (cause) => new WhoamiSetupError({ cause }), - }), - (owned) => Effect.sync(() => owned[Symbol.dispose]()), + const home = yield* makeTempHome.pipe( + Effect.mapError((cause) => new WhoamiSetupError({ cause })), ); const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/tests/e2e-sequencer.ts b/apps/cli/tests/e2e-sequencer.ts index aec487fedb..290301b9d9 100644 --- a/apps/cli/tests/e2e-sequencer.ts +++ b/apps/cli/tests/e2e-sequencer.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; -import { relative, resolve } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { BaseSequencer, type TestSpecification } from "vitest/node"; /** Bun `--timings` file of measured e2e file durations, refreshed from CI artifacts. */ @@ -123,45 +123,68 @@ function isTimingsEnvelope( ); } +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + /** Reads the committed Bun `--timings` file under `root`; anything but that envelope reads as absent. */ -export function readTimings(root: string): Readonly> | undefined { - try { - const parsed: unknown = JSON.parse(readFileSync(resolve(root, TIMINGS_FILE), "utf8")); - return isTimingsEnvelope(parsed) ? parsed.files : undefined; - } catch { - return undefined; - } -} +export const readTimings = Effect.fn("E2eSequencer.readTimings")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFile(path.resolve(root, TIMINGS_FILE)).pipe( + Effect.map((bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes)), + Effect.flatMap(decodeJson), + Effect.map((parsed): Readonly> | undefined => + isTimingsEnvelope(parsed) ? parsed.files : undefined, + ), + Effect.orElseSucceed(() => undefined), + ); +}); + +const sequencerLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); + +class HashSplitFailed extends Data.TaggedError("HashSplitFailed")<{ readonly cause: unknown }> {} /** * Shards the e2e projects by measured duration. Other projects, and the e2e projects while * the timings file is absent, keep Vitest's hash split. */ export class E2eSequencer extends BaseSequencer { - override async shard(files: TestSpecification[]): Promise { + override shard(files: TestSpecification[]): Promise { const { root, shard } = this.ctx.config; - const timings = files.every((spec) => E2E_PROJECTS.has(spec.project.name)) - ? readTimings(root) - : undefined; - if (shard === undefined || timings === undefined) { - return super.shard(files); - } + const hashSplit = () => super.shard(files); + return Effect.runPromise( + Effect.gen(function* () { + const timings = files.every((spec) => E2E_PROJECTS.has(spec.project.name)) + ? yield* readTimings(root) + : undefined; + if (shard === undefined || timings === undefined) { + return yield* Effect.tryPromise({ + try: hashSplit, + catch: (cause) => new HashSplitFailed({ cause }), + }); + } - const byKey = new Map( - files.map((spec) => [relative(root, spec.moduleId).replaceAll("\\", "/"), spec]), + const path = yield* Path.Path; + const byKey = new Map( + files.map((spec) => [path.relative(root, spec.moduleId).replaceAll("\\", "/"), spec]), + ); + const shardFiles = [...byKey].map(([key, spec]) => ({ + key, + exclusive: spec.project.name === E2E_EXCLUSIVE_PROJECT, + })); + const maxWorkers = + files.find((spec) => spec.project.name === E2E_PROJECT)?.project.config.maxWorkers ?? 1; + // Concurrent files share one Docker daemon and slow each other, so pack against half the slots. + const workers = Math.max(1, Math.floor(maxWorkers / 2)); + const assigned = + packShards(shardFiles, timings, shard.count, workers)[shard.index - 1] ?? []; + return assigned.flatMap((key) => { + const spec = byKey.get(key); + return spec === undefined ? [] : [spec]; + }); + }).pipe( + Effect.provide(sequencerLayer), + Effect.catchTag("HashSplitFailed", (error) => Effect.die(error.cause)), + ), ); - const shardFiles = [...byKey].map(([key, spec]) => ({ - key, - exclusive: spec.project.name === E2E_EXCLUSIVE_PROJECT, - })); - const maxWorkers = - files.find((spec) => spec.project.name === E2E_PROJECT)?.project.config.maxWorkers ?? 1; - // Concurrent files share one Docker daemon and slow each other, so pack against half the slots. - const workers = Math.max(1, Math.floor(maxWorkers / 2)); - const assigned = packShards(shardFiles, timings, shard.count, workers)[shard.index - 1] ?? []; - return assigned.flatMap((key) => { - const spec = byKey.get(key); - return spec === undefined ? [] : [spec]; - }); } } diff --git a/apps/cli/tests/e2e-sequencer.unit.test.ts b/apps/cli/tests/e2e-sequencer.unit.test.ts index d90a7e3813..07a9467268 100644 --- a/apps/cli/tests/e2e-sequencer.unit.test.ts +++ b/apps/cli/tests/e2e-sequencer.unit.test.ts @@ -1,7 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { describe, expect, test } from "vitest"; import { DEFAULT_DURATION_MS, fallbackDuration, packShards, readTimings } from "./e2e-sequencer.ts"; const files = ["src/a.e2e.test.ts", "src/b.e2e.test.ts", "src/c.e2e.test.ts", "src/d.e2e.test.ts"]; @@ -126,61 +126,83 @@ describe("fallbackDuration", () => { }); describe("readTimings", () => { - let root: string; - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "e2e-timings-")); - mkdirSync(join(root, "tests")); - }); - - afterEach(() => { - rmSync(root, { recursive: true, force: true }); - }); - - const writeTimings = (content: string) => - writeFileSync(join(root, "tests", "e2e-timings.json"), content); - - test("reads the files map from a version 1 envelope", () => { - writeTimings( - JSON.stringify({ - version: 1, - files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }, - }), - ); - - expect(readTimings(root)).toEqual({ "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }); - }); - - test("reads as absent when the file is missing", () => { - expect(readTimings(root)).toBeUndefined(); - }); + const fsLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); + const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + + const timingsRoot = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "e2e-timings-" }); + yield* fs.makeDirectory(path.join(root, "tests")); + const writeTimings = (content: string) => + fs.writeFileString(path.join(root, "tests", "e2e-timings.json"), content); + return { root, writeTimings }; + }); + + it.effect("reads the files map from a version 1 envelope", () => + Effect.gen(function* () { + const { root, writeTimings } = yield* timingsRoot; + yield* writeTimings( + yield* encodeJson({ + version: 1, + files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }, + }), + ); + + expect(yield* readTimings(root)).toEqual({ + "src/a.e2e.test.ts": 1200, + "src/b.e2e.test.ts": 300, + }); + }).pipe(Effect.provide(fsLayer)), + ); - test("reads as absent rather than half-applying when any duration is not a number", () => { - writeTimings( - JSON.stringify({ - version: 1, - files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": "300" }, - }), - ); + it.effect("reads as absent when the file is missing", () => + Effect.gen(function* () { + const { root } = yield* timingsRoot; + expect(yield* readTimings(root)).toBeUndefined(); + }).pipe(Effect.provide(fsLayer)), + ); - expect(readTimings(root)).toBeUndefined(); - }); + it.effect("reads as absent rather than half-applying when any duration is not a number", () => + Effect.gen(function* () { + const { root, writeTimings } = yield* timingsRoot; + yield* writeTimings( + yield* encodeJson({ + version: 1, + files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": "300" }, + }), + ); + + expect(yield* readTimings(root)).toBeUndefined(); + }).pipe(Effect.provide(fsLayer)), + ); - test("reads as absent when the file is not JSON", () => { - writeTimings("{ not json"); + it.effect("reads as absent when the file is not JSON", () => + Effect.gen(function* () { + const { root, writeTimings } = yield* timingsRoot; + yield* writeTimings("{ not json"); - expect(readTimings(root)).toBeUndefined(); - }); + expect(yield* readTimings(root)).toBeUndefined(); + }).pipe(Effect.provide(fsLayer)), + ); - test("reads as absent for a flat map with no envelope", () => { - writeTimings(JSON.stringify({ "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 })); + it.effect("reads as absent for a flat map with no envelope", () => + Effect.gen(function* () { + const { root, writeTimings } = yield* timingsRoot; + yield* writeTimings( + yield* encodeJson({ "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }), + ); - expect(readTimings(root)).toBeUndefined(); - }); + expect(yield* readTimings(root)).toBeUndefined(); + }).pipe(Effect.provide(fsLayer)), + ); - test("reads as absent for an unknown envelope version", () => { - writeTimings(JSON.stringify({ version: 2, files: { "src/a.e2e.test.ts": 1200 } })); + it.effect("reads as absent for an unknown envelope version", () => + Effect.gen(function* () { + const { root, writeTimings } = yield* timingsRoot; + yield* writeTimings(yield* encodeJson({ version: 2, files: { "src/a.e2e.test.ts": 1200 } })); - expect(readTimings(root)).toBeUndefined(); - }); + expect(yield* readTimings(root)).toBeUndefined(); + }).pipe(Effect.provide(fsLayer)), + ); }); diff --git a/apps/cli/tests/e2e-setup.ts b/apps/cli/tests/e2e-setup.ts index 118c0ab7d9..6655c64db7 100644 --- a/apps/cli/tests/e2e-setup.ts +++ b/apps/cli/tests/e2e-setup.ts @@ -1,6 +1,4 @@ import { afterEach } from "vitest"; import { cleanupRegisteredStackProjects } from "./helpers/stack-e2e-cleanup.ts"; -afterEach(async () => { - await cleanupRegisteredStackProjects(); -}); +afterEach(() => cleanupRegisteredStackProjects()); diff --git a/apps/cli/tests/fixtures/compiled-cli-version.ts b/apps/cli/tests/fixtures/compiled-cli-version.ts index 07b930c371..3241498ee7 100644 --- a/apps/cli/tests/fixtures/compiled-cli-version.ts +++ b/apps/cli/tests/fixtures/compiled-cli-version.ts @@ -1,3 +1,4 @@ +import { Console, Effect } from "effect"; import { CLI_VERSION } from "../../src/shared/cli/version.ts"; -console.log(CLI_VERSION); +Effect.runSync(Console.log(CLI_VERSION)); diff --git a/apps/cli/tests/fixtures/compiled-libpg-query.ts b/apps/cli/tests/fixtures/compiled-libpg-query.ts index d421a00054..3286d1d1bc 100644 --- a/apps/cli/tests/fixtures/compiled-libpg-query.ts +++ b/apps/cli/tests/fixtures/compiled-libpg-query.ts @@ -6,6 +6,7 @@ // Bun's `--compile` traces that dynamic import inside pg-delta's dist and // still embeds the libpg-query WASM asset it ultimately depends on. import { analyzeForShadow } from "@supabase/pg-delta/sql-order"; +import { Console, Effect } from "effect"; const embeddedParser = Bun.embeddedFiles.find((file) => file.type === "application/wasm"); @@ -28,4 +29,4 @@ if (result.files.length !== 1) { throw new Error("analyzeForShadow did not reorder the expected statement"); } -console.log("libpg-query.wasm loaded"); +Effect.runSync(Console.log("libpg-query.wasm loaded")); diff --git a/apps/cli/tests/helpers/branches-live.integration.test.ts b/apps/cli/tests/helpers/branches-live.integration.test.ts index 0dd5e7cfd8..8365653d5f 100644 --- a/apps/cli/tests/helpers/branches-live.integration.test.ts +++ b/apps/cli/tests/helpers/branches-live.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Fiber } from "effect"; +import { Cause, Data, Effect, Exit, Fiber, Schema } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { @@ -31,30 +31,34 @@ type State = { deleteAttempts: number; }; +class FakeCliError extends Data.TaggedError("FakeCliError")<{ readonly message: string }> {} + +const json = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + function result(stdout = "", stderr = "", exitCode = 0) { return { stdout, stderr, exitCode }; } function statefulCli(state: State): BranchCli { return (args) => - Effect.sync(() => { + Effect.gen(function* () { state.calls.push(args.join(" ")); if (args[1] === "create") { - return result(JSON.stringify({ ...branch, project_ref: branchRef })); + return result(yield* json({ ...branch, project_ref: branchRef })); } if (args[1] === "get") { return state.getReady - ? result(JSON.stringify(branch)) + ? result(yield* json(branch)) : result("", "Request failed with status 404", 1); } if (args[1] === "list") { - return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(yield* json(state.listed ? [defaultBranch, branch] : [defaultBranch])); } if (args[1] === "delete") { state.deleteAttempts += 1; if (state.deleteAttempts === 1) return result("", "Request failed with status 404", 1); state.listed = false; - return result(JSON.stringify(branch)); + return result(yield* json(branch)); } return result("", "unexpected command", 1); }); @@ -108,13 +112,13 @@ describe("live branch lifecycle helpers", () => { Effect.gen(function* () { const state: State = { calls: [], listed: true, getReady: true, deleteAttempts: 0 }; const cli: BranchCli = (args) => - Effect.sync(() => { + Effect.gen(function* () { state.calls.push(args.join(" ")); if (args[1] === "list") { if (state.calls.filter((call) => call.includes("branches list")).length > 1) { state.listed = false; } - return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(yield* json(state.listed ? [defaultBranch, branch] : [defaultBranch])); } return result(); }); @@ -146,9 +150,9 @@ describe("live branch lifecycle helpers", () => { Effect.gen(function* () { let attempts = 0; const cli: BranchCli = () => - Effect.sync(() => { + Effect.gen(function* () { attempts += 1; - return result(JSON.stringify([{}])); + return result(yield* json([{}])); }); const exit = yield* Effect.exit(awaitLiveBranchListedEffect(cli, project, branch.name)); if (!Exit.isFailure(exit)) throw new Error("expected malformed list failure"); @@ -174,16 +178,16 @@ describe("live branch lifecycle helpers", () => { Effect.gen(function* () { const state: State = { calls: [], listed: true, getReady: false, deleteAttempts: 0 }; const cli: BranchCli = (args) => - Effect.sync(() => { + Effect.gen(function* () { state.calls.push(args.join(" ")); if (args[1] === "create") - return result(JSON.stringify({ message: "Created preview branch" })); + return result(yield* json({ message: "Created preview branch" })); if (args[1] === "delete") { state.listed = false; return result(); } if (args[1] === "list") - return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(yield* json(state.listed ? [defaultBranch, branch] : [defaultBranch])); return result(); }); const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); @@ -200,11 +204,11 @@ describe("live branch lifecycle helpers", () => { Effect.gen(function* () { const state: State = { calls: [], listed: true, getReady: false, deleteAttempts: 0 }; const cli: BranchCli = (args) => - Effect.sync(() => { + Effect.gen(function* () { state.calls.push(args.join(" ")); if (args[1] === "create") return result( - JSON.stringify({ _tag: "Error", error: { message: "branch quota exceeded" } }), + yield* json({ _tag: "Error", error: { message: "branch quota exceeded" } }), "", 1, ); @@ -213,7 +217,7 @@ describe("live branch lifecycle helpers", () => { return result(); } if (args[1] === "list") - return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(yield* json(state.listed ? [defaultBranch, branch] : [defaultBranch])); return result(); }); const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); @@ -233,14 +237,14 @@ describe("live branch lifecycle helpers", () => { const state: State = { calls: [], listed: false, getReady: false, deleteAttempts: 0 }; let creates = 0; const cli: BranchCli = (args) => - Effect.sync(() => { + Effect.gen(function* () { state.calls.push(args.join(" ")); if (args[1] === "create") { creates += 1; state.listed = true; return creates === 1 ? result( - JSON.stringify({ + yield* json({ _tag: "Error", error: { message: @@ -250,16 +254,14 @@ describe("live branch lifecycle helpers", () => { "", 1, ) - : result(JSON.stringify(branch)); + : result(yield* json(branch)); } if (args[1] === "delete") { state.listed = false; return result(); } if (args[1] === "list") - return result( - JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch]), - ); + return result(yield* json(state.listed ? [defaultBranch, branch] : [defaultBranch])); return result(); }); const ref = yield* createLiveBranchEffect(cli, project, branch.name); @@ -279,12 +281,12 @@ describe("live branch lifecycle helpers", () => { Effect.gen(function* () { let creates = 0; const cli: BranchCli = (args) => - Effect.sync(() => { + Effect.gen(function* () { if (args[1] === "create") { creates += 1; return result("", "status 400: the database system is shutting down", 1); } - return result(JSON.stringify([defaultBranch])); + return result(yield* json([defaultBranch])); }); const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); if (!Exit.isFailure(exit)) throw new Error("expected create failure"); @@ -317,7 +319,7 @@ describe("live branch lifecycle helpers", () => { const exit = yield* Effect.exit(awaitLiveBranchEffect(cli, project, branch.name)); if (!Exit.isFailure(exit)) throw new Error("expected get failure"); expect(String(exit.cause)).not.toContain("sentinel-secret"); - expect(JSON.stringify(Cause.squash(exit.cause))).not.toContain("sentinel-secret"); + expect(yield* json(Cause.squash(exit.cause))).not.toContain("sentinel-secret"); }), ); @@ -326,9 +328,10 @@ describe("live branch lifecycle helpers", () => { const calls: string[] = []; const cli: BranchCli = (args) => { calls.push(args.join(" ")); - if (args[1] === "create") return Effect.fail(new Error("invocation failed")); + if (args[1] === "create") + return Effect.fail(new FakeCliError({ message: "invocation failed" })); if (args[1] === "delete") return Effect.succeed(result()); - if (args[1] === "list") return Effect.succeed(result(JSON.stringify([defaultBranch]))); + if (args[1] === "list") return json([defaultBranch]).pipe(Effect.map(result)); return Effect.succeed(result()); }; const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); @@ -342,7 +345,7 @@ describe("live branch lifecycle helpers", () => { Effect.gen(function* () { const cli: BranchCli = (args) => args[1] === "create" - ? Effect.fail(new Error("create invocation failed")) + ? Effect.fail(new FakeCliError({ message: "create invocation failed" })) : Effect.succeed(result("", "cleanup forbidden", 1)); const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); if (!Exit.isFailure(exit)) throw new Error("expected aggregate failure"); @@ -359,10 +362,10 @@ describe("live branch lifecycle helpers", () => { Effect.gen(function* () { const cli: BranchCli = (args) => args[1] === "create" - ? Effect.fail(new Error("create invocation failed")) + ? Effect.fail(new FakeCliError({ message: "create invocation failed" })) : args[1] === "delete" ? Effect.succeed(result("", "Request failed with status 404", 1)) - : Effect.succeed(result(JSON.stringify([defaultBranch]))); + : json([defaultBranch]).pipe(Effect.map(result)); const cleanup = yield* createLiveBranchEffect(cli, project, branch.name).pipe( Effect.forkChild({ startImmediately: true }), ); @@ -382,7 +385,7 @@ describe("live branch lifecycle helpers", () => { const cli: BranchCli = (args) => args[1] === "delete" ? Effect.sleep("60 seconds").pipe(Effect.as(result())) - : Effect.succeed(result(JSON.stringify([defaultBranch, branch]))); + : json([defaultBranch, branch]).pipe(Effect.map(result)); const waiting = yield* awaitLiveBranchRemovedEffect(cli, project, branchRef).pipe( Effect.forkChild({ startImmediately: true }), ); diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index e02bdb2b14..e373e336c9 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -1,13 +1,25 @@ +import { BunServices } from "@effect/platform-bun"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import type * as PlatformError from "effect/PlatformError"; import { createServer } from "node:net"; import { homedir, tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { Data, Effect, Exit, Stream } from "effect"; +import { + Config, + Data, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Path, + Predicate, + PubSub, + Schema, + Scope, + Stream, +} from "effect"; import { noteStackCliProjectHome, registerTempHome, @@ -56,16 +68,6 @@ function subprocessBaseEnv(): Record { return env; } -function assertBuildArtifactsExist(binaryPath: string): void { - if (!existsSync(SHIM_PATH) || !existsSync(binaryPath)) { - throw new Error( - `Missing CLI build artifacts. Run \`pnpm --filter supabase build\` before invoking e2e tests.\n` + - ` expected shim: ${SHIM_PATH}\n` + - ` expected binary: ${binaryPath}`, - ); - } -} - export type RunResult = { stdout: string; stderr: string; @@ -92,6 +94,11 @@ class CliSpawnError extends Data.TaggedError("CliSpawnError")<{ } } +/** The compiled CLI is missing, so there is nothing to spawn. */ +class CliBuildArtifactsError extends Data.TaggedError("CliBuildArtifactsError")<{ + readonly message: string; +}> {} + /** Disposing a run-owned or test-owned temp `SUPABASE_HOME` failed. */ export class CliHomeDisposeError extends Data.TaggedError("CliHomeDisposeError")<{ readonly cause: unknown; @@ -103,75 +110,144 @@ export class CliHomeDisposeError extends Data.TaggedError("CliHomeDisposeError") export type CliRunError = CliSpawnError | CliStdinWriteError | CliHomeDisposeError; +/** The CLI exited, or the wait timed out, before its stdout matched. */ +class CliOutputWaitError extends Data.TaggedError("CliOutputWaitError")<{ + readonly message: string; +}> {} + +/** No free port could be allocated for this e2e worker. */ +class E2ePortError extends Data.TaggedError("E2ePortError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** Stopping the stack of an owned temp project failed. */ +class TempStackCleanupError extends Data.TaggedError("TempStackCleanupError")<{ + readonly message: string; +}> {} + +const helperServices = BunServices.layer; + +/* UTF-8 decoding that keeps a leading byte order mark, like Node's `Buffer#toString`. */ +const chunkDecoder = new TextDecoder("utf-8", { ignoreBOM: true }); + +/** The host error a platform failure carries, which is what the file system call raised. */ +const hostCause = (error: PlatformError.PlatformError): unknown => error.cause ?? error; + +/* A platform or port failure's host error, else a plain `Error` with the failure's message. */ +function hostOrPlainError(error: Error): Error { + if (Predicate.isTagged(error, "PlatformError") || error instanceof E2ePortError) { + return error.cause instanceof Error ? error.cause : new Error(error.message); + } + if ( + error instanceof TempStackCleanupError || + error instanceof CliOutputWaitError || + error instanceof CliBuildArtifactsError + ) { + return new Error(error.message); + } + return error; +} + +/** + * What a Promise helper rejects with: the host error a platform or port failure carries, else a + * plain `Error` with the failure's message. A home disposal failure stays typed. + */ +export function helperRejection(error: Error): Error { + if (error instanceof CliHomeDisposeError) { + return error; + } + if (error instanceof CliSpawnError || error instanceof CliStdinWriteError) { + return error.cause instanceof Error ? hostOrPlainError(error.cause) : new Error(error.message); + } + return hostOrPlainError(error); +} + +/** Promise edge for the helpers non-Effect tests `await`. */ +const runHelper = ( + effect: Effect.Effect, +): Promise => + Effect.runPromise(effect.pipe(Effect.mapError(helperRejection), Effect.provide(helperServices))); + +const removeOwnedDir = (dir: string) => + FileSystem.FileSystem.use((fs) => fs.remove(dir, { recursive: true, force: true })); + +/** Like `existsSync`: any failure to reach the path reads as "absent". */ +const pathExists = (fs: FileSystem.FileSystem, pathname: string) => + fs.exists(pathname).pipe(Effect.orElseSucceed(() => false)); + const DEFAULT_EXIT_TIMEOUT_MS = 60_000; const DEFAULT_STACK_CLEANUP_TIMEOUT_MS = 120_000; const OUTPUT_TAIL_LENGTH = 4_000; -interface SpawnedSupabase { - readonly pid: number; - readonly homeDir: string; - readonly stdout: () => string; - readonly stderr: () => string; - readonly kill: (signal?: NodeJS.Signals) => void; - readonly waitForOutput: (pattern: RegExp, timeoutMs?: number, startAt?: number) => Promise; - readonly waitForExit: (timeoutMs?: number) => Promise; - /** Effect-native exit path; `waitForExit` is the Promise facade over this. */ - readonly exitEffect: (timeoutMs?: number) => Effect.Effect; - /** The deferred stdin write failure, if the CLI closed stdin early. */ - readonly stdinFailure: (result: RunResult) => Error | undefined; - /** - * Effect-path scope release: kills the group once (unless the caller opted out on a - * successful run), then disposes the owned temp home. Idempotent across the close path. - */ - readonly releaseOwned: (opts: { readonly successOptOut: boolean }) => void; +/** A temp `SUPABASE_HOME`; `dispose` removes it (repeatably) and keeps failures typed. */ +export interface TempHome { + readonly dir: string; + readonly dispose: Effect.Effect; } -export function makeTempHome() { - const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; - const dir = mkdtempSync(path.join(tempRoot, "sb-test-")); +/** The run's owned temp `SUPABASE_HOME` could not be created. */ +export class TempHomeSetupError extends Data.TaggedError("TempHomeSetupError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const createTempHomeEffect = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ + directory: process.platform === "win32" ? tmpdir() : "/tmp", + prefix: "sb-test-", + }); // Share the real binary cache so tests don't re-download binaries. const realBinDir = path.join(homedir(), ".supabase", "bin"); - if (existsSync(realBinDir)) { - mkdirSync(dir, { recursive: true }); - symlinkSync(realBinDir, path.join(dir, "bin")); + if (yield* pathExists(fs, realBinDir)) { + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.symlink(realBinDir, path.join(dir, "bin")); } - const home = { - dir, - [Symbol.dispose]() { - rmSync(dir, { recursive: true, force: true }); - }, - }; - registerTempHome(home); + const context = yield* Effect.context(); + const dispose = removeOwnedDir(dir).pipe( + Effect.mapError((cause) => new CliHomeDisposeError({ cause: hostCause(cause) })), + Effect.provide(context), + ); + const home: TempHome = { dir, dispose }; + yield* Effect.sync(() => + registerTempHome({ + dir, + dispose: () => + Effect.runPromiseWith(context)(removeOwnedDir(dir).pipe(Effect.mapError(helperRejection))), + }), + ); return home; -} +}); -/** The run's owned temp `SUPABASE_HOME` could not be created. */ -export class TempHomeSetupError extends Data.TaggedError("TempHomeSetupError")<{ - readonly message: string; - readonly cause: unknown; -}> {} +/** + * Creates and registers a temp `SUPABASE_HOME` without a release: the caller disposes it, or + * the e2e cleanup drain does when a stack project uses it. + */ +export const createTempHome: Effect.Effect = + createTempHomeEffect.pipe( + Effect.mapError( + (cause) => + new TempHomeSetupError({ message: "temp home setup failed", cause: hostCause(cause) }), + ), + Effect.provide(helperServices), + ); + +/** A temp `SUPABASE_HOME` owned by the calling scope, removed when the scope closes. */ +export const makeTempHome: Effect.Effect = + Effect.acquireRelease(createTempHome, (home) => Effect.orDie(home.dispose)); /** * Runs `use` with an owned temp `SUPABASE_HOME`. Setup and disposal failures both stay in - * the typed channel (`rmSync` can throw), which a scoped release could not express. + * the typed channel, which a scoped release could not express. */ export const withTempHome = ( - use: (home: ReturnType) => Effect.Effect, + use: (home: TempHome) => Effect.Effect, ): Effect.Effect => - Effect.acquireUseRelease( - Effect.try({ - try: () => makeTempHome(), - catch: (cause) => new TempHomeSetupError({ message: "temp home setup failed", cause }), - }), - use, - (owned) => - Effect.try({ - try: () => owned[Symbol.dispose](), - catch: (cause) => new CliHomeDisposeError({ cause }), - }), - ); + Effect.acquireUseRelease(createTempHome, use, (owned) => owned.dispose); /** The run's owned temp CLI project could not be created or removed. */ export class TempCliProjectError extends Data.TaggedError("TempCliProjectError")<{ @@ -188,56 +264,105 @@ export const withTempCliProject = ( use: (project: Awaited>) => Effect.Effect, ): Effect.Effect => Effect.acquireUseRelease( - Effect.tryPromise({ - try: () => makeTempCliProject(prefix), - catch: (cause) => - new TempCliProjectError({ message: "temp CLI project setup failed", cause }), - }), + makeTempCliProjectEffect(prefix).pipe( + Effect.provide(helperServices), + Effect.mapError( + (cause) => + new TempCliProjectError({ + message: "temp CLI project setup failed", + cause: hostCause(cause), + }), + ), + ), use, (project) => - Effect.tryPromise({ - try: () => project.cleanup(), - catch: (cause) => - new TempCliProjectError({ message: "temp CLI project cleanup failed", cause }), - }), + removeOwnedDir(project.dir).pipe( + Effect.provide(helperServices), + Effect.mapError( + (cause) => + new TempCliProjectError({ + message: "temp CLI project cleanup failed", + cause: hostCause(cause), + }), + ), + ), ); +const portError = (error: Error) => new E2ePortError({ message: error.message, cause: error }); + +const pickReleasedPort = Effect.callback((resume) => { + const server = createServer(); + let settled = false; + const settle = (result: Effect.Effect) => { + if (settled) return; + settled = true; + resume(result); + }; + /* Stays attached after cancellation so a late listen error is never an unhandled event. */ + server.on("error", (error) => settle(Effect.fail(portError(error)))); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address == null || typeof address === "string") { + server.close(() => + settle(Effect.fail(new E2ePortError({ message: "Failed to allocate a free port" }))), + ); + return; + } + server.close((error) => { + settle(error ? Effect.fail(portError(error)) : Effect.succeed(address.port)); + }); + }); + return Effect.sync(() => { + settled = true; + server.close(); + }); +}); + /** * Concurrent e2e workers each keep only ports in their own slot, so a port one worker picked and * released before Docker binds it is never handed to another worker. Slots skip the low bit * because Linux hands out mostly odd ports for `listen(0)`. */ -const e2eWorkers = Number(process.env.SUPABASE_E2E_WORKERS || "1"); -const e2eWorkerSlot = Number(process.env.VITEST_POOL_ID || "1") % e2eWorkers; +const envOrEmpty = (name: string) => Config.String(name).pipe(Config.withDefault("")); -async function pickFreePort(): Promise { +const pickFreePort = Effect.gen(function* () { + const e2eWorkers = Number((yield* envOrEmpty("SUPABASE_E2E_WORKERS")) || "1"); + const e2eWorkerSlot = Number((yield* envOrEmpty("VITEST_POOL_ID")) || "1") % e2eWorkers; for (let attempt = 0; attempt < 1000; attempt++) { - const port = await pickReleasedPort(); + const port = yield* pickReleasedPort; if (Math.floor(port / 2) % e2eWorkers === e2eWorkerSlot) return port; } - throw new Error(`No free port for e2e worker slot ${e2eWorkerSlot} of ${e2eWorkers}`); -} + return yield* new E2ePortError({ + message: `No free port for e2e worker slot ${e2eWorkerSlot} of ${e2eWorkers}`, + }); +}); -function pickReleasedPort(): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address == null || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate a free port"))); - return; +const overrideStackPortsEffect = (projectDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const configPath = pathService.join(projectDir, "supabase", "config.toml"); + /* Decoded keeping a leading byte order mark, like Node's `Buffer#toString`. */ + const config = chunkDecoder.decode(yield* fs.readFile(configPath)); + const assigned = new Set(); + const lines: string[] = []; + for (const line of config.split("\n")) { + const match = /^(\s*(?:port|smtp_port|pop3_port|inspector_port|shadow_port) = )\d+$/.exec( + line, + ); + if (match === null) { + lines.push(line); + continue; } - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(address.port); - }); - }); - server.on("error", reject); + let port = yield* pickFreePort; + while (assigned.has(port)) { + port = yield* pickFreePort; + } + assigned.add(port); + lines.push(`${match[1]}${port}`); + } + yield* fs.writeFileString(configPath, lines.join("\n")); }); -} /** * Rewrites every active port assignment in an `init`-generated @@ -245,152 +370,166 @@ function pickReleasedPort(): Promise { * from default configs cannot collide on host ports with other e2e stacks on * the same runner. Commented-out port lines are left untouched. */ -export async function overrideStackPorts(projectDir: string) { - const configPath = path.join(projectDir, "supabase", "config.toml"); - const config = await readFile(configPath, "utf8"); - const assigned = new Set(); - const lines: string[] = []; - for (const line of config.split("\n")) { - const match = /^(\s*(?:port|smtp_port|pop3_port|inspector_port|shadow_port) = )\d+$/.exec(line); - if (match === null) { - lines.push(line); - continue; - } - let port = await pickFreePort(); - while (assigned.has(port)) { - port = await pickFreePort(); - } - assigned.add(port); - lines.push(`${match[1]}${port}`); - } - await writeFile(configPath, lines.join("\n")); +export function overrideStackPorts(projectDir: string): Promise { + return runHelper(overrideStackPortsEffect(projectDir)); } -async function makeTempProject(prefix = "supabase-project-e2e-") { - const projectDir = await mkdtemp(path.join(tmpdir(), prefix)); +const makeTempProjectEffect = (prefix = "supabase-project-e2e-") => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const context = yield* Effect.context(); + const projectDir = yield* fs.makeTempDirectory({ prefix }); + + return { + dir: projectDir, + cleanup: (): Promise => + Effect.runPromiseWith(context)( + removeOwnedDir(projectDir).pipe(Effect.mapError(helperRejection)), + ), + }; + }); - return { - dir: projectDir, - async cleanup() { - await rm(projectDir, { recursive: true, force: true }); - }, - }; -} +const makeTempCliProjectEffect = (prefix = "supabase-cli-e2e-") => + makeTempProjectEffect(prefix).pipe( + Effect.tap((project) => Effect.sync(() => registerTempStackProject(project))), + ); /** Create an isolated CLI project without pre-allocating released ports. */ -export async function makeTempCliProject(prefix = "supabase-cli-e2e-") { - const project = await makeTempProject(prefix); - registerTempStackProject(project); - return project; +export function makeTempCliProject(prefix?: string) { + return runHelper(makeTempCliProjectEffect(prefix)); } -export async function makeTempCliStackProject( - prefix = "supabase-stack-e2e-", - cleanupTimeoutMs = DEFAULT_STACK_CLEANUP_TIMEOUT_MS, -) { - const project = await makeTempProject(prefix); - const cleanup = async () => { - if (!existsSync(project.dir)) return; +const stopAndRemoveStackProject = (dir: string, cleanupTimeoutMs: number) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + if (!(yield* pathExists(fs, dir))) return; // `init` can fail before creating a project config. There is no stack to // stop in that case, so remove the exact owned directory directly. - if (!existsSync(path.join(project.dir, "supabase", "config.toml"))) { - await rm(project.dir, { recursive: true, force: true }); + if (!(yield* pathExists(fs, pathService.join(dir, "supabase", "config.toml")))) { + yield* removeOwnedDir(dir); return; } - const stopped = await runSupabase(["stop", "--no-backup"], { - cwd: project.dir, + const stopped = yield* runSupabaseEffect(["stop", "--no-backup"], { + cwd: dir, exitTimeoutMs: cleanupTimeoutMs, }); if (stopped.exitCode !== 0) { - throw new Error( - [ - `Failed to stop stack in ${project.dir} (exit code ${stopped.exitCode}).`, + return yield* new TempStackCleanupError({ + message: [ + `Failed to stop stack in ${dir} (exit code ${stopped.exitCode}).`, `stdout:\n${stopped.stdout}`, `stderr:\n${stopped.stderr}`, ].join("\n"), - ); + }); } - await rm(project.dir, { recursive: true, force: true }); - }; + yield* removeOwnedDir(dir); + }); - const stackProject = { dir: project.dir, cleanup }; - registerTempStackProject(stackProject); - return stackProject; +const makeTempCliStackProjectEffect = (prefix: string, cleanupTimeoutMs: number) => + Effect.gen(function* () { + const context = yield* Effect.context(); + const project = yield* makeTempProjectEffect(prefix); + const stackProject = { + dir: project.dir, + cleanup: (): Promise => + Effect.runPromiseWith(context)( + stopAndRemoveStackProject(project.dir, cleanupTimeoutMs).pipe( + Effect.mapError(helperRejection), + ), + ), + }; + yield* Effect.sync(() => registerTempStackProject(stackProject)); + return stackProject; + }); + +export function makeTempCliStackProject( + prefix = "supabase-stack-e2e-", + cleanupTimeoutMs = DEFAULT_STACK_CLEANUP_TIMEOUT_MS, +) { + return runHelper(makeTempCliStackProjectEffect(prefix, cleanupTimeoutMs)); } /** Concurrent e2e files each run a stack, and Docker names its containers after the project id. */ -const projectIdFor = (dir: string) => - `e2e-${path +const projectIdFor = (pathService: Path.Path, dir: string) => + `e2e-${pathService .basename(dir) .toLowerCase() .replace(/[^a-z0-9]/g, "") .slice(-8)}`; -export async function makeTempStackProject(prefix = "supabase-stack-e2e-") { - const project = await makeTempProject(prefix); - const ports = { - apiPort: await pickFreePort(), - dbPort: await pickFreePort(), - authPort: await pickFreePort(), - postgrestPort: await pickFreePort(), - postgrestAdminPort: await pickFreePort(), - edgeRuntimePort: await pickFreePort(), - edgeRuntimeInspectorPort: await pickFreePort(), - realtimePort: await pickFreePort(), - storagePort: await pickFreePort(), - imgproxyPort: await pickFreePort(), - mailpitPort: await pickFreePort(), - mailpitSmtpPort: await pickFreePort(), - mailpitPop3Port: await pickFreePort(), - pgmetaPort: await pickFreePort(), - studioPort: await pickFreePort(), - analyticsPort: await pickFreePort(), - poolerPort: await pickFreePort(), - poolerApiPort: await pickFreePort(), - }; +const makeTempStackProjectEffect = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const project = yield* makeTempProjectEffect(prefix); + const ports = { + apiPort: yield* pickFreePort, + dbPort: yield* pickFreePort, + authPort: yield* pickFreePort, + postgrestPort: yield* pickFreePort, + postgrestAdminPort: yield* pickFreePort, + edgeRuntimePort: yield* pickFreePort, + edgeRuntimeInspectorPort: yield* pickFreePort, + realtimePort: yield* pickFreePort, + storagePort: yield* pickFreePort, + imgproxyPort: yield* pickFreePort, + mailpitPort: yield* pickFreePort, + mailpitSmtpPort: yield* pickFreePort, + mailpitPop3Port: yield* pickFreePort, + pgmetaPort: yield* pickFreePort, + studioPort: yield* pickFreePort, + analyticsPort: yield* pickFreePort, + poolerPort: yield* pickFreePort, + poolerApiPort: yield* pickFreePort, + }; + + const supabaseDir = pathService.join(project.dir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString( + pathService.join(supabaseDir, "config.toml"), + [ + `project_id = "${projectIdFor(pathService, project.dir)}"`, + "", + "[api]", + `port = ${ports.apiPort}`, + "", + "[db]", + `port = ${ports.dbPort}`, + "", + "[db.pooler]", + `port = ${ports.poolerPort}`, + "", + "[edge_runtime]", + `inspector_port = ${ports.edgeRuntimeInspectorPort}`, + "", + "[local_smtp]", + `port = ${ports.mailpitPort}`, + `smtp_port = ${ports.mailpitSmtpPort}`, + `pop3_port = ${ports.mailpitPop3Port}`, + "", + "[studio]", + `port = ${ports.studioPort}`, + "", + "[analytics]", + `port = ${ports.analyticsPort}`, + "", + ].join("\n"), + ); - const supabaseDir = path.join(project.dir, "supabase"); - await mkdir(supabaseDir, { recursive: true }); - await writeFile( - path.join(supabaseDir, "config.toml"), - [ - `project_id = "${projectIdFor(project.dir)}"`, - "", - "[api]", - `port = ${ports.apiPort}`, - "", - "[db]", - `port = ${ports.dbPort}`, - "", - "[db.pooler]", - `port = ${ports.poolerPort}`, - "", - "[edge_runtime]", - `inspector_port = ${ports.edgeRuntimeInspectorPort}`, - "", - "[local_smtp]", - `port = ${ports.mailpitPort}`, - `smtp_port = ${ports.mailpitSmtpPort}`, - `pop3_port = ${ports.mailpitPop3Port}`, - "", - "[studio]", - `port = ${ports.studioPort}`, - "", - "[analytics]", - `port = ${ports.analyticsPort}`, - "", - ].join("\n"), - ); + const stackProject = { + ...project, + ports, + }; + yield* Effect.sync(() => registerTempStackProject(stackProject)); + return stackProject; + }); - const stackProject = { - ...project, - ports, - }; - registerTempStackProject(stackProject); - return stackProject; +export function makeTempStackProject(prefix = "supabase-stack-e2e-") { + return runHelper(makeTempStackProjectEffect(prefix)); } /** Send a signal to the process group led by `pid`. */ @@ -400,6 +539,13 @@ function killProcessGroup(pid: number, signal: NodeJS.Signals): void { } catch {} } +/** Send a signal to the process `pid` alone. */ +function killProcess(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal); + } catch {} +} + function outputTail(label: string, output: string): string { if (output.length === 0) { return `${label}: `; @@ -410,33 +556,91 @@ function outputTail(label: string, output: string): string { return `${label}:\n${tail}`; } -export function spawnSupabase( - args: string[], - options?: { - cwd?: string; - /** `undefined` REMOVES the key from the child env (base env and pins included). */ - env?: Record; - /** Reuse a temp SUPABASE_HOME directory instead of creating a new one per call. */ - home?: string; - /** Write this string to stdin, then close it. */ - stdin?: string; - /** Whether to kill the whole process group once the root process exits. */ - cleanupProcessGroupOnClose?: boolean; - /** Maximum time to wait for the process to exit before force-killing it. */ - exitTimeoutMs?: number; - /** Close the read end of stdout at spawn, like a `| head` reader that has already exited. */ - closeStdout?: boolean; - }, -): SpawnedSupabase { - const ownHome = options?.home ? null : makeTempHome(); - const homeDir = options?.home ?? ownHome!.dir; - noteStackCliProjectHome(options?.cwd, homeDir); +export interface SpawnSupabaseOptions { + readonly cwd?: string; + /** `undefined` REMOVES the key from the child env (base env and pins included). */ + readonly env?: Record; + /** Reuse a temp SUPABASE_HOME directory instead of creating a new one per call. */ + readonly home?: string; + /** Write this string to stdin, then close it. */ + readonly stdin?: string; + /** Whether to kill the whole process group once the root process exits. */ + readonly cleanupProcessGroupOnClose?: boolean; + /** Maximum time to wait for the process to exit before force-killing it. */ + readonly exitTimeoutMs?: number; + /** Close the read end of stdout at spawn, like a `| head` reader that has already exited. */ + readonly closeStdout?: boolean; +} + +export interface SpawnedSupabase { + readonly pid: number; + readonly homeDir: string; + readonly stdout: () => string; + readonly stderr: () => string; + /** Signals the process group, then the root process while it still runs. */ + readonly kill: (signal?: NodeJS.Signals) => Effect.Effect; + readonly waitForOutput: ( + pattern: RegExp, + timeoutMs?: number, + startAt?: number, + ) => Effect.Effect; + /** + * The single exit path: waits for exit and drained output within the exit bound (SIGKILLing + * the group when it fires), kills the group, disposes the owned home, and surfaces a deferred + * stdin write failure. + */ + readonly waitForExit: ( + timeoutMs?: number, + ) => Effect.Effect; +} + +const isBrokenPipe = (error: { readonly cause?: unknown }) => + Predicate.hasProperty(error.cause, "code") && error.cause.code === "EPIPE"; + +/* + * The Effect spawner only hands out stdout as a stream it reads itself, so a pipe whose reader + * has exited needs a relay: it runs the CLI on a real pipe, closes the read end at once, and + * mirrors the CLI's exit code. It ignores group signals so the CLI's own exit wins. + */ +const closedStdoutRelay = (argvJson: string) => + [ + `const { spawn } = require("node:child_process");`, + `const child = spawn(process.execPath, ${argvJson}, { stdio: ["inherit", "pipe", "inherit"] });`, + `child.stdout.destroy();`, + `for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => {});`, + `child.on("exit", (code) => process.exit(code ?? 1));`, + ].join("\n"); + +const encodeArgv = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); + +const spawnSupabaseProgram = Effect.fnUntraced(function* ( + args: ReadonlyArray, + options: SpawnSupabaseOptions | undefined, +) { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let disposedOwnHome = false; + const disposeHome = (home: TempHome) => + Effect.suspend(() => { + if (disposedOwnHome) { + return Effect.void; + } + disposedOwnHome = true; + return home.dispose; + }); + /* The run-owned home's removal is registered with its creation, so cancellation never leaks it; + as the first release it runs last, after the child is killed. */ + const ownHome = options?.home + ? undefined + : yield* Effect.acquireRelease( + createTempHomeEffect.pipe( + Effect.mapError((cause) => new CliSpawnError({ cause: hostCause(cause) })), + ), + (home) => Effect.orDie(disposeHome(home)), + ); + const homeDir = options?.home ?? ownHome?.dir ?? ""; + yield* Effect.sync(() => noteStackCliProjectHome(options?.cwd, homeDir)); const usesStartWrapper = args[0] === "start"; - // Exercise the same shim + compiled shell binary handoff that published - // packages use. `SUPABASE_CLI_BINARY_OVERRIDE` points the shim at the local - // build artifact without needing platform wrapper packages. - let execCmd: string; - let execArgs: string[]; // An `undefined` in `options.env` removes the key entirely — pins and ambient values alike — // so a cache-subject test can run with a variable genuinely absent (the shipped default), // not just overridden. @@ -454,76 +658,138 @@ export function spawnSupabase( for (const [key, value] of Object.entries(mergedEnv)) { if (value !== undefined) env[key] = value; } - assertBuildArtifactsExist(BINARY_PATH); + const artifactsExist = (yield* pathExists(fs, SHIM_PATH)) && (yield* pathExists(fs, BINARY_PATH)); + if (!artifactsExist) { + return yield* new CliBuildArtifactsError({ + message: + `Missing CLI build artifacts. Run \`pnpm --filter supabase build\` before invoking e2e tests.\n` + + ` expected shim: ${SHIM_PATH}\n` + + ` expected binary: ${BINARY_PATH}`, + }); + } + // Exercise the same shim + compiled shell binary handoff that published + // packages use. `SUPABASE_CLI_BINARY_OVERRIDE` points the shim at the local + // build artifact without needing platform wrapper packages. env["SUPABASE_CLI_BINARY_OVERRIDE"] = BINARY_PATH; - execCmd = "node"; - execArgs = [SHIM_PATH, ...args]; - const proc = spawn(execCmd, execArgs, { + const shimArgs = [SHIM_PATH, ...args]; + const commandArgs = options?.closeStdout + ? ["-e", closedStdoutRelay(yield* encodeArgv(shimArgs))] + : shimArgs; + const command = ChildProcess.make("node", commandArgs, { cwd: options?.cwd, env, - stdio: - usesStartWrapper || options?.stdin !== undefined - ? ["pipe", "pipe", "pipe"] - : ["ignore", "pipe", "pipe"], + extendEnv: false, + stdin: usesStartWrapper || options?.stdin !== undefined ? "pipe" : "ignore", + stdout: "pipe", + stderr: "pipe", // Own process group so tests can distinguish product cleanup from helper cleanup. detached: true, + /* The harness owns every group kill. The spawner signals the group itself on a nonzero exit + and at release; SIGURG is ignored by default and leaves stopped processes stopped. */ + killSignal: "SIGURG", }); - const stdoutStream = proc.stdout; - const stderrStream = proc.stderr; - if (stdoutStream == null || stderrStream == null) { - throw new Error("Expected spawned Supabase process to expose stdout/stderr pipes"); - } - if (options?.closeStdout) stdoutStream.destroy(); + let closeResult: RunResult | undefined; + let cleanedUpProcessGroup = false; + const signalGroup = (handle: ChildProcessSpawner.ChildProcessHandle, name: NodeJS.Signals) => + Effect.flatMap( + Effect.orElseSucceed(handle.isRunning, () => false), + (running) => + Effect.sync(() => { + killProcessGroup(handle.pid, name); + if (running) killProcess(handle.pid, name); + }), + ); + + // Scope release owns the spawned group: a run that did not complete always kills it so the + // child is never orphaned, without re-signaling a group the close path already cleaned. On + // successful completion the caller's `cleanupProcessGroupOnClose: false` opt-out is honoured. + const handle = yield* Effect.acquireRelease(spawner.spawn(command), (handle, exit) => + Effect.gen(function* () { + const successOptOut = Exit.isSuccess(exit) && options?.cleanupProcessGroupOnClose === false; + // `closeResult === undefined` is the honest liveness test: the pid is still owned, + // so signaling is safe and necessary; once the child has closed, signaling again + // would only race pid reuse. + if (successOptOut) { + yield* Effect.ignore(handle.unref); + } else if (closeResult === undefined) { + cleanedUpProcessGroup = true; + yield* signalGroup(handle, "SIGKILL"); + } + }), + ); + const pid = handle.pid; let stdout = ""; let stderr = ""; - let closeResult: RunResult | undefined; - let cleanedUpProcessGroup = false; - let disposedOwnHome = false; - const closeWaiters = new Set<(result: RunResult) => void>(); + let stdinError: unknown; + const closed = yield* Deferred.make(); + const stdoutChanged = yield* PubSub.unbounded(); - const cleanupProcessGroupOnClose = () => { + const cleanupProcessGroupOnClose = Effect.sync(() => { if (cleanedUpProcessGroup || !(options?.cleanupProcessGroupOnClose ?? true)) { return; } cleanedUpProcessGroup = true; - killProcessGroup(proc.pid!, "SIGKILL"); - }; + killProcessGroup(pid, "SIGKILL"); + }); - const disposeOwnHome = () => { - if (disposedOwnHome) { - return; - } - disposedOwnHome = true; - ownHome?.[Symbol.dispose](); - }; + const disposeOwnHome = ownHome === undefined ? Effect.void : disposeHome(ownHome); - stdoutStream.on("data", (data: Buffer) => { - stdout += data.toString(); - }); + const signal = (name: NodeJS.Signals) => signalGroup(handle, name); - stderrStream.on("data", (data: Buffer) => { - stderr += data.toString(); - }); + /* Each chunk is decoded on its own and keeps a byte order mark, like `Buffer#toString`. */ + const drain = ( + stream: Stream.Stream, + append: (chunk: string) => Effect.Effect, + ) => Stream.runForEach(stream, (chunk) => append(chunkDecoder.decode(chunk))).pipe(Effect.ignore); - proc.once("close", (code) => { - closeResult = { stdout, stderr, exitCode: code ?? 1 }; - for (const waiter of closeWaiters) { - waiter(closeResult); - } - closeWaiters.clear(); - }); + const stdoutDrained = yield* Effect.forkScoped( + drain(handle.stdout, (chunk) => + Effect.sync(() => { + stdout += chunk; + }).pipe(Effect.andThen(PubSub.publish(stdoutChanged, undefined))), + ), + ); + const stderrDrained = yield* Effect.forkScoped( + drain(handle.stderr, (chunk) => + Effect.sync(() => { + stderr += chunk; + }), + ), + ); + /* "close" in Node terms: the root exited and every holder of its stdout/stderr let go. */ + yield* Effect.forkScoped( + Effect.gen(function* () { + const [exitCode] = yield* Effect.all( + [ + /* A signal death has no code and reports exit code 1. */ + handle.exitCode.pipe( + Effect.map((code): number => code), + Effect.orElseSucceed(() => 1), + ), + Fiber.join(stdoutDrained), + Fiber.join(stderrDrained), + ], + { concurrency: "unbounded" }, + ); + const result: RunResult = { stdout, stderr, exitCode }; + closeResult = result; + yield* PubSub.publish(stdoutChanged, undefined); + yield* Deferred.succeed(closed, result); + }), + ); - let stdinError: unknown; - if (options?.stdin !== undefined && proc.stdin) { - proc.stdin.on("error", (error) => { - if (!("code" in error && error.code === "EPIPE")) { - stdinError = error; - } - }); - proc.stdin.write(options.stdin); - proc.stdin.end(); + if (options?.stdin !== undefined) { + yield* Effect.forkScoped( + Stream.run(Stream.make(new TextEncoder().encode(options.stdin)), handle.stdin).pipe( + Effect.catchTag("PlatformError", (error) => + Effect.sync(() => { + if (!isBrokenPipe(error)) stdinError = hostCause(error); + }), + ), + ), + ); } const stdinFailure = (result: RunResult) => @@ -531,7 +797,7 @@ export function spawnSupabase( [ `stdin write to the CLI failed`, `Command: supabase ${args.join(" ")}`, - `PID: ${proc.pid ?? ""}`, + `PID: ${pid}`, `exit code: ${result.exitCode}${ result.timedOutAfterMs === undefined ? "" @@ -543,172 +809,145 @@ export function spawnSupabase( { cause: stdinError }, ); - // The single exit path. `waitForExit` is the Promise facade over it, so both callers - // share one implementation of the exit bound, the process-group kill and home disposal. - const exitEffect = ( + const waitForExit = ( timeoutMs = options?.exitTimeoutMs ?? DEFAULT_EXIT_TIMEOUT_MS, - ): Effect.Effect => - Effect.callback((resume) => { - if (closeResult) { - cleanupProcessGroupOnClose(); - resume(Effect.succeed(closeResult)); - return Effect.void; - } - - let settled = false; - let timedOut = false; - - const timeout = setTimeout(() => { - timedOut = true; - killProcessGroup(proc.pid!, "SIGKILL"); - try { - proc.kill("SIGKILL"); - } catch {} - }, timeoutMs); - timeout.unref(); - - const onClose = (result: RunResult) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - closeWaiters.delete(onClose); - cleanupProcessGroupOnClose(); - resume(Effect.succeed(timedOut ? { ...result, timedOutAfterMs: timeoutMs } : result)); - }; - - closeWaiters.add(onClose); - - return Effect.sync(() => { - settled = true; - clearTimeout(timeout); - closeWaiters.delete(onClose); - cleanupProcessGroupOnClose(); - // The home may still be in use when the opt-out kept the child alive here; in - // that case `releaseOwned` disposes it after the outer release kills the group. - if (closeResult !== undefined || cleanedUpProcessGroup) { - disposeOwnHome(); - } - }); - }).pipe( - // Disposal can throw; run it here so it fails the caller instead of escaping the - // `close` listener and leaving this effect pending. - Effect.tap(() => - Effect.try({ - try: disposeOwnHome, - catch: (cause) => new CliHomeDisposeError({ cause }), - }), + ): Effect.Effect => + Deferred.await(closed).pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + signal("SIGKILL").pipe( + Effect.andThen(Deferred.await(closed)), + Effect.map((result): RunResult => ({ ...result, timedOutAfterMs: timeoutMs })), + ), + }), + Effect.tap(() => cleanupProcessGroupOnClose), + Effect.onInterrupt(() => + cleanupProcessGroupOnClose.pipe( + // The home may still be in use when the opt-out kept the child alive here; in + // that case the scope release disposes it. + Effect.andThen( + Effect.suspend(() => + closeResult !== undefined || cleanedUpProcessGroup + ? Effect.orDie(disposeOwnHome) + : Effect.void, + ), + ), + ), + ), + Effect.tap(() => disposeOwnHome), + Effect.flatMap((result) => + stdinError === undefined + ? Effect.succeed(result) + : Effect.fail(new CliStdinWriteError({ cause: stdinFailure(result) })), ), ); - const waitForExit = async ( - timeoutMs = options?.exitTimeoutMs ?? DEFAULT_EXIT_TIMEOUT_MS, - ): Promise => { - const result = await Effect.runPromise(exitEffect(timeoutMs)); - if (stdinError !== undefined) { - throw stdinFailure(result); - } - return result; + const outputWaitError = (reason: string, pattern: RegExp) => + new CliOutputWaitError({ + message: [ + `${reason} ${pattern}`, + `Command: supabase ${args.join(" ")}`, + `PID: ${pid}`, + outputTail("stdout tail", stdout), + outputTail("stderr tail", stderr), + ].join("\n\n"), + }); + + const outputMatches = (pattern: RegExp, startAt: number) => { + pattern.lastIndex = 0; + return pattern.test(stdout.slice(startAt)); }; - return { - pid: proc.pid!, + const waitForOutput = ( + pattern: RegExp, + timeoutMs = 60_000, + startAt = 0, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + /* Subscribe before the first check so no chunk lands between the check and the wait. */ + const changes = yield* PubSub.subscribe(stdoutChanged); + while (!outputMatches(pattern, startAt)) { + if (closeResult !== undefined) { + return yield* outputWaitError("Process exited before output matched", pattern); + } + yield* PubSub.take(changes); + } + }), + ).pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail(outputWaitError("Timed out waiting for output matching", pattern)), + }), + ); + + const spawned: SpawnedSupabase = { + pid, homeDir, stdout: () => stdout, stderr: () => stderr, - kill: (signal = "SIGTERM") => { - killProcessGroup(proc.pid!, signal); - try { - proc.kill(signal); - } catch {} - }, - waitForOutput: async (pattern: RegExp, timeoutMs = 60_000, startAt = 0) => { - pattern.lastIndex = 0; - if (pattern.test(stdout.slice(startAt))) { - return; - } - if (closeResult) { - throw new Error( - [ - `Process exited before output matched ${pattern}`, - `Command: supabase ${args.join(" ")}`, - `PID: ${proc.pid ?? ""}`, - outputTail("stdout tail", stdout), - outputTail("stderr tail", stderr), - ].join("\n\n"), - ); - } + kill: (name = "SIGTERM") => signal(name), + waitForOutput, + waitForExit, + }; + return spawned; +}); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject( - new Error( - [ - `Timed out waiting for output matching ${pattern}`, - `Command: supabase ${args.join(" ")}`, - `PID: ${proc.pid ?? ""}`, - outputTail("stdout tail", stdout), - outputTail("stderr tail", stderr), - ].join("\n\n"), - ), - ); - }, timeoutMs); - - const onStdout = (_data: Buffer) => { - pattern.lastIndex = 0; - if (pattern.test(stdout.slice(startAt))) { - cleanup(); - resolve(); - } - }; - - const onClose = () => { - cleanup(); - reject( - new Error( - [ - `Process exited before output matched ${pattern}`, - `Command: supabase ${args.join(" ")}`, - `PID: ${proc.pid ?? ""}`, - outputTail("stdout tail", stdout), - outputTail("stderr tail", stderr), - ].join("\n\n"), - ), - ); - }; +/** + * Spawns the compiled CLI in its own process group, owned by the calling scope: closing the + * scope kills a run that has not closed (unless `cleanupProcessGroupOnClose: false` and the + * scope succeeded) and disposes the run-owned temp home. + */ +export const spawnSupabase = ( + args: ReadonlyArray, + options?: SpawnSupabaseOptions, +): Effect.Effect => + spawnSupabaseProgram(args, options).pipe( + Effect.catchTags({ + PlatformError: (cause) => Effect.fail(new CliSpawnError({ cause: hostCause(cause) })), + CliBuildArtifactsError: (cause) => + Effect.fail(new CliSpawnError({ cause: hostOrPlainError(cause) })), + SchemaError: (cause) => Effect.fail(new CliSpawnError({ cause })), + }), + Effect.provide(helperServices), + ); - const cleanup = () => { - clearTimeout(timeout); - stdoutStream.off("data", onStdout); - proc.off("close", onClose); - }; +/** + * Effect-native CLI run. The spawned process group is owned by the run's scope, so an + * interrupted test kills the child instead of orphaning it; `cleanupProcessGroupOnClose: + * false` is honoured only on successful completion. `runSupabase` is the Promise facade + * over the same exit path. + */ +export const runSupabaseEffect = ( + args: string[], + options?: SpawnSupabaseOptions, +): Effect.Effect => + Effect.scoped(Effect.flatMap(spawnSupabase(args, options), (spawned) => spawned.waitForExit())); - stdoutStream.on("data", onStdout); - proc.on("close", onClose); - }); - }, - waitForExit, - exitEffect, - stdinFailure: (result) => (stdinError === undefined ? undefined : stdinFailure(result)), - releaseOwned: ({ successOptOut }) => { - // `closeResult === undefined` is the honest liveness test: the pid is still owned, - // so signaling is safe and necessary; once the child has closed, signaling again - // would only race pid reuse. The group flag alone cannot carry this — on Windows - // the group signal is a swallowed no-op, and the direct kill below is the only one - // that works there. - if (!successOptOut && closeResult === undefined) { - cleanedUpProcessGroup = true; - killProcessGroup(proc.pid!, "SIGKILL"); - try { - proc.kill("SIGKILL"); - } catch {} - } - disposeOwnHome(); - }, - }; -} +/** Kills the run once stdout matches `until`; the harness then reports exit code 0. */ +const runSupabaseUntil = ( + args: string[], + until: RegExp, + options: SpawnSupabaseOptions & { readonly untilTimeoutMs?: number }, +) => + Effect.scoped( + Effect.gen(function* () { + const spawned = yield* spawnSupabase(args, options); + yield* spawned.waitForOutput(until, options.untilTimeoutMs); + yield* spawned.kill("SIGTERM"); + yield* Effect.forkChild( + Effect.sleep(15_000).pipe( + Effect.andThen(Effect.sync(() => killProcessGroup(spawned.pid, "SIGKILL"))), + ), + ); + const result = yield* spawned.waitForExit(); + return { ...result, exitCode: 0 }; + }), + ); -export async function runSupabase( +export function runSupabase( args: string[], options?: { cwd?: string; @@ -726,62 +965,15 @@ export async function runSupabase( exitTimeoutMs?: number; }, ): Promise { - const spawned = spawnSupabase(args, options); - let killedByUntil = false; - - if (options?.until) { - await spawned.waitForOutput(options.until, options.untilTimeoutMs); - killedByUntil = true; - spawned.kill("SIGTERM"); - - const timer = setTimeout(() => { - killProcessGroup(spawned.pid, "SIGKILL"); - }, 15_000); - timer.unref(); - } - - const result = await spawned.waitForExit(); - return { ...result, exitCode: killedByUntil ? 0 : result.exitCode }; -} - -/** - * Effect-native CLI run. The spawned process group is owned by the calling scope, so an - * interrupted test kills the child instead of orphaning it; `cleanupProcessGroupOnClose: - * false` is honoured only on successful completion. `runSupabase` is the Promise facade - * over the same exit path. - */ -export const runSupabaseEffect = ( - args: string[], - options?: Parameters[1], -): Effect.Effect => - Effect.acquireRelease( - Effect.try({ - try: () => spawnSupabase(args, options), - catch: (cause) => new CliSpawnError({ cause }), - }), - // Scope teardown owns the spawned group: an interrupted run always kills it so the - // child is never orphaned, without re-signaling a group the close path already - // cleaned. On successful completion the caller's `cleanupProcessGroupOnClose: false` - // opt-out is honoured, matching the Promise path. - (spawned, exit) => - Effect.sync(() => - spawned.releaseOwned({ - successOptOut: Exit.isSuccess(exit) && options?.cleanupProcessGroupOnClose === false, - }), - ), - ).pipe( - Effect.flatMap((spawned) => - spawned.exitEffect().pipe( - Effect.flatMap((result) => { - const failure = spawned.stdinFailure(result); - return failure === undefined - ? Effect.succeed(result) - : Effect.fail(new CliStdinWriteError({ cause: failure })); - }), - ), + return Effect.runPromise( + Effect.mapError( + options?.until === undefined + ? runSupabaseEffect(args, options) + : runSupabaseUntil(args, options.until, options), + helperRejection, ), - Effect.scoped, ); +} export function requireCliSuccess( result: { diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 313187abba..def4013944 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -1,18 +1,22 @@ import { legacyProjectStateLayer } from "../../src/command-internal/legacy-project-state.layer.ts"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; -import { type ApiClient, makeApiClient, type SupabaseApiConfigError } from "@supabase/api/effect"; +import { BunFileSystem, BunPath, BunServices } from "@effect/platform-bun"; +import { + type ApiClient, + makeApiClient, + type SupabaseApiConfigError, + type SupabaseApiError, +} from "@supabase/api/effect"; import { ConfigProvider, Effect, FileSystem, Layer, Option, + Path, Predicate, Redacted, + Schema, Sink, Stream, } from "effect"; @@ -77,6 +81,12 @@ import { processEnvLayer, } from "./mocks.ts"; +const hostPath = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = (value: unknown) => encodeUnknownJson(value).pipe(Effect.orDie); + // Fixtures shared across every native-port integration test. Centralized so a change to the // project-ref schema (e.g. updated length) only needs to update one constant. export const VALID_REF = "abcdefghijklmnopqrst"; @@ -177,11 +187,9 @@ export function mockCommandCredentialsTracked( Effect.gen(function* () { deletedRefs.push(projectRef); if (opts.deleteFails === true) { - return yield* Effect.fail( - new CredentialDeleteError({ - message: "failed to delete project credential: permission denied", - }), - ); + return yield* new CredentialDeleteError({ + message: "failed to delete project credential: permission denied", + }); } return true; }), @@ -419,7 +427,7 @@ export function mockCommandSettings(opts: { githubToken: opts.githubToken ?? Option.none(), profile: opts.profile ?? "supabase", profileEnvValue: opts.profileEnvValue ?? Option.none(), - supabaseHome: opts.supabaseHome ?? join(opts.workdir, ".supabase"), + supabaseHome: opts.supabaseHome ?? hostPath.join(opts.workdir, ".supabase"), apiUrl: opts.apiUrl ?? DEFAULT_API_URL, projectHost: opts.projectHost ?? "supabase.co", poolerHost: opts.poolerHost ?? "supabase.com", @@ -553,11 +561,7 @@ export function mockCommandPlatformApi( let body: unknown = undefined; if (request.body._tag === "Uint8Array") { const decoded = new TextDecoder().decode(request.body.body); - try { - body = JSON.parse(decoded); - } catch { - body = decoded; - } + body = yield* decodeJson(decoded).pipe(Effect.orElseSucceed(() => decoded)); } const params = UrlParams.toString(request.urlParams); const recorded: RecordedRequest = { @@ -571,7 +575,7 @@ export function mockCommandPlatformApi( requests.push(recorded); if (opts.network === "fail") { - return yield* Effect.fail(transportFailure(request)); + return yield* transportFailure(request); } if (opts.handler !== undefined) { return yield* opts.handler(request, recorded); @@ -660,7 +664,7 @@ export function mockCommandPlatformApiService( Effect.gen(function* () { requests.push({ method: prop, input }); const stub = (stubs as Record)[prop] as - | ((i: unknown) => Effect.Effect) + | ((i: unknown) => Effect.Effect) | undefined; if (stub === undefined) { return yield* Effect.die(`Unmocked CommandPlatformApi.v1.${prop}`); @@ -691,7 +695,7 @@ export function mockCommandPlatformApiService( `https://api.supabase.com/mock-raw/${definition.id}`, ); if (stub === "fail") { - return yield* Effect.fail(transportFailure(request)); + return yield* transportFailure(request); } return jsonResponse(request, stub.status, stub.body); }); @@ -737,15 +741,30 @@ export function useTempWorkdir(prefix = "supabase-test-"): { readonly current: string; } { let root: string | undefined; - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), prefix)); - }); - afterEach(() => { - if (root !== undefined) { - rmSync(root, { recursive: true, force: true }); - root = undefined; - } - }); + beforeEach(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + root = yield* fs.makeTempDirectory({ prefix }); + }).pipe( + /* A host failure rejects the hook with the raw host error. */ + Effect.catch((error) => Effect.die(error.cause ?? error)), + Effect.provide(BunFileSystem.layer), + Effect.runPromise, + ), + ); + afterEach(() => + Effect.gen(function* () { + if (root !== undefined) { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(root, { recursive: true, force: true }); + root = undefined; + } + }).pipe( + Effect.catch((error) => Effect.die(error.cause ?? error)), + Effect.provide(BunFileSystem.layer), + Effect.runPromise, + ), + ); return { get current() { if (root === undefined) { @@ -758,6 +777,14 @@ export function useTempWorkdir(prefix = "supabase-test-"): { }; } +/* Sets or unsets `name` in `env` and returns the value it replaced. */ +function setEnvVar(env: NodeJS.ProcessEnv, name: string, value: string | undefined) { + const previous = env[name]; + if (value === undefined) delete env[name]; + else env[name] = value; + return previous; +} + /** * Sets `name` to `value` (or unsets it when `value` is `undefined`) for the duration of `body`, * restoring whatever was there before. @@ -768,17 +795,11 @@ export const withEnvVar = ( body: Effect.Effect, ): Effect.Effect => Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env[name]; - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - return previous; - }), + Effect.sync(() => setEnvVar(process.env, name, value)), () => body, (previous) => Effect.sync(() => { - if (previous === undefined) delete process.env[name]; - else process.env[name] = previous; + setEnvVar(process.env, name, previous); }), ); @@ -980,15 +1001,13 @@ export function mockShadowContainerCliSpawner( const args = command._tag === "StandardCommand" ? command.args : []; spawned.push({ args }); if (command._tag !== "StandardCommand") { - return yield* Effect.fail( - new PlatformError( - new SystemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ), + return yield* new PlatformError( + new SystemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), ); } const isLocalDbInspect = @@ -1016,7 +1035,7 @@ export function mockShadowContainerCliSpawner( }); } if (isLocalDbInspect && opts.dbInspectImage !== undefined) { - const inspectJson = JSON.stringify([{ Config: { Image: opts.dbInspectImage } }]); + const inspectJson = yield* encodeJson([{ Config: { Image: opts.dbInspectImage } }]); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), stdout: Stream.fromIterable([encoder.encode(inspectJson)]), @@ -1332,7 +1351,7 @@ export function mockDockerDaemonCliSpawner( stdout = container === undefined ? "" - : JSON.stringify({ + : yield* encodeJson({ Running: container.running, Status: container.running ? "running" : "exited", Health: { Status: "healthy" }, diff --git a/apps/cli/tests/helpers/docker-image.ts b/apps/cli/tests/helpers/docker-image.ts index cf388af81e..141dcd759a 100644 --- a/apps/cli/tests/helpers/docker-image.ts +++ b/apps/cli/tests/helpers/docker-image.ts @@ -1,5 +1,5 @@ import { BunServices } from "@effect/platform-bun"; -import { Cause, Duration, Effect, Layer } from "effect"; +import { Cause, Clock, Data, Duration, Effect, Layer } from "effect"; import * as ChildProcess from "effect/process/ChildProcess"; import { ChildProcessSpawner } from "effect/process"; import type { ChildProcessSpawner as ChildProcessSpawnerTag } from "effect/process/ChildProcessSpawner"; @@ -12,6 +12,10 @@ type Spawner = ChildProcessSpawnerTag["Service"]; // must leave the caller room to run its test body. export const RESOLVE_BUDGET_MS = 90_000; +class DockerImageError extends Data.TaggedError("DockerImageError")<{ + readonly message: string; +}> {} + const resolvedImages = new Map>(); /** @@ -57,8 +61,19 @@ export function ensureImage( // enqueued it — otherwise time spent waiting behind another image's resolution would // silently shrink this one's window. An explicit deadline is shared budget by contract and // is left as given. - return yield* resolveImage(spawner, image, deadline ?? resolveDeadline()); - }).pipe(Effect.provide(services), Effect.runPromise), + return yield* resolveImage( + spawner, + image, + deadline ?? (yield* deadlineAfter(RESOLVE_BUDGET_MS)), + ); + }).pipe( + /* The Promise rejects with a plain Error carrying the message. */ + Effect.catch((error) => + Effect.die(error instanceof DockerImageError ? new Error(error.message) : error), + ), + Effect.provide(services), + Effect.runPromise, + ), ); // Both the queue link and the memo swallow nothing: callers still see the // rejection, this only stops one failure from becoming an unhandled rejection @@ -76,7 +91,11 @@ export function ensureImage( * window. */ export function resolveDeadline(budgetMs = RESOLVE_BUDGET_MS): number { - return Date.now() + budgetMs; + return Effect.runSync(deadlineAfter(budgetMs)); +} + +function deadlineAfter(budgetMs: number): Effect.Effect { + return Effect.map(Clock.currentTimeMillis, (now) => now + budgetMs); } /** @@ -89,10 +108,16 @@ export function resolveDeadline(budgetMs = RESOLVE_BUDGET_MS): number { */ function requireDocker(spawner: Spawner): Effect.Effect { return spawner.exitCode(ChildProcess.make("docker", ["--version"])).pipe( - Effect.mapError(() => new Error("docker is required for this test but could not be spawned")), + Effect.mapError( + () => + new DockerImageError({ + message: "docker is required for this test but could not be spawned", + }), + ), Effect.filterOrFail( (exitCode) => Number(exitCode) === 0, - () => new Error("docker is required for this test but exited non-zero"), + () => + new DockerImageError({ message: "docker is required for this test but exited non-zero" }), ), Effect.asVoid, ); @@ -102,13 +127,17 @@ function requireDocker(spawner: Spawner): Effect.Effect { * The resolve itself, taking its spawner explicitly so unit tests can drive it * with a fake instead of a real Docker daemon. */ -export function resolveImage( +export const resolveImage: ( spawner: Spawner, image: string, deadline: number, -): Effect.Effect { - const remainingMs = Math.max(1, deadline - Date.now()); - return requireDocker(spawner).pipe( +) => Effect.Effect = Effect.fn("DockerImage.resolveImage")(function* ( + spawner: Spawner, + image: string, + deadline: number, +) { + const remainingMs = Math.max(1, deadline - (yield* Clock.currentTimeMillis)); + return yield* requireDocker(spawner).pipe( // The deadline goes into the resolver, which divides it across the registry candidates — a // stalled registry cannot starve the ECR → GHCR → Docker Hub fallbacks behind it, and an // exhausted share is reported against the candidate that spent it. The outer timeout is only @@ -119,9 +148,9 @@ export function resolveImage( Effect.timeout(Duration.millis(remainingMs + 1_000)), Effect.mapError((cause) => { if (Cause.isTimeoutError(cause)) { - return new Error( - `timed out resolving ${image} after ${remainingMs}ms — is the docker daemon responding?`, - ); + return new DockerImageError({ + message: `timed out resolving ${image} after ${remainingMs}ms — is the docker daemon responding?`, + }); } // The registry-pin hint only helps when the registries themselves were // the problem; gluing it onto a missing binary or an unreachable daemon @@ -129,7 +158,9 @@ export function resolveImage( const hint = cause.message.includes("failed to pull docker image from all registries") ? " (set SUPABASE_INTERNAL_IMAGE_REGISTRY to pin one)" : ""; - return new Error(`failed to resolve ${image}${hint}: ${cause.message}`); + return new DockerImageError({ + message: `failed to resolve ${image}${hint}: ${cause.message}`, + }); }), ); -} +}); diff --git a/apps/cli/tests/helpers/docker-image.unit.test.ts b/apps/cli/tests/helpers/docker-image.unit.test.ts index 50830a4cf3..89a885552e 100644 --- a/apps/cli/tests/helpers/docker-image.unit.test.ts +++ b/apps/cli/tests/helpers/docker-image.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, PlatformError, Sink, Stream } from "effect"; +import { Clock, Deferred, Effect, Layer, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { runDockerEffect } from "./cli.ts"; @@ -66,11 +66,13 @@ function mockSpawner( const IMAGE = "supabase/postgres:17"; describe("resolveDeadline", () => { - it("defaults to the shared budget and accepts a caller-sized one", () => { - const before = Date.now(); - expect(resolveDeadline()).toBeGreaterThanOrEqual(before + RESOLVE_BUDGET_MS - 50); - expect(resolveDeadline(1_000)).toBeLessThanOrEqual(Date.now() + 1_000); - }); + it.live("defaults to the shared budget and accepts a caller-sized one", () => + Effect.gen(function* () { + const before = yield* Clock.currentTimeMillis; + expect(resolveDeadline()).toBeGreaterThanOrEqual(before + RESOLVE_BUDGET_MS - 50); + expect(resolveDeadline(1_000)).toBeLessThanOrEqual((yield* Clock.currentTimeMillis) + 1_000); + }), + ); }); describe("resolveImage", () => { @@ -172,7 +174,8 @@ describe("resolveImage", () => { if (args[0] === "--version") return { exitCode: 0 }; return { exitCode: 1, stderr: "no such image" }; }); - return resolveImage(mock.spawner, IMAGE, Date.now() - 10_000).pipe( + return Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => resolveImage(mock.spawner, IMAGE, now - 10_000)), Effect.flip, Effect.map((error) => { expect(error.message).toContain("candidate budget exhausted"); @@ -202,46 +205,64 @@ describe("ensureImage", () => { const layerFor = (mock: ReturnType) => Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, mock.spawner); - it("memoizes per image, including across differing deadlines", async () => { - const mock = mockSpawner(() => ({ exitCode: 0 })); - const image = `memo-${Date.now()}-a`; - const first = await ensureImage(image, resolveDeadline(5_000), layerFor(mock)); - const spawnsAfterFirst = mock.spawned.length; - const second = await ensureImage(image, resolveDeadline(60_000), layerFor(mock)); - expect(second).toBe(first); - expect(mock.spawned.length).toBe(spawnsAfterFirst); - }); + it.live("memoizes per image, including across differing deadlines", () => + Effect.gen(function* () { + const mock = mockSpawner(() => ({ exitCode: 0 })); + const image = `memo-${yield* Clock.currentTimeMillis}-a`; + const first = yield* Effect.promise(() => + ensureImage(image, resolveDeadline(5_000), layerFor(mock)), + ); + const spawnsAfterFirst = mock.spawned.length; + const second = yield* Effect.promise(() => + ensureImage(image, resolveDeadline(60_000), layerFor(mock)), + ); + expect(second).toBe(first); + expect(mock.spawned.length).toBe(spawnsAfterFirst); + }), + ); - it("memoizes failures so the retry ladder is never re-paid", async () => { - const mock = mockSpawner((args) => - args[0] === "--version" ? { exitCode: 0 } : { exitCode: 1, stderr: "no such image" }, - ); - const image = `memo-${Date.now()}-fail`; - await expect(ensureImage(image, Date.now() - 1_000, layerFor(mock))).rejects.toThrow(); - const spawnsAfterFirst = mock.spawned.length; - await expect(ensureImage(image, Date.now() - 1_000, layerFor(mock))).rejects.toThrow(); - expect(mock.spawned.length).toBe(spawnsAfterFirst); - }); + it.live("memoizes failures so the retry ladder is never re-paid", () => + Effect.gen(function* () { + const mock = mockSpawner((args) => + args[0] === "--version" ? { exitCode: 0 } : { exitCode: 1, stderr: "no such image" }, + ); + const image = `memo-${yield* Clock.currentTimeMillis}-fail`; + const firstDeadline = (yield* Clock.currentTimeMillis) - 1_000; + yield* Effect.promise(() => + expect(ensureImage(image, firstDeadline, layerFor(mock))).rejects.toThrow(), + ); + const spawnsAfterFirst = mock.spawned.length; + const secondDeadline = (yield* Clock.currentTimeMillis) - 1_000; + yield* Effect.promise(() => + expect(ensureImage(image, secondDeadline, layerFor(mock))).rejects.toThrow(), + ); + expect(mock.spawned.length).toBe(spawnsAfterFirst); + }), + ); - it("serializes distinct images: the second never spawns before the first settles", async () => { - const firstSpawnCounts: Array = []; - const mock = mockSpawner((args) => { - if (args[0] === "--version") return { exitCode: 0 }; - return { exitCode: 1, stderr: "no such image" }; - }); - const imageA = `queue-${Date.now()}-a`; - const imageB = `queue-${Date.now()}-b`; - // Enqueue both before awaiting either; the queue must fully settle A - // (including its failure) before B's first spawn happens. - const a = ensureImage(imageA, Date.now() - 1_000, layerFor(mock)).catch(() => "a-done"); - const spawnsWhenBEnqueued = mock.spawned.length; - const b = ensureImage(imageB, Date.now() - 1_000, layerFor(mock)).catch(() => "b-done"); - expect(mock.spawned.length).toBe(spawnsWhenBEnqueued); - await a; - firstSpawnCounts.push(mock.spawned.length); - await b; - expect(firstSpawnCounts[0]).toBeLessThanOrEqual(mock.spawned.length); - }); + it.live("serializes distinct images: the second never spawns before the first settles", () => + Effect.gen(function* () { + const firstSpawnCounts: Array = []; + const mock = mockSpawner((args) => { + if (args[0] === "--version") return { exitCode: 0 }; + return { exitCode: 1, stderr: "no such image" }; + }); + const imageA = `queue-${yield* Clock.currentTimeMillis}-a`; + const imageB = `queue-${yield* Clock.currentTimeMillis}-b`; + const deadlineA = (yield* Clock.currentTimeMillis) - 1_000; + const deadlineB = (yield* Clock.currentTimeMillis) - 1_000; + // Enqueue both before awaiting either; the queue must fully settle A + // (including its failure) before B's first spawn happens. + const a = ensureImage(imageA, deadlineA, layerFor(mock)).catch(() => "a-done"); + const spawnsWhenBEnqueued = mock.spawned.length; + const b = ensureImage(imageB, deadlineB, layerFor(mock)).catch(() => "b-done"); + expect(mock.spawned.length).toBe(spawnsWhenBEnqueued); + yield* Effect.promise(() => a); + firstSpawnCounts.push(mock.spawned.length); + yield* Effect.promise(() => b); + expect(firstSpawnCounts[0]).toBeLessThanOrEqual(mock.spawned.length); + }), + ); }); describe("runDockerEffect", () => { diff --git a/apps/cli/tests/helpers/legacy-state-guard.ts b/apps/cli/tests/helpers/legacy-state-guard.ts index f023936a5b..b7f702601d 100644 --- a/apps/cli/tests/helpers/legacy-state-guard.ts +++ b/apps/cli/tests/helpers/legacy-state-guard.ts @@ -1,10 +1,12 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Layer, Path } from "effect"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, FileSystem, Layer } from "effect"; - -const LEGACY_STATE_SEGMENT = join("supabase", ".temp"); +const LEGACY_STATE_SEGMENT = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join("supabase", ".temp"); + }).pipe(Effect.provide(BunPath.layer)), +); /** Wrong-on-purpose values for every `supabase/.temp` file a stack-mode command might still read. */ const LEGACY_STATE_DECOYS: Readonly> = { @@ -27,13 +29,20 @@ const LEGACY_STATE_DECOYS: Readonly> = { }; /** Writes the decoys into `/supabase/.temp`, outside any recording filesystem. */ -export function seedLegacyStateDecoys(root: string): void { - const dir = join(root, LEGACY_STATE_SEGMENT); - mkdirSync(dir, { recursive: true }); - for (const [name, value] of Object.entries(LEGACY_STATE_DECOYS)) { - writeFileSync(join(dir, name), value); - } -} +export const seedLegacyStateDecoys = (root: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(root, LEGACY_STATE_SEGMENT); + yield* fs.makeDirectory(dir, { recursive: true }); + for (const [name, value] of Object.entries(LEGACY_STATE_DECOYS)) { + yield* fs.writeFileString(path.join(dir, name), value); + } + }).pipe( + /* A host failure surfaces as the raw host error. */ + Effect.catchTag("PlatformError", (error) => Effect.die(error.cause ?? error)), + Effect.provide(BunServices.layer), + ); /** The recorded paths that fall under a `supabase/.temp` directory. */ export function legacyStateAccesses(accessed: ReadonlyArray): ReadonlyArray { diff --git a/apps/cli/tests/helpers/live-env.ts b/apps/cli/tests/helpers/live-env.ts index 284e40906b..fb154931a1 100644 --- a/apps/cli/tests/helpers/live-env.ts +++ b/apps/cli/tests/helpers/live-env.ts @@ -1,5 +1,7 @@ /** Environment-only live-suite configuration. */ +import { Config, Data, Effect, Option } from "effect"; + export const LIVE_EXIT_TIMEOUT_MS = 240_000; /** @@ -8,51 +10,77 @@ export const LIVE_EXIT_TIMEOUT_MS = 240_000; */ export const LIVE_SHADOW_PORT = "24320"; -export function liveApiUrl(): string { - const value = process.env["SUPABASE_LIVE_API_URL"]?.trim(); +class LiveConfigError extends Data.TaggedError("LiveConfigError")<{ + readonly message: string; +}> {} + +const envValue = (name: string) => + Config.option(Config.String(name)).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((error) => new LiveConfigError({ message: error.message })), + ); + +export const liveApiUrl = Effect.gen(function* () { + const value = (yield* envValue("SUPABASE_LIVE_API_URL"))?.trim(); if (value === undefined || value.length === 0) { - throw new Error("SUPABASE_LIVE_API_URL is required to run the live suite"); - } - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error(`SUPABASE_LIVE_API_URL must be an absolute HTTP(S) URL: ${value}`); + return yield* new LiveConfigError({ + message: "SUPABASE_LIVE_API_URL is required to run the live suite", + }); } + const url = yield* Effect.try({ + try: () => new URL(value), + catch: () => + new LiveConfigError({ + message: `SUPABASE_LIVE_API_URL must be an absolute HTTP(S) URL: ${value}`, + }), + }); if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`SUPABASE_LIVE_API_URL must use http:// or https://: ${value}`); + return yield* new LiveConfigError({ + message: `SUPABASE_LIVE_API_URL must use http:// or https://: ${value}`, + }); } return url.toString().replace(/\/+$/u, ""); -} +}); -export function liveAccessToken(): string { - const token = process.env["SUPABASE_ACCESS_TOKEN"]?.trim(); +export const liveAccessToken = Effect.gen(function* () { + const token = (yield* envValue("SUPABASE_ACCESS_TOKEN"))?.trim(); if (token === undefined || token.length === 0) { - throw new Error("SUPABASE_ACCESS_TOKEN is required to run the live suite"); + return yield* new LiveConfigError({ + message: "SUPABASE_ACCESS_TOKEN is required to run the live suite", + }); } return token; -} +}); -export function validateLiveConfig(): { readonly apiUrl: string; readonly accessToken: string } { - return { apiUrl: liveApiUrl(), accessToken: liveAccessToken() }; -} +export const validateLiveConfig = Effect.gen(function* () { + const apiUrl = yield* liveApiUrl; + const accessToken = yield* liveAccessToken; + return { apiUrl, accessToken }; +}); -export function keepLiveProject(): boolean { - return process.env["SUPABASE_LIVE_KEEP_PROJECT"] === "1"; -} +export const keepLiveProject = Effect.map( + envValue("SUPABASE_LIVE_KEEP_PROJECT"), + (value) => value === "1", +); -export function liveProjectName(): string { - return process.env["SUPABASE_LIVE_PROJECT_NAME"]?.trim() || "supabase-cli-live"; -} +export const liveProjectName = Effect.map( + envValue("SUPABASE_LIVE_PROJECT_NAME"), + (value) => value?.trim() || "supabase-cli-live", +); -export function liveRegion(): string { - return process.env["SUPABASE_LIVE_REGION"]?.trim() || "us-east-1"; -} +export const liveRegion = Effect.map( + envValue("SUPABASE_LIVE_REGION"), + (value) => value?.trim() || "us-east-1", +); -export function liveOrgId(): string | undefined { - const value = process.env["SUPABASE_LIVE_ORG_ID"]?.trim(); - return value === undefined || value.length === 0 ? undefined : value; -} +export const liveRunId = Effect.gen(function* () { + return (yield* envValue("GITHUB_RUN_ID")) ?? (yield* envValue("CI_JOB_ID")); +}); + +export const liveOrgId = Effect.map(envValue("SUPABASE_LIVE_ORG_ID"), (value) => { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +}); /** Resolve `.` from a database host such as `db..supabase.co`. */ export function deriveLiveProjectHost(databaseHost: string, projectRef: string): string { diff --git a/apps/cli/tests/helpers/live-env.unit.test.ts b/apps/cli/tests/helpers/live-env.unit.test.ts index b5704a6a9c..1383862cfe 100644 --- a/apps/cli/tests/helpers/live-env.unit.test.ts +++ b/apps/cli/tests/helpers/live-env.unit.test.ts @@ -1,36 +1,39 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { ConfigProvider, Effect } from "effect"; +import { describe, expect, it } from "vitest"; import { deriveLiveProjectHost, liveApiUrl, validateLiveConfig } from "./live-env.ts"; -const originalApiUrl = process.env["SUPABASE_LIVE_API_URL"]; -const originalToken = process.env["SUPABASE_ACCESS_TOKEN"]; - -afterEach(() => { - if (originalApiUrl === undefined) delete process.env["SUPABASE_LIVE_API_URL"]; - else process.env["SUPABASE_LIVE_API_URL"] = originalApiUrl; - if (originalToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; - else process.env["SUPABASE_ACCESS_TOKEN"] = originalToken; -}); +const runWithEnv = (env: Record, effect: Effect.Effect): A => + Effect.runSync( + Effect.provideService( + effect, + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), + ), + ); describe("live environment", () => { it("requires both the API URL and access token", () => { - delete process.env["SUPABASE_LIVE_API_URL"]; - delete process.env["SUPABASE_ACCESS_TOKEN"]; - expect(() => validateLiveConfig()).toThrow("SUPABASE_LIVE_API_URL is required"); + expect(() => runWithEnv({}, validateLiveConfig)).toThrow("SUPABASE_LIVE_API_URL is required"); - process.env["SUPABASE_LIVE_API_URL"] = "http://localhost:8080"; - expect(() => validateLiveConfig()).toThrow("SUPABASE_ACCESS_TOKEN is required"); + const apiUrlOnly = { SUPABASE_LIVE_API_URL: "http://localhost:8080" }; + expect(() => runWithEnv(apiUrlOnly, validateLiveConfig)).toThrow( + "SUPABASE_ACCESS_TOKEN is required", + ); }); it("normalizes and validates HTTP API URLs", () => { - process.env["SUPABASE_LIVE_API_URL"] = "http://localhost:8080///"; - process.env["SUPABASE_ACCESS_TOKEN"] = " token "; - expect(validateLiveConfig()).toEqual({ + const env = { + SUPABASE_LIVE_API_URL: "http://localhost:8080///", + SUPABASE_ACCESS_TOKEN: " token ", + }; + expect(runWithEnv(env, validateLiveConfig)).toEqual({ apiUrl: "http://localhost:8080", accessToken: "token", }); - process.env["SUPABASE_LIVE_API_URL"] = "not-a-url"; - expect(() => liveApiUrl()).toThrow("absolute HTTP(S) URL"); + expect(() => runWithEnv({ ...env, SUPABASE_LIVE_API_URL: "not-a-url" }, liveApiUrl)).toThrow( + "absolute HTTP(S) URL", + ); }); it("derives the project host from the typed database host", () => { diff --git a/apps/cli/tests/helpers/live-project.integration.test.ts b/apps/cli/tests/helpers/live-project.integration.test.ts index 3472036075..3d99a9c9fe 100644 --- a/apps/cli/tests/helpers/live-project.integration.test.ts +++ b/apps/cli/tests/helpers/live-project.integration.test.ts @@ -1,10 +1,12 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { makeApiClient } from "@supabase/api/effect"; -import { Effect, Layer } from "effect"; +import { Cause, Effect, Exit, Layer } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { cliConfigProviderLayer } from "../../src/shared/config/cli-config-provider.layer.ts"; import { provisionLiveEnvironment } from "./live-project.ts"; const ref = "abcdefghijklmnopqrst"; @@ -65,7 +67,7 @@ describe("live project provisioning", () => { body: "sentinel-secret", headers: {}, }, - ])("redacts $name while cleaning up", async ({ status, body, headers }) => { + ])("redacts $name while cleaning up", ({ status, body, headers }) => { vi.stubEnv("SUPABASE_LIVE_API_URL", "https://api.supabase.green"); vi.stubEnv("SUPABASE_ACCESS_TOKEN", "test-token"); vi.stubEnv("SUPABASE_LIVE_ORG_ID", "org-slug"); @@ -74,66 +76,65 @@ describe("live project provisioning", () => { const requests: string[] = []; const ownedProjects = new Set(); - const client = await Effect.runPromise( - makeApiClient({ baseUrl: "https://api.supabase.green", accessToken: "test-token" }).pipe( + const platformApi = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}`); + if (request.method === "GET" && url.pathname === "/v1/organizations") { + return Effect.succeed( + jsonResponse(request, 200, [{ id: "org-id", slug: "org-slug", name: "Test" }]), + ); + } + if (request.method === "POST" && url.pathname === "/v1/projects") { + ownedProjects.add(ref); + return Effect.succeed(jsonResponse(request, 201, { ...project, database: undefined })); + } + if (request.method === "GET" && url.pathname === `/v1/projects/${ref}`) { + return Effect.succeed(jsonResponse(request, 200, project)); + } + if (request.method === "GET" && url.pathname === `/v1/projects/${ref}/api-keys`) { + return Effect.succeed(jsonResponse(request, status, body, headers)); + } + if (request.method === "DELETE" && url.pathname === `/v1/projects/${ref}`) { + ownedProjects.delete(ref); + return Effect.succeed(jsonResponse(request, 200, { id: 1, ref, name: "live-test" })); + } + return Effect.die(`unexpected request ${request.method} ${url.pathname}`); + }), + ); + + return Effect.runPromise( + Effect.gen(function* () { + const client = yield* makeApiClient({ + baseUrl: "https://api.supabase.green", + accessToken: "test-token", + }); + + const exit = yield* Effect.exit(provisionLiveEnvironment(client)); + const failure = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + if (status === 403) { + expect(String(failure)).toMatch( + /project API keys failed: GET \/v1\/projects\/abcdefghijklmnopqrst\/api-keys returned HTTP 403.*x-request-id=req-live-403.*response body omitted/, + ); + } else { + expect(String(failure)).toContain( + "project API keys failed: management API response schema validation failed", + ); + } + expect(requests.filter((request) => request.includes("/api-keys"))).toHaveLength(1); + expect(requests).toContain("DELETE /v1/projects/abcdefghijklmnopqrst"); + expect(ownedProjects).toEqual(new Set()); + const message = String(failure); + expect(message).not.toContain("test-token"); + expect(message).not.toContain("secret-value"); + expect(message).not.toContain("authorization denied"); + expect(message).not.toContain("sentinel-secret"); + }).pipe( Effect.provide( - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - const url = new URL(request.url); - requests.push(`${request.method} ${url.pathname}`); - if (request.method === "GET" && url.pathname === "/v1/organizations") { - return Effect.succeed( - jsonResponse(request, 200, [{ id: "org-id", slug: "org-slug", name: "Test" }]), - ); - } - if (request.method === "POST" && url.pathname === "/v1/projects") { - ownedProjects.add(ref); - return Effect.succeed( - jsonResponse(request, 201, { ...project, database: undefined }), - ); - } - if (request.method === "GET" && url.pathname === `/v1/projects/${ref}`) { - return Effect.succeed(jsonResponse(request, 200, project)); - } - if (request.method === "GET" && url.pathname === `/v1/projects/${ref}/api-keys`) { - return Effect.succeed(jsonResponse(request, status, body, headers)); - } - if (request.method === "DELETE" && url.pathname === `/v1/projects/${ref}`) { - ownedProjects.delete(ref); - return Effect.succeed( - jsonResponse(request, 200, { id: 1, ref, name: "live-test" }), - ); - } - return Effect.die(`unexpected request ${request.method} ${url.pathname}`); - }), - ), + Layer.mergeAll(platformApi, BunFileSystem.layer, BunPath.layer, cliConfigProviderLayer), ), ), ); - - let failure: unknown; - try { - await Effect.runPromise(provisionLiveEnvironment(client)); - } catch (error) { - failure = error; - } - if (status === 403) { - expect(String(failure)).toMatch( - /project API keys failed: GET \/v1\/projects\/abcdefghijklmnopqrst\/api-keys returned HTTP 403.*x-request-id=req-live-403.*response body omitted/, - ); - } else { - expect(String(failure)).toContain( - "project API keys failed: management API response schema validation failed", - ); - } - expect(requests.filter((request) => request.includes("/api-keys"))).toHaveLength(1); - expect(requests).toContain("DELETE /v1/projects/abcdefghijklmnopqrst"); - expect(ownedProjects).toEqual(new Set()); - const message = String(failure); - expect(message).not.toContain("test-token"); - expect(message).not.toContain("secret-value"); - expect(message).not.toContain("authorization denied"); - expect(message).not.toContain("sentinel-secret"); }); }); diff --git a/apps/cli/tests/helpers/live-project.ts b/apps/cli/tests/helpers/live-project.ts index 70765ff971..f63727ec70 100644 --- a/apps/cli/tests/helpers/live-project.ts +++ b/apps/cli/tests/helpers/live-project.ts @@ -1,11 +1,22 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { makeApiClient, type OperationOutput } from "@supabase/api/effect"; -import { Cause, Data, Effect, Exit, Schedule, Schema } from "effect"; +import { + Cause, + Clock, + Console, + Data, + Effect, + Exit, + FileSystem, + Path, + type PlatformError, + Schedule, + Schema, +} from "effect"; +import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { getDomain } from "tldts"; import { @@ -15,6 +26,7 @@ import { liveOrgId, liveProjectName, liveRegion, + liveRunId, } from "./live-env.ts"; const PROJECT_REF_RE = /^[a-z]{20}$/u; @@ -94,10 +106,19 @@ class LiveStorageError extends Data.TaggedError("LiveStorageError")<{ readonly cause?: unknown; }> {} +class LiveProjectError extends Data.TaggedError("LiveProjectError")<{ + readonly message: string; +}> {} + function apiError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +/** Filesystem failures surface as the underlying Node error. */ +function platformCause(error: PlatformError.PlatformError): Error { + return apiError(error.cause); +} + const DIAGNOSTIC_HEADER_NAMES = [ "x-request-id", "x-correlation-id", @@ -140,7 +161,11 @@ function liveApiErrorMessage(phase: string, cause: unknown): string { export function supportedRegion(value: string): Effect.Effect { const region = REGIONS.find((candidate) => candidate === value); return region === undefined - ? Effect.fail(new Error(`Unsupported SUPABASE_LIVE_REGION ${JSON.stringify(value)}`)) + ? Effect.fail( + new LiveProjectError({ + message: `Unsupported SUPABASE_LIVE_REGION ${JSON.stringify(value)}`, + }), + ) : Effect.succeed(region); } @@ -229,14 +254,14 @@ function timeoutLiveRequest( ): Effect.Effect { return Effect.timeoutOrElse(effect, { duration: POLL_TIMEOUT, - orElse: () => Effect.fail(new Error(`${phase} timed out`)), + orElse: () => Effect.fail(new LiveProjectError({ message: `${phase} timed out` })), }); } -function uniqueProjectName(): string { - const runId = process.env["GITHUB_RUN_ID"] ?? process.env["CI_JOB_ID"] ?? String(Date.now()); - return `${liveProjectName()}-${runId}-${randomUUID().slice(0, 8)}`; -} +const uniqueProjectName = Effect.gen(function* () { + const runId = (yield* liveRunId) ?? String(yield* Clock.currentTimeMillis); + return `${yield* liveProjectName}-${runId}-${randomUUID().slice(0, 8)}`; +}); function databasePassword(): string { return `supabase-cli-live-${randomBytes(12).toString("hex")}`; @@ -245,24 +270,26 @@ function databasePassword(): string { function resolveOrganization(api: LiveApi): Effect.Effect { return timeoutLiveRequest("organization lookup", api.v1.listAllOrganizations()).pipe( Effect.mapError(apiError), - Effect.flatMap((organizations) => { - const requested = liveOrgId(); - const organization = - requested === undefined - ? organizations[0] - : organizations.find( - (candidate) => candidate.id === requested || candidate.slug === requested, - ); - return organization === undefined - ? Effect.fail( - new Error( - requested === undefined - ? "No organizations found; cannot create the live project" - : `Organization ${requested} was not found; cannot create the live project`, - ), - ) - : Effect.succeed(organization); - }), + Effect.flatMap((organizations) => + Effect.flatMap(liveOrgId, (requested) => { + const organization = + requested === undefined + ? organizations[0] + : organizations.find( + (candidate) => candidate.id === requested || candidate.slug === requested, + ); + return organization === undefined + ? Effect.fail( + new LiveProjectError({ + message: + requested === undefined + ? "No organizations found; cannot create the live project" + : `Organization ${requested} was not found; cannot create the live project`, + }), + ) + : Effect.succeed(organization); + }), + ), ); } @@ -271,7 +298,8 @@ function createProject( name: string, password: string, ): Effect.Effect { - return supportedRegion(liveRegion()).pipe( + return liveRegion.pipe( + Effect.flatMap(supportedRegion), Effect.flatMap((region) => resolveOrganization(api).pipe( Effect.flatMap((organization) => @@ -290,7 +318,11 @@ function createProject( Effect.flatMap((project) => PROJECT_REF_RE.test(project.ref) ? Effect.succeed(project.ref) - : Effect.fail(new Error(`Unexpected project ref from project creation: ${project.ref}`)), + : Effect.fail( + new LiveProjectError({ + message: `Unexpected project ref from project creation: ${project.ref}`, + }), + ), ), ); } @@ -335,8 +367,9 @@ function waitForProject(api: LiveApi, ref: string): Effect.Effect error instanceof LiveTransientPoll, }).pipe( Effect.mapError((error) => { - if (error instanceof LiveTerminalPoll) return new Error(error.message); - if (error instanceof LivePollTimeout) return new Error(error.message); + if (error instanceof LiveTerminalPoll) + return new LiveProjectError({ message: error.message }); + if (error instanceof LivePollTimeout) return new LiveProjectError({ message: error.message }); return apiError(error); }), ); @@ -378,9 +411,11 @@ function resolveKeys( }).pipe( Effect.mapError((error) => error instanceof LivePollTimeout - ? new Error(`Project ${ref} did not return API keys within ${POLL_TIMEOUT}`) + ? new LiveProjectError({ + message: `Project ${ref} did not return API keys within ${POLL_TIMEOUT}`, + }) : error instanceof LiveTerminalPoll - ? new Error(error.message) + ? new LiveProjectError({ message: error.message }) : apiError(error), ), ); @@ -437,11 +472,11 @@ function resolveDbUrl( }).pipe( Effect.mapError((error) => error instanceof LivePollTimeout - ? new Error( - `Project ${ref} did not return a pooler connection string within ${POLL_TIMEOUT}`, - ) + ? new LiveProjectError({ + message: `Project ${ref} did not return a pooler connection string within ${POLL_TIMEOUT}`, + }) : error instanceof LiveTerminalPoll - ? new Error(error.message) + ? new LiveProjectError({ message: error.message }) : apiError(error), ), ); @@ -452,85 +487,111 @@ function createStorageBucket( host: string, serviceRoleKey: string, bucket: string, -): Effect.Effect { - const attempt = Effect.tryPromise({ - try: async (signal) => { - const response = await fetch(`https://${ref}.${host}/storage/v1/bucket`, { - method: "POST", - headers: { Authorization: `Bearer ${serviceRoleKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ id: bucket, name: bucket, public: false }), - signal, - }); - if (!response.ok && response.status !== 409) { - throw new LiveStorageError({ - message: `Failed to create storage bucket ${bucket}: ${response.status} ${await response.text()}`, +): Effect.Effect { + return Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const attempt = Effect.gen(function* () { + const request = yield* HttpClientRequest.post( + `https://${ref}.${host}/storage/v1/bucket`, + ).pipe( + HttpClientRequest.setHeaders({ + Authorization: `Bearer ${serviceRoleKey}`, + "Content-Type": "application/json", + }), + HttpClientRequest.bodyJson({ id: bucket, name: bucket, public: false }), + ); + const response = yield* client.execute(request); + const ok = response.status >= 200 && response.status <= 299; + if (!ok && response.status !== 409) { + return yield* new LiveStorageError({ + message: `Failed to create storage bucket ${bucket}: ${response.status} ${yield* response.text}`, retryable: isTransientStorageStatus(response.status), }); } - }, - catch: (cause) => - cause instanceof LiveStorageError - ? cause - : new LiveStorageError({ - message: `Failed to create storage bucket ${bucket}: ${apiError(cause).message}`, - retryable: true, - cause, - }), + }).pipe( + Effect.mapError((cause) => + cause instanceof LiveStorageError + ? cause + : new LiveStorageError({ + message: `Failed to create storage bucket ${bucket}: ${apiError(cause).message}`, + retryable: true, + cause, + }), + ), + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + ); + return yield* retryLiveEffect("storage bucket", attempt, { + shouldRetry: (error) => error instanceof LiveStorageError && error.retryable, + }).pipe( + Effect.mapError((error) => + error instanceof LivePollTimeout + ? new LiveProjectError({ message: `storage bucket ${bucket} creation timed out` }) + : error instanceof LiveStorageError + ? new LiveProjectError({ message: error.message }) + : apiError(error), + ), + ); }); - return retryLiveEffect("storage bucket", attempt, { - shouldRetry: (error) => error instanceof LiveStorageError && error.retryable, - }).pipe( - Effect.mapError((error) => - error instanceof LivePollTimeout - ? new Error(`storage bucket ${bucket} creation timed out`) - : error instanceof LiveStorageError - ? new Error(error.message) - : apiError(error), - ), - ); } function writeProfile( projectRef: string, projectHost: string, dbUrl: string, -): Effect.Effect { - return Effect.tryPromise({ - try: async () => { - const directory = await mkdtemp(path.join(tmpdir(), "supabase-live-profile-")); - const profilePath = path.join(directory, "profile.yaml"); - try { - const poolerHost = resolvePoolerDomain(dbUrl); - await writeFile( +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs + .makeTempDirectory({ prefix: "supabase-live-profile-" }) + .pipe(Effect.mapError(platformCause)); + const profilePath = path.join(directory, "profile.yaml"); + const write = Effect.gen(function* () { + const poolerHost = yield* Effect.try({ + try: () => resolvePoolerDomain(dbUrl), + catch: (cause) => new LiveProjectError({ message: apiError(cause).message }), + }); + const apiUrl = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.String))( + yield* liveApiUrl, + ); + yield* fs + .writeFileString( profilePath, [ `name: ${PROFILE_NAME}`, - `api_url: ${JSON.stringify(liveApiUrl())}`, - `dashboard_url: ${JSON.stringify(liveApiUrl())}`, + `api_url: ${apiUrl}`, + `dashboard_url: ${apiUrl}`, `project_host: ${projectHost}`, `pooler_host: ${poolerHost}`, `# provisioned project: ${projectRef}`, "", ].join("\n"), - ); - return profilePath; - } catch (cause) { - try { - await rm(directory, { recursive: true, force: true }); - } catch (cleanup) { - throw cleanupErrors(cause, [cleanup]); - } - throw cause; - } - }, - catch: apiError, + ) + .pipe(Effect.mapError(platformCause)); + return profilePath; + }); + return yield* write.pipe( + Effect.catch((cause) => + fs.remove(directory, { recursive: true, force: true }).pipe( + Effect.matchEffect({ + onFailure: (cleanup) => Effect.fail(cleanupErrors(cause, [platformCause(cleanup)])), + onSuccess: () => Effect.fail(cause), + }), + ), + ), + ); }); } -function cleanupDirectory(profilePath: string): Effect.Effect { - return Effect.tryPromise({ - try: () => rm(path.dirname(profilePath), { recursive: true, force: true }), - catch: apiError, +function cleanupDirectory( + profilePath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs + .remove(path.dirname(profilePath), { recursive: true, force: true }) + .pipe(Effect.mapError(platformCause)); }); } @@ -538,21 +599,21 @@ function cleanupRemote( api: LiveApi, environment: LiveCliProjectEnvironment, ): Effect.Effect { - return keepLiveProject() - ? Effect.sync(() => { - console.log(`SUPABASE_LIVE_KEEP_PROJECT=1 — leaving ${environment.project.ref} alive`); - }) - : deleteProject(api, environment.project.ref); + return Effect.flatMap(keepLiveProject, (keep) => + keep + ? Console.log(`SUPABASE_LIVE_KEEP_PROJECT=1 — leaving ${environment.project.ref} alive`) + : deleteProject(api, environment.project.ref), + ); } function cleanupCreatedProject(api: LiveApi, ref: string): Effect.Effect { - return keepLiveProject() - ? Effect.sync(() => { - console.log( + return Effect.flatMap(keepLiveProject, (keep) => + keep + ? Console.log( `SUPABASE_LIVE_KEEP_PROJECT=1 — leaving ${ref} alive after provisioning failure`, - ); - }) - : deleteProject(api, ref); + ) + : deleteProject(api, ref), + ); } function combineCleanupExits( @@ -578,57 +639,59 @@ export interface LiveCliProjectEnvironment { } /** Provision one project; the caller owns the outer Effect runtime boundary. */ -export function provisionLiveEnvironment( +export const provisionLiveEnvironment: ( api: LiveApi, -): Effect.Effect { - return Effect.gen(function* () { - const password = databasePassword(); - const ref = yield* createProject(api, uniqueProjectName(), password); - const setup = Effect.gen(function* () { - const project = yield* waitForProject(api, ref); - const projectHost = deriveLiveProjectHost(project.database.host, ref); - const keys = yield* resolveKeys(api, ref); - const dbUrl = yield* resolveDbUrl(api, ref, password); - const storageBucket = "supabase-cli-live-bucket"; - yield* createStorageBucket(ref, projectHost, keys.serviceRoleKey, storageBucket); - const profilePath = yield* writeProfile(ref, projectHost, dbUrl); - return { - project: { - ref, - dbUrl, - dbPassword: password, - anonKey: keys.anonKey, - serviceRoleKey: keys.serviceRoleKey, - functionsUrl: `https://${ref}.${projectHost}/functions/v1`, - storageBucket, - }, - profilePath, - } satisfies LiveCliProjectEnvironment; - }); - const setupExit = yield* Effect.exit(setup); - if (Exit.isSuccess(setupExit)) return setupExit.value; - - const cleanupExit = yield* Effect.exit(cleanupCreatedProject(api, ref)); - if (Exit.isSuccess(cleanupExit)) return yield* Effect.failCause(setupExit.cause); - return yield* Effect.fail( - cleanupErrors(Cause.squash(setupExit.cause), [Cause.squash(cleanupExit.cause)]), - ); +) => Effect.Effect< + LiveCliProjectEnvironment, + Error, + FileSystem.FileSystem | Path.Path | HttpClient.HttpClient +> = Effect.fn("LiveProject.provision")(function* (api: LiveApi) { + const password = databasePassword(); + const ref = yield* createProject(api, yield* uniqueProjectName, password); + const setup = Effect.gen(function* () { + const project = yield* waitForProject(api, ref); + const projectHost = deriveLiveProjectHost(project.database.host, ref); + const keys = yield* resolveKeys(api, ref); + const dbUrl = yield* resolveDbUrl(api, ref, password); + const storageBucket = "supabase-cli-live-bucket"; + yield* createStorageBucket(ref, projectHost, keys.serviceRoleKey, storageBucket); + const profilePath = yield* writeProfile(ref, projectHost, dbUrl); + return { + project: { + ref, + dbUrl, + dbPassword: password, + anonKey: keys.anonKey, + serviceRoleKey: keys.serviceRoleKey, + functionsUrl: `https://${ref}.${projectHost}/functions/v1`, + storageBucket, + }, + profilePath, + } satisfies LiveCliProjectEnvironment; }); -} + const setupExit = yield* Effect.exit(setup); + if (Exit.isSuccess(setupExit)) return setupExit.value; + + const cleanupExit = yield* Effect.exit(cleanupCreatedProject(api, ref)); + if (Exit.isSuccess(cleanupExit)) return yield* Effect.failCause(setupExit.cause); + return yield* Effect.fail( + cleanupErrors(Cause.squash(setupExit.cause), [Cause.squash(cleanupExit.cause)]), + ); +}); /** Delete the exact owned project and always remove its temporary profile. */ -export function cleanupLiveEnvironment( +export const cleanupLiveEnvironment: ( api: LiveApi, environment: LiveCliProjectEnvironment, -): Effect.Effect { - return Effect.gen(function* () { - const [profileExit, projectExit] = yield* Effect.all( - [ - Effect.exit(cleanupDirectory(environment.profilePath)), - Effect.exit(cleanupRemote(api, environment)), - ], - { concurrency: "unbounded" }, - ); - yield* combineCleanupExits([profileExit, projectExit]); - }); -} +) => Effect.Effect = Effect.fn( + "LiveProject.cleanup", +)(function* (api: LiveApi, environment: LiveCliProjectEnvironment) { + const [profileExit, projectExit] = yield* Effect.all( + [ + Effect.exit(cleanupDirectory(environment.profilePath)), + Effect.exit(cleanupRemote(api, environment)), + ], + { concurrency: "unbounded" }, + ); + yield* combineCleanupExits([profileExit, projectExit]); +}); diff --git a/apps/cli/tests/helpers/live-project.unit.test.ts b/apps/cli/tests/helpers/live-project.unit.test.ts index 9d8b712039..eec23dfa7e 100644 --- a/apps/cli/tests/helpers/live-project.unit.test.ts +++ b/apps/cli/tests/helpers/live-project.unit.test.ts @@ -1,8 +1,8 @@ -import { Effect } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit } from "effect"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { describe, expect, it } from "vitest"; import { cleanupErrors, @@ -16,6 +16,10 @@ import { type PoolerConfig, } from "./live-project.ts"; +class TemporarilyUnavailable extends Data.TaggedError("TemporarilyUnavailable")<{ + readonly message: string; +}> {} + function statusError(status: number): HttpClientError.HttpClientError { const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/test"); const response = HttpClientResponse.fromWeb(request, new Response(null, { status })); @@ -43,37 +47,37 @@ function poolerConfig(overrides: Partial = {}): PoolerConfig { } describe("live project lifecycle", () => { - it("fails invalid regions before provisioning", async () => { - await expect( - Effect.runPromise(Effect.flip(supportedRegion("not-a-region"))), - ).resolves.toMatchObject({ - message: expect.stringContaining("Unsupported SUPABASE_LIVE_REGION"), - }); - await expect(Effect.runPromise(supportedRegion("us-east-1"))).resolves.toBe("us-east-1"); - }); - - it("retries transient failures until the management operation succeeds", async () => { - let attempts = 0; - const result = await Effect.runPromise( - retryLiveEffect( + it.effect("fails invalid regions before provisioning", () => + Effect.gen(function* () { + expect(yield* Effect.flip(supportedRegion("not-a-region"))).toMatchObject({ + message: expect.stringContaining("Unsupported SUPABASE_LIVE_REGION"), + }); + expect(yield* supportedRegion("us-east-1")).toBe("us-east-1"); + }), + ); + + it.live("retries transient failures until the management operation succeeds", () => + Effect.gen(function* () { + let attempts = 0; + const result = yield* retryLiveEffect( "project readiness", Effect.suspend(() => Effect.sync(() => { attempts += 1; return attempts < 3 - ? Effect.fail(new Error("temporarily unavailable")) + ? Effect.fail(new TemporarilyUnavailable({ message: "temporarily unavailable" })) : Effect.succeed("ACTIVE_HEALTHY"); }).pipe(Effect.flatten), ), { interval: "1 millis", timeout: "100 millis" }, - ), - ); + ); - expect(result).toBe("ACTIVE_HEALTHY"); - expect(attempts).toBe(3); - }); + expect(result).toBe("ACTIVE_HEALTHY"); + expect(attempts).toBe(3); + }), + ); - it("fails a poll when its wall-clock deadline expires", async () => { + it("fails a poll when its wall-clock deadline expires", () => { const result = Effect.runPromise( retryLiveEffect("project keys", Effect.never, { interval: "1 millis", @@ -81,7 +85,7 @@ describe("live project lifecycle", () => { }), ); - await expect(result).rejects.toThrow("project keys timed out"); + return expect(result).rejects.toThrow("project keys timed out"); }); it("preserves both target and cleanup failures", () => { @@ -99,30 +103,28 @@ describe("live project lifecycle", () => { ]); }); - it("retries transient API statuses but fails authorization errors immediately", async () => { - expect(isTransientLiveError(statusError(404))).toBe(true); - expect(isTransientLiveError(statusError(503))).toBe(true); - expect(isTransientLiveError(statusError(401))).toBe(false); - expect(isTransientLiveError(statusError(403))).toBe(false); + it.live("retries transient API statuses but fails authorization errors immediately", () => + Effect.gen(function* () { + expect(isTransientLiveError(statusError(404))).toBe(true); + expect(isTransientLiveError(statusError(503))).toBe(true); + expect(isTransientLiveError(statusError(401))).toBe(false); + expect(isTransientLiveError(statusError(403))).toBe(false); - let attempts = 0; - const transient = statusError(503); - const result = await Effect.runPromise( - retryLiveEffect( + let attempts = 0; + const transient = statusError(503); + const result = yield* retryLiveEffect( "storage bucket", Effect.suspend(() => { attempts += 1; return attempts < 3 ? Effect.fail(transient) : Effect.succeed("created"); }), { interval: "1 millis", timeout: "100 millis", shouldRetry: isTransientLiveError }, - ), - ); - expect(result).toBe("created"); - expect(attempts).toBe(3); + ); + expect(result).toBe("created"); + expect(attempts).toBe(3); - attempts = 0; - await expect( - Effect.runPromise( + attempts = 0; + const exit = yield* Effect.exit( retryLiveEffect( "project readiness", Effect.suspend(() => { @@ -135,10 +137,11 @@ describe("live project lifecycle", () => { shouldRetry: isTransientLiveError, }, ), - ), - ).rejects.toThrow(); - expect(attempts).toBe(1); - }); + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(attempts).toBe(1); + }), + ); it("classifies storage responses for retry without retrying terminal client errors", () => { expect(isTransientStorageStatus(408)).toBe(true); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 3a29b8c569..0e54133712 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -1,18 +1,18 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { Effect, Predicate } from "effect"; +import { BunFileSystem } from "@effect/platform-bun"; +import { Data, DateTime, Effect, FileSystem, Layer, Predicate, Random, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import pg from "pg"; import { expect, inject, test as vitestTest } from "vitest"; import { rootCaBundle } from "../../src/commands/gen/types/types.shared.ts"; import { type CliRunError, - makeTempHome, requireCliSuccess, runSupabase, runSupabaseEffect, + createTempHome, + helperRejection, + type TempHome, } from "./cli.ts"; import { LIVE_EXIT_TIMEOUT_MS, LIVE_SHADOW_PORT } from "./live-env.ts"; import type { LiveCliProjectEnvironment } from "./live-project.ts"; @@ -35,7 +35,7 @@ export interface InvokeResult { export interface LiveFixtures { readonly project: LiveProject; readonly workspace: LiveWorkspace; - readonly home: ReturnType; + readonly home: TempHome; readonly cli: (args: string[], options?: RunOptions) => Promise; readonly cliEffect: ( args: string[], @@ -47,40 +47,129 @@ export interface LiveFixtures { ) => Promise; } +class LiveFixtureError extends Data.TaggedError("LiveFixtureError")<{ + readonly message: string; +}> {} + +class LiveForeignFailure extends Data.TaggedError("LiveForeignFailure")<{ + readonly cause: unknown; +}> {} + +const foreignFailure = (error: { readonly cause?: unknown }) => + new LiveForeignFailure({ cause: error.cause }); + +const awaitForeign = (run: () => Promise) => + Effect.tryPromise({ try: run, catch: (cause) => new LiveForeignFailure({ cause }) }); + +const callForeign = (run: () => A) => + Effect.try({ try: run, catch: (cause) => new LiveForeignFailure({ cause }) }); + +/** + * Runs a helper at its Promise edge, rejecting with a foreign failure's original value and with a + * plain `Error` for a fixture failure. + */ +function runLive(effect: Effect.Effect): Promise { + return Effect.runPromise( + effect.pipe( + Effect.catchTags({ + LiveForeignFailure: (failure) => Effect.die(failure.cause), + LiveFixtureError: (failure) => Effect.die(new Error(failure.message)), + }), + ), + ); +} + +/** `try { body } finally { cleanup }`: a cleanup failure replaces the body's outcome. */ +function withFinally( + body: Effect.Effect, + cleanup: Effect.Effect, +): Effect.Effect { + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exit = yield* Effect.exit(restore(body)); + yield* cleanup; + return yield* exit; + }), + ); +} + +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); + +const invokeFunction = Effect.fnUntraced(function* ( + project: LiveProject, + slug: string, + options?: { readonly anonKey?: string; readonly payload?: unknown }, +) { + const key = options?.anonKey ?? project.anonKey; + const headers: Record = { "Content-Type": "application/json" }; + if (key.length > 0) { + headers["Authorization"] = `Bearer ${key}`; + headers["apikey"] = key; + } + const client = yield* HttpClient.HttpClient; + const request = yield* HttpClientRequest.post(`${project.functionsUrl}/${slug}`).pipe( + HttpClientRequest.setHeaders(headers), + HttpClientRequest.bodyJson(options?.payload ?? {}), + ); + const response = yield* client.execute(request); + const text = yield* response.text; + const body = yield* Schema.decodeEffect(UnknownFromJsonString)(text).pipe( + Effect.orElseSucceed(() => text), + ); + return { status: response.status, body, text }; +}); + const base = vitestTest.extend({ - project: async ({ task: _task }, use) => use(inject("liveProject")), - - home: async ({ task: _task }, use) => { - const home = makeTempHome(); - try { - await use(home); - } finally { - home[Symbol.dispose](); - } - }, - - workspace: async ({ task, home }, use) => { - const suffix = task.name.replace(/[^a-z0-9-]+/giu, "-").slice(0, 40); - const directory = mkdtempSync(path.join(tmpdir(), `supabase-live-${suffix || "test"}-`)); - try { - const initialized = await runSupabase(["init"], { - cwd: directory, - home: home.dir, - env: { SUPABASE_PROFILE: inject("liveProfilePath") }, - }); - if (initialized.exitCode !== 0) { - throw new Error( - `supabase init failed (exit ${initialized.exitCode})\n${initialized.stderr || initialized.stdout}`, + project: ({ task: _task }, use) => use(inject("liveProject")), + + home: ({ task: _task }, use) => + runLive( + /* `try { use } finally { dispose }`: a failed disposal replaces the fixture's outcome. */ + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const home = yield* createTempHome.pipe(Effect.mapError(foreignFailure)); + yield* withFinally( + restore(awaitForeign(() => use(home))), + home.dispose.pipe(Effect.mapError(foreignFailure)), + ); + }), + ), + ), + + workspace: ({ task, home }, use) => + runLive( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const suffix = task.name.replace(/[^a-z0-9-]+/giu, "-").slice(0, 40); + const directory = yield* fs + .makeTempDirectory({ prefix: `supabase-live-${suffix || "test"}-` }) + .pipe(Effect.mapError(foreignFailure)); + yield* withFinally( + Effect.gen(function* () { + const initialized = yield* runSupabaseEffect(["init"], { + cwd: directory, + home: home.dir, + env: { SUPABASE_PROFILE: inject("liveProfilePath") }, + }).pipe( + /* The fixture rejects with what `runSupabase` rejects with for the same failure. */ + Effect.mapError((cause) => new LiveForeignFailure({ cause: helperRejection(cause) })), + ); + if (initialized.exitCode !== 0) { + return yield* new LiveFixtureError({ + message: `supabase init failed (exit ${initialized.exitCode})\n${initialized.stderr || initialized.stdout}`, + }); + } + yield* awaitForeign(() => use({ path: directory })); + }), + fs + .remove(directory, { recursive: true, force: true }) + .pipe(Effect.mapError(foreignFailure)), ); - } - await use({ path: directory }); - } finally { - rmSync(directory, { recursive: true, force: true }); - } - }, + }).pipe(Effect.provide(BunFileSystem.layer)), + ), - cli: async ({ workspace, home }, use) => { - await use((args, options) => + cli: ({ workspace, home }, use) => + use((args, options) => runSupabase(args, { ...options, cwd: options?.cwd ?? workspace.path, @@ -92,11 +181,10 @@ const base = vitestTest.extend({ ...options?.env, }, }), - ); - }, + ), - cliEffect: async ({ workspace, home }, use) => { - await use((args, options) => + cliEffect: ({ workspace, home }, use) => + use((args, options) => runSupabaseEffect(args, { ...options, cwd: options?.cwd ?? workspace.path, @@ -108,32 +196,22 @@ const base = vitestTest.extend({ ...options?.env, }, }), - ); - }, - - invoke: async ({ project }, use) => { - await use(async (slug, options) => { - const key = options?.anonKey ?? project.anonKey; - const headers: Record = { "Content-Type": "application/json" }; - if (key.length > 0) { - headers["Authorization"] = `Bearer ${key}`; - headers["apikey"] = key; - } - const response = await fetch(`${project.functionsUrl}/${slug}`, { - method: "POST", - headers, - body: JSON.stringify(options?.payload ?? {}), - }); - const text = await response.text(); - let body: unknown; - try { - body = JSON.parse(text); - } catch { - body = text; - } - return { status: response.status, body, text }; - }); - }, + ), + + invoke: ({ project }, use) => + use((slug, options) => + runLive( + invokeFunction(project, slug, options).pipe( + Effect.mapError(foreignFailure), + Effect.provide( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + ), + ), + ), + ), + ), }); /** The sole live fixture. The live global setup owns the shared project. */ @@ -165,17 +243,25 @@ export const storageLiveFlags: ReadonlyArray = ["--linked", "--experimen * remote object, tolerating an already-removed target so teardown stays * idempotent across the moved/renamed paths a test may leave behind. */ -export async function removeStorageLiveObject( +export function removeStorageLiveObject( cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, remote: string, ): Promise { - const removed = await cli(["storage", "rm", remote, "--yes", ...storageLiveFlags]); - if ( - removed.exitCode !== 0 && - !/not found|does not exist/i.test(`${removed.stdout}\n${removed.stderr}`) - ) { - throw new Error(`storage rm cleanup failed:\n${removed.stdout}\n${removed.stderr}`); - } + return runLive( + Effect.gen(function* () { + const removed = yield* awaitForeign(() => + cli(["storage", "rm", remote, "--yes", ...storageLiveFlags]), + ); + if ( + removed.exitCode !== 0 && + !/not found|does not exist/i.test(`${removed.stdout}\n${removed.stderr}`) + ) { + return yield* new LiveFixtureError({ + message: `storage rm cleanup failed:\n${removed.stdout}\n${removed.stderr}`, + }); + } + }), + ); } /** Flags for experimental-gated live tests that address the shared project by @@ -189,38 +275,46 @@ export function experimentalProjectLiveFlags(project: LiveProject): ReadonlyArra * without a database restart. Deleting an absent key is a no-op PUT, so the * teardown stays idempotent. */ -export async function removePostgresConfigLiveOverride( +export function removePostgresConfigLiveOverride( cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, project: LiveProject, key: string, ): Promise { - const removed = await cli([ - "postgres-config", - "delete", - "--config", - key, - ...experimentalProjectLiveFlags(project), - "--no-restart", - ]); - requireCliSuccess(removed, `postgres-config delete cleanup for ${key}`); + return runLive( + Effect.gen(function* () { + const removed = yield* awaitForeign(() => + cli([ + "postgres-config", + "delete", + "--config", + key, + ...experimentalProjectLiveFlags(project), + "--no-restart", + ]), + ); + yield* callForeign(() => + requireCliSuccess(removed, `postgres-config delete cleanup for ${key}`), + ); + }), + ); } /** Exact-version cleanup for migration live tests; reverting an absent row is a no-op delete. */ -export async function removeLiveMigration( +export function removeLiveMigration( cli: LiveFixtures["cli"], project: LiveProject, version: string, ): Promise { - const reverted = await cli([ - "migration", - "repair", - version, - "--status", - "reverted", - "--db-url", - project.dbUrl, - ]); - requireCliSuccess(reverted, `migration repair cleanup for ${version}`); + return runLive( + Effect.gen(function* () { + const reverted = yield* awaitForeign(() => + cli(["migration", "repair", version, "--status", "reverted", "--db-url", project.dbUrl]), + ); + yield* callForeign(() => + requireCliSuccess(reverted, `migration repair cleanup for ${version}`), + ); + }), + ); } /** @@ -229,29 +323,37 @@ export async function removeLiveMigration( * (2s apart, 60s deadline, each attempt bounded) until `key` reads `expected` * (`undefined` for no override). */ -export async function expectPostgresConfigLiveOverride( +export function expectPostgresConfigLiveOverride( cli: LiveFixtures["cli"], project: LiveProject, key: string, expected: string | undefined, label: string, ): Promise { - const read = async (): Promise => { - const proof = await cli( - ["postgres-config", "get", ...experimentalProjectLiveFlags(project), "-o", "json"], - { exitTimeoutMs: 20_000 }, + const read = Effect.gen(function* () { + const proof = yield* awaitForeign(() => + cli(["postgres-config", "get", ...experimentalProjectLiveFlags(project), "-o", "json"], { + exitTimeoutMs: 20_000, + }), ); - requireCliSuccess(proof, label); - const config = requireLiveJson(proof, label); + yield* callForeign(() => requireCliSuccess(proof, label)); + const config = yield* callForeign(() => requireLiveJson(proof, label)); if (!Predicate.isObject(config)) { - throw new Error( - `${label}: unexpected postgres-config get payload\nstdout:\n${proof.stdout}\nstderr:\n${proof.stderr}`, - ); + return yield* new LiveFixtureError({ + message: `${label}: unexpected postgres-config get payload\nstdout:\n${proof.stdout}\nstderr:\n${proof.stderr}`, + }); } return config[key]; - }; - if (Object.is(await read(), expected)) return; - await expect.poll(read, { interval: 2_000, timeout: 60_000, message: label }).toBe(expected); + }); + const readPromise = (): Promise => runLive(read); + /* Polling stays at the Promise edge: vitest's `expect.poll` drives its own reads. */ + return readPromise().then((first) => + Object.is(first, expected) + ? undefined + : expect + .poll(readPromise, { interval: 2_000, timeout: 60_000, message: label }) + .toBe(expected), + ); } /** @@ -260,13 +362,15 @@ export async function expectPostgresConfigLiveOverride( * timestamp version already in the shared project's migration history. */ export function liveMigrationVersion(): string { - const stamp = new Date() - .toISOString() - .replaceAll(/[-:TZ.]/gu, "") - .slice(0, 14); - return `${stamp}${Math.floor(Math.random() * 10_000) - .toString() - .padStart(4, "0")}`; + return Effect.runSync( + Effect.gen(function* () { + const stamp = DateTime.formatIso(yield* DateTime.now) + .replaceAll(/[-:TZ.]/gu, "") + .slice(0, 14); + const suffix = yield* Random.nextIntBetween(0, 10_000, { halfOpen: true }); + return `${stamp}${suffix.toString().padStart(4, "0")}`; + }), + ); } /** @@ -274,27 +378,39 @@ export function liveMigrationVersion(): string { * live assertions can verify database state without invoking another CLI * command. */ -export async function queryLiveDb>( +export function queryLiveDb>( dbUrl: string, query: string, values?: ReadonlyArray, connectionTimeoutMillis?: number, ): Promise { - // Verified TLS against the Supabase CA, so the query works whatever the project's SSL enforcement. - const client = new pg.Client({ - connectionString: dbUrl, - ssl: { ca: rootCaBundle() }, - connectionTimeoutMillis, - }); - // A restart that drops an idle or closing client surfaces only as an `error` event. - client.on("error", () => {}); - await client.connect(); - try { - const result = await client.query(query, values === undefined ? undefined : [...values]); - return result.rows as T[]; - } finally { - await client.end(); - } + return runLive( + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + // Verified TLS against the Supabase CA, so the query works whatever the project's SSL enforcement. + const client = yield* callForeign( + () => + new pg.Client({ + connectionString: dbUrl, + ssl: { ca: rootCaBundle() }, + connectionTimeoutMillis, + }), + ); + // A restart that drops an idle or closing client surfaces only as an `error` event. + client.on("error", () => {}); + yield* restore(awaitForeign(() => client.connect())); + /* `try { query } finally { end }`: a failed `end()` replaces the query's outcome. */ + return yield* withFinally( + restore( + awaitForeign(() => + client.query(query, values === undefined ? undefined : [...values]), + ).pipe(Effect.map((result) => result.rows)), + ), + awaitForeign(() => client.end()), + ); + }), + ), + ); } /** Rethrow a target failure without discarding failures from exact cleanup. */ diff --git a/apps/cli/tests/helpers/local-reset.ts b/apps/cli/tests/helpers/local-reset.ts index 361f355860..429c96b195 100644 --- a/apps/cli/tests/helpers/local-reset.ts +++ b/apps/cli/tests/helpers/local-reset.ts @@ -32,14 +32,12 @@ export function mockContainerCliSpawner(route: (args: ReadonlyArray) => spawned.push({ args }); if (command._tag !== "StandardCommand") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }); } const result = route(args); diff --git a/apps/cli/tests/helpers/macos-signature.ts b/apps/cli/tests/helpers/macos-signature.ts index 8663ee076f..5f91e23b41 100644 --- a/apps/cli/tests/helpers/macos-signature.ts +++ b/apps/cli/tests/helpers/macos-signature.ts @@ -1,8 +1,7 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { Effect, FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { macIdentifierFor } from "../../scripts/macos-signing.ts"; -import { runCli } from "./release-shell.ts"; +import { jsonQuote, type ReleaseStepError, runCliEffect } from "./release-shell.ts"; export type SignatureCheckResult = { readonly passed: boolean; @@ -18,23 +17,30 @@ export type SignatureCheckResult = { * (Phase 2), it additionally checks the hardened-runtime flag and that Gatekeeper accepts a * quarantined copy, which validates the online notarization ticket. */ -export async function verifyMacSignature(binPath: string): Promise { +export const verifyMacSignature = Effect.fn("MacosSignature.verifyMacSignature")(function* ( + binPath: string, +): Effect.fn.Return< + SignatureCheckResult, + ReleaseStepError | PlatformError, + FileSystem.FileSystem | Path.Path +> { + const path = yield* Path.Path; const binary = path.basename(binPath); const expectedId = macIdentifierFor(binary); if (!expectedId) { return { passed: false, detail: `no expected identifier configured for ${binary}` }; } - const verify = await runCli("codesign", ["--verify", "--strict", "--verbose=2", binPath]); + const verify = yield* runCliEffect("codesign", ["--verify", "--strict", "--verbose=2", binPath]); if (verify.exitCode !== 0) { return { passed: false, - detail: `codesign --verify --strict failed: exit=${verify.exitCode}, stderr=${JSON.stringify(verify.stderr)}`, + detail: `codesign --verify --strict failed: exit=${verify.exitCode}, stderr=${yield* jsonQuote(verify.stderr)}`, }; } // codesign writes the signature display to stderr. - const display = await runCli("codesign", ["-dvv", binPath]); + const display = yield* runCliEffect("codesign", ["-dvv", binPath]); const info = [display.stdout, display.stderr].filter(Boolean).join("\n"); // Match the whole identifier value (codesign prints `Identifier=` on its @@ -63,8 +69,8 @@ export async function verifyMacSignature(binPath: string): Promise { - const dir = await mkdtemp(path.join(tmpdir(), "supabase-gatekeeper-")); - const copy = path.join(dir, path.basename(binPath)); - try { - await runCli("cp", [binPath, copy]); - await runCli("xattr", ["-w", "com.apple.quarantine", "0081;00000000;smoke;", copy]); - const assess = await runCli("spctl", ["--assess", "--type", "execute", "--verbose=2", copy]); - if (assess.exitCode !== 0) { +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const assessCopy = (dir: string) => + Effect.gen(function* () { + const copy = path.join(dir, path.basename(binPath)); + yield* runCliEffect("cp", [binPath, copy]); + yield* runCliEffect("xattr", ["-w", "com.apple.quarantine", "0081;00000000;smoke;", copy]); + const assess = yield* runCliEffect("spctl", [ + "--assess", + "--type", + "execute", + "--verbose=2", + copy, + ]); + if (assess.exitCode !== 0) { + return { + passed: false, + detail: `spctl rejected quarantined binary: exit=${assess.exitCode}, stderr=${yield* jsonQuote(assess.stderr)}`, + }; + } return { - passed: false, - detail: `spctl rejected quarantined binary: exit=${assess.exitCode}, stderr=${JSON.stringify(assess.stderr)}`, + passed: true, + detail: `Developer ID + notarized signature ok (Identifier=${expectedId})`, }; - } - return { - passed: true, - detail: `Developer ID + notarized signature ok (Identifier=${expectedId})`, - }; - } finally { - await rm(dir, { recursive: true, force: true }); - } -} + }); + /* `try { assess } finally { remove }`: a failed removal replaces the assessment's outcome. */ + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const dir = yield* fs.makeTempDirectory({ prefix: "supabase-gatekeeper-" }); + const outcome = yield* Effect.exit(restore(assessCopy(dir))); + yield* fs.remove(dir, { recursive: true, force: true }); + return yield* outcome; + }), + ); +}); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 072f1d594a..ed5de4d4e9 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -1,8 +1,19 @@ import { tmpdir } from "node:os"; -import { join } from "node:path"; import process from "node:process"; -import { BunServices } from "@effect/platform-bun"; -import { Console, ConfigProvider, Deferred, Effect, Layer, Option, Stream } from "effect"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { + Console, + ConfigProvider, + DateTime, + Deferred, + Effect, + Layer, + Option, + Path, + Random, + Schema, + Stream, +} from "effect"; import type { CliProjectEnvironment, CliProjectPaths } from "@supabase/config"; import { testRunEnvVar as stackTestRunEnvVar } from "@supabase/stack/internal/test-run-label"; import { cliSettingsLayer } from "../../src/shared/config/cli-settings.layer.ts"; @@ -51,16 +62,33 @@ type OutputEvent = { // created on disk, so a test combining it with a real FileSystem layer can't collide // with another run's files. Tests that read/write real files under homeDir should use // `useTempWorkdir` in `command-mocks.ts` instead. -const defaultTestHomeDir = join( - tmpdir(), - `supabase-cli-test-home-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 8)}`, +const { defaultTestHomeDir, defaultTestConfigDir } = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path; + const suffix = (yield* Random.next).toString(36).slice(2, 8); + const homeDir = path.join( + tmpdir(), + `supabase-cli-test-home-${process.pid.toString(36)}-${suffix}`, + ); + return { defaultTestHomeDir: homeDir, defaultTestConfigDir: path.join(homeDir, ".supabase") }; + }).pipe(Effect.provide(BunPath.layer)), ); -export function mockBrowser(): Layer.Layer { - return Layer.succeed(Browser, { - open: () => Effect.void, - }); -} +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +/* JSON.stringify returns undefined for undefined, functions and symbols; those print "undefined". */ +const jsonText = (value: unknown): Effect.Effect => + encodeJson(value).pipe( + Effect.catch((error) => + value === undefined || typeof value === "function" || typeof value === "symbol" + ? Effect.succeed("undefined") + : Effect.die(error), + ), + ); + +export const mockBrowser: Layer.Layer = Layer.succeed(Browser, { + open: () => Effect.void, +}); export function mockStdin(isTTY: boolean, pipedInput?: string | Uint8Array): Layer.Layer { const pipedBytes = @@ -286,25 +314,25 @@ export function mockOutput( }; }), event: (event) => - Effect.sync(() => { + Effect.gen(function* () { events.push(event as OutputEvent); messages.push({ type: "info", message: event.type === "log-entry" ? `[${event.service}] ${event.line}` - : JSON.stringify(event), + : yield* jsonText(event), }); }), result: (data: unknown) => - Effect.sync(() => { + Effect.gen(function* () { if (opts.format === "json") { - rawChunks.push({ text: `${JSON.stringify(data)}\n`, stream: "stdout" }); + rawChunks.push({ text: `${yield* jsonText(data)}\n`, stream: "stdout" }); } else if (opts.format === "stream-json") { events.push({ type: "result", data, - timestamp: new Date().toISOString(), + timestamp: DateTime.formatIso(DateTime.nowUnsafe()), }); } }), @@ -534,7 +562,7 @@ export function mockTelemetryRuntime( return Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ - configDir: opts.configDir ?? join(defaultTestHomeDir, ".supabase"), + configDir: opts.configDir ?? defaultTestConfigDir, consent: opts.consent ?? "granted", deviceId: opts.deviceId ?? "test-device-id", sessionId: opts.sessionId ?? "test-session-id", @@ -549,34 +577,43 @@ export function mockTelemetryRuntime( ); } -function applyProcessEnv(values: Readonly>) { - const snapshot = { ...process.env }; +function applyProcessEnv( + env: NodeJS.ProcessEnv, + values: Readonly>, +) { + const snapshot = { ...env }; - for (const key of Object.keys(process.env)) { - delete process.env[key]; + for (const key of Object.keys(env)) { + delete env[key]; } for (const [key, value] of Object.entries(values)) { if (value !== undefined) { - process.env[key] = value; + env[key] = value; } } return snapshot; } +/* Keeps the ambient stack test-run label unless `values` pins its own. */ +function withAmbientTestRun( + env: NodeJS.ProcessEnv, + values: Readonly>, +): Readonly> { + const ambientTestRun = env[stackTestRunEnvVar]; + return stackTestRunEnvVar in values || ambientTestRun === undefined + ? values + : { [stackTestRunEnvVar]: ambientTestRun, ...values }; +} + export function processEnvLayer( values: Readonly> = {}, ): Layer.Layer { return ConfigProvider.layer( Effect.acquireRelease( Effect.sync(() => { - const ambientTestRun = process.env[stackTestRunEnvVar]; - const snapshot = applyProcessEnv( - stackTestRunEnvVar in values || ambientTestRun === undefined - ? values - : { [stackTestRunEnvVar]: ambientTestRun, ...values }, - ); + const snapshot = applyProcessEnv(process.env, withAmbientTestRun(process.env, values)); return { provider: ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true }), snapshot, @@ -584,7 +621,7 @@ export function processEnvLayer( }), ({ snapshot }) => Effect.sync(() => { - applyProcessEnv(snapshot); + applyProcessEnv(process.env, snapshot); }), ).pipe(Effect.map(({ provider }) => provider)), ); @@ -660,10 +697,10 @@ function mockProjectLinkState( ); } -export function emptyEnv() { +export function emptyEnv(env: Readonly> = {}) { const runtimeInfoLayer = mockRuntimeInfo(); const cliProjectContextLayer = mockCliProjectContext(); - const envLayer = processEnvLayer(); + const envLayer = processEnvLayer(env); const cliProjectHomeLayer = mockCliProjectHome(); const projectLinkStateLayer = mockProjectLinkState(); const analytics = mockAnalytics(); diff --git a/apps/cli/tests/helpers/npm-registry.ts b/apps/cli/tests/helpers/npm-registry.ts index b1dd6c0dd5..17597ac0f1 100644 --- a/apps/cli/tests/helpers/npm-registry.ts +++ b/apps/cli/tests/helpers/npm-registry.ts @@ -1,21 +1,30 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; import { - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - readlink, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; + Cause, + Clock, + Console, + Data, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schedule, + Schema, +} from "effect"; +import { FetchHttpClient, HttpClient } from "effect/http"; +import type { PlatformError } from "effect/PlatformError"; import process from "node:process"; -import { runCli, verifyExpectedShell } from "./release-shell.ts"; - -const root = path.resolve(import.meta.dir, "../../../.."); +import { + jsonQuote, + ReleaseStepError, + releaseStep, + runCliEffect, + verifyExpectedShellEffect, +} from "./release-shell.ts"; const PACKAGE_PATHS = { "cli-darwin-arm64": ["packages", "cli-darwin-arm64"], @@ -31,173 +40,304 @@ const PACKAGE_PATHS = { const ALL_PACKAGES = Object.keys(PACKAGE_PATHS) as Array; -export async function createTmpDir(prefix: string): Promise { - const dir = await mkdtemp(path.join(tmpdir(), prefix)); - return { - path: dir, - async [Symbol.asyncDispose]() { - await rm(dir, { recursive: true }); - }, - }; +/** The local Verdaccio registry never answered its ping. */ +class VerdaccioStartTimeout extends Data.TaggedError("VerdaccioStartTimeout")<{ + readonly message: string; +}> {} + +type RegistryServices = FileSystem.FileSystem | Path.Path | HttpClient.HttpClient; + +const registryLayer: Layer.Layer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), +); + +/** A resource release failed after its use had already failed; `suppressed` is the use's cause. */ +class DisposalSuppressedError extends Data.TaggedError("DisposalSuppressedError")<{ + readonly error: unknown; + readonly suppressed: Cause.Cause; +}> {} + +type RegistryFailure = + | ReleaseStepError + | PlatformError + | VerdaccioStartTimeout + | Schema.SchemaError + | DisposalSuppressedError; + +/* What a facade rejects with: the original error, or a SuppressedError as `await using` builds. */ +function thrownValue(error: unknown): unknown { + if (error instanceof ReleaseStepError) return error.cause; + if (error instanceof VerdaccioStartTimeout) return new Error(error.message); + if (error instanceof DisposalSuppressedError) { + return new SuppressedError( + thrownValue(error.error), + thrownValue(Cause.squash(error.suppressed)), + ); + } + if (Predicate.isTagged(error, "PlatformError") && Predicate.hasProperty(error, "cause")) { + return error.cause ?? error; + } + return error; } -async function startVerdaccio( - configPath: string, - port: number, -): Promise { - const url = `http://localhost:${port}`; - const proc = Bun.spawn(["bunx", "verdaccio", "--config", configPath], { - stdout: "ignore", - stderr: "ignore", +/* Promise facades reject with the original thrown value. */ +const runRegistry = (effect: Effect.Effect): Promise => + Effect.runPromise( + effect.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(registryLayer), + ), + ); + +/** Uses then releases a resource like `await using`: both failures surface when use and release fail. */ +const usingResource = ( + acquire: Effect.Effect, + use: (resource: A) => Effect.Effect, + release: (resource: A) => Effect.Effect, +) => + Effect.acquireUseRelease( + acquire, + (resource) => Effect.exit(use(resource)), + (resource, used) => + release(resource).pipe( + Effect.mapError((error) => + Exit.isSuccess(used) && Exit.isFailure(used.value) + ? new DisposalSuppressedError({ error, suppressed: used.value.cause }) + : error, + ), + ), + ).pipe(Effect.flatten); + +/* Reads UTF-8 text and keeps a leading byte order mark, like `readFile(path, "utf-8")`. */ +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); +const readUtf8 = (fs: FileSystem.FileSystem, pathname: string) => + fs.readFile(pathname).pipe(Effect.map((bytes) => utf8.decode(bytes))); + +const repoRoot = Effect.gen(function* () { + const path = yield* Path.Path; + return path.resolve(import.meta.dir, "../../../.."); +}); + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const decodePackageIdentity = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + name: Schema.optional(Schema.Unknown), + version: Schema.optional(Schema.Unknown), + }), + ), +); + +const decodePackageName = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ name: Schema.String })), +); + +/* Renders a caught platform failure as `${error}` renders its host error. */ +const foreignText = (error: PlatformError) => + error.cause instanceof Error ? error.cause.toString() : error.toString(); + +const isNotFound = (error: PlatformError) => Predicate.isTagged(error.reason, "NotFound"); + +export function createTmpDir(prefix: string): Promise { + return runRegistry( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix }); + return { + path: dir, + [Symbol.asyncDispose]: () => runRegistry(fs.remove(dir, { recursive: true })), + }; + }), + ); +} + +const spawnVerdaccio = (configPath: string) => + Effect.try({ + try: () => + Bun.spawn(["bunx", "verdaccio", "--config", configPath], { + stdout: "ignore", + stderr: "ignore", + }), + catch: (cause) => new ReleaseStepError({ cause }), }); +type PingState = "ready" | "pending" | "expired"; + +const awaitVerdaccio = Effect.fnUntraced(function* (url: string) { const timeout = 120_000; - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - try { - const res = await fetch(`${url}/-/ping`); - if (res.ok) return { url, [Symbol.asyncDispose]: async () => proc.kill() }; - } catch {} - await Bun.sleep(500); - } + const deadline = (yield* Clock.currentTimeMillis) + timeout; + /* Each round checks the deadline before it pings; an unready ping waits 500 ms for the next. */ + const ping = Effect.flatMap( + Clock.currentTimeMillis, + (now): Effect.Effect => + now < deadline + ? HttpClient.get(`${url}/-/ping`).pipe( + Effect.map((res): PingState => + res.status >= 200 && res.status < 300 ? "ready" : "pending", + ), + Effect.orElseSucceed((): PingState => "pending"), + ) + : Effect.succeed("expired"), + ); + const state = yield* ping.pipe( + Effect.repeat({ schedule: Schedule.spaced(500), while: (current) => current === "pending" }), + ); + if (state === "ready") return; - proc.kill(); - throw new Error(`Verdaccio failed to start within ${timeout / 1000}s`); -} + return yield* new VerdaccioStartTimeout({ + message: `Verdaccio failed to start within ${timeout / 1000}s`, + }); +}); -async function savePackageJsons() { +const savePackageJsons = Effect.fnUntraced(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const originals = new Map(); for (const pkg of ALL_PACKAGES) { const p = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); - originals.set(p, await readFile(p, "utf-8")); + originals.set(p, yield* readUtf8(fs, p)); } - return { - async [Symbol.asyncDispose]() { - for (const [p, content] of originals) { - await writeFile(p, content); - } - }, - }; -} + return originals; +}); + +const restorePackageJsons = Effect.fnUntraced(function* (originals: ReadonlyMap) { + const fs = yield* FileSystem.FileSystem; + for (const [p, content] of originals) { + yield* fs.writeFileString(p, content); + } +}); function modeOctal(mode: number): string { return `0${(mode & 0o777).toString(8).padStart(3, "0")}`; } -async function describePath(p: string): Promise { - try { - const ls = await lstat(p); - if (ls.isSymbolicLink()) { - const target = await readlink(p); - try { - const st = await stat(p); - return `symlink ${modeOctal(ls.mode)} -> ${target} (target ${modeOctal(st.mode)} ${st.size}B)`; - } catch (e) { - return `symlink ${modeOctal(ls.mode)} -> ${target} (target unreadable: ${e})`; - } - } - return `file ${modeOctal(ls.mode)} ${ls.size}B`; - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") return "MISSING"; - return `unstattable: ${e}`; +/* `FileSystem` has no `lstat`: a readable link marks a symlink, and the link's own mode is unknown. */ +const describePath = Effect.fnUntraced(function* (p: string) { + const fs = yield* FileSystem.FileSystem; + const linkTarget = yield* fs.readLink(p).pipe(Effect.option); + if (Option.isSome(linkTarget)) { + return yield* fs.stat(p).pipe( + Effect.map( + (st) => `symlink -> ${linkTarget.value} (target ${modeOctal(st.mode)} ${st.size}B)`, + ), + Effect.catch((e) => + Effect.succeed(`symlink -> ${linkTarget.value} (target unreadable: ${foreignText(e)})`), + ), + ); } -} + return yield* fs.stat(p).pipe( + Effect.map((st) => `file ${modeOctal(st.mode)} ${st.size}B`), + Effect.catch((e) => + Effect.succeed(isNotFound(e) ? "MISSING" : `unstattable: ${foreignText(e)}`), + ), + ); +}); -async function dumpInstalledTree(testDir: string, ext: string): Promise { - console.log("\nInstalled tree state:"); +const dumpInstalledTree = Effect.fnUntraced(function* (testDir: string, ext: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Console.log("\nInstalled tree state:"); const interesting = [ path.join(testDir, "node_modules", ".bin", `supabase${ext}`), path.join(testDir, "node_modules", "supabase", "package.json"), path.join(testDir, "node_modules", "supabase", "dist", "supabase.js"), ]; for (const p of interesting) { - console.log(` ${path.relative(testDir, p)}: ${await describePath(p)}`); + yield* Console.log(` ${path.relative(testDir, p)}: ${yield* describePath(p)}`); } const supabaseScope = path.join(testDir, "node_modules", "@supabase"); - let scopeEntries: string[] = []; - try { - scopeEntries = await readdir(supabaseScope); - } catch { - console.log(` node_modules/@supabase: MISSING`); + const scopeEntries = yield* fs.readDirectory(supabaseScope).pipe(Effect.option); + if (Option.isNone(scopeEntries)) { + yield* Console.log(` node_modules/@supabase: MISSING`); return; } - for (const entry of scopeEntries.sort()) { + for (const entry of scopeEntries.value.sort()) { const pkgDir = path.join(supabaseScope, entry); const pkgJsonPath = path.join(pkgDir, "package.json"); - try { - const pkgJson = JSON.parse(await readFile(pkgJsonPath, "utf-8")); - console.log(` node_modules/@supabase/${entry}: ${pkgJson.name}@${pkgJson.version}`); - } catch { - console.log(` node_modules/@supabase/${entry}: `); - } + yield* readUtf8(fs, pkgJsonPath).pipe( + Effect.flatMap(decodePackageIdentity), + Effect.flatMap((pkgJson) => + Console.log( + ` node_modules/@supabase/${entry}: ${String(pkgJson.name)}@${String(pkgJson.version)}`, + ), + ), + Effect.catch(() => + Console.log(` node_modules/@supabase/${entry}: `), + ), + ); const binDir = path.join(pkgDir, "bin"); - try { - const binEntries = await readdir(binDir); - for (const b of binEntries.sort()) { + const binEntries = yield* fs.readDirectory(binDir).pipe(Effect.option); + if (Option.isSome(binEntries)) { + for (const b of binEntries.value.sort()) { const bp = path.join(binDir, b); - console.log(` bin/${b}: ${await describePath(bp)}`); + yield* Console.log(` bin/${b}: ${yield* describePath(bp)}`); } - } catch {} + } } -} +}); -async function findPlatformBinary(testDir: string, ext: string): Promise { +const findPlatformBinary = Effect.fnUntraced(function* (testDir: string, ext: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const supabaseScope = path.join(testDir, "node_modules", "@supabase"); - let entries: string[] = []; - try { - entries = await readdir(supabaseScope); - } catch { + const entries = yield* fs.readDirectory(supabaseScope).pipe(Effect.option); + if (Option.isNone(entries)) { return null; } - for (const entry of entries) { + for (const entry of entries.value) { const candidate = path.join(supabaseScope, entry, "bin", `supabase${ext}`); - try { - await stat(candidate); - return candidate; - } catch {} + const found = yield* fs.stat(candidate).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (found) return candidate; } return null; -} +}); -async function inspectVerdaccioTarball(storageDir: string, pkg: string): Promise { +const inspectVerdaccioTarball = Effect.fnUntraced(function* (storageDir: string, pkg: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const pkgStorage = path.join(storageDir, "@supabase", pkg); - let files: string[] = []; - try { - files = await readdir(pkgStorage); - } catch { - console.log(` @supabase/${pkg}: `); + const files = yield* fs.readDirectory(pkgStorage).pipe(Effect.option); + if (Option.isNone(files)) { + yield* Console.log(` @supabase/${pkg}: `); return; } - const tarball = files.find((f) => f.endsWith(".tgz")); + const tarball = files.value.find((f) => f.endsWith(".tgz")); if (!tarball) { - console.log(` @supabase/${pkg}: `); + yield* Console.log(` @supabase/${pkg}: `); return; } const tarballPath = path.join(pkgStorage, tarball); - const listing = await $`tar -tvf ${tarballPath}`.text(); + const listing = yield* releaseStep(() => $`tar -tvf ${tarballPath}`.text()); // Surface only the bin entries — full listings drown the log. const binLines = listing .split("\n") .filter((line) => line.includes("/bin/")) .map((line) => ` ${line.trim()}`); if (binLines.length === 0) { - console.log(` @supabase/${pkg} (${tarball}): `); + yield* Console.log(` @supabase/${pkg} (${tarball}): `); return; } - console.log(` @supabase/${pkg} (${tarball}):`); - for (const line of binLines) console.log(line); -} + yield* Console.log(` @supabase/${pkg} (${tarball}):`); + for (const line of binLines) yield* Console.log(line); +}); -async function hasVerdaccioTarball(storageDir: string, pkg: string): Promise { +const hasVerdaccioTarball = Effect.fnUntraced(function* (storageDir: string, pkg: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const pkgStorage = path.join(storageDir, "@supabase", pkg); - try { - const files = await readdir(pkgStorage); - return files.some((f) => f.endsWith(".tgz")); - } catch { - return false; - } -} + return yield* fs.readDirectory(pkgStorage).pipe( + Effect.map((files) => files.some((f) => f.endsWith(".tgz"))), + Effect.orElseSucceed(() => false), + ); +}); function outputText(value: unknown): string { if (typeof value === "string") return value; @@ -217,23 +357,43 @@ export function describeError(e: unknown): string { return String(e); } -export async function runNpmTest(version: string, tag = "latest"): Promise { - await using _pkgJsons = await savePackageJsons(); - await using tmp = await createTmpDir("npm-smoke-"); +const runNpmTestEffect = Effect.fnUntraced(function* (version: string, tag: string) { + const root = yield* repoRoot; + const fs = yield* FileSystem.FileSystem; + return yield* usingResource( + savePackageJsons(root), + () => + usingResource( + fs.makeTempDirectory({ prefix: "npm-smoke-" }), + (tmpPath) => publishAndVerify(root, tmpPath, version, tag), + (tmpPath) => fs.remove(tmpPath, { recursive: true }), + ), + restorePackageJsons, + ); +}); + +const publishAndVerify = Effect.fnUntraced(function* ( + root: string, + tmpPath: string, + version: string, + tag: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const PORT = 4873; - const configPath = path.join(tmp.path, "config.yaml"); - const storageDir = path.join(tmp.path, "storage"); + const configPath = path.join(tmpPath, "config.yaml"); + const storageDir = path.join(tmpPath, "storage"); // Stores our published tarballs; the umbrella package's `@supabase/cli-*` // optional deps resolve here, while the public npm uplink stays available // for npm installer internals and other tooling. - await writeFile( + yield* fs.writeFileString( configPath, `storage: ${storageDir} auth: htpasswd: - file: ${path.join(tmp.path, "htpasswd")} + file: ${path.join(tmpPath, "htpasswd")} max_users: 100 uplinks: npmjs: @@ -257,81 +417,113 @@ listen: 0.0.0.0:${PORT} // pnpm publish delegates to npm internals, which only honor per-registry auth // in an .npmrc (not `NPM_CONFIG_TOKEN`); write one and point npm at it via // `npm_config_userconfig`. - const publishNpmrc = path.join(tmp.path, "publish.npmrc"); - await writeFile(publishNpmrc, `//localhost:${PORT}/:_authToken=dummy\n`); + const publishNpmrc = path.join(tmpPath, "publish.npmrc"); + yield* fs.writeFileString(publishNpmrc, `//localhost:${PORT}/:_authToken=dummy\n`); const publishEnv = { ...process.env, npm_config_userconfig: publishNpmrc }; // Sync versions across all packages - console.log(`Syncing versions to ${version}...`); - await $`bun ../../scripts/sync-versions.ts --version ${version}`.cwd(import.meta.dir).quiet(); + yield* Console.log(`Syncing versions to ${version}...`); + yield* releaseStep(() => + $`bun ../../scripts/sync-versions.ts --version ${version}`.cwd(import.meta.dir).quiet(), + ); - console.log("Starting local npm registry..."); - await using registry = await startVerdaccio(configPath, PORT); - console.log(`Registry ready at ${registry.url}\n`); + yield* Console.log("Starting local npm registry..."); + const registryUrl = `http://localhost:${PORT}`; + return yield* Effect.acquireUseRelease( + spawnVerdaccio(configPath), + () => + awaitVerdaccio(registryUrl).pipe( + Effect.andThen( + verifyWithRegistry(root, tmpPath, storageDir, registryUrl, PORT, tag, publishEnv), + ), + ), + (proc) => Effect.sync(() => proc.kill()), + ); +}); + +const verifyWithRegistry = Effect.fnUntraced(function* ( + root: string, + tmpPath: string, + storageDir: string, + registryUrl: string, + PORT: number, + tag: string, + publishEnv: Record, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Console.log(`Registry ready at ${registryUrl}\n`); const platformPackages = ALL_PACKAGES.filter((p) => p !== "cli"); - console.log("Publishing platform packages..."); + yield* Console.log("Publishing platform packages..."); for (const pkg of platformPackages) { const pkgDir = path.join(root, "packages", pkg); - try { - await $`pnpm publish --registry ${registry.url} --tag ${tag} --no-git-checks` + const published = yield* releaseStep(() => + $`pnpm publish --registry ${registryUrl} --tag ${tag} --no-git-checks` .cwd(pkgDir) - .env(publishEnv); - console.log(` @supabase/${pkg}`); - } catch (e) { - if (await hasVerdaccioTarball(storageDir, pkg)) { - console.log(` @supabase/${pkg} (already present in local registry)`); - continue; - } - throw e; - } + .env(publishEnv), + ).pipe( + Effect.as(true), + Effect.catch((e) => + Effect.flatMap(hasVerdaccioTarball(storageDir, pkg), (present) => + present ? Effect.succeed(false) : Effect.fail(e), + ), + ), + ); + yield* Console.log( + published ? ` @supabase/${pkg}` : ` @supabase/${pkg} (already present in local registry)`, + ); } // Confirms `publishConfig.executableFiles` made it into the published tarball. - console.log("\nVerdaccio tarball contents (bin entries only):"); + yield* Console.log("\nVerdaccio tarball contents (bin entries only):"); for (const pkg of platformPackages) { - await inspectVerdaccioTarball(storageDir, pkg); + yield* inspectVerdaccioTarball(storageDir, pkg); } const cliDir = path.join(root, "apps", "cli"); - console.log("\nBuilding umbrella package shim..."); - await $`pnpm build:shim`.cwd(cliDir).quiet(); + yield* Console.log("\nBuilding umbrella package shim..."); + yield* releaseStep(() => $`pnpm build:shim`.cwd(cliDir).quiet()); - const cliPkgJson = await readFile(path.join(cliDir, "package.json"), "utf-8").then(JSON.parse); + const cliPkgJson = yield* readUtf8(fs, path.join(cliDir, "package.json")).pipe( + Effect.flatMap(decodePackageName), + ); const umbrellaName: string = cliPkgJson.name; - console.log("Publishing umbrella package..."); - await $`pnpm publish --registry ${registry.url} --tag ${tag} --no-git-checks` - .cwd(cliDir) - .env(publishEnv); - console.log(` ${umbrellaName}\n`); + yield* Console.log("Publishing umbrella package..."); + yield* releaseStep(() => + $`pnpm publish --registry ${registryUrl} --tag ${tag} --no-git-checks` + .cwd(cliDir) + .env(publishEnv), + ); + yield* Console.log(` ${umbrellaName}\n`); - console.log("Verdaccio umbrella tarball contents:"); + yield* Console.log("Verdaccio umbrella tarball contents:"); const umbrellaStorage = path.join(storageDir, umbrellaName); - try { - const files = await readdir(umbrellaStorage); + yield* Effect.gen(function* () { + const files = yield* fs.readDirectory(umbrellaStorage); const tarball = files.find((f) => f.endsWith(".tgz")); if (tarball) { - const listing = await $`tar -tvf ${path.join(umbrellaStorage, tarball)}`.text(); + const listing = yield* releaseStep(() => + $`tar -tvf ${path.join(umbrellaStorage, tarball)}`.text(), + ); for (const line of listing.split("\n").filter(Boolean)) { - console.log(` ${line.trim()}`); + yield* Console.log(` ${line.trim()}`); } } else { - console.log(` `); + yield* Console.log(` `); } - } catch { - console.log(` `); - } + }).pipe(Effect.catch(() => Console.log(` `))); - const testDir = path.join(tmp.path, "test-project"); - await mkdir(testDir); - await writeFile( + const testDir = path.join(tmpPath, "test-project"); + yield* fs.makeDirectory(testDir); + yield* fs.writeFileString( path.join(testDir, "package.json"), - JSON.stringify({ name: "test-npm-smoke", version: "0.0.0", private: true }), + yield* encodeJson({ name: "test-npm-smoke", version: "0.0.0", private: true }), ); - await writeFile( + yield* fs.writeFileString( path.join(testDir, ".npmrc"), - `registry=${registry.url}\n//localhost:${PORT}/:_authToken=dummy\n`, + `registry=${registryUrl}\n//localhost:${PORT}/:_authToken=dummy\n`, ); // Pass --registry explicitly: pnpm-set `npm_config_*` env vars can override the @@ -339,45 +531,49 @@ listen: 0.0.0.0:${PORT} // the published CLI rather than the umbrella just packed. The CLI flag wins over // both env vars and .npmrc. const installSpec = tag === "latest" ? umbrellaName : `${umbrellaName}@${tag}`; - console.log(`\nInstalling ${installSpec}...`); - await $`npm install --registry ${registry.url} ${installSpec}`.cwd(testDir); + yield* Console.log(`\nInstalling ${installSpec}...`); + yield* releaseStep(() => $`npm install --registry ${registryUrl} ${installSpec}`.cwd(testDir)); - console.log("\nVerifying..."); + yield* Console.log("\nVerifying..."); const ext = process.platform === "win32" ? ".cmd" : ""; const binPath = path.join(testDir, "node_modules", ".bin", `supabase${ext}`); - await dumpInstalledTree(testDir, ext); + yield* dumpInstalledTree(testDir, ext); - const versionResult = await runCli(binPath, ["--version"]); + const versionResult = yield* runCliEffect(binPath, ["--version"]); const hasValidVersion = versionResult.exitCode === 0 && /^\d+\.\d+\.\d+/.test(versionResult.stdout); if (!hasValidVersion) { - console.log(`\n[verify] supabase --version FAILED:`); - console.log(` exit=${versionResult.exitCode}`); - console.log(` stdout=${JSON.stringify(versionResult.stdout)}`); - console.log(` stderr=${JSON.stringify(versionResult.stderr)}`); + yield* Console.log(`\n[verify] supabase --version FAILED:`); + yield* Console.log(` exit=${versionResult.exitCode}`); + yield* Console.log(` stdout=${yield* jsonQuote(versionResult.stdout)}`); + yield* Console.log(` stderr=${yield* jsonQuote(versionResult.stderr)}`); // Isolate "shim broken" from "platform binary broken". - const platformBin = await findPlatformBinary(testDir, ext); + const platformBin = yield* findPlatformBinary(testDir, ext); if (platformBin) { - console.log(`\n[verify] retrying via platform binary: ${platformBin}`); - const direct = await runCli(platformBin, ["--version"]); - console.log(` exit=${direct.exitCode}`); - console.log(` stdout=${JSON.stringify(direct.stdout)}`); - console.log(` stderr=${JSON.stringify(direct.stderr)}`); + yield* Console.log(`\n[verify] retrying via platform binary: ${platformBin}`); + const direct = yield* runCliEffect(platformBin, ["--version"]); + yield* Console.log(` exit=${direct.exitCode}`); + yield* Console.log(` stdout=${yield* jsonQuote(direct.stdout)}`); + yield* Console.log(` stderr=${yield* jsonQuote(direct.stderr)}`); } else { - console.log(`\n[verify] no platform binary found under node_modules/@supabase/*/bin/`); + yield* Console.log(`\n[verify] no platform binary found under node_modules/@supabase/*/bin/`); } } - const shellCheck = await verifyExpectedShell(binPath); + const shellCheck = yield* verifyExpectedShellEffect(binPath); const passed = hasValidVersion && shellCheck.passed; - console.log( - `\n${passed ? "PASS" : "FAIL"} — supabase --version exit=${versionResult.exitCode} stdout=${JSON.stringify(versionResult.stdout)}`, + yield* Console.log( + `\n${passed ? "PASS" : "FAIL"} — supabase --version exit=${versionResult.exitCode} stdout=${yield* jsonQuote(versionResult.stdout)}`, ); - console.log(shellCheck.detail); + yield* Console.log(shellCheck.detail); return passed; +}); + +export function runNpmTest(version: string, tag = "latest"): Promise { + return runRegistry(runNpmTestEffect(version, tag)); } diff --git a/apps/cli/tests/helpers/release-fixture.ts b/apps/cli/tests/helpers/release-fixture.ts index fe89bedeea..a7598fb289 100644 --- a/apps/cli/tests/helpers/release-fixture.ts +++ b/apps/cli/tests/helpers/release-fixture.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- the fixture owns a local native listener. import { createServer } from "node:http"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; const TARGETS = ["darwin-arm64", "linux-amd64", "linux-arm64"] as const; @@ -20,13 +20,15 @@ export interface ReleaseFixture { readonly requests: Array; } +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + const sha256 = (text: string | Uint8Array) => createHash("sha256").update(text).digest("hex"); /** Serves `/releases/…`, `/token` and `/v2/…` from OS-assigned loopback port until the scope closes. */ export const startReleaseFixture = Effect.acquireRelease( - Effect.callback<{ close: () => Promise; fixture: ReleaseFixture }>((resume) => { + Effect.gen(function* () { const routes = new Map([ - ["/token", { body: JSON.stringify({ token: "anon" }) }], + ["/token", { body: yield* encodeJson({ token: "anon" }) }], ]); const failing = new Map(); const requests: Array = []; @@ -44,22 +46,29 @@ export const startReleaseFixture = Effect.acquireRelease( response.setHeader(name, value); response.end(served?.body ?? ""); }); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address === null || typeof address === "string") return; - resume( - Effect.succeed({ - close: () => - new Promise((done) => { - server.close(() => done()); - server.closeAllConnections(); - }), - fixture: { origin: `http://127.0.0.1:${address.port}`, routes, failing, requests }, - }), - ); + const port = yield* Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.die(error)); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (address === null || typeof address === "string") return; + resume(Effect.succeed(address.port)); + }); }); + const fixture: ReleaseFixture = { + origin: `http://127.0.0.1:${port}`, + routes, + failing, + requests, + }; + return { server, fixture }; }), - ({ close }) => Effect.promise(close), + ({ server }) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + server.closeAllConnections(); + }), ).pipe(Effect.map(({ fixture }) => fixture)); /** The env that points the stack's release resolver at `fixture`. */ diff --git a/apps/cli/tests/helpers/release-shell.ts b/apps/cli/tests/helpers/release-shell.ts index a45798b3ad..14985c4481 100644 --- a/apps/cli/tests/helpers/release-shell.ts +++ b/apps/cli/tests/helpers/release-shell.ts @@ -1,41 +1,60 @@ -type ShellCheckResult = { - readonly passed: boolean; - readonly detail: string; -}; - -export interface CliRunResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} - -export async function runCli(binPath: string, args: Array): Promise { - const proc = Bun.spawn([binPath, ...args], { - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); +import { Data, Effect, Exit, Schema } from "effect"; + +export class ReleaseStepError extends Data.TaggedError("ReleaseStepError")<{ + readonly cause: unknown; +}> {} + +export const releaseStep = (run: () => PromiseLike) => + Effect.tryPromise({ try: run, catch: (cause) => new ReleaseStepError({ cause }) }); + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +/** `JSON.stringify` of captured output; encoding a string cannot fail. */ +export const jsonQuote = (text: string) => encodeJson(text).pipe(Effect.orDie); + +export const runCliEffect = Effect.fn("ReleaseShell.runCli")(function* ( + binPath: string, + args: Array, +) { + /* The scope owns the child: an interrupted run kills it and waits for it to exit. */ + const proc = yield* Effect.acquireRelease( + Effect.try({ + try: () => + Bun.spawn([binPath, ...args], { + stdout: "pipe", + stderr: "pipe", + }), + catch: (cause) => new ReleaseStepError({ cause }), + }), + (spawned, exit) => + Exit.hasInterrupts(exit) + ? Effect.sync(() => spawned.kill("SIGKILL")).pipe( + Effect.andThen(Effect.promise(() => spawned.exited)), + ) + : Effect.void, + ); + + const [stdout, stderr, exitCode] = yield* releaseStep(() => + Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]), + ); return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode, }; -} +}, Effect.scoped); -export async function verifyExpectedShell(binPath: string): Promise { - const result = await runCli(binPath, ["init", "--help"]); +export const verifyExpectedShellEffect = Effect.fn("ReleaseShell.verifyExpectedShell")(function* ( + binPath: string, +) { + const result = yield* runCliEffect(binPath, ["init", "--help"]); const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); const passed = result.exitCode === 0 && output.includes("init"); return { passed, detail: passed ? 'dispatch ok: "init --help" succeeded' - : `expected dispatch via "init --help", got exit=${result.exitCode}, stdout=${JSON.stringify(result.stdout)}, stderr=${JSON.stringify(result.stderr)}`, + : `expected dispatch via "init --help", got exit=${result.exitCode}, stdout=${yield* jsonQuote(result.stdout)}, stderr=${yield* jsonQuote(result.stderr)}`, }; -} +}); diff --git a/apps/cli/tests/helpers/stack-cleanup.integration.test.ts b/apps/cli/tests/helpers/stack-cleanup.integration.test.ts index 57430d8b08..99740cfddd 100644 --- a/apps/cli/tests/helpers/stack-cleanup.integration.test.ts +++ b/apps/cli/tests/helpers/stack-cleanup.integration.test.ts @@ -27,7 +27,7 @@ describe("test stack cleanup", () => { }); const result = yield* Effect.exit( Effect.acquireUseRelease( - Effect.succeed(undefined), + Effect.void, () => Effect.gen(function* () { yield* stack.services.create({ diff --git a/apps/cli/tests/helpers/stack-cleanup.ts b/apps/cli/tests/helpers/stack-cleanup.ts index c5326195a5..5a2aaead59 100644 --- a/apps/cli/tests/helpers/stack-cleanup.ts +++ b/apps/cli/tests/helpers/stack-cleanup.ts @@ -27,6 +27,6 @@ export const destroyTestStacks = ( const combined = failures .slice(1) .reduce((cause, exit) => Cause.combine(cause, exit.cause), first.cause); - yield* Effect.failCause(combined); + return yield* Effect.failCause(combined); } }).pipe(Effect.orDie); diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index 24854055a6..7b546bbea2 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -1,11 +1,27 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; -import path from "node:path"; +import { BunChildProcessSpawner, BunFileSystem, BunPath } from "@effect/platform-bun"; +import { + Cause, + Clock, + Console, + Data, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schedule, + Schema, + Stream, +} from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { runSupabase } from "./cli.ts"; type TempHome = { readonly dir: string; - readonly dispose: () => void; + readonly dispose: () => void | Promise; }; type StackProject = { @@ -21,9 +37,14 @@ interface StackRuntimeSnapshot { readonly trackedPids: ReadonlyArray; } +type MaybePromise = A | PromiseLike; + interface CleanupEnvironment { readonly stopStack: (projectDir: string, homeDir: string) => Promise<{ exitCode: number }>; - readonly captureSnapshot: (projectDir: string, homeDir?: string) => StackRuntimeSnapshot; + readonly captureSnapshot: ( + projectDir: string, + homeDir?: string, + ) => MaybePromise; readonly waitForCleanup: ( projectDir: string, homeDir: string | undefined, @@ -35,8 +56,8 @@ interface CleanupEnvironment { snapshot: StackRuntimeSnapshot, ) => Promise; readonly removeProjectWithDocker: (projectDir: string) => Promise; - readonly repairProjectPermissions: (projectDir: string) => void; - readonly describeProjectPermissions: (projectDir: string) => string; + readonly repairProjectPermissions: (projectDir: string) => MaybePromise; + readonly describeProjectPermissions: (projectDir: string) => MaybePromise; } interface StackE2eCleanupManager { @@ -50,14 +71,74 @@ interface StackE2eCleanupManager { reset(): void; } -function normalizeDir(dir: string): string { +type HostServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner; + +const hostLayer: Layer.Layer = BunChildProcessSpawner.layer.pipe( + Layer.provideMerge(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), +); + +/* Promise edge of the real environment: a host failure rejects with the original error. */ +const runHost = (effect: Effect.Effect): Promise => + Effect.runPromise( + effect.pipe( + Effect.catch((error) => Effect.die(error.cause ?? error)), + Effect.provide(hostLayer), + ), + ); + +/** A foreign cleanup callback or environment member rejected or threw. */ +class CleanupForeignFailure extends Data.TaggedError("CleanupForeignFailure")<{ + readonly cause: unknown; +}> {} + +/** An owned path could not be removed; `detail` is the reported reason. */ +class OwnedPathCleanupFailure extends Data.TaggedError("OwnedPathCleanupFailure")<{ + readonly detail: string; +}> {} + +const callForeign = (run: () => MaybePromise) => + Effect.tryPromise({ + try: () => Promise.resolve(run()), + catch: (cause) => new CleanupForeignFailure({ cause }), + }); + +const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +const foreignMessage = (error: PlatformError) => errorMessage(error.cause ?? error); + +const normalizeDir = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const resolved = path.resolve(dir); - try { - return realpathSync.native(resolved); - } catch { - return resolved; + return yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); +}); + +const pathExists = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(pathname).pipe(Effect.orElseSucceed(() => false)); +}); + +/* + * Directory entries never follow symlinks. `FileSystem` has no `lstat`, so `readLink` decides: only + * its "not a symlink" answer (`EINVAL`) lets `stat` judge the entry; a link, or an entry `readLink` + * is denied on (a mode-000 link on macOS), is never walked. + */ +const isDirectoryEntry = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + const notASymlink = yield* fs.readLink(pathname).pipe( + Effect.as(false), + Effect.catchTag("PlatformError", (error) => + Effect.succeed(Predicate.hasProperty(error.cause, "code") && error.cause.code === "EINVAL"), + ), + ); + if (!notASymlink) { + return false; } -} + return yield* fs.stat(pathname).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); +}); function isProcessAlive(pid: number): boolean { try { @@ -68,48 +149,121 @@ function isProcessAlive(pid: number): boolean { } } -function parsePsTable(): Array<{ +function killQuietly(pid: number): void { + try { + process.kill(pid, "SIGKILL"); + } catch {} +} + +function parsePsOutput(output: string): Array<{ readonly pid: number; readonly ppid: number; readonly command: string; }> { - try { - const output = execFileSync("ps", ["-Ao", "pid=,ppid=,command="], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - - return output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const match = line.match(/^(\d+)\s+(\d+)\s+(.*)$/); - if (match == null) { - return undefined; - } - return { - pid: Number.parseInt(match[1]!, 10), - ppid: Number.parseInt(match[2]!, 10), - command: match[3] ?? "", - }; - }) - .filter( - ( - entry, - ): entry is { readonly pid: number; readonly ppid: number; readonly command: string } => - entry != null, - ); - } catch { - return []; - } + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const match = line.match(/^(\d+)\s+(\d+)\s+(.*)$/); + if (match == null) { + return undefined; + } + return { + pid: Number.parseInt(match[1]!, 10), + ppid: Number.parseInt(match[2]!, 10), + command: match[3] ?? "", + }; + }) + .filter( + (entry): entry is { readonly pid: number; readonly ppid: number; readonly command: string } => + entry != null, + ); } +/** The output limit of a synchronous spawn: its default 1 MiB `maxBuffer`. */ +const SYNC_SPAWN_MAX_BUFFER = 1024 * 1024; + +/** The most a synchronous spawn reads from a pipe at once. */ +const SYNC_SPAWN_READ_SIZE = 64 * 1024; + +/** The output outgrew the synchronous-spawn buffer limit. */ +class OutputBufferExceeded extends Data.TaggedError("OutputBufferExceeded")<{ + readonly bytes: Uint8Array; +}> {} + +const concatBytes = (chunks: ReadonlyArray, size: number) => { + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +}; + +/** + * Buffers a stream the way a synchronous spawn does: once the total passes the limit it stops + * with what was read so far, the chunk that crossed the limit included. + */ +const collectBounded = (stream: Stream.Stream) => + Effect.suspend(() => { + const chunks: Array = []; + let size = 0; + return Stream.runForEach(stream, (chunk) => + Effect.suspend(() => { + chunks.push(chunk); + size += chunk.byteLength; + return size > SYNC_SPAWN_MAX_BUFFER + ? Effect.fail(new OutputBufferExceeded({ bytes: concatBytes(chunks, size) })) + : Effect.void; + }), + ).pipe(Effect.map(() => concatBytes(chunks, size))); + }); + +/* Recovers every failure a spawn can raise, its throws included; interruption passes through. */ +const recoverUnlessInterrupted = ( + effect: Effect.Effect, + recover: (failure: unknown) => B, +): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(Cause.fromReasons(cause.reasons.filter(Cause.isInterruptReason))) + : Effect.succeed(recover(Cause.squash(cause))), + ), + ); + +const parsePsTable = recoverUnlessInterrupted( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make("ps", ["-Ao", "pid=,ppid=,command="], { + /* A plain child, not a process group: no process-group signals. */ + detached: false, + stdin: "ignore", + stderr: "ignore", + }), + ); + const [bytes, exitCode] = yield* Effect.all( + [collectBounded(handle.stdout), handle.exitCode], + { concurrency: "unbounded" }, + ); + return exitCode === 0 ? Option.some(utf8.decode(bytes)) : Option.none(); + }), + ); + return Option.match(output, { onNone: () => [], onSome: parsePsOutput }); + }), + () => [], +); + function descendantPids( - rootPids: ReadonlyArray, + rootPids: ReadonlyArray, table: ReadonlyArray<{ readonly pid: number; readonly ppid: number }>, -): Array { - const visited = new Set(); +): Array { + const visited = new Set(); const pending = [...rootPids]; while (pending.length > 0) { @@ -128,25 +282,45 @@ function descendantPids( return [...visited]; } -function readDocumentPid(documentFile: string): number | undefined { - try { - const parsed = JSON.parse(readFileSync(documentFile, "utf8")) as { - readonly runtime?: { readonly pid?: number }; - }; - return parsed.runtime?.pid; - } catch { - return undefined; - } -} - -function cleanupErrorDetail( - pathname: string, - error: unknown, - resource = "temp stack project", -): string { - return `Failed to remove ${resource} ${pathname}: ${ - error instanceof Error ? error.message : String(error) - }`; +const decodeStackWorkspace = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + workspace: Schema.optional(Schema.Struct({ path: Schema.optional(Schema.String) })), + }), + ), +); + +const decodeJsonDocument = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +/* Reads UTF-8 text and keeps a leading byte order mark, like `readFileSync(path, "utf8")`. */ +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); +const readUtf8 = (fs: FileSystem.FileSystem, pathname: string) => + fs.readFile(pathname).pipe(Effect.map((bytes) => utf8.decode(bytes))); + +const readWorkspacePath = Effect.fnUntraced(function* (documentFile: string) { + const fs = yield* FileSystem.FileSystem; + return yield* readUtf8(fs, documentFile).pipe( + Effect.flatMap(decodeStackWorkspace), + Effect.map((document) => document.workspace?.path), + Effect.orElseSucceed(() => undefined), + ); +}); + +const readDocumentPid = Effect.fnUntraced(function* (documentFile: string) { + const fs = yield* FileSystem.FileSystem; + return yield* readUtf8(fs, documentFile).pipe( + Effect.flatMap(decodeJsonDocument), + /* `runtime.pid` as written, whatever its JSON type, like a plain property read. */ + Effect.map((document) => { + const runtime = Predicate.hasProperty(document, "runtime") ? document.runtime : undefined; + return Predicate.hasProperty(runtime, "pid") ? runtime.pid : undefined; + }), + Effect.orElseSucceed(() => undefined), + ); +}); + +function cleanupErrorDetail(pathname: string, detail: string, resource = "temp stack project") { + return `Failed to remove ${resource} ${pathname}: ${detail}`; } // Walks the `cause` chain so a typed wrapper classifies by the errno it carries. @@ -166,169 +340,228 @@ function formatMode(mode: number): string { return `0${(mode & 0o777).toString(8)}`; } -function describePath(pathname: string): string { - try { - const stats = statSync(pathname); - return `${pathname} uid=${stats.uid} gid=${stats.gid} mode=${formatMode(stats.mode)}`; - } catch (error) { - return `${pathname} ${error instanceof Error ? error.message : String(error)}`; - } -} - -function describeProjectPermissions(projectDir: string): string { +const describePath = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(pathname).pipe( + Effect.map( + (stats) => + `${pathname} uid=${Option.getOrUndefined(stats.uid)} gid=${Option.getOrUndefined(stats.gid)} mode=${formatMode(stats.mode)}`, + ), + Effect.catch((error) => Effect.succeed(`${pathname} ${foreignMessage(error)}`)), + ); +}); + +const describeProjectPermissions = Effect.fnUntraced(function* (projectDir: string) { + const path = yield* Path.Path; const details = [ "Permission diagnostics:", - describePath(projectDir), - describePath(path.join(projectDir, ".supabase")), + yield* describePath(projectDir), + yield* describePath(path.join(projectDir, ".supabase")), ]; return details.join("\n"); -} - -function repairProjectPermissions(projectDir: string): void { - try { - execFileSync("chmod", ["-R", "u+rwX", projectDir], { - stdio: "ignore", - timeout: 5_000, - }); - } catch {} -} - -async function removeProjectWithDocker(projectDir: string): Promise { - const parentDir = path.dirname(projectDir); - const projectName = path.basename(projectDir); - - let dockerErr: string | undefined; - try { - execFileSync( - "docker", - [ - "run", - "--rm", - "--user", - "0:0", - "-v", - `${parentDir}:/parent`, - "-e", - `TARGET_NAME=${projectName}`, - "--entrypoint", - "sh", - "public.ecr.aws/docker/library/busybox:1.36", - "-c", - 'cd /parent && rm -rf -- "$TARGET_NAME"', - ], - { stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 }, - ); - } catch (error) { - const rawStderr = - error != null && typeof error === "object" && "stderr" in error - ? (error as { stderr: unknown }).stderr - : ""; - const stderr = ( - typeof rawStderr === "string" - ? rawStderr - : rawStderr instanceof Uint8Array - ? new TextDecoder().decode(rawStderr) - : "" - ).trim(); - dockerErr = stderr || (error instanceof Error ? error.message : String(error)); - } +}); + +const repairProjectPermissions = Effect.fnUntraced(function* (projectDir: string) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* spawner + .exitCode( + ChildProcess.make("chmod", ["-R", "u+rwX", projectDir], { + detached: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe(Effect.timeoutOption(5_000), (run) => recoverUnlessInterrupted(run, () => undefined)); +}); + +const removeProjectWithDocker = Effect.fnUntraced(function* (projectDir: string) { + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const args = [ + "run", + "--rm", + "--user", + "0:0", + "-v", + `${path.dirname(projectDir)}:/parent`, + "-e", + `TARGET_NAME=${path.basename(projectDir)}`, + "--entrypoint", + "sh", + "public.ecr.aws/docker/library/busybox:1.36", + "-c", + 'cd /parent && rm -rf -- "$TARGET_NAME"', + ]; - const removed = !existsSync(projectDir); + /* The stderr read so far, kept across a timeout or an outgrown buffer for the report. */ + const stderrChunks: Array = []; + let stderrSize = 0; + const failure = yield* recoverUnlessInterrupted( + Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make("docker", args, { + /* A plain child, not a process group: its descendants are left alone. */ + detached: false, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }), + ); + const [, exitCode] = yield* Effect.all( + [ + Stream.runForEach(handle.stderr, (chunk) => + Effect.suspend(() => { + stderrChunks.push(chunk); + stderrSize += chunk.byteLength; + return stderrSize > SYNC_SPAWN_MAX_BUFFER + ? Effect.fail(new OutputBufferExceeded({ bytes: new Uint8Array() })) + : Effect.void; + }), + ), + /* A signal death fails like a nonzero exit. */ + handle.exitCode.pipe(Effect.orElseSucceed(() => -1)), + ], + { concurrency: "unbounded" }, + ); + return exitCode; + }), + ).pipe( + Effect.timeoutOption(30_000), + Effect.map( + Option.match({ + onNone: () => "spawnSync docker ETIMEDOUT", + onSome: (exitCode) => + exitCode === 0 ? undefined : `Command failed: docker ${args.join(" ")}`, + }), + ), + Effect.catchTag("OutputBufferExceeded", () => + Effect.succeed( + "spawnSync docker ENOBUFS (stdout or stderr buffer reached maxBuffer size limit)", + ), + ), + ), + (thrown) => + errorMessage( + Predicate.isTagged(thrown, "PlatformError") && Predicate.hasProperty(thrown, "cause") + ? thrown.cause + : thrown, + ), + ); + /* A synchronous spawn reads in 64 KiB pieces, so an outgrown buffer keeps through the piece that + crossed the limit. */ + const keptSize = Math.min(stderrSize, SYNC_SPAWN_MAX_BUFFER + SYNC_SPAWN_READ_SIZE); + const stderr = new TextDecoder().decode( + concatBytes(stderrChunks, stderrSize).subarray(0, keptSize), + ); + const dockerErr = failure === undefined ? undefined : stderr.trim() || failure; + + const removed = !(yield* pathExists(projectDir)); if (!removed && dockerErr) { - console.warn(`[stack-e2e-cleanup] docker fallback for ${projectDir} failed: ${dockerErr}`); + yield* Console.warn( + `[stack-e2e-cleanup] docker fallback for ${projectDir} failed: ${dockerErr}`, + ); } return removed; -} +}); -async function cleanupOwnedPath( - pathname: string, - cleanup: () => void | Promise, - environment: Pick< - CleanupEnvironment, - "removeProjectWithDocker" | "repairProjectPermissions" | "describeProjectPermissions" - >, -): Promise { - try { - await cleanup(); - } catch (error) { - if (!isPermissionError(error)) { - throw error; - } +type PermissionFallback = Pick< + CleanupEnvironment, + "removeProjectWithDocker" | "repairProjectPermissions" | "describeProjectPermissions" +>; - const removedByDocker = await environment.removeProjectWithDocker(pathname); - if (removedByDocker) { - return; - } +const reportedAs = (run: () => MaybePromise) => + callForeign(run).pipe( + Effect.mapError( + (failure) => new OwnedPathCleanupFailure({ detail: errorMessage(failure.cause) }), + ), + ); - environment.repairProjectPermissions(pathname); - try { - await cleanup(); - } catch (retryError) { - throw new Error( - `${retryError instanceof Error ? retryError.message : String(retryError)}\n${environment.describeProjectPermissions( - pathname, - )}`, - ); - } +const recoverPermissionBlockedPath = Effect.fnUntraced(function* ( + pathname: string, + cleanup: () => void | Promise, + environment: PermissionFallback, +) { + const removedByDocker = yield* reportedAs(() => environment.removeProjectWithDocker(pathname)); + if (removedByDocker) { + return; } -} - -async function cleanupProject( - project: StackProject, - environment: Pick< - CleanupEnvironment, - "removeProjectWithDocker" | "repairProjectPermissions" | "describeProjectPermissions" - >, -): Promise { - await cleanupOwnedPath(project.dir, project.cleanup, environment); -} -function captureSnapshot(projectDir: string, homeDir?: string): StackRuntimeSnapshot { - const normalized = normalizeDir(projectDir); + yield* reportedAs(() => environment.repairProjectPermissions(pathname)); + yield* callForeign(cleanup).pipe( + Effect.catch((retryFailure) => + reportedAs(() => environment.describeProjectPermissions(pathname)).pipe( + Effect.flatMap((diagnostics) => + Effect.fail( + new OwnedPathCleanupFailure({ + detail: `${errorMessage(retryFailure.cause)}\n${diagnostics}`, + }), + ), + ), + ), + ), + ); +}); + +const cleanupOwnedPath = ( + pathname: string, + cleanup: () => void | Promise, + environment: PermissionFallback, +) => + callForeign(cleanup).pipe( + Effect.catch((failure) => + isPermissionError(failure.cause) + ? recoverPermissionBlockedPath(pathname, cleanup, environment) + : Effect.fail(new OwnedPathCleanupFailure({ detail: errorMessage(failure.cause) })), + ), + ); + +const captureSnapshot = Effect.fnUntraced(function* (projectDir: string, homeDir?: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const normalized = yield* normalizeDir(projectDir); const managedStacksRoot = - homeDir === undefined ? undefined : path.join(normalizeDir(homeDir), "stacks"); - if (managedStacksRoot === undefined || !existsSync(managedStacksRoot)) { + homeDir === undefined ? undefined : path.join(yield* normalizeDir(homeDir), "stacks"); + if (managedStacksRoot === undefined || !(yield* pathExists(managedStacksRoot))) { return { managedStacksRootExists: false, documentFiles: [], stackDirs: [], trackedPids: [], - }; + } satisfies StackRuntimeSnapshot; } const stackDirs: Array = []; const documentFiles: Array = []; - for (const entry of readdirSync(managedStacksRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) { + for (const entry of yield* fs.readDirectory(managedStacksRoot)) { + const stackDir = path.join(managedStacksRoot, entry); + if (!(yield* isDirectoryEntry(stackDir))) { continue; } - const stackDir = path.join(managedStacksRoot, entry.name); const documentFile = path.join(stackDir, "stack.json"); - if (!existsSync(documentFile)) { + if (!(yield* pathExists(documentFile))) { continue; } - try { - const document = JSON.parse(readFileSync(documentFile, "utf8")) as { - readonly workspace?: { readonly path?: string }; - }; - if ( - document.workspace?.path === undefined || - normalizeDir(document.workspace.path) !== normalized - ) { - continue; - } - } catch { + const workspacePath = yield* readWorkspacePath(documentFile); + if (workspacePath === undefined || (yield* normalizeDir(workspacePath)) !== normalized) { continue; } stackDirs.push(stackDir); documentFiles.push(documentFile); } - const rootPids = documentFiles - .map(readDocumentPid) - .filter((pid): pid is number => pid != null && pid > 0); - const table = parsePsTable(); + /* A pid is kept as written when it compares above zero; only a numeric one matches a `ps` row, + and process signals coerce the others to numbers. */ + const rootPids: Array = []; + for (const documentFile of documentFiles) { + const pid = yield* readDocumentPid(documentFile); + if (pid != null && Number(pid) > 0) { + rootPids.push(pid); + } + } + const table = yield* parsePsTable; const descendants = descendantPids(rootPids, table); const commandPids = table.filter((row) => row.command.includes(normalized)).map((row) => row.pid); @@ -336,165 +569,236 @@ function captureSnapshot(projectDir: string, homeDir?: string): StackRuntimeSnap managedStacksRootExists: stackDirs.length > 0, documentFiles, stackDirs, - trackedPids: [...new Set([...descendants, ...commandPids])].sort((left, right) => left - right), - }; -} + trackedPids: [...new Set([...descendants, ...commandPids])] + .sort((left, right) => Number(left) - Number(right)) + .map(Number), + } satisfies StackRuntimeSnapshot; +}); -async function waitForCleanup( - projectDir: string, - homeDir: string | undefined, - snapshot: StackRuntimeSnapshot, -): Promise { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - const nextSnapshot = captureSnapshot(projectDir, homeDir); - const filesGone = nextSnapshot.documentFiles.length === 0; - const pidsGone = snapshot.trackedPids.every((pid) => !isProcessAlive(pid)); - if (filesGone && pidsGone) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - - return false; -} +type CleanupRound = "clean" | "pending" | "expired"; -async function forceCleanup( +const waitForCleanup = Effect.fnUntraced(function* ( projectDir: string, homeDir: string | undefined, snapshot: StackRuntimeSnapshot, -): Promise { +) { + const deadline = (yield* Clock.currentTimeMillis) + 15_000; + /* Each round checks the deadline before it looks; a round that still sees the stack waits 200 ms. */ + const round = Effect.flatMap(Clock.currentTimeMillis, (now) => + now < deadline + ? Effect.map(captureSnapshot(projectDir, homeDir), (nextSnapshot): CleanupRound => { + const filesGone = nextSnapshot.documentFiles.length === 0; + const pidsGone = snapshot.trackedPids.every((pid) => !isProcessAlive(pid)); + return filesGone && pidsGone ? "clean" : "pending"; + }) + : Effect.succeed("expired"), + ); + const state = yield* round.pipe( + Effect.repeat({ schedule: Schedule.spaced(200), while: (current) => current === "pending" }), + ); + return state === "clean"; +}); + +const forceCleanup = Effect.fnUntraced(function* (snapshot: StackRuntimeSnapshot) { + const fs = yield* FileSystem.FileSystem; for (const pid of snapshot.trackedPids) { - try { - process.kill(pid, "SIGKILL"); - } catch {} + killQuietly(pid); } for (const stackDir of snapshot.stackDirs) { - try { - rmSync(stackDir, { recursive: true, force: true }); - } catch {} + yield* fs.remove(stackDir, { recursive: true, force: true }).pipe(Effect.ignore); } -} +}); function createRealEnvironment(): CleanupEnvironment { return { - stopStack: async (projectDir, homeDir) => { - const result = await runSupabase(["stack", "destroy", "--yes"], { + stopStack: (projectDir, homeDir) => + runSupabase(["stack", "destroy", "--yes"], { cwd: projectDir, home: homeDir, env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, exitTimeoutMs: 15_000, - }); - return { exitCode: result.exitCode }; - }, - captureSnapshot, - waitForCleanup, - forceCleanup, - removeProjectWithDocker, - repairProjectPermissions, - describeProjectPermissions, + }).then((result) => ({ exitCode: result.exitCode })), + captureSnapshot: (projectDir, homeDir) => runHost(captureSnapshot(projectDir, homeDir)), + waitForCleanup: (projectDir, homeDir, snapshot) => + runHost(waitForCleanup(projectDir, homeDir, snapshot)), + forceCleanup: (_projectDir, _homeDir, snapshot) => runHost(forceCleanup(snapshot)), + removeProjectWithDocker: (projectDir) => runHost(removeProjectWithDocker(projectDir)), + repairProjectPermissions: (projectDir) => runHost(repairProjectPermissions(projectDir)), + describeProjectPermissions: (projectDir) => runHost(describeProjectPermissions(projectDir)), }; } +type Registration = + | { + readonly kind: "home"; + readonly dir: Fiber.Fiber; + readonly dispose: () => void | Promise; + } + | { + readonly kind: "project"; + readonly dir: Fiber.Fiber; + readonly cleanup: () => Promise; + } + | { + readonly kind: "association"; + readonly projectDir: Fiber.Fiber; + readonly homeDir: Fiber.Fiber; + }; + +/* Registration stays synchronous, so canonicalization starts now, while the owned path exists. */ +const startNormalizeDir = (dir: string): Fiber.Fiber => + Effect.runFork(normalizeDir(dir).pipe(Effect.provide(hostLayer))); + +/* Replays registrations in order into maps keyed by canonical dir. */ +const resolveRegistrations = Effect.fnUntraced(function* ( + registrations: ReadonlyArray, +) { + const homes = new Map(); + const projects = new Map(); + for (const registration of registrations) { + switch (registration.kind) { + case "home": { + const dir = yield* Fiber.join(registration.dir); + homes.set(dir, { dir, dispose: registration.dispose }); + break; + } + case "project": { + const dir = yield* Fiber.join(registration.dir); + projects.set(dir, { dir, cleanup: registration.cleanup }); + break; + } + case "association": { + const project = projects.get(yield* Fiber.join(registration.projectDir)); + if (project !== undefined) { + project.homeDir = yield* Fiber.join(registration.homeDir); + } + break; + } + } + } + return { pendingProjects: [...projects.values()], pendingHomes: homes }; +}); + +const drainRegistrations = Effect.fnUntraced(function* ( + environment: CleanupEnvironment, + registrations: ReadonlyArray, +) { + const { pendingProjects, pendingHomes } = yield* resolveRegistrations(registrations); + + const failures: Array = []; + const associatedHomes = new Map(); + + for (const project of pendingProjects) { + const homeDir = project.homeDir; + const home = homeDir === undefined ? undefined : pendingHomes.get(homeDir); + if (home !== undefined && homeDir !== undefined) { + associatedHomes.set(homeDir, home); + } + const snapshot = yield* callForeign(() => + environment.captureSnapshot(project.dir, project.homeDir), + ); + const hasRuntimeArtifacts = + snapshot.documentFiles.length > 0 || snapshot.trackedPids.some((pid) => isProcessAlive(pid)); + const hasStackPersistence = snapshot.managedStacksRootExists || snapshot.stackDirs.length > 0; + let stopExitCode: number | undefined; + + const stackHomeDir = project.homeDir; + if (hasStackPersistence && stackHomeDir !== undefined) { + stopExitCode = yield* callForeign(() => + environment.stopStack(project.dir, stackHomeDir), + ).pipe( + Effect.map((stopResult) => stopResult.exitCode), + Effect.orElseSucceed(() => 1), + ); + } + + if (hasRuntimeArtifacts) { + const cleaned = yield* callForeign(() => + environment.waitForCleanup(project.dir, project.homeDir, snapshot), + ); + if (!cleaned) { + yield* callForeign(() => environment.forceCleanup(project.dir, project.homeDir, snapshot)); + failures.push( + stopExitCode != null && stopExitCode !== 0 + ? `Centralized e2e cleanup detected leaked stack resources for ${project.dir} after stop exited ${stopExitCode}.` + : `Centralized e2e cleanup detected leaked stack resources for ${project.dir}.`, + ); + } + } + + yield* cleanupOwnedPath(project.dir, project.cleanup, environment).pipe( + Effect.catch((failure) => + Effect.sync(() => { + failures.push(cleanupErrorDetail(project.dir, failure.detail)); + }), + ), + ); + } + + for (const home of associatedHomes.values()) { + yield* cleanupOwnedPath(home.dir, home.dispose, environment).pipe( + Effect.catch((failure) => + Effect.sync(() => { + failures.push(cleanupErrorDetail(home.dir, failure.detail, "temp home")); + }), + ), + ); + } + + // Cleanup failures are best-effort and don't fail the test: assertions already + // ran, CI runners are ephemeral, and cases like `functions dev`'s root-owned + // docker files can make cleanup itself impossible. + if (failures.length > 0) { + yield* Console.warn( + `[stack-e2e-cleanup] ${failures.length} resource(s) could not be cleaned up:\n${failures.join("\n")}`, + ); + } +}); + export function createStackE2eCleanupManager( environment: CleanupEnvironment = createRealEnvironment(), ): StackE2eCleanupManager { - const homes = new Map(); - const projects = new Map(); + let registrations: Array = []; return { registerHome(home) { - const dir = normalizeDir(home.dir); - homes.set(dir, { - dir, + registrations.push({ + kind: "home", + dir: startNormalizeDir(home.dir), dispose: () => home.dispose(), }); }, registerStackProject(project) { - const dir = normalizeDir(project.dir); - projects.set(dir, { - dir, + registrations.push({ + kind: "project", + dir: startNormalizeDir(project.dir), cleanup: () => project.cleanup(), }); }, associateHome(projectDir, homeDir) { - const project = projects.get(normalizeDir(projectDir)); - if (project !== undefined) { - project.homeDir = normalizeDir(homeDir); + /* With no stack project registered yet there is nothing to associate, so nothing is kept. */ + if (!registrations.some((registration) => registration.kind === "project")) { + return; } + registrations.push({ + kind: "association", + projectDir: startNormalizeDir(projectDir), + homeDir: startNormalizeDir(homeDir), + }); }, - async drain() { - const pendingProjects = [...projects.values()]; - const pendingHomes = new Map(homes); - projects.clear(); - homes.clear(); - - const failures: Array = []; - const associatedHomes = new Map(); - - for (const project of pendingProjects) { - const homeDir = project.homeDir; - const home = homeDir === undefined ? undefined : pendingHomes.get(homeDir); - if (home !== undefined && homeDir !== undefined) { - associatedHomes.set(homeDir, home); - } - const snapshot = environment.captureSnapshot(project.dir, project.homeDir); - const hasRuntimeArtifacts = - snapshot.documentFiles.length > 0 || - snapshot.trackedPids.some((pid) => isProcessAlive(pid)); - const hasStackPersistence = - snapshot.managedStacksRootExists || snapshot.stackDirs.length > 0; - let stopExitCode: number | undefined; - - if (hasStackPersistence && project.homeDir !== undefined) { - try { - const stopResult = await environment.stopStack(project.dir, project.homeDir); - stopExitCode = stopResult.exitCode; - } catch { - stopExitCode = 1; - } - } - - if (hasRuntimeArtifacts) { - const cleaned = await environment.waitForCleanup(project.dir, project.homeDir, snapshot); - if (!cleaned) { - await environment.forceCleanup(project.dir, project.homeDir, snapshot); - failures.push( - stopExitCode != null && stopExitCode !== 0 - ? `Centralized e2e cleanup detected leaked stack resources for ${project.dir} after stop exited ${stopExitCode}.` - : `Centralized e2e cleanup detected leaked stack resources for ${project.dir}.`, - ); - } - } - - try { - await cleanupProject(project, environment); - } catch (error) { - failures.push(cleanupErrorDetail(project.dir, error)); - } - } - - for (const home of associatedHomes.values()) { - try { - await cleanupOwnedPath(home.dir, home.dispose, environment); - } catch (error) { - failures.push(cleanupErrorDetail(home.dir, error, "temp home")); - } - } - - // Cleanup failures are best-effort and don't fail the test: assertions already - // ran, CI runners are ephemeral, and cases like `functions dev`'s root-owned - // docker files can make cleanup itself impossible. - if (failures.length > 0) { - console.warn( - `[stack-e2e-cleanup] ${failures.length} resource(s) could not be cleaned up:\n${failures.join("\n")}`, - ); - } + drain() { + const pending = registrations; + registrations = []; + /* A foreign rejection rejects the drain with the original value. */ + return Effect.runPromise( + drainRegistrations(environment, pending).pipe( + Effect.catch((failure) => Effect.die(failure.cause)), + ), + ); }, reset() { - homes.clear(); - projects.clear(); + registrations = []; }, }; } @@ -503,12 +807,9 @@ const manager = createStackE2eCleanupManager(); export function registerTempHome(home: { readonly dir: string; - readonly [Symbol.dispose]: () => void; + readonly dispose: () => Promise; }): void { - manager.registerHome({ - dir: home.dir, - dispose: () => home[Symbol.dispose](), - }); + manager.registerHome(home); } export function registerTempStackProject(project: { @@ -525,6 +826,6 @@ export function noteStackCliProjectHome(projectDir: string | undefined, homeDir: manager.associateHome(projectDir, homeDir); } -export async function cleanupRegisteredStackProjects(): Promise { - await manager.drain(); +export function cleanupRegisteredStackProjects(): Promise { + return manager.drain(); } diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index e5a81fc817..c24e933cec 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -1,8 +1,5 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; -import { ConfigProvider, Effect, Layer, Option, Redacted, Stream } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Redacted, Stream } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import type { @@ -316,15 +313,28 @@ export function buildStorageStackApi( * Management API factory for api-keys. */ export function setupStorage(workdir: string, opts: SetupStorageOptions) { - if (opts.toml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); - } - for (const [rel, content] of Object.entries(opts.files ?? {})) { - const abs = join(workdir, rel); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content); - } + /* The fixture files are written once per setup, on first use: later acquisitions of the + returned layers never rewrite what a test changed since. */ + const writeWorkdirFiles = Effect.runSync( + Effect.cached( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (opts.toml !== undefined) { + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = path.join(workdir, rel); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }); + yield* fs.writeFileString(abs, content); + } + }), + ), + ); + const workdirFiles = Layer.effectDiscard(writeWorkdirFiles).pipe( + Layer.provide(BunServices.layer), + ); const out = mockOutput({ format: opts.format ?? "text", @@ -482,7 +492,7 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true }), ), ]), - ); + ).pipe(Layer.provide(workdirFiles)); return { layer, diff --git a/apps/cli/tests/helpers/target-default-warning.ts b/apps/cli/tests/helpers/target-default-warning.ts index 54f052ce78..322771d746 100644 --- a/apps/cli/tests/helpers/target-default-warning.ts +++ b/apps/cli/tests/helpers/target-default-warning.ts @@ -1,8 +1,6 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; import { expect } from "@effect/vitest"; -import { Cause, Effect, Exit } from "effect"; +import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { stripAnsi } from "./ansi.ts"; import { VALID_REF } from "./command-mocks.ts"; @@ -24,15 +22,18 @@ export const describeFailure = (exit: Exit.Exit): string | und }; /** Writes (`linked`) or removes the `supabase link` ref file in `workdir`. */ -export const setLinkedRefFile = (workdir: string, linked: boolean, ref = VALID_REF) => { - const tempDir = join(workdir, "supabase", ".temp"); - if (linked) { - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), ref); - } else { - rmSync(join(tempDir, "project-ref"), { force: true }); - } -}; +export const setLinkedRefFile = (workdir: string, linked: boolean, ref = VALID_REF) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(workdir, "supabase", ".temp"); + if (linked) { + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), ref); + } else { + yield* fs.remove(path.join(tempDir, "project-ref"), { force: true }); + } + }).pipe(Effect.provide(BunServices.layer)); /** * A bare run warns exactly once and otherwise prints what the `sameTarget` selector prints; every diff --git a/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts b/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts index 62d0f0a0f0..ef2e720be8 100644 --- a/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts +++ b/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts @@ -1,13 +1,10 @@ -import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); -const oxlintBin = join(repoRoot, "node_modules/.bin/oxlint"); -const pluginPath = join(repoRoot, "tools/oxlint/supabase-plugin.js"); const RULE_NAME = "no-raw-management-api-url"; const RULE = `supabase/${RULE_NAME}`; @@ -49,45 +46,74 @@ const allowed = { ].join("\n"), }; +const LintReport = Schema.Struct({ + diagnostics: Schema.Array(Schema.Struct({ filename: Schema.String, code: Schema.String })), + number_of_files: Schema.Finite, +}); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeLintReport = Schema.decodeUnknownEffect(Schema.fromJsonString(LintReport)); + describe("no-raw-management-api-url", () => { let dir: string; let flaggedFiles: Set; let lintedFiles: Set; - beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), "supabase-lint-rule-")); - mkdirSync(join(dir, "src")); - writeFileSync( - join(dir, ".oxlintrc.json"), - JSON.stringify({ - jsPlugins: [pluginPath], - categories: { correctness: "off" }, - rules: { [RULE]: "error" }, - }), - ); - for (const [name, source] of Object.entries({ ...flagged, ...allowed })) { - writeFileSync(join(dir, "src", name), `${source}\n`); - } + beforeAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const oxlintBin = path.join(repoRoot, "node_modules/.bin/oxlint"); + const pluginPath = path.join(repoRoot, "tools/oxlint/supabase-plugin.js"); + dir = yield* fs.makeTempDirectory({ prefix: "supabase-lint-rule-" }); + yield* fs.makeDirectory(path.join(dir, "src")); + yield* fs.writeFileString( + path.join(dir, ".oxlintrc.json"), + yield* encodeJson({ + jsPlugins: [pluginPath], + categories: { correctness: "off" }, + rules: { [RULE]: "error" }, + }), + ); + for (const [name, source] of Object.entries({ ...flagged, ...allowed })) { + yield* fs.writeFileString(path.join(dir, "src", name), `${source}\n`); + } - const result = spawnSync( - oxlintBin, - ["--config", join(dir, ".oxlintrc.json"), "--format", "json", join(dir, "src")], - { encoding: "utf8" }, - ); - const report = JSON.parse(result.stdout) as { - diagnostics: { filename: string; code: string }[]; - number_of_files: number; - }; - flaggedFiles = new Set( - report.diagnostics.filter((d) => d.code.includes(RULE_NAME)).map((d) => basename(d.filename)), - ); - lintedFiles = new Set(Object.keys({ ...flagged, ...allowed })); - expect(report.number_of_files).toBe(lintedFiles.size); - }); + const stdout = yield* spawner.string( + ChildProcess.make( + oxlintBin, + [ + "--config", + path.join(dir, ".oxlintrc.json"), + "--format", + "json", + path.join(dir, "src"), + ], + /* A plain child, not a process group: no process-group signals. */ + { detached: false, stdin: "ignore", stderr: "ignore" }, + ), + ); + const report = yield* decodeLintReport(stdout); + flaggedFiles = new Set( + report.diagnostics + .filter((d) => d.code.includes(RULE_NAME)) + .map((d) => path.basename(d.filename)), + ); + lintedFiles = new Set(Object.keys({ ...flagged, ...allowed })); + expect(report.number_of_files).toBe(lintedFiles.size); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - afterAll(() => { - rmSync(dir, { recursive: true, force: true }); - }); + afterAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); test.each(Object.keys(flagged))("flags %s", (name) => { expect(flaggedFiles.has(name)).toBe(true); diff --git a/apps/cli/tests/live-global-setup.ts b/apps/cli/tests/live-global-setup.ts index 71dbfc2839..48700a8451 100644 --- a/apps/cli/tests/live-global-setup.ts +++ b/apps/cli/tests/live-global-setup.ts @@ -1,9 +1,11 @@ import type { ProvidedContext } from "vitest"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { makeApiClient } from "@supabase/api/effect"; -import { Effect } from "effect"; +import { Data, Effect, FileSystem, Layer, Path, Predicate } from "effect"; import { FetchHttpClient } from "effect/http"; +import { cliConfigProviderLayer } from "../src/shared/config/cli-config-provider.layer.ts"; import "./helpers/live-provided-context.ts"; import { cleanupLiveEnvironment, provisionLiveEnvironment } from "./helpers/live-project.ts"; import { liveAccessToken, liveApiUrl, validateLiveConfig } from "./helpers/live-env.ts"; @@ -12,21 +14,71 @@ type LiveSetupContext = { provide: (key: K, value: ProvidedContext[K]) => void; }; +/** Vitest rejected a provided value; setup rejects with its original error. */ +class LiveProvideFailure extends Data.TaggedError("LiveProvideFailure")<{ + readonly cause: unknown; +}> {} + +/* + * What the live setup rejects with: a plain `Error` with the same message for a live configuration + * or project failure, also inside a lifecycle `AggregateError`. + */ +function thrownLiveFailure(error: unknown): unknown { + if ( + (Predicate.isTagged(error, "LiveProjectError") || + Predicate.isTagged(error, "LiveConfigError")) && + error instanceof Error + ) { + return new Error(error.message); + } + if (error instanceof AggregateError) { + const errors: Array = Array.from(error.errors, thrownLiveFailure); + return errors.every((inner, index) => inner === error.errors[index]) + ? error + : new AggregateError(errors, error.message); + } + return error; +} + /** Provision one disposable project for the entire serial live Vitest run. */ -export async function setup({ provide }: LiveSetupContext): Promise<() => Promise> { - validateLiveConfig(); - const { api, environment } = await Effect.runPromise( +export function setup({ provide }: LiveSetupContext): Promise<() => Promise> { + return Effect.runPromise( Effect.gen(function* () { - const api = yield* makeApiClient({ baseUrl: liveApiUrl(), accessToken: liveAccessToken() }); + yield* validateLiveConfig; + const api = yield* makeApiClient({ + baseUrl: yield* liveApiUrl, + accessToken: yield* liveAccessToken, + }); const environment = yield* provisionLiveEnvironment(api); - return { api, environment }; - }).pipe(Effect.provide(FetchHttpClient.layer)), + yield* Effect.try({ + try: () => { + provide("liveProject", environment.project); + provide("liveProfilePath", environment.profilePath); + }, + catch: (cause) => new LiveProvideFailure({ cause }), + }); + const services = yield* Effect.context(); + return () => + Effect.runPromiseWith(services)( + cleanupLiveEnvironment(api, environment).pipe( + Effect.catch((failure) => Effect.die(thrownLiveFailure(failure))), + ), + ); + }).pipe( + Effect.catchIf(Predicate.isTagged("LiveProvideFailure"), (failure) => + Effect.die(failure.cause), + ), + Effect.catch((failure) => Effect.die(thrownLiveFailure(failure))), + Effect.provide( + Layer.mergeAll( + FetchHttpClient.layer, + BunFileSystem.layer, + BunPath.layer, + cliConfigProviderLayer, + ), + ), + ), ); - provide("liveProject", environment.project); - provide("liveProfilePath", environment.profilePath); - return async () => { - await Effect.runPromise(cleanupLiveEnvironment(api, environment)); - }; } export default setup; diff --git a/apps/cli/tests/smoke-test-linux.ts b/apps/cli/tests/smoke-test-linux.ts index 0002a59ff0..cd3597d5ed 100644 --- a/apps/cli/tests/smoke-test-linux.ts +++ b/apps/cli/tests/smoke-test-linux.ts @@ -1,10 +1,15 @@ +import { BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import path from "node:path"; +import { Console, Effect, Path } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import { isPublishableNpmTag, PUBLISHABLE_NPM_TAGS_HINT } from "../scripts/release-channels.ts"; import { describeError, runNpmTest } from "./helpers/npm-registry.ts"; -import { verifyExpectedShell } from "./helpers/release-shell.ts"; +import { + type ReleaseStepError, + releaseStep, + verifyExpectedShellEffect, +} from "./helpers/release-shell.ts"; const { values } = parseArgs({ options: { @@ -15,172 +20,196 @@ const { values } = parseArgs({ const version = values.version!; const tag = values.tag; -if (!isPublishableNpmTag(tag)) { - console.error(`Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`); - process.exit(1); -} -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); - -const dispatchProbe = "supabase init --help 2>&1 | grep -q init"; - -interface TestResult { - name: string; - status: "pass" | "fail"; -} - -const results: TestResult[] = []; - -console.log(`\n${"=".repeat(60)}`); -console.log("Native binary tests"); -console.log("=".repeat(60)); - -{ - const arch = process.arch; - const name = `native-linux-${arch}`; - const binPath = path.join(root, "packages", `cli-linux-${arch}`, "bin", "supabase"); - - console.log(`[${name}] Running ${binPath} --version...`); - try { - const output = await $`${binPath} --version`.text(); - const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell(binPath); - const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); - results.push({ name, status: passed ? "pass" : "fail" }); - } catch (e) { - console.log(`[${name}] FAIL —\n${describeError(e)}`); - results.push({ name, status: "fail" }); - } -} -console.log(`\n${"=".repeat(60)}`); -console.log("Docker-based Linux package tests"); -console.log("=".repeat(60)); +const main = Effect.gen(function* () { + if (!isPublishableNpmTag(tag)) { + yield* Console.error( + `Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); + const distDir = path.join(root, "dist"); -const hasDocker = await $`docker --version`.quiet().then( - () => true, - () => false, -); + const dispatchProbe = "supabase init --help 2>&1 | grep -q init"; -if (!hasDocker) { - console.log("[docker] SKIP — docker not found"); -} else { - interface DockerResult { + interface TestResult { name: string; - passed: boolean; - output: string; - } - - async function runDockerTest( - name: string, - image: string, - platform: string, - commands: string, - ): Promise { - console.log(`[${name}] Running...`); - for (let attempt = 1; attempt <= 2; attempt++) { - const result = - await $`docker run --rm --platform ${platform} -v ${distDir}:/dist:ro ${image} sh -c ${commands}` - .nothrow() - .quiet(); - const stdout = result.stdout.toString().trim(); - const stderr = result.stderr.toString().trim(); - if (result.exitCode === 0) { - const lastLine = stdout.split("\n").pop() ?? ""; - const passed = /^\d+\.\d+\.\d+/.test(lastLine); - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${lastLine}`); - if (!passed && stderr) console.log(`[${name}] stderr: ${stderr}`); - return { name, passed, output: stdout }; - } - // Exit 125 is a docker daemon / container-start error, not a container - // exit code. Retry once before giving up. - if (result.exitCode === 125 && attempt === 1) { - console.log(`[${name}] docker exit 125, retrying once. stderr: ${stderr}`); - continue; - } - console.log(`[${name}] FAIL — exit ${result.exitCode}`); - if (stderr) console.log(`[${name}] stderr: ${stderr}`); - if (stdout) console.log(`[${name}] stdout: ${stdout}`); - return { name, passed: false, output: `${stdout}\n${stderr}`.trim() }; - } - return { name, passed: false, output: "unreachable" }; + status: "pass" | "fail"; } - const jobs: Promise[] = []; - - for (const arch of ["arm64", "amd64"] as const) { - const dockerPlatform = `linux/${arch}`; - - jobs.push( - runDockerTest( - `linux-${arch}-tarball`, - "debian:bookworm-slim", - dockerPlatform, - `tar -xzf /dist/supabase_${version}_linux_${arch}.tar.gz -C /usr/local/bin && supabase --version && ${dispatchProbe}`, + const results: TestResult[] = []; + + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Native binary tests"); + yield* Console.log("=".repeat(60)); + + { + const arch = process.arch; + const name = `native-linux-${arch}`; + const binPath = path.join(root, "packages", `cli-linux-${arch}`, "bin", "supabase"); + + yield* Console.log(`[${name}] Running ${binPath} --version...`); + yield* Effect.gen(function* () { + const output = yield* releaseStep(() => $`${binPath} --version`.text()); + const trimmed = output.trim(); + const shellCheck = yield* verifyExpectedShellEffect(binPath); + const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + yield* Console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); + yield* Console.log(`[${name}] ${shellCheck.detail}`); + results.push({ name, status: passed ? "pass" : "fail" }); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.log(`[${name}] FAIL —\n${describeError(e.cause)}`); + results.push({ name, status: "fail" }); + }), ), ); + } - jobs.push( - runDockerTest( - `linux-${arch}-deb`, - "debian:bookworm-slim", - dockerPlatform, - `dpkg -i /dist/supabase_${version}_linux_${arch}.deb && supabase --version && ${dispatchProbe}`, - ), - ); + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Docker-based Linux package tests"); + yield* Console.log("=".repeat(60)); + + const hasDocker = yield* releaseStep(() => $`docker --version`.quiet()).pipe( + Effect.match({ + onSuccess: () => true, + onFailure: () => false, + }), + ); + + if (!hasDocker) { + yield* Console.log("[docker] SKIP — docker not found"); + } else { + interface DockerResult { + name: string; + passed: boolean; + output: string; + } - jobs.push( - runDockerTest( - `linux-${arch}-rpm`, - "amazonlinux:2023", - dockerPlatform, - `rpm -ivh /dist/supabase_${version}_linux_${arch}.rpm && supabase --version && ${dispatchProbe}`, - ), - ); + const runDockerTest = Effect.fnUntraced(function* ( + name: string, + image: string, + platform: string, + commands: string, + ) { + yield* Console.log(`[${name}] Running...`); + for (let attempt = 1; attempt <= 2; attempt++) { + const result = yield* releaseStep(() => + $`docker run --rm --platform ${platform} -v ${distDir}:/dist:ro ${image} sh -c ${commands}` + .nothrow() + .quiet(), + ); + const stdout = result.stdout.toString().trim(); + const stderr = result.stderr.toString().trim(); + if (result.exitCode === 0) { + const lastLine = stdout.split("\n").pop() ?? ""; + const passed = /^\d+\.\d+\.\d+/.test(lastLine); + yield* Console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${lastLine}`); + if (!passed && stderr) yield* Console.log(`[${name}] stderr: ${stderr}`); + return { name, passed, output: stdout }; + } + // Exit 125 is a docker daemon / container-start error, not a container + // exit code. Retry once before giving up. + if (result.exitCode === 125 && attempt === 1) { + yield* Console.log(`[${name}] docker exit 125, retrying once. stderr: ${stderr}`); + continue; + } + yield* Console.log(`[${name}] FAIL — exit ${result.exitCode}`); + if (stderr) yield* Console.log(`[${name}] stderr: ${stderr}`); + if (stdout) yield* Console.log(`[${name}] stdout: ${stdout}`); + return { name, passed: false, output: `${stdout}\n${stderr}`.trim() }; + } + return { name, passed: false, output: "unreachable" }; + }); + + const jobs: Effect.Effect[] = []; + + for (const arch of ["arm64", "amd64"] as const) { + const dockerPlatform = `linux/${arch}`; + + jobs.push( + runDockerTest( + `linux-${arch}-tarball`, + "debian:bookworm-slim", + dockerPlatform, + `tar -xzf /dist/supabase_${version}_linux_${arch}.tar.gz -C /usr/local/bin && supabase --version && ${dispatchProbe}`, + ), + ); + + jobs.push( + runDockerTest( + `linux-${arch}-deb`, + "debian:bookworm-slim", + dockerPlatform, + `dpkg -i /dist/supabase_${version}_linux_${arch}.deb && supabase --version && ${dispatchProbe}`, + ), + ); + + jobs.push( + runDockerTest( + `linux-${arch}-rpm`, + "amazonlinux:2023", + dockerPlatform, + `rpm -ivh /dist/supabase_${version}_linux_${arch}.rpm && supabase --version && ${dispatchProbe}`, + ), + ); + + jobs.push( + runDockerTest( + `linux-${arch}-apk`, + "alpine:3.21", + dockerPlatform, + `apk add --allow-untrusted /dist/supabase_${version}_linux_${arch}.apk && supabase --version && ${dispatchProbe}`, + ), + ); + } - jobs.push( - runDockerTest( - `linux-${arch}-apk`, - "alpine:3.21", - dockerPlatform, - `apk add --allow-untrusted /dist/supabase_${version}_linux_${arch}.apk && supabase --version && ${dispatchProbe}`, - ), - ); + const dockerResults = yield* Effect.all(jobs, { concurrency: "unbounded" }); + for (const r of dockerResults) { + results.push({ name: r.name, status: r.passed ? "pass" : "fail" }); + } } - const dockerResults = await Promise.all(jobs); - for (const r of dockerResults) { - results.push({ name: r.name, status: r.passed ? "pass" : "fail" }); + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("npm (Verdaccio) test"); + yield* Console.log("=".repeat(60)); + + yield* Effect.gen(function* () { + const npmPassed = yield* releaseStep(() => runNpmTest(version, tag)); + results.push({ name: "npm", status: npmPassed ? "pass" : "fail" }); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.error(`[npm] Error:\n${describeError(e.cause)}`); + results.push({ name: "npm", status: "fail" }); + }), + ), + ); + + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Linux Smoke Test Summary"); + yield* Console.log("=".repeat(60)); + + for (const r of results) { + yield* Console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); } -} - -console.log(`\n${"=".repeat(60)}`); -console.log("npm (Verdaccio) test"); -console.log("=".repeat(60)); - -try { - const npmPassed = await runNpmTest(version, tag); - results.push({ name: "npm", status: npmPassed ? "pass" : "fail" }); -} catch (e) { - console.error(`[npm] Error:\n${describeError(e)}`); - results.push({ name: "npm", status: "fail" }); -} - -console.log(`\n${"=".repeat(60)}`); -console.log("Linux Smoke Test Summary"); -console.log("=".repeat(60)); -for (const r of results) { - console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); -} + const passed = results.filter((r) => r.status === "pass").length; + const failed = results.filter((r) => r.status === "fail").length; -const passed = results.filter((r) => r.status === "pass").length; -const failed = results.filter((r) => r.status === "fail").length; + yield* Console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); -console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); + if (failed > 0) { + return yield* Effect.sync(() => process.exit(1)); + } +}); -if (failed > 0) { - process.exit(1); -} +await Effect.runPromise( + main.pipe( + Effect.catch((e) => Effect.die(e.cause)), + Effect.provide(BunPath.layer), + ), +); diff --git a/apps/cli/tests/smoke-test-macos.ts b/apps/cli/tests/smoke-test-macos.ts index fed05328b0..67c52cc47c 100644 --- a/apps/cli/tests/smoke-test-macos.ts +++ b/apps/cli/tests/smoke-test-macos.ts @@ -1,13 +1,12 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { existsSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; -import path from "node:path"; +import { Cause, Console, Data, Effect, Exit, FileSystem, Layer, Path, Predicate } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import { isPublishableNpmTag, PUBLISHABLE_NPM_TAGS_HINT } from "../scripts/release-channels.ts"; import { verifyMacSignature } from "./helpers/macos-signature.ts"; import { createTmpDir, describeError, runNpmTest } from "./helpers/npm-registry.ts"; -import { verifyExpectedShell } from "./helpers/release-shell.ts"; +import { releaseStep, verifyExpectedShellEffect } from "./helpers/release-shell.ts"; const { values } = parseArgs({ options: { @@ -18,139 +17,237 @@ const { values } = parseArgs({ const version = values.version!; const tag = values.tag; -if (!isPublishableNpmTag(tag)) { - console.error(`Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`); - process.exit(1); -} -const root = path.resolve(import.meta.dir, "../../.."); - -interface TestResult { - name: string; - status: "pass" | "fail"; -} -const results: TestResult[] = []; - -console.log(`\n${"=".repeat(60)}`); -console.log("Native binary tests"); -console.log("=".repeat(60)); - -{ - const arch = process.arch; - const name = `native-darwin-${arch}`; - const binPath = path.join(root, "packages", `cli-darwin-${arch}`, "bin", "supabase"); - - console.log(`[${name}] Running ${binPath} --version...`); - try { - const output = await $`${binPath} --version`.text(); - const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell(binPath); - const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); - results.push({ name, status: passed ? "pass" : "fail" }); - } catch (e) { - console.log(`[${name}] FAIL —\n${describeError(e)}`); - results.push({ name, status: "fail" }); +/** Removing the tap failed after the brew steps had already failed; both causes are kept. */ +class TapDisposalSuppressed extends Data.TaggedError("TapDisposalSuppressed")<{ + readonly error: Cause.Cause; + readonly suppressed: Cause.Cause; +}> {} + +/* What a brew step reports: the wrapped error, or a SuppressedError as `await using` builds. */ +function thrownValue(failure: unknown): unknown { + if (failure instanceof TapDisposalSuppressed) { + return new SuppressedError( + thrownValue(Cause.squash(failure.error)), + thrownValue(Cause.squash(failure.suppressed)), + ); + } + if ( + (Predicate.isTagged(failure, "ReleaseStepError") || + Predicate.isTagged(failure, "PlatformError")) && + Predicate.hasProperty(failure, "cause") + ) { + return failure.cause; } + return failure; } -{ - const arch = process.arch; - const binDir = path.join(root, "packages", `cli-darwin-${arch}`, "bin"); - const binaries = ["supabase"]; - if (existsSync(path.join(binDir, "supabase-go"))) { - binaries.push("supabase-go"); +/** `try { body } finally { cleanup }`: a cleanup failure replaces the body's outcome. */ +const withFinally = ( + body: Effect.Effect, + cleanup: Effect.Effect, +): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exit = yield* Effect.exit(restore(body)); + yield* cleanup; + return yield* exit; + }), + ); + +const main = Effect.gen(function* () { + if (!isPublishableNpmTag(tag)) { + yield* Console.error( + `Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`, + ); + return yield* Effect.sync(() => process.exit(1)); } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); - for (const binary of binaries) { - const name = `native-darwin-${arch}-signature-${binary}`; - const binPath = path.join(binDir, binary); - console.log(`[${name}] Verifying signature of ${binPath}...`); - try { - const sig = await verifyMacSignature(binPath); - console.log(`[${name}] ${sig.passed ? "PASS" : "FAIL"} — ${sig.detail}`); - results.push({ name, status: sig.passed ? "pass" : "fail" }); - } catch (e) { - console.log(`[${name}] FAIL —\n${describeError(e)}`); - results.push({ name, status: "fail" }); - } + interface TestResult { + name: string; + status: "pass" | "fail"; } -} - -console.log(`\n${"=".repeat(60)}`); -console.log("npm (Verdaccio) test"); -console.log("=".repeat(60)); - -try { - const npmPassed = await runNpmTest(version, tag); - results.push({ name: "npm", status: npmPassed ? "pass" : "fail" }); -} catch (e) { - console.error(`[npm] Error:\n${describeError(e)}`); - results.push({ name: "npm", status: "fail" }); -} - -console.log(`\n${"=".repeat(60)}`); -console.log("Homebrew test"); -console.log("=".repeat(60)); - -const hasBrew = await $`brew --version`.quiet().then( - () => true, - () => false, -); - -if (!hasBrew) { - console.log("[brew] SKIP — brew not found"); -} else { - try { - console.log("Generating Homebrew formula..."); - await $`bun run apps/cli/scripts/update-homebrew.ts --version ${version} --local`.cwd(root); - // Create a local git-backed tap - await using tap = await createTmpDir("brew-smoke-"); - await mkdir(path.join(tap.path, "Formula")); - await $`cp ${path.join(root, "dist", "supabase.rb")} ${path.join(tap.path, "Formula", "supabase.rb")}`; - await $`git -C ${tap.path} init`.quiet(); - await $`git -C ${tap.path} add .`.quiet(); - await $`git -C ${tap.path} commit -m init`.quiet(); + const results: TestResult[] = []; - console.log("Installing via Homebrew..."); - await $`brew tap --force supabase/test-tap ${tap.path}`; + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Native binary tests"); + yield* Console.log("=".repeat(60)); - try { - await $`brew install supabase/test-tap/supabase`; + { + const arch = process.arch; + const name = `native-darwin-${arch}`; + const binPath = path.join(root, "packages", `cli-darwin-${arch}`, "bin", "supabase"); - const output = await $`supabase --version`.text(); + yield* Console.log(`[${name}] Running ${binPath} --version...`); + yield* Effect.gen(function* () { + const output = yield* releaseStep(() => $`${binPath} --version`.text()); const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell("supabase"); + const shellCheck = yield* verifyExpectedShellEffect(binPath); const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + yield* Console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); + yield* Console.log(`[${name}] ${shellCheck.detail}`); + results.push({ name, status: passed ? "pass" : "fail" }); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.log(`[${name}] FAIL —\n${describeError(e.cause)}`); + results.push({ name, status: "fail" }); + }), + ), + ); + } + + { + const arch = process.arch; + const binDir = path.join(root, "packages", `cli-darwin-${arch}`, "bin"); + const binaries = ["supabase"]; + if ( + yield* fs.exists(path.join(binDir, "supabase-go")).pipe(Effect.orElseSucceed(() => false)) + ) { + binaries.push("supabase-go"); + } - console.log(`[brew] ${passed ? "PASS" : "FAIL"} — supabase --version: ${trimmed}`); - console.log(`[brew] ${shellCheck.detail}`); - results.push({ name: "brew", status: passed ? "pass" : "fail" }); - } finally { - await $`brew uninstall supabase`.nothrow(); - await $`brew untap supabase/test-tap`.nothrow(); + for (const binary of binaries) { + const name = `native-darwin-${arch}-signature-${binary}`; + const binPath = path.join(binDir, binary); + yield* Console.log(`[${name}] Verifying signature of ${binPath}...`); + yield* Effect.gen(function* () { + const sig = yield* verifyMacSignature(binPath); + yield* Console.log(`[${name}] ${sig.passed ? "PASS" : "FAIL"} — ${sig.detail}`); + results.push({ name, status: sig.passed ? "pass" : "fail" }); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.log(`[${name}] FAIL —\n${describeError(e.cause)}`); + results.push({ name, status: "fail" }); + }), + ), + ); } - } catch (e) { - console.error(`[brew] Error:\n${describeError(e)}`); - results.push({ name: "brew", status: "fail" }); } -} -console.log(`\n${"=".repeat(60)}`); -console.log("macOS Smoke Test Summary"); -console.log("=".repeat(60)); + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("npm (Verdaccio) test"); + yield* Console.log("=".repeat(60)); + + yield* Effect.gen(function* () { + const npmPassed = yield* releaseStep(() => runNpmTest(version, tag)); + results.push({ name: "npm", status: npmPassed ? "pass" : "fail" }); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.error(`[npm] Error:\n${describeError(e.cause)}`); + results.push({ name: "npm", status: "fail" }); + }), + ), + ); + + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Homebrew test"); + yield* Console.log("=".repeat(60)); + + const hasBrew = yield* releaseStep(() => $`brew --version`.quiet()).pipe( + Effect.match({ + onSuccess: () => true, + onFailure: () => false, + }), + ); + + if (!hasBrew) { + yield* Console.log("[brew] SKIP — brew not found"); + } else { + yield* Effect.gen(function* () { + yield* Console.log("Generating Homebrew formula..."); + yield* releaseStep(() => + $`bun run apps/cli/scripts/update-homebrew.ts --version ${version} --local`.cwd(root), + ); + + // Create a local git-backed tap + const useTap = (tap: AsyncDisposable & { path: string }) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(tap.path, "Formula")); + yield* releaseStep( + () => + $`cp ${path.join(root, "dist", "supabase.rb")} ${path.join(tap.path, "Formula", "supabase.rb")}`, + ); + yield* releaseStep(() => $`git -C ${tap.path} init`.quiet()); + yield* releaseStep(() => $`git -C ${tap.path} add .`.quiet()); + yield* releaseStep(() => $`git -C ${tap.path} commit -m init`.quiet()); + + yield* Console.log("Installing via Homebrew..."); + yield* releaseStep(() => $`brew tap --force supabase/test-tap ${tap.path}`); + + yield* withFinally( + Effect.gen(function* () { + yield* releaseStep(() => $`brew install supabase/test-tap/supabase`); + + const output = yield* releaseStep(() => $`supabase --version`.text()); + const trimmed = output.trim(); + const shellCheck = yield* verifyExpectedShellEffect("supabase"); + const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + + yield* Console.log( + `[brew] ${passed ? "PASS" : "FAIL"} — supabase --version: ${trimmed}`, + ); + yield* Console.log(`[brew] ${shellCheck.detail}`); + results.push({ name: "brew", status: passed ? "pass" : "fail" }); + }), + Effect.gen(function* () { + yield* releaseStep(() => $`brew uninstall supabase`.nothrow()); + yield* releaseStep(() => $`brew untap supabase/test-tap`.nothrow()); + }), + ); + }); + /* `await using tap`: a failed removal replaces the outcome, or suppresses a failed one. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tap = yield* releaseStep(() => createTmpDir("brew-smoke-")); + const used = yield* Effect.exit(restore(useTap(tap))); + const disposed = yield* Effect.exit(releaseStep(() => tap[Symbol.asyncDispose]())); + if (Exit.isFailure(disposed) && Exit.isFailure(used)) { + /* An interrupted use keeps its interruption, with the removal failure alongside. */ + return yield* Cause.hasInterrupts(used.cause) + ? Effect.failCause(Cause.combine(used.cause, disposed.cause)) + : Effect.fail( + new TapDisposalSuppressed({ error: disposed.cause, suppressed: used.cause }), + ); + } + yield* disposed; + return yield* used; + }), + ); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.error(`[brew] Error:\n${describeError(thrownValue(e))}`); + results.push({ name: "brew", status: "fail" }); + }), + ), + ); + } -for (const r of results) { - console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); -} + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("macOS Smoke Test Summary"); + yield* Console.log("=".repeat(60)); -const passed = results.filter((r) => r.status === "pass").length; -const failed = results.filter((r) => r.status === "fail").length; + for (const r of results) { + yield* Console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); + } -console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); + const passed = results.filter((r) => r.status === "pass").length; + const failed = results.filter((r) => r.status === "fail").length; -if (failed > 0) { - process.exit(1); -} + yield* Console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); + + if (failed > 0) { + return yield* Effect.sync(() => process.exit(1)); + } +}); + +await Effect.runPromise( + main.pipe(Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer))), +); diff --git a/apps/cli/tests/smoke-test-windows.ts b/apps/cli/tests/smoke-test-windows.ts index 19c65060ac..b0b02dba37 100644 --- a/apps/cli/tests/smoke-test-windows.ts +++ b/apps/cli/tests/smoke-test-windows.ts @@ -1,11 +1,10 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { Console, Effect, FileSystem, Layer, Path } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import { isPublishableNpmTag, PUBLISHABLE_NPM_TAGS_HINT } from "../scripts/release-channels.ts"; -import { verifyExpectedShell } from "./helpers/release-shell.ts"; +import { releaseStep, verifyExpectedShellEffect } from "./helpers/release-shell.ts"; const { values } = parseArgs({ options: { @@ -16,130 +15,185 @@ const { values } = parseArgs({ const version = values.version!; const tag = values.tag; -if (!isPublishableNpmTag(tag)) { - console.error(`Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`); - process.exit(1); -} -const root = path.resolve(import.meta.dir, "../../.."); - -async function gitBashPath(filePath: string) { - return process.platform === "win32" ? (await $`cygpath -u ${filePath}`.text()).trim() : filePath; -} - -interface TestResult { - name: string; - status: "pass" | "fail"; -} - -const results: TestResult[] = []; - -console.log(`\n${"=".repeat(60)}`); -console.log("Native binary tests"); -console.log("=".repeat(60)); - -const arch = process.arch === "arm64" ? "arm64" : "x64"; - -{ - const name = `native-windows-${arch}`; - const binPath = path.join(root, "packages", `cli-windows-${arch}`, "bin", "supabase.exe"); - - console.log(`[${name}] Running ${binPath} --version...`); - try { - const output = await $`${binPath} --version`.text(); - const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell(binPath); - const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); - results.push({ name, status: passed ? "pass" : "fail" }); - } catch (e) { - console.log(`[${name}] FAIL — ${e}`); - results.push({ name, status: "fail" }); - } -} - -console.log(`\n${"=".repeat(60)}`); -console.log("Release tarball test"); -console.log("=".repeat(60)); - -{ - const archiveArch = arch === "arm64" ? "arm64" : "amd64"; - const name = `windows-${archiveArch}-tarball`; - const archivePath = path.join(root, "dist", `supabase_${version}_windows_${archiveArch}.tar.gz`); - const extractDir = await mkdtemp(path.join(tmpdir(), "supabase-windows-tarball-")); - - console.log(`[${name}] Extracting ${archivePath}...`); - try { - await $`tar -xzf ${await gitBashPath(archivePath)} -C ${await gitBashPath(extractDir)}`; - const binPath = path.join(extractDir, "supabase.exe"); - const output = await $`${binPath} --version`.text(); - const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell(binPath); - const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); - results.push({ name, status: passed ? "pass" : "fail" }); - } catch (e) { - console.error(`[${name}] Error: ${e}`); - results.push({ name, status: "fail" }); - } finally { - await rm(extractDir, { recursive: true, force: true }); + +const gitBashPath = Effect.fnUntraced(function* (filePath: string) { + return process.platform === "win32" + ? (yield* releaseStep(() => $`cygpath -u ${filePath}`.text())).trim() + : filePath; +}); + +/** `try { body } finally { cleanup }`: a cleanup failure replaces the body's outcome. */ +const withFinally = ( + body: Effect.Effect, + cleanup: Effect.Effect, +): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exit = yield* Effect.exit(restore(body)); + yield* cleanup; + return yield* exit; + }), + ); + +const main = Effect.gen(function* () { + if (!isPublishableNpmTag(tag)) { + yield* Console.error( + `Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`, + ); + return yield* Effect.sync(() => process.exit(1)); } -} + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); -console.log(`\n${"=".repeat(60)}`); -console.log("Scoop test"); -console.log("=".repeat(60)); + interface TestResult { + name: string; + status: "pass" | "fail"; + } -const hasScoop = await $`scoop --version`.quiet().then( - () => true, - () => false, -); + const results: TestResult[] = []; -if (!hasScoop) { - console.log("[scoop] SKIP — scoop not found"); -} else { - const manifest = path.join(root, "dist", "supabase.json"); + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Native binary tests"); + yield* Console.log("=".repeat(60)); - try { - console.log("Generating Scoop manifest..."); - await $`bun run apps/cli/scripts/update-scoop.ts --version ${version} --local`.cwd(root); + const arch = process.arch === "arm64" ? "arm64" : "x64"; - console.log("Installing via Scoop..."); - await $`scoop install ${manifest}`; + { + const name = `native-windows-${arch}`; + const binPath = path.join(root, "packages", `cli-windows-${arch}`, "bin", "supabase.exe"); - try { - const output = await $`supabase --version`.text(); + yield* Console.log(`[${name}] Running ${binPath} --version...`); + yield* Effect.gen(function* () { + const output = yield* releaseStep(() => $`${binPath} --version`.text()); const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell("supabase"); + const shellCheck = yield* verifyExpectedShellEffect(binPath); const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + yield* Console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); + yield* Console.log(`[${name}] ${shellCheck.detail}`); + results.push({ name, status: passed ? "pass" : "fail" }); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.log(`[${name}] FAIL — ${e.cause}`); + results.push({ name, status: "fail" }); + }), + ), + ); + } - console.log(`[scoop] ${passed ? "PASS" : "FAIL"} — supabase --version: ${trimmed}`); - console.log(`[scoop] ${shellCheck.detail}`); - results.push({ name: "scoop", status: passed ? "pass" : "fail" }); - } finally { - await $`scoop uninstall supabase`.nothrow(); - } - } catch (e) { - console.error(`[scoop] Error: ${e}`); - results.push({ name: "scoop", status: "fail" }); + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Release tarball test"); + yield* Console.log("=".repeat(60)); + + { + const archiveArch = arch === "arm64" ? "arm64" : "amd64"; + const name = `windows-${archiveArch}-tarball`; + const archivePath = path.join( + root, + "dist", + `supabase_${version}_windows_${archiveArch}.tar.gz`, + ); + const extractDir = yield* fs.makeTempDirectory({ prefix: "supabase-windows-tarball-" }); + + yield* Console.log(`[${name}] Extracting ${archivePath}...`); + yield* Effect.onExit( + Effect.gen(function* () { + const archiveBashPath = yield* gitBashPath(archivePath); + const extractBashPath = yield* gitBashPath(extractDir); + yield* releaseStep(() => $`tar -xzf ${archiveBashPath} -C ${extractBashPath}`); + const binPath = path.join(extractDir, "supabase.exe"); + const output = yield* releaseStep(() => $`${binPath} --version`.text()); + const trimmed = output.trim(); + const shellCheck = yield* verifyExpectedShellEffect(binPath); + const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + + yield* Console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); + yield* Console.log(`[${name}] ${shellCheck.detail}`); + results.push({ name, status: passed ? "pass" : "fail" }); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.error(`[${name}] Error: ${e.cause}`); + results.push({ name, status: "fail" }); + }), + ), + ), + () => fs.remove(extractDir, { recursive: true, force: true }), + ); } -} -console.log(`\n${"=".repeat(60)}`); -console.log("Windows Smoke Test Summary"); -console.log("=".repeat(60)); + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Scoop test"); + yield* Console.log("=".repeat(60)); + + const hasScoop = yield* releaseStep(() => $`scoop --version`.quiet()).pipe( + Effect.match({ + onSuccess: () => true, + onFailure: () => false, + }), + ); + + if (!hasScoop) { + yield* Console.log("[scoop] SKIP — scoop not found"); + } else { + const manifest = path.join(root, "dist", "supabase.json"); + + yield* Effect.gen(function* () { + yield* Console.log("Generating Scoop manifest..."); + yield* releaseStep(() => + $`bun run apps/cli/scripts/update-scoop.ts --version ${version} --local`.cwd(root), + ); + + yield* Console.log("Installing via Scoop..."); + yield* releaseStep(() => $`scoop install ${manifest}`); + + yield* withFinally( + Effect.gen(function* () { + const output = yield* releaseStep(() => $`supabase --version`.text()); + const trimmed = output.trim(); + const shellCheck = yield* verifyExpectedShellEffect("supabase"); + const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + + yield* Console.log( + `[scoop] ${passed ? "PASS" : "FAIL"} — supabase --version: ${trimmed}`, + ); + yield* Console.log(`[scoop] ${shellCheck.detail}`); + results.push({ name: "scoop", status: passed ? "pass" : "fail" }); + }), + releaseStep(() => $`scoop uninstall supabase`.nothrow()), + ); + }).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Console.error(`[scoop] Error: ${e.cause}`); + results.push({ name: "scoop", status: "fail" }); + }), + ), + ); + } + + yield* Console.log(`\n${"=".repeat(60)}`); + yield* Console.log("Windows Smoke Test Summary"); + yield* Console.log("=".repeat(60)); -for (const r of results) { - console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); -} + for (const r of results) { + yield* Console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); + } -const passed = results.filter((r) => r.status === "pass").length; -const failed = results.filter((r) => r.status === "fail").length; + const passed = results.filter((r) => r.status === "pass").length; + const failed = results.filter((r) => r.status === "fail").length; -console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); + yield* Console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); -if (failed > 0) { - process.exit(1); -} + if (failed > 0) { + return yield* Effect.sync(() => process.exit(1)); + } +}); + +await Effect.runPromise( + main.pipe( + Effect.catch((e) => Effect.die(e.cause)), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/tests/smoke-test.ts b/apps/cli/tests/smoke-test.ts index 73d7b3be20..bcd620add1 100644 --- a/apps/cli/tests/smoke-test.ts +++ b/apps/cli/tests/smoke-test.ts @@ -1,7 +1,9 @@ -import path from "node:path"; +import { BunPath } from "@effect/platform-bun"; +import { Console, Effect, Path } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import { isPublishableNpmTag, PUBLISHABLE_NPM_TAGS_HINT } from "../scripts/release-channels.ts"; +import { ReleaseStepError } from "./helpers/release-shell.ts"; const { values } = parseArgs({ options: { @@ -12,32 +14,49 @@ const { values } = parseArgs({ const version = values.version!; const tag = values.tag; -if (!isPublishableNpmTag(tag)) { - console.error(`Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`); - process.exit(1); -} -const testsDir = import.meta.dir; - -const platformScripts: Record = { - linux: "smoke-test-linux.ts", - darwin: "smoke-test-macos.ts", - win32: "smoke-test-windows.ts", -}; - -const script = platformScripts[process.platform]; -if (!script) { - console.error(`Unsupported platform: ${process.platform}`); - process.exit(1); -} - -const scriptPath = path.join(testsDir, script); -console.log(`Detected platform: ${process.platform} — running ${script}\n`); - -const proc = Bun.spawn(["bun", "run", scriptPath, "--version", version, "--tag", tag], { - stdout: "inherit", - stderr: "inherit", - env: process.env, + +const main = Effect.gen(function* () { + if (!isPublishableNpmTag(tag)) { + yield* Console.error( + `Invalid --tag value: ${String(tag)}. Expected ${PUBLISHABLE_NPM_TAGS_HINT}.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + const testsDir = import.meta.dir; + + const platformScripts: Record = { + linux: "smoke-test-linux.ts", + darwin: "smoke-test-macos.ts", + win32: "smoke-test-windows.ts", + }; + + const script = platformScripts[process.platform]; + if (!script) { + yield* Console.error(`Unsupported platform: ${process.platform}`); + return yield* Effect.sync(() => process.exit(1)); + } + + const path = yield* Path.Path; + const scriptPath = path.join(testsDir, script); + yield* Console.log(`Detected platform: ${process.platform} — running ${script}\n`); + + const proc = yield* Effect.try({ + try: () => + Bun.spawn(["bun", "run", scriptPath, "--version", version, "--tag", tag], { + stdout: "inherit", + stderr: "inherit", + env: process.env, + }), + catch: (cause) => new ReleaseStepError({ cause }), + }); + + const exitCode = yield* Effect.promise(() => proc.exited); + return yield* Effect.sync(() => process.exit(exitCode)); }); -const exitCode = await proc.exited; -process.exit(exitCode); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(error.cause)), + Effect.provide(BunPath.layer), + ), +); diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 531aefc51c..be34ee20ea 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,14 +1,16 @@ -import { readFileSync } from "node:fs"; +import { BunFileSystem } from "@effect/platform-bun"; +import { Config, Effect, FileSystem, Schema } from "effect"; import { defaultClientConditions, defaultServerConditions } from "vite"; import { configDefaults, defineConfig } from "vitest/config"; import { E2eSequencer } from "./tests/e2e-sequencer.ts"; /** E2e files that run concurrently; each stack project picks free ports and its own project id. */ -const e2eWorkers = Number(process.env.SUPABASE_E2E_WORKERS || "1"); +const e2eWorkersSetting = Effect.runSync( + Config.String("SUPABASE_E2E_WORKERS").pipe(Config.withDefault("1")), +); +const e2eWorkers = Number(e2eWorkersSetting); if (!Number.isSafeInteger(e2eWorkers) || e2eWorkers < 1) { - throw new Error( - `SUPABASE_E2E_WORKERS must be a positive integer, got "${process.env.SUPABASE_E2E_WORKERS}"`, - ); + throw new Error(`SUPABASE_E2E_WORKERS must be a positive integer, got "${e2eWorkersSetting}"`); } /** @@ -27,6 +29,15 @@ const E2E_EXCLUSIVE_FILES = [ "src/commands/db/start/start.roles.e2e.test.ts", ]; +const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); + +const dockerfileModule = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath)); + return `export default ${yield* encodeJsonString(text)};`; + }); + function dockerfileTextPlugin() { return { name: "dockerfile-text-loader", @@ -36,7 +47,12 @@ function dockerfileTextPlugin() { return undefined; } - return `export default ${JSON.stringify(readFileSync(filePath, "utf8"))};`; + return Effect.runPromise( + dockerfileModule(filePath).pipe( + Effect.catchTag("PlatformError", (error) => Effect.die(error.cause)), + Effect.provide(BunFileSystem.layer), + ), + ); }, }; }