diff --git a/.github/workflows/dispatch-cli-e2e-ci.yml b/.github/workflows/dispatch-cli-e2e-ci.yml index 4dfecad569..109b85cf49 100644 --- a/.github/workflows/dispatch-cli-e2e-ci.yml +++ b/.github/workflows/dispatch-cli-e2e-ci.yml @@ -3,10 +3,11 @@ name: Dispatch cli-e2e-ci # Asks the supabase/cli-e2e-ci harness to run the cli `test:live` suite against # a full supabox stack, built from THIS PR's head commit (CLI-1825 / CLI-1831). # -# This is distinct from `live-e2e.yml`, which runs the cli-e2e package against -# real staging (api.supabase.green). Here the suite runs against a local supabox -# stack stood up inside the private cli-e2e-ci repo; we only fire the trigger and -# pass our head SHA — cli-e2e-ci checks that SHA out into its `cli` submodule. +# This is distinct from `live-e2e.yml`, which runs the collocated live suite +# against managed staging (api.supabase.green). Here the same `apps/cli` suite +# runs against a local Supabox stack stood up inside the private cli-e2e-ci repo; +# we only fire the trigger and pass our head SHA — cli-e2e-ci checks that SHA out +# into its `cli` submodule. # # Opt-in by label to keep the expensive full-stack run off every PR: add the # `run-live-e2e-ci` label (re-dispatches on each subsequent push while labeled). diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 2d1aff3a99..8ccd621401 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -1,7 +1,7 @@ name: Live E2E -# Live e2e suite (ADR-0013). Runs the real CLI against the real staging -# Management API + Docker bundler, then invokes the deployed functions over HTTP. +# Live e2e suite. Runs the collocated `apps/cli` tests against the real staging +# Management API + Docker bundler, then invokes deployed functions over HTTP. # # Non-blocking by construction: this is a standalone workflow, NOT part of the # required-checks set, and it never runs on the default PR path of test.yml. @@ -99,10 +99,8 @@ jobs: # Non-secret config is job-level; the staging token is scoped to only the two # steps that need it (run + cleanup) so build/checkout/docker never see it. env: - CLI_E2E_MODE: live - CLI_E2E_TARGET_ENV: staging - CLI_E2E_API_URL: https://api.supabase.green - CLI_E2E_PROJECT_HOST: supabase.red + SUPABASE_LIVE_API_URL: https://api.supabase.green + SUPABASE_LIVE_PROJECT_NAME: supabase-cli-live-${{ matrix.target }} CLI_HARNESS_TARGET: ${{ matrix.target }} steps: - name: Checkout @@ -131,26 +129,11 @@ jobs: - name: Docker preflight run: docker info - - name: Run live e2e (retry up to 3x) + - name: Run live e2e + timeout-minutes: 20 env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - run: | - PREFIX="cli-e2e-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-" - # GitHub runs this step as `bash -e`; use `if cmd; then` (errexit-exempt) - # so a failing attempt does not abort the step before the retry. - for attempt in 1 2 3; do - echo "::group::live e2e attempt ${attempt}" - if [ "$attempt" -gt 1 ]; then - bash .github/scripts/sweep-live-projects.sh "$PREFIX" || true - fi - if pnpm --filter @supabase/cli-e2e test:e2e:live; then - echo "::endgroup::" - exit 0 - fi - echo "::endgroup::" - echo "attempt ${attempt} failed" - done - exit 1 + run: pnpm --filter supabase test:live # Backstop: delete any project this job created that survived a crash. # The script exits non-zero (failing this step) if any delete failed. @@ -158,7 +141,7 @@ jobs: if: always() env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - run: bash .github/scripts/sweep-live-projects.sh "cli-e2e-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-" + run: bash apps/cli/scripts/sweep-live-projects.sh "supabase-cli-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-" # Record that this beta tested green so the next scheduled run skips it. Needs # the whole matrix: the marker is saved only if the ts-legacy leg passed (a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ea9a7eac9..fe01bcedb5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,13 +52,13 @@ mise install `mise install` resolves the versions this repo expects from a handful of files, rather than hardcoding them all in one place: -| Tool | Version source | -| --- | --- | -| Bun | `.bun-version` | -| Node.js | `devEngines.runtime` field in `package.json` | -| pnpm | `packageManager` field in `package.json` | -| Go | `mise.toml` | -| golangci-lint | `mise.toml` | +| Tool | Version source | +| ------------- | -------------------------------------------- | +| Bun | `.bun-version` | +| Node.js | `devEngines.runtime` field in `package.json` | +| pnpm | `packageManager` field in `package.json` | +| Go | `mise.toml` | +| golangci-lint | `mise.toml` | The Go and golangci-lint entries in `mise.toml` are intentionally temporary while the Go CLI remains in the repo. The canonical Go module metadata still lives in `apps/cli-go/go.mod`; keep the `mise.toml` entries aligned only until the Go code is removed. @@ -105,28 +105,28 @@ That pulls `.repos/effect/`, which is the local source of truth for Effect v4 AP ## Apps -| Workspace | Purpose | -| --- | --- | -| `apps/cli` | Main `supabase` package. Contains command handlers, runtime services, auth, output, telemetry, and docs generation scripts. | +| Workspace | Purpose | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/cli` | Main `supabase` package. Contains command handlers, runtime services, auth, output, telemetry, and docs generation scripts. | | `apps/cli-e2e` | Compatibility e2e test suite. Record-and-replay harness for testing the TS Legacy port against real Supabase Management API responses. | -| `apps/docs` | Internal docs site built with Next.js and generated from the CLI docs sources. | +| `apps/docs` | Internal docs site built with Next.js and generated from the CLI docs sources. | ## Packages -| Workspace | Purpose | -| --- | --- | -| `packages/api` | Auto-generated TypeScript client for the Supabase Management API. | -| `packages/cli-test-helpers` | CLI test harness library — `createHarness`/`exec` API for spawning TS Legacy and TS Next CLI subprocesses in tests. | -| `packages/config` | JSON Schema and generated TypeScript types for Supabase configuration. | -| `packages/process-compose` | TypeScript/Bun port of `process-compose` used for multi-service orchestration. | -| `packages/stack` | Programmatic local Supabase stack used by the CLI and other tooling. | -| `packages/cli-darwin-arm64` | Published native CLI binary wrapper for macOS arm64. | -| `packages/cli-darwin-x64` | Published native CLI binary wrapper for macOS x64. | -| `packages/cli-linux-arm64` | Published native CLI binary wrapper for Linux arm64 (glibc). | -| `packages/cli-linux-arm64-musl` | Published native CLI binary wrapper for Linux arm64 (musl). | -| `packages/cli-linux-x64` | Published native CLI binary wrapper for Linux x64 (glibc). | -| `packages/cli-linux-x64-musl` | Published native CLI binary wrapper for Linux x64 (musl). | -| `packages/cli-windows-x64` | Published native CLI binary wrapper for Windows x64. | +| Workspace | Purpose | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `packages/api` | Auto-generated TypeScript client for the Supabase Management API. | +| `packages/cli-test-helpers` | CLI test harness library — `createHarness`/`exec` API for spawning TS Legacy and TS Next CLI subprocesses in tests. | +| `packages/config` | JSON Schema and generated TypeScript types for Supabase configuration. | +| `packages/process-compose` | TypeScript/Bun port of `process-compose` used for multi-service orchestration. | +| `packages/stack` | Programmatic local Supabase stack used by the CLI and other tooling. | +| `packages/cli-darwin-arm64` | Published native CLI binary wrapper for macOS arm64. | +| `packages/cli-darwin-x64` | Published native CLI binary wrapper for macOS x64. | +| `packages/cli-linux-arm64` | Published native CLI binary wrapper for Linux arm64 (glibc). | +| `packages/cli-linux-arm64-musl` | Published native CLI binary wrapper for Linux arm64 (musl). | +| `packages/cli-linux-x64` | Published native CLI binary wrapper for Linux x64 (glibc). | +| `packages/cli-linux-x64-musl` | Published native CLI binary wrapper for Linux x64 (musl). | +| `packages/cli-windows-x64` | Published native CLI binary wrapper for Windows x64. | ## Working In The Monorepo @@ -143,22 +143,22 @@ pnpm run fix:all # run all fixers across every project All standard TypeScript workspaces (`apps/cli`, `packages/api`, `packages/config`, `packages/process-compose`, `packages/stack`) expose the following scripts: -| Script | What it does | -|--------|--------------| -| `test` | Run the full test suite (unit + integration + e2e) | -| `test:core` | Run unit and integration tests | -| `test:unit` | Run unit tests _(inferred by Nx plugin)_ | -| `test:integration` | Run integration tests _(inferred by Nx plugin)_ | -| `test:e2e` | Run end-to-end tests _(inferred by Nx plugin)_ | -| `check:all` | Run all check targets for this project | -| `fix:all` | Run all fix targets for this project | -| `types:check` | Type-check with `tsc --noEmit` _(inferred by Nx plugin)_ | -| `lint:check` | Check for lint errors with `oxlint` _(inferred by Nx plugin)_ | -| `lint:fix` | Auto-fix lint errors _(inferred by Nx plugin)_ | -| `fmt:check` | Check formatting with `oxfmt --check` _(inferred by Nx plugin)_ | -| `fmt:fix` | Auto-fix formatting _(inferred by Nx plugin)_ | -| `knip:check` | Find unused exports and dependencies with `knip-bun` _(inferred by Nx plugin)_ | -| `knip:fix` | Auto-remove unused exports and dependencies _(inferred by Nx plugin)_ | +| Script | What it does | +| ------------------ | ------------------------------------------------------------------------------ | +| `test` | Run the full test suite (unit + integration + e2e) | +| `test:core` | Run unit and integration tests | +| `test:unit` | Run unit tests _(inferred by Nx plugin)_ | +| `test:integration` | Run integration tests _(inferred by Nx plugin)_ | +| `test:e2e` | Run end-to-end tests _(inferred by Nx plugin)_ | +| `check:all` | Run all check targets for this project | +| `fix:all` | Run all fix targets for this project | +| `types:check` | Type-check with `tsc --noEmit` _(inferred by Nx plugin)_ | +| `lint:check` | Check for lint errors with `oxlint` _(inferred by Nx plugin)_ | +| `lint:fix` | Auto-fix lint errors _(inferred by Nx plugin)_ | +| `fmt:check` | Check formatting with `oxfmt --check` _(inferred by Nx plugin)_ | +| `fmt:fix` | Auto-fix formatting _(inferred by Nx plugin)_ | +| `knip:check` | Find unused exports and dependencies with `knip-bun` _(inferred by Nx plugin)_ | +| `knip:fix` | Auto-remove unused exports and dependencies _(inferred by Nx plugin)_ | The inferred scripts (`test:unit`, `test:integration`, `test:e2e`, `types:check`, `lint:*`, `fmt:*`, `knip:*`) are not declared in `package.json` — they are injected by local Nx plugins in `tools/nx-plugins/`. They are fully cached and can be discovered via `nx show project `. @@ -176,18 +176,41 @@ pnpm run check:all ## E2E Compatibility Test Suite -`apps/cli-e2e` implements a record-and-replay test harness for testing the TypeScript Legacy CLI (`ts-legacy`, the only shipped CLI shell) against real Supabase Management API responses without hitting staging on every run. It still shells out to the bundled Go binary for the handful of commands the TS port proxies (`db diff`, `db pull`, `db branch *`, `db remote *`, `gen keys`, `functions download`), so `apps/cli-go/` is built alongside the TS CLI for this suite, but the suite itself no longer compares Go and TS output — that go-target parity harness was retired once the legacy port and the CLI-1970 Go binary trim landed. +`apps/cli-e2e` implements the replay-and-record compatibility harness for the TypeScript Legacy CLI (`ts-legacy`, the only shipped CLI shell). Live tests are owned by `apps/cli` and run from the command they cover. The CLI still shells out to the bundled Go binary for the handful of commands the TS port proxies (`db diff`, `db pull`, `db branch *`, `db remote *`, `gen keys`, `functions download`), so `apps/cli-go/` is built alongside the TS CLI for these suites, but there is no Go-vs-TypeScript parity runner. ### Architecture -Fixtures are recorded by running `ts-legacy` against the real Supabase staging API and capturing the request/response pairs. Every other run replays those committed fixtures against the same CLI, so tests are fast and deterministic with no network access. +Replay fixtures are recorded by running `ts-legacy` against the real Supabase staging API and capturing request/response pairs. Replay runs serve those committed fixtures back to the same CLI, so compatibility tests are fast and deterministic with no network access. The replay/record suite remains entirely under `apps/cli-e2e`. -The harness works in two modes: +The replay/record harness has two modes: -| Mode | When | What it does | -|------|------|-------------| +| Mode | When | What it does | +| -------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------- | | **Replay** (default) | Every PR / local dev | Loads committed fixtures; serves recorded responses to the CLI subprocess. Fast and deterministic — no network access. | -| **Record** | `RECORD=true` | Proxies CLI traffic to staging and captures request/response pairs as fixture files. | +| **Record** | `RECORD=true` | Proxies CLI traffic to staging and captures request/response pairs as fixture files. | + +### Live remote-project coverage + +The live suite lives in `apps/cli/src/**` as collocated `*.live.test.ts` files and runs in the CLI package's separate, serial `live` Vitest project. Global setup requires `SUPABASE_LIVE_API_URL` and `SUPABASE_ACCESS_TOKEN`, then provisions one uniquely named project through the typed Management API client, waits for it to become healthy, creates the shared storage fixture, and writes a temporary YAML profile. Every live subprocess receives that profile, so the same contract works with Supabox, a Docker-hosted API platform, or staging by changing only the URL and token. Teardown always removes the temporary profile and deletes the exact owned project unless `SUPABASE_LIVE_KEEP_PROJECT=1` is set. + +The configured URL is the Management API endpoint. Tenant data-plane URLs keep +the CLI profile contract (`https://.`) using the host derived +from the provisioned project's database metadata. + +Live coverage is smoke coverage, not an exhaustive command matrix. Add one representative golden-path test for each user-facing command, colocated beside that command. A live test should assert one target command; setup and teardown may invoke other commands when they prepare or clean up state, but those commands are not asserted in that test. Keep validation, formatting, fallback, error, and matrix details in integration tests unless the remote/runtime boundary itself is the behavior under test. See [ADR 0013](docs/adr/0013-live-e2e-bypasses-replay-server.md) and [`apps/cli/live.env.example`](apps/cli/live.env.example). + +To run the live suite locally, copy [`apps/cli/live.env.example`](apps/cli/live.env.example), set the API URL and access token for the target platform, and run the Nx target from the repository root. The target's build dependency prepares the CLI artifacts before Vitest starts: + +```sh +pnpm exec nx run supabase:test:live +``` + +Optional `SUPABASE_LIVE_ORG_ID`, `SUPABASE_LIVE_REGION`, and +`SUPABASE_LIVE_PROJECT_NAME` values select provisioning details. Set +`SUPABASE_LIVE_KEEP_PROJECT=1` only when debugging a failed run; the temporary +profile is still cleaned up. + +Live CI is manual or daily scheduled and is not PR-blocking; run it manually on a PR branch when you need pre-merge remote coverage. ### Running the tests @@ -303,13 +326,13 @@ supabase --version ### Troubleshooting -| Problem | Fix | -|---------|-----| -| `Error: Something is already running on port 4873` | Kill the leftover Verdaccio process (`lsof -ti:4873 \| xargs kill`) and retry | -| `go not found in PATH` (legacy only) | Install Go from https://go.dev/dl/ | -| `Error: Go CLI source not found` (legacy only) | Run `pnpm repos:install` to clone `apps/cli-go` | +| Problem | Fix | +| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Error: Something is already running on port 4873` | Kill the leftover Verdaccio process (`lsof -ti:4873 \| xargs kill`) and retry | +| `go not found in PATH` (legacy only) | Install Go from https://go.dev/dl/ | +| `Error: Go CLI source not found` (legacy only) | Run `pnpm repos:install` to clone `apps/cli-go` | | `npm` / `pnpm` tries to fetch from `localhost:4873` when no registry is running | Stale global registry override left behind by an older version of `local-registry.ts` (the current script never modifies global config). Run `npm config delete registry` and `pnpm config delete registry`. Note that pnpm stores the override in its own global config (`~/Library/Preferences/pnpm/auth.ini` on macOS, `~/.config/pnpm/` on Linux), not `~/.npmrc` — check there if the delete command fails | -| `npx` resolves from npm instead of local | Pass `--registry http://localhost:4873` explicitly to `npx` / `npm install` | +| `npx` resolves from npm instead of local | Pass `--registry http://localhost:4873` explicitly to `npx` / `npm install` | ## Using Nx diff --git a/apps/cli-e2e/.env.example b/apps/cli-e2e/.env.example index 24e2ff10e9..77de786a27 100644 --- a/apps/cli-e2e/.env.example +++ b/apps/cli-e2e/.env.example @@ -1,18 +1,14 @@ -# cli-e2e environment — copy to `.env.local` (gitignored) and fill in. -# Only the live/record modes need real values; replay mode (the default) needs none. +# cli-e2e replay/record environment — copy to `.env.local` (gitignored) and fill in. +# Replay mode (the default) needs no environment variables. -# Mode: replay (default, no creds) | record (capture fixtures) | live (ADR-0013). -CLI_E2E_MODE=live - -# Backend the live/record suite targets. Only `staging` is wired today. -CLI_E2E_TARGET_ENV=staging +# Set RECORD=true (or CLI_E2E_MODE=record) to capture fixtures from staging. +CLI_E2E_MODE=record # CLI target under test: ts-legacy (the shipped shell, default) | ts-next. # (The `go` target was retired when the Go CLI was trimmed to the proxied subset.) CLI_HARNESS_TARGET=ts-legacy -# Staging Management API token. Either name works (the suite also reads -# SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN). Required in record/live mode. +# Staging Management API token. Required in record mode. SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # ts-legacy shells out to the bundled Go binary for the proxied commands @@ -21,14 +17,9 @@ SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # cd apps/cli-go && go build -o /tmp/supabase-test-binary . SUPABASE_GO_BINARY=/tmp/supabase-test-binary -# --- Optional overrides (sensible defaults in src/tests/env.ts) --- -# Management API base + per-project host (default to staging: api.supabase.green / supabase.red). +# --- Optional record overrides (sensible defaults in src/tests/env.ts) --- +# Management API base (also accepted as SUPABASE_STAGING_URL). # CLI_E2E_API_URL=https://api.supabase.green -# CLI_E2E_PROJECT_HOST=supabase.red -# DB password for the ephemeral project (default: random per run). +# DB password for the recording project (default: random per run). # CLI_E2E_DB_PASSWORD= -# Skip org resolution / region / pick a specific org. -# CLI_E2E_ORG_ID= # CLI_E2E_REGION=us-east-1 -# Leave the ephemeral live project alive after the run (debugging). -# CLI_E2E_KEEP_PROJECT=1 diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index b309111a1f..d66cc8f665 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -141,18 +141,6 @@ In **record mode**: global setup resolves the org, deletes any orphaned test pro The pre-recording cleanup deletes projects named `cli-e2e-test`, `my-project`, and `to-delete` so re-recording never hits a 409 name-conflict. Do not add tests that rely on pre-existing named projects existing on staging. -## Live mode (ADR-0013) - -`live` is a third mode (`CLI_E2E_MODE=live`) that, unlike replay/record, **does not use the replay server**. The harness is wired straight at the real Management API (`CLI_E2E_API_URL`) and the real Docker socket; tests assert on **real outcomes**. - -- Live tests are `src/tests/live/**/*.live.e2e.test.ts`, run only via `vitest.live.config.ts` (the default config excludes them). They `skipIf(!isLive)`, so they are inert on the replay suite. -- Global setup (`tests/live-setup.ts`) provisions **one ephemeral project per run** (`cli-e2e-live-{target}-{runId}-{short}`), waits for `ACTIVE_HEALTHY`, resolves the anon JWT, the IPv4 **session-pooler `dbUrl`** (for `--db-url` DB commands), the functions URL, and a seeded storage bucket, exposing them via `inject()`. It deletes the project on teardown (even on failure). Setup is intentionally **dumb** — no provisioning retry; the CI job re-runs the step on flake. -- Use `testLive` from `src/tests/live/live-context.ts`: `run(cmd)` (direct-wired CLI), `invoke(slug)` (direct HTTP call sending the **anon JWT** in both `Authorization: Bearer` and `apikey`), plus `workspace` (a fresh `supabase init` config so golden paths exercise a generated config), `projectRef`, `anonKey`, `functionsUrl`, `dbUrl`, `storageBucket`. The functions deploy tests call `seedFunctions(workspace.path)` to layer the `deploy-e2e-*` fixtures + their `[functions.*]` config onto the init'd config. -- **Assertion style:** outcome-based — assert `exitCode`/`stdout` substrings and the function's HTTP status + JSON body. This is ID-agnostic, so **no normalization/snapshots by default**. If the CLI's own diagnostic output is ever the assertion target, add a scoped normalizer for that one test — do not make normalization the default. -- **Authoring/CI target is `ts-legacy`** — the only shipped CLI shell. It still shells out to the Go binary for the handful of commands the TS port proxies (`db diff`, `db pull`, `db branch *`, `db remote *`, `gen keys`, `functions download`), so `SUPABASE_GO_BINARY` must point at a built Go binary for those to resolve. -- Retargeting to another env (e.g. `supabox`) is an env swap only: `CLI_E2E_TARGET_ENV` + `CLI_E2E_API_URL` + `CLI_E2E_PROJECT_HOST` + token. Tests assert on function output, not hostnames. -- **CI triggers** (`.github/workflows/live-e2e.yml`): `workflow_dispatch` (manual; the Actions branch picker selects the ref — no free-form `ref` input, so the staging token never reaches arbitrary code) and an hourly `schedule`. There is **no `pull_request` trigger** — run it manually on a PR branch for pre-merge coverage. The scheduled run exercises the `@beta` channel: `develop` is the default branch and the beta release source, so it builds from `develop` source and runs the `ts-legacy` job. A `gate` job skips the run unless the published `supabase@beta` version changed since the last green run (an `actions/cache` marker keyed on the version, written by `finalize` only after the job passes), so a staging project is spent only when there is a new beta to test. Because the marker is written only on a green run, a chronically-failing `@beta` keeps re-running every hour until it goes green or a newer beta supersedes it (intended — the failure stays visible). - ## Running the suite ```sh @@ -162,18 +150,11 @@ pnpm nx run @supabase/cli-e2e:test:legacy # ts-legacy target # Record (requires staging access) SUPABASE_ACCESS_TOKEN=sbp_... SUPABASE_STAGING_URL=https://api.supabase.green \ pnpm nx run @supabase/cli-e2e:record - -# Live (requires staging access; creates + deletes a real project; needs Docker). -# Build the Go binary first so newly-added proxy commands resolve (the system -# `supabase` may be stale) — mirrors what CI does. -cd apps/cli-go && go build -o /tmp/supabase-test-binary . && cd - -SUPABASE_GO_BINARY=/tmp/supabase-test-binary \ - SUPABASE_ACCESS_TOKEN=sbp_... \ - pnpm --filter @supabase/cli-e2e test:e2e:live ``` -See `apps/cli-e2e/.env.example` for the full set of live/record env vars (copy to -a gitignored `.env.local`). +See `apps/cli-e2e/.env.example` for replay/record env vars (copy to a gitignored +`.env.local`). Live environment setup is documented in `apps/cli/AGENTS.md` and +`apps/cli/live.env.example`. After recording, replay must pass with no changes between the two commands. diff --git a/apps/cli-e2e/fixtures/live/functions-config.toml b/apps/cli-e2e/fixtures/live/functions-config.toml deleted file mode 100644 index d210d7800e..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-config.toml +++ /dev/null @@ -1,19 +0,0 @@ -# Per-function config appended onto the `supabase init`-generated config.toml by -# seedFunctions() for the functions deploy tests (the import-map, custom -# entrypoint, static-file, and no-jwt fixtures need these). Everything else runs -# against the bare generated config. - -[functions."deploy-e2e-root-map"] -import_map = "./import_map.json" - -[functions."deploy-e2e-custom-entry"] -entrypoint = "./functions/deploy-e2e-custom-entry/handler.ts" - -[functions."deploy-e2e-static-in-fn"] -static_files = ["./functions/deploy-e2e-static-in-fn/static/*.txt"] - -[functions."deploy-e2e-static-asset"] -static_files = ["./assets/*.svg", "./functions/deploy-e2e-static-asset/assets/*.svg"] - -[functions."deploy-e2e-no-jwt"] -verify_jwt = false diff --git a/apps/cli-e2e/fixtures/live/functions-project/assets/badge.svg b/apps/cli-e2e/fixtures/live/functions-project/assets/badge.svg deleted file mode 100644 index 914f94e2e0..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/assets/badge.svg +++ /dev/null @@ -1,3 +0,0 @@ - - outside-static - diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/_shared/greet.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/_shared/greet.ts deleted file mode 100644 index d901eb79d4..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/_shared/greet.ts +++ /dev/null @@ -1 +0,0 @@ -export const greet = () => "hello"; diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/index.ts deleted file mode 100644 index cc000c3fc7..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-basic", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/handler.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/handler.ts deleted file mode 100644 index ff43ad2065..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/handler.ts +++ /dev/null @@ -1,3 +0,0 @@ -Deno.serve(() => - Response.json({ case: "deploy-e2e-custom-entry", ok: true, entry: "handler.ts" }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/deno.jsonc b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/deno.jsonc deleted file mode 100644 index 6f14fbcc6e..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/deno.jsonc +++ /dev/null @@ -1,6 +0,0 @@ -{ - // scoped alias with comments - "imports": { - "@shared/": "../_shared/" - } -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/index.ts deleted file mode 100644 index 8b1ba2da96..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@shared/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-deno-jsonc", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/import_map.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/import_map.json deleted file mode 100644 index 4e99a415b5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/import_map.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "@shared/": "../_shared/" - } -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/index.ts deleted file mode 100644 index 21231dc870..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@shared/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-deprecated-map", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/index.ts deleted file mode 100644 index 41a2055f44..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -Deno.serve(async () => { - const { value } = await import("./lazy.ts"); - return Response.json({ case: "deploy-e2e-dynamic-import", ok: true, value }); -}); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/lazy.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/lazy.ts deleted file mode 100644 index 636afa7830..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/lazy.ts +++ /dev/null @@ -1 +0,0 @@ -export const value = "lazy-ok"; diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/index.ts deleted file mode 100644 index b136d09c48..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - -Deno.serve((req) => - Response.json({ case: "deploy-e2e-jsr", ok: true, method: req.method }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/index.ts deleted file mode 100644 index 81648d03de..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-jwt-required", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/helpers.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/helpers.ts deleted file mode 100644 index 16e3e308e4..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const suffix = "-imports"; diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/index.ts deleted file mode 100644 index fb7ea13f9d..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { greet } from "../_shared/greet.ts"; -import { suffix } from "./helpers.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-local-imports", ok: true, message: greet() + suffix }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/deno.json deleted file mode 100644 index f6ca8454c5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/index.ts deleted file mode 100644 index e344e16514..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-mode-api", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/deno.json deleted file mode 100644 index f6ca8454c5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/index.ts deleted file mode 100644 index dbdfe144ff..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-mode-default", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/deno.json deleted file mode 100644 index f6ca8454c5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/index.ts deleted file mode 100644 index fcd8ea060a..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-mode-docker", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/index.ts deleted file mode 100644 index 1697305182..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-no-jwt", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/index.ts deleted file mode 100644 index 76b0dbb54a..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createClient } from "npm:@supabase/supabase-js@2"; - -Deno.serve(() => { - const client = createClient("https://example.supabase.co", "anon-key"); - return Response.json({ - case: "deploy-e2e-npm", - ok: true, - hasClient: typeof client.from === "function", - }); -}); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/index.ts deleted file mode 100644 index c2671f20ac..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-package-json", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/package.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/package.json deleted file mode 100644 index b667d153ab..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "module", - "dependencies": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/index.ts deleted file mode 100644 index b911f4475e..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-remote-only", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/index.ts deleted file mode 100644 index fd1cd5a53f..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@root/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-root-map", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/deno.json deleted file mode 100644 index 4e99a415b5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/deno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "@shared/": "../_shared/" - } -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/index.ts deleted file mode 100644 index 783b8506d6..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@shared/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-scoped-map", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/assets/badge.svg b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/assets/badge.svg deleted file mode 100644 index 914f94e2e0..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/assets/badge.svg +++ /dev/null @@ -1,3 +0,0 @@ - - outside-static - diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/index.ts deleted file mode 100644 index 9a598ec2c9..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// static_files bundles supabase/assets/*.svg (outside functions/) plus the function-local -// assets/ copy used at runtime (same pattern as deploy-e2e-static-in-fn). -Deno.serve(async () => { - const svg = await Deno.readTextFile(new URL("./assets/badge.svg", import.meta.url)); - return Response.json({ - case: "deploy-e2e-static-asset", - ok: true, - static: svg.includes("outside-static") || svg.includes(" { - const text = await Deno.readTextFile(new URL("./static/note.txt", import.meta.url)); - return Response.json({ - case: "deploy-e2e-static-in-fn", - ok: true, - static: text.trim(), - }); -}); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/static/note.txt b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/static/note.txt deleted file mode 100644 index 99337dc661..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/static/note.txt +++ /dev/null @@ -1 +0,0 @@ -in-fn-static diff --git a/apps/cli-e2e/fixtures/live/functions-project/import_map.json b/apps/cli-e2e/fixtures/live/functions-project/import_map.json deleted file mode 100644 index c84d752202..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/import_map.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "@root/": "./functions/_shared/" - } -} diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index 84261f76a4..3d34d2ca2b 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -8,7 +8,6 @@ "test:e2e": "bun --bun vitest run", "test:legacy": "CLI_HARNESS_TARGET=ts-legacy bun --bun vitest run", "test:next": "CLI_HARNESS_TARGET=ts-next bun --bun vitest run", - "test:e2e:live": "CLI_E2E_MODE=live CLI_E2E_TARGET_ENV=staging bun --bun vitest run --config vitest.live.config.ts", "record": "RECORD=true CLI_HARNESS_TARGET=ts-legacy bun --bun vitest run", "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" @@ -30,9 +29,8 @@ "knip": { "entry": [ "src/**/*.e2e.test.ts", - "src/**/*.live.e2e.test.ts", "tests/**/*.ts", - "vitest.live.config.ts" + "vitest.config.ts" ], "ignore": [ "fixtures/**" diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 57c1af2cc7..50f70ddbc4 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -1,18 +1,15 @@ import type { CLITarget } from "@supabase/cli-test-helpers"; -type CliE2eMode = "replay" | "record" | "live"; -type CliE2eTargetEnv = "staging" | "supabox"; +type CliE2eMode = "replay" | "record"; // Runtime mode. `replay` (default) serves recorded fixtures; `record` proxies to -// staging and captures fixtures; `live` (ADR-0013) bypasses the replay server and -// wires the CLI straight at the real Management API + Docker socket. +// staging and captures fixtures. // Back-compat: RECORD=true still maps to `record`. const MODE: CliE2eMode = (process.env["CLI_E2E_MODE"] as CliE2eMode | undefined) ?? (process.env["RECORD"] === "true" ? "record" : "replay"); export const isRecording = MODE === "record"; -export const isLive = MODE === "live"; // The replay server + tests/setup.ts key recording off the RECORD env var // directly. Keep RECORD in sync with MODE in BOTH directions so an explicit @@ -31,42 +28,14 @@ if (isRecording && !process.env["SUPABASE_STAGING_URL"] && process.env["CLI_E2E_ process.env["SUPABASE_STAGING_URL"] = process.env["CLI_E2E_API_URL"]; } -// Which backend the live/record suite targets. Only `staging` is wired today; -// `supabox` is a later env swap (CLI_E2E_API_URL + CLI_E2E_PROJECT_HOST + token). -const TARGET_ENV: CliE2eTargetEnv = - (process.env["CLI_E2E_TARGET_ENV"] as CliE2eTargetEnv | undefined) ?? "staging"; - -// Base Management API URL for record/live modes (the real API). In live mode the -// harness apiUrl is wired here directly — there is no replay server in front. -// Replay mode never reads this. -export const TARGET_API_URL = - process.env["CLI_E2E_API_URL"] ?? - process.env["SUPABASE_STAGING_URL"] ?? - "https://api.supabase.green"; - -// Host used to build the deployed-function invoke URL: -// https://{ref}.{PROJECT_HOST}/functions/v1 -// Environment-specific (staging is not supabase.co), so it is configurable. -export const PROJECT_HOST = - process.env["CLI_E2E_PROJECT_HOST"] ?? (TARGET_ENV === "staging" ? "supabase.red" : ""); - -// In replay mode the token never reaches a real API, but the Go CLI validates -// the format before making any request (must match sbp_[a-f0-9]{40}). -// In record/live mode it must be a valid token for the target env. Falls back to -// the live staging secret name so a local `.env.local` works without remapping. +// In replay mode the token never reaches a real API, but the CLI validates the +// format before making any request (must match sbp_[a-f0-9]{40}). In record mode +// it must be a valid token for the staging API. export const ACCESS_TOKEN = - process.env["SUPABASE_ACCESS_TOKEN"] ?? - process.env["SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN"] ?? - "sbp_0000000000000000000000000000000000000000"; - -// Whether a real token was supplied (vs the replay placeholder above). Live mode -// must fail fast on a missing token instead of letting every API call 401. -export const isAccessTokenProvided = Boolean( - process.env["SUPABASE_ACCESS_TOKEN"] ?? process.env["SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN"], -); + process.env["SUPABASE_ACCESS_TOKEN"] ?? "sbp_0000000000000000000000000000000000000000"; // Which target to run. Defaults to "ts-legacy" — the only shipped CLI shell and -// therefore the authoritative target for both recording and live tests. Validated +// therefore the authoritative target for replay and recording. Validated // eagerly so a stale value (e.g. the retired "go" target) fails with a clear error // instead of an undefined-command crash inside the harness. const VALID_TARGETS: ReadonlyArray = ["ts-legacy", "ts-next"]; @@ -80,16 +49,9 @@ if (matchedTarget === undefined) { } export const TARGET = matchedTarget; -// Optional org for the fresh live project. When unset, live-setup resolves it via -// `orgs list` (which also exercises that command against the real API). -export const ORG_ID_OVERRIDE = process.env["CLI_E2E_ORG_ID"]; - -// Region for the fresh live project. +// Region for the fresh recording project. export const REGION = process.env["CLI_E2E_REGION"] ?? "us-east-1"; -// Skip live-project teardown for debugging. -export const KEEP_PROJECT = process.env["CLI_E2E_KEEP_PROJECT"] === "1"; - // In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__ // in the fixture key. In record mode supply a real project ref via env. export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? "aaaaaaaaaaaaaaaaaaaa"; diff --git a/apps/cli-e2e/src/tests/live/branches.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/branches.live.e2e.test.ts deleted file mode 100644 index 5c88ce20ce..0000000000 --- a/apps/cli-e2e/src/tests/live/branches.live.e2e.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Preview branches (workflow 3). `branches create` provisions a real branch and -// requires a paid plan; the cli-e2e test org may be on the free plan, in which -// case the CLI must surface the plan requirement rather than crash. Handle both: -// on a paid org, create → list → delete; on a free org, assert the plan-gate. -describe("branches (live)", () => { - testLive("create + list + delete (or surface the plan gate)", async ({ run, projectRef }) => { - // Unique per attempt so a retry (vitest retry:2) after a post-create flake - // can't collide on the name; a finally guarantees cleanup either way. - const name = `e2e-branch-${Date.now()}`; - const created = await run(["branches", "create", name, "--project-ref", projectRef]); - - if (created.exitCode !== 0) { - // Free-plan org: the command must clearly report that branching needs a - // paid plan (not fail opaquely). - expect(created.stderr, created.stderr).toMatch(/paid plan|upgrade|not.*support/i); - return; - } - - let branchDeleted = false; - try { - expect(created.stdout).toContain("Created preview branch"); - - const listed = await run([ - "branches", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(listed.exitCode, listed.stderr).toBe(0); - const names = (JSON.parse(listed.stdout) as Array<{ name?: string }>).map((b) => b.name); - expect(names).toContain(name); - - const deleted = await run(["branches", "delete", name, "--project-ref", projectRef, "--yes"]); - expect(deleted.exitCode, deleted.stderr).toBe(0); - branchDeleted = true; - } finally { - // Retry/leak safety: clean up only if the in-try delete didn't already - // succeed (e.g. an earlier assertion threw). Tolerates a not-found branch. - if (!branchDeleted) { - await run(["branches", "delete", name, "--project-ref", projectRef, "--yes"]); - } - } - }); -}); diff --git a/apps/cli-e2e/src/tests/live/database.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/database.live.e2e.test.ts deleted file mode 100644 index 6a99f7131f..0000000000 --- a/apps/cli-e2e/src/tests/live/database.live.e2e.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// DB-connectivity commands against the fresh project's Postgres via the IPv4 -// session-mode Supavisor pooler (`dbUrl` from live-setup). The direct host -// (db..supabase.red) is IPv6-only and unreachable from IPv4-only CI -// runners; the pooler is IPv4, and session mode is required for pg_dump. -// A non-zero exit here means the connection itself failed. -describe("database (live, session pooler --db-url)", () => { - testLive("inspect db db-stats connects and reports stats", async ({ run, dbUrl }) => { - const res = await run(["inspect", "db", "db-stats", "--db-url", dbUrl]); - expect(res.exitCode, res.stderr).toBe(0); - expect(res.stdout).toContain("Database Size"); - }); - - testLive("migration list connects to the remote migration history", async ({ run, dbUrl }) => { - const res = await run(["migration", "list", "--db-url", dbUrl]); - // Fresh project has no migrations, but exit 0 proves it connected and - // queried the remote history table. - expect(res.exitCode, res.stderr).toBe(0); - }); - - testLive("db dump exports the remote schema", async ({ run, dbUrl, workspace }) => { - const file = join(workspace.path, "dump.sql"); - const res = await run(["db", "dump", "--db-url", dbUrl, "-f", file]); - expect(res.exitCode, res.stderr).toBe(0); - expect(existsSync(file)).toBe(true); - expect(readFileSync(file, "utf8")).toMatch(/CREATE|PostgreSQL database dump|SCHEMA/i); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts deleted file mode 100644 index d3fba9d7e7..0000000000 --- a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { TARGET } from "../env.ts"; -import { testLive } from "./live-context.ts"; - -// Real-backend live coverage for the native `db start` / `db reset` ports. -// -// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` -// port (the `next` shell has no `db` group), so skip the `ts-next` target. -// -// The live suite runs serially (`fileParallelism: false`, `maxWorkers: 1`), so the -// destructive remote reset below is safe against the throwaway per-run project. - -// --- Local leg: db start + db reset --local against the real Docker socket ----- -// Exercises `db start`'s native container-bootstrap sequence (network/volume/container -// bring-up, health wait, the fresh-volume SetupLocalDatabase-equivalent pipeline, and -// `_current_branch`) and `db reset --local`'s container-recreate flow end-to-end — the -// real-Docker boundary the in-process integration suites mock. Both are fully native TS -// now: `db reset --local`'s hidden Go `db __db-bootstrap` seam (`--mode recreate`/ -// `--mode await-storage`) was removed in CLI-1955 (see -// `commands/db/reset/reset.handler.ts` / `shared/db-bootstrap/recreate-local-database.ts`), -// the same way `db start`'s own seam usage was removed in CLI-1954 (see -// `commands/db/start/start.handler.ts`). The start → already-running → reset cycle runs -// in one test so it shares a single booted stack, and `finally` stops it (legacy proxies -// `stop` to Go) so the run never leaves containers behind. -describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { - testLive( - "db start boots, is idempotent, and db reset --local recreates", - { timeout: 600_000 }, - async ({ run }) => { - try { - const start = await run(["db", "start"]); - expect(start.exitCode, start.stderr).toBe(0); - // Bootstrap progress goes to stderr on every target (Go, and native TS since CLI-1954). - expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); - - // Second start is a no-op: the db is already running, exit 0. - const again = await run(["db", "start"]); - expect(again.exitCode, again.stderr).toBe(0); - expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); - - // Local reset recreates the container and prints the git-branch line. - const reset = await run(["db", "reset", "--local"]); - expect(reset.exitCode, reset.stderr).toBe(0); - expect(reset.stderr).toContain("on branch "); - } finally { - await run(["stop", "--no-backup"]).catch(() => undefined); - } - }, - ); -}); - -// --- Remote leg: db reset against the staging project over the session pooler --- -// Exercises the native remote reset path (drop user schemas → apply local -// migrations → seed) against a real Postgres, no Docker. `--yes` auto-accepts the -// confirmation prompt (the non-interactive default is decline). Mutates the -// throwaway project's schema — deleted on teardown. The IPv4 session pooler -// `dbUrl` is used because the direct host is IPv6-only and unreachable from -// IPv4-only CI runners. -describe.skipIf(TARGET === "ts-next")("db reset (live, remote session pooler)", () => { - testLive( - "resets the remote schema and re-applies a local migration", - { timeout: 600_000 }, - async ({ run, dbUrl, workspace }) => { - const migrations = join(workspace.path, "supabase", "migrations"); - mkdirSync(migrations, { recursive: true }); - writeFileSync( - join(migrations, "20240101000000_e2e_reset.sql"), - "create table if not exists e2e_reset (id int);\n", - ); - - const reset = await run(["db", "reset", "--db-url", dbUrl, "--yes"]); - expect(reset.exitCode, reset.stderr).toBe(0); - expect(reset.stderr).toContain("Resetting remote database"); - // A real connection failure must never be mistaken for a benign outcome. - expect(`${reset.stdout}${reset.stderr}`, "db reset hit a connection error").not.toMatch( - /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, - ); - - // The migration history shows the re-applied version → proves the drop + - // migrate ran against the remote database. - const listed = await run(["migration", "list", "--db-url", dbUrl]); - expect(listed.exitCode, listed.stderr).toBe(0); - expect(listed.stdout).toContain("20240101000000"); - }, - ); -}); diff --git a/apps/cli-e2e/src/tests/live/db-sync.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-sync.live.e2e.test.ts deleted file mode 100644 index 4780b56537..0000000000 --- a/apps/cli-e2e/src/tests/live/db-sync.live.e2e.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Local↔remote schema sync (workflows 1-2) over the IPv4 session pooler. Done as -// one round-trip in a single workspace: pushing first makes the local migration -// history match the remote, so the subsequent pull's consistency check passes -// (a separate fresh-workspace pull would see a history mismatch on the shared -// per-run project). db push/pull confirm via a prompt that only auto-accepts -// with --yes. Mutates the throwaway project's schema — deleted on teardown. -describe("db push + pull (live, session pooler)", () => { - testLive( - "pushes a local migration and pulls the remote schema back", - async ({ run, dbUrl, workspace }) => { - const migrations = join(workspace.path, "supabase", "migrations"); - mkdirSync(migrations, { recursive: true }); - writeFileSync( - join(migrations, "20240101000000_e2e_push.sql"), - "create table if not exists e2e_push (id int);\n", - ); - - const pushed = await run(["db", "push", "--db-url", dbUrl, "--yes"]); - expect(pushed.exitCode, pushed.stderr).toBe(0); - - const listed = await run(["migration", "list", "--db-url", dbUrl]); - expect(listed.exitCode, listed.stderr).toBe(0); - expect(listed.stdout).toContain("20240101000000"); - - // Local history now matches remote, so pull connects and runs the diff. - // It either finds a remote-only change (exit 0, writes a migration) or - // reports no changes — both prove connectivity; only a real connection - // failure would surface a different error. - const pulled = await run(["db", "pull", "--db-url", dbUrl, "--yes"]); - const pullOutput = `${pulled.stdout}${pulled.stderr}`; - // The point of this test is connectivity over the pooler: a real connection - // failure must never be mistaken for a benign "no changes" outcome. - expect(pullOutput, "db pull hit a connection error").not.toMatch( - /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, - ); - expect( - pulled.exitCode === 0 || /No schema changes found/i.test(pullOutput), - pulled.stderr, - ).toBe(true); - }, - ); -}); diff --git a/apps/cli-e2e/src/tests/live/functions-deploy.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/functions-deploy.live.e2e.test.ts deleted file mode 100644 index e9101cc1be..0000000000 --- a/apps/cli-e2e/src/tests/live/functions-deploy.live.e2e.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readdirSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { expectFunctionOk } from "./invoke.ts"; -import { seedFunctions, testLive } from "./live-context.ts"; - -// Pilot (ADR-0013): deploy with the real CLI across the three bundler paths, -// then invoke the deployed function over HTTP and assert the body it returns. -// Each mode deploys a DISTINCT slug so the invoke proves THAT mode's deploy -// produced a running function — the shared project means a single slug could -// otherwise be served by an earlier mode's deploy. Negative/arg-validation -// cases live in apps/cli integration tests. -const MODES = [ - { name: "default", slug: "deploy-e2e-mode-default", flags: [] as string[] }, - { name: "use-api", slug: "deploy-e2e-mode-api", flags: ["--use-api"] }, - { name: "use-docker", slug: "deploy-e2e-mode-docker", flags: ["--use-docker"] }, -] as const; - -describe.each(MODES)("functions deploy ($name)", ({ slug, flags }) => { - testLive("deploys and the function responds", async ({ run, invoke, workspace, projectRef }) => { - seedFunctions(workspace.path); - const deployed = await run([ - "functions", - "deploy", - slug, - "--project-ref", - projectRef, - ...flags, - ]); - expect(deployed.exitCode, deployed.stderr).toBe(0); - expect(deployed.stdout).toContain("Deployed Functions"); - - const res = await invoke(slug); - expectFunctionOk(res, slug); - }); -}); - -// No slug → the CLI walks every function declared under supabase/functions and -// deploys them all. Assert each declared function appears in the deploy output, -// then smoke-invoke a representative one. -testLive( - "deploys every declared function when no slug is given", - async ({ run, invoke, workspace, projectRef }) => { - seedFunctions(workspace.path); - const declared = readdirSync(join(workspace.path, "supabase", "functions"), { - withFileTypes: true, - }) - .filter((e) => e.isDirectory() && !e.name.startsWith("_")) - .map((e) => e.name); - expect(declared.length).toBeGreaterThan(1); - - const deployed = await run(["functions", "deploy", "--project-ref", projectRef]); - expect(deployed.exitCode, deployed.stderr).toBe(0); - expect(deployed.stdout).toContain("Deployed Functions"); - - // Each declared function must be listed in the deploy output AND respond - // with its own {case: slug, ok: true}. A handler returns that marker only if - // it actually executed — and for the npm/jsr/local-imports/scoped-map - // fixtures only if their imports resolved at runtime — so this proves the - // feature ran end-to-end, not merely that the function deployed and booted. - for (const slug of declared) { - expect(deployed.stdout, `expected "${slug}" in deploy output`).toContain(slug); - expectFunctionOk(await invoke(slug), slug); - } - }, -); diff --git a/apps/cli-e2e/src/tests/live/functions-lifecycle.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/functions-lifecycle.live.e2e.test.ts deleted file mode 100644 index b800b8bda2..0000000000 --- a/apps/cli-e2e/src/tests/live/functions-lifecycle.live.e2e.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Write a throwaway Edge Function into the test workspace so the lifecycle tests -// own a dedicated slug (the shared per-run project is cleaned up on teardown). -function writeFunction(workspacePath: string, slug: string, jsonBody: string): void { - const dir = join(workspacePath, "supabase", "functions", slug); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "index.ts"), `Deno.serve(() => Response.json(${jsonBody}));\n`); - writeFileSync(join(dir, "deno.json"), `{\n "imports": {}\n}\n`); -} - -// Active (non-REMOVED) function slugs. The Management API can keep deleted -// functions in the list with status REMOVED (the Go prune path skips them), so a -// successful delete may leave a REMOVED row — filter those out. -function activeSlugs(stdout: string): string[] { - return (JSON.parse(stdout) as Array<{ slug?: string; name?: string; status?: string }>) - .filter((f) => (f.status ?? "").toUpperCase() !== "REMOVED") - .map((f) => f.slug ?? f.name ?? ""); -} - -describe("functions update + delete (live)", () => { - // There is no dedicated `functions update` command — re-deploying a slug - // upserts it. Verify the second deploy replaces the running code. - testLive( - "re-deploying a function updates the running code", - async ({ run, invoke, workspace, projectRef }) => { - const slug = "deploy-e2e-update"; - - writeFunction(workspace.path, slug, `{ case: "${slug}", version: 1 }`); - expect((await run(["functions", "deploy", slug, "--project-ref", projectRef])).exitCode).toBe( - 0, - ); - expect((await invoke(slug)).body).toMatchObject({ case: slug, version: 1 }); - - writeFunction(workspace.path, slug, `{ case: "${slug}", version: 2 }`); - expect((await run(["functions", "deploy", slug, "--project-ref", projectRef])).exitCode).toBe( - 0, - ); - expect((await invoke(slug)).body).toMatchObject({ case: slug, version: 2 }); - }, - ); - - testLive("delete removes a deployed function", async ({ run, workspace, projectRef }) => { - const slug = "deploy-e2e-delete"; - - writeFunction(workspace.path, slug, `{ case: "${slug}", ok: true }`); - expect((await run(["functions", "deploy", slug, "--project-ref", projectRef])).exitCode).toBe( - 0, - ); - - const before = await run([ - "functions", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(before.exitCode, before.stderr).toBe(0); - expect(activeSlugs(before.stdout)).toContain(slug); - - const del = await run(["functions", "delete", slug, "--project-ref", projectRef]); - expect(del.exitCode, del.stderr).toBe(0); - expect(del.stdout).toContain("Deleted Function"); - - const after = await run(["functions", "list", "--output", "json", "--project-ref", projectRef]); - expect(after.exitCode, after.stderr).toBe(0); - expect(activeSlugs(after.stdout)).not.toContain(slug); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/gen-types.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/gen-types.live.e2e.test.ts deleted file mode 100644 index e75455989b..0000000000 --- a/apps/cli-e2e/src/tests/live/gen-types.live.e2e.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// gen types introspects the remote schema over the IPv4 session pooler and emits -// TypeScript types. It pulls the postgres-meta Docker image, so it needs Docker -// (present in the CI live job alongside the --use-docker bundler cell). -describe("gen types (live, session pooler)", () => { - testLive("generates TypeScript types from the remote schema", async ({ run, dbUrl }) => { - const res = await run(["gen", "types", "--db-url", dbUrl, "--lang", "typescript"]); - expect(res.exitCode, res.stderr).toBe(0); - expect(res.stdout).toMatch(/export type (Database|Json)/); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/invoke.ts b/apps/cli-e2e/src/tests/live/invoke.ts deleted file mode 100644 index a5efcb3e58..0000000000 --- a/apps/cli-e2e/src/tests/live/invoke.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect } from "vitest"; - -export interface InvokeResult { - status: number; - body: unknown; - text: string; -} - -/** Direct HTTP-invoke a deployed Edge Function and return status + parsed body. - * The replay server is not involved (ADR-0013) — this is a real call to the - * deployed function. Staging expects the publishable/anon key in BOTH the - * Authorization Bearer header and the apikey header. */ -export async function invokeFunction(opts: { - functionsUrl: string; - slug: string; - anonKey?: string; - payload?: unknown; -}): Promise { - const headers: Record = { "Content-Type": "application/json" }; - if (opts.anonKey) { - headers["Authorization"] = `Bearer ${opts.anonKey}`; - headers["apikey"] = opts.anonKey; - } - const res = await fetch(`${opts.functionsUrl}/${opts.slug}`, { - method: "POST", - headers, - body: JSON.stringify(opts.payload ?? {}), - }); - const text = await res.text(); - let body: unknown; - try { - body = JSON.parse(text); - } catch { - body = text; - } - return { status: res.status, body, text }; -} - -/** Assert the playbook's default per-slug expectation: 200 + `{case: slug, ok: true}`. */ -export function expectFunctionOk( - result: InvokeResult, - slug: string, - extra?: Record, -): void { - expect(result.status, result.text).toBe(200); - expect(result.body).toMatchObject({ case: slug, ok: true, ...extra }); -} diff --git a/apps/cli-e2e/src/tests/live/link.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/link.live.e2e.test.ts deleted file mode 100644 index f0c75b5e7b..0000000000 --- a/apps/cli-e2e/src/tests/live/link.live.e2e.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// `link` is the backbone of workflows 1-3. --skip-pooler keeps it -// Management-API-only (no IPv6-only DB connection): it validates the ref and -// writes the linked-project cache into the workspace's supabase/.temp. -describe("link (live)", () => { - testLive("links the project so ref-less commands resolve it", async ({ run, projectRef }) => { - const linked = await run(["link", "--project-ref", projectRef, "--skip-pooler"]); - expect(linked.exitCode, linked.stderr).toBe(0); - expect(linked.stdout).toContain("Finished supabase link"); - - // No --project-ref and no SUPABASE_PROJECT_ID env: a remote command must now - // resolve the ref from the link written above. - const listed = await run(["secrets", "list", "--output", "json"], { - env: { SUPABASE_PROJECT_ID: "" }, - }); - expect(listed.exitCode, listed.stderr).toBe(0); - expect(Array.isArray(JSON.parse(listed.stdout))).toBe(true); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/live-context.ts b/apps/cli-e2e/src/tests/live/live-context.ts deleted file mode 100644 index 2cabe016e3..0000000000 --- a/apps/cli-e2e/src/tests/live/live-context.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { appendFileSync, cpSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { inject, test } from "vitest"; -import { - createHarness, - exec, - makeTempDir, - type CLIResult, - type TempDir, -} from "@supabase/cli-test-helpers"; -import { ACCESS_TOKEN, isLive, PROJECT_HOST, TARGET, TARGET_API_URL } from "../env.ts"; -import { invokeFunction, type InvokeResult } from "./invoke.ts"; - -type ExecOptions = NonNullable[2]>; - -// deploy-e2e-* function files (functions/, import_map.json, assets/) + the -// [functions.*] config snippet, layered onto an init-generated config by -// seedFunctions() for the functions deploy tests. -const FUNCTIONS_PROJECT_DIR = new URL("../../../fixtures/live/functions-project", import.meta.url) - .pathname; -const FUNCTIONS_CONFIG_SNIPPET = new URL( - "../../../fixtures/live/functions-config.toml", - import.meta.url, -).pathname; - -function liveHarness(cwd: string) { - return createHarness(TARGET, { - apiUrl: TARGET_API_URL, - accessToken: ACCESS_TOKEN, - cwd, - projectId: inject("projectRef"), - // Real host so host-derived commands (storage --linked → .) reach - // the live endpoint instead of localhost. - projectHost: PROJECT_HOST, - }); -} - -/** Layer the deploy-e2e-* function files + their [functions.*] config onto an - * init-generated workspace. Used by the functions deploy tests; every other - * test runs against the bare `supabase init` config. */ -export function seedFunctions(workspacePath: string): void { - const supabaseDir = join(workspacePath, "supabase"); - cpSync(FUNCTIONS_PROJECT_DIR, supabaseDir, { recursive: true }); - appendFileSync( - join(supabaseDir, "config.toml"), - `\n${readFileSync(FUNCTIONS_CONFIG_SNIPPET, "utf8")}`, - ); -} - -interface LiveFixtures { - projectRef: string; - anonKey: string; - functionsUrl: string; - dbUrl: string; - dbPassword: string; - storageBucket: string; - workspace: TempDir; - run: (cmd: string[], execOpts?: ExecOptions) => Promise; - invoke: (slug: string, opts?: { anonKey?: string; payload?: unknown }) => Promise; -} - -const base = test.extend({ - // eslint-disable-next-line no-empty-pattern - projectRef: async ({}, use) => { - await use(inject("projectRef")); - }, - - // eslint-disable-next-line no-empty-pattern - anonKey: async ({}, use) => { - await use(inject("anonKey")); - }, - - // eslint-disable-next-line no-empty-pattern - functionsUrl: async ({}, use) => { - await use(inject("functionsUrl")); - }, - - // eslint-disable-next-line no-empty-pattern - dbUrl: async ({}, use) => { - await use(inject("dbUrl")); - }, - - // eslint-disable-next-line no-empty-pattern - dbPassword: async ({}, use) => { - await use(inject("dbPassword")); - }, - - // eslint-disable-next-line no-empty-pattern - storageBucket: async ({}, use) => { - await use(inject("storageBucket")); - }, - - workspace: async ({ task }, use) => { - const dir = makeTempDir(`cli-e2e-live-${task.name.slice(0, 30)}-`); - // Generate config.toml via `supabase init` so the golden paths run against a - // freshly-generated config (functions tests add functions via seedFunctions). - const init = await exec(liveHarness(dir.path), ["init"]); - if (init.exitCode !== 0) throw new Error(`supabase init failed: ${init.stderr}`); - await use(dir); - dir[Symbol.dispose](); - }, - - run: async ({ workspace }, use) => { - const harness = liveHarness(workspace.path); - await use((cmd, execOpts) => exec(harness, cmd, execOpts)); - }, - - invoke: async ({ functionsUrl, anonKey }, use) => { - await use((slug, opts) => - invokeFunction({ - functionsUrl, - slug, - anonKey: opts && "anonKey" in opts ? opts.anonKey : anonKey, - payload: opts?.payload, - }), - ); - }, -}); - -/** Live test API — skipped unless CLI_E2E_MODE=live, so files are inert on - * replay/PR runs (and globalSetup provisions nothing). */ -export const testLive = base.skipIf(!isLive); diff --git a/apps/cli-e2e/src/tests/live/projects.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/projects.live.e2e.test.ts deleted file mode 100644 index 1b17aad672..0000000000 --- a/apps/cli-e2e/src/tests/live/projects.live.e2e.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// projects create/delete are exercised implicitly by live-setup (it provisions -// and tears down the per-run project). Here we cover the read paths against the -// real Management API: the fresh project shows up in `projects list`, and -// `projects api-keys` returns its keys. -describe("projects (live)", () => { - testLive( - "list includes the project and api-keys returns the anon key", - async ({ run, projectRef }) => { - const listed = await run(["projects", "list", "--output", "json"]); - expect(listed.exitCode, listed.stderr).toBe(0); - const refs = (JSON.parse(listed.stdout) as Array<{ id?: string; ref?: string }>).map( - (p) => p.ref ?? p.id, - ); - expect(refs).toContain(projectRef); - - const keys = await run([ - "projects", - "api-keys", - "--project-ref", - projectRef, - "--output", - "json", - ]); - expect(keys.exitCode, keys.stderr).toBe(0); - // Accept either a legacy anon JWT or a new-style publishable key — projects - // that only issue new keys still return a usable key. - const rows = JSON.parse(keys.stdout) as Array<{ name?: string; api_key?: string }>; - const hasUsableKey = rows.some( - (k) => k.name === "anon" || k.api_key?.startsWith("sb_publishable_"), - ); - expect(hasUsableKey, "expected an anon or publishable key").toBe(true); - }, - ); -}); diff --git a/apps/cli-e2e/src/tests/live/secrets.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/secrets.live.e2e.test.ts deleted file mode 100644 index b5c2170b68..0000000000 --- a/apps/cli-e2e/src/tests/live/secrets.live.e2e.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -interface SecretRow { - name: string; -} - -// Live secrets flow (Management API only — no Docker, no DB). The fresh per-run -// project isolates the secret; the unset at the end cleans it up. Asserts on the -// real remote outcome: the key appears in `secrets list` after set and is gone -// after unset. -describe("secrets", () => { - testLive("set surfaces the key in list, unset removes it", async ({ run, projectRef }) => { - const key = "LIVE_E2E_SECRET"; - - const set = await run(["secrets", "set", `${key}=live-value`, "--project-ref", projectRef]); - expect(set.exitCode, set.stderr).toBe(0); - expect(set.stdout).toContain("Finished"); - - const afterSet = await run([ - "secrets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(afterSet.exitCode, afterSet.stderr).toBe(0); - const setNames = (JSON.parse(afterSet.stdout) as SecretRow[]).map((s) => s.name); - expect(setNames).toContain(key); - - const unset = await run(["secrets", "unset", key, "--project-ref", projectRef, "--yes"]); - expect(unset.exitCode, unset.stderr).toBe(0); - expect(unset.stdout).toContain("Finished"); - - const afterUnset = await run([ - "secrets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(afterUnset.exitCode, afterUnset.stderr).toBe(0); - const unsetNames = (JSON.parse(afterUnset.stdout) as SecretRow[]).map((s) => s.name); - expect(unsetNames).not.toContain(key); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/storage.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/storage.live.e2e.test.ts deleted file mode 100644 index 2d705f690d..0000000000 --- a/apps/cli-e2e/src/tests/live/storage.live.e2e.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Storage object round-trip against the project's real Storage API. `storage -// --linked` opens a DB connection to resolve storage config; the direct host is -// IPv6-only (unreachable from IPv4-only CI), so we `link` first (with the db -// password) to persist the IPv4 pooler connection that storage then reuses. -// The bucket is pre-seeded by live-setup; storage is gated behind --experimental. -const STORAGE_FLAGS = ["--linked", "--experimental"]; -describe("storage (live --linked)", () => { - testLive( - "uploads, lists, and removes an object", - async ({ run, workspace, projectRef, storageBucket, dbPassword }) => { - const linked = await run(["link", "--project-ref", projectRef], { - env: { SUPABASE_DB_PASSWORD: dbPassword }, - }); - expect(linked.exitCode, linked.stderr).toBe(0); - - const local = join(workspace.path, "upload.txt"); - writeFileSync(local, "live-e2e storage payload\n"); - const remote = `ss:///${storageBucket}/upload.txt`; - - const cp = await run(["storage", "cp", local, remote, ...STORAGE_FLAGS]); - expect(cp.exitCode, cp.stderr).toBe(0); - - // Trailing slash lists the bucket's contents (without it, ls returns the - // bucket entry itself). - const ls = await run(["storage", "ls", `ss:///${storageBucket}/`, ...STORAGE_FLAGS]); - expect(ls.exitCode, ls.stderr).toBe(0); - expect(ls.stdout).toContain("upload.txt"); - - // --yes: rm prompts (default No) and would otherwise skip deletion in the - // non-TTY harness yet still exit 0. - const rm = await run(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); - expect(rm.exitCode, rm.stderr).toBe(0); - - // Confirm the object is actually gone (guards against a no-op delete). - const after = await run(["storage", "ls", `ss:///${storageBucket}/`, ...STORAGE_FLAGS]); - expect(after.exitCode, after.stderr).toBe(0); - expect(after.stdout).not.toContain("upload.txt"); - }, - ); -}); diff --git a/apps/cli-e2e/tests/live-setup.ts b/apps/cli-e2e/tests/live-setup.ts deleted file mode 100644 index 4672cf8a2e..0000000000 --- a/apps/cli-e2e/tests/live-setup.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { ProvidedContext } from "vitest"; -import { - isAccessTokenProvided, - isLive, - KEEP_PROJECT, - ORG_ID_OVERRIDE, - PROJECT_HOST, - TARGET, - TARGET_API_URL, -} from "../src/tests/env.ts"; -import { - createStorageBucket, - createTestProject, - deleteTestProject, - generateDbPassword, - getAnonKey, - getPoolerSessionUrl, - getServiceRoleKey, - resolveOrgId, - waitForProjectReady, -} from "./staging-project.ts"; -import "./provided-context.ts"; // centralized `inject()` key augmentation - -const STORAGE_BUCKET = "cli-e2e-live-bucket"; - -// Live e2e global setup (ADR-0013). Provisions ONE ephemeral project per run, -// wired straight at the real Management API — no replay server. Intentionally -// dumb: no provisioning retry (the CI job re-runs the whole step on flake). -export async function setup({ - provide, -}: { - provide: (key: K, value: ProvidedContext[K]) => void; -}) { - if (!isLive) { - // The live config was invoked without CLI_E2E_MODE=live. Every test is - // skipIf(!isLive), so provision nothing. - return () => {}; - } - if (!isAccessTokenProvided) { - throw new Error( - "Live mode requires a staging access token: set SUPABASE_ACCESS_TOKEN " + - "(or SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN). Refusing to provision against an empty token.", - ); - } - if (!PROJECT_HOST) { - throw new Error("CLI_E2E_PROJECT_HOST is required in live mode (function invoke host)"); - } - - // Resolving the org via `orgs list` also exercises that command against the - // real API; CLI_E2E_ORG_ID short-circuits it when set. - const orgId = ORG_ID_OVERRIDE ?? (await resolveOrgId(TARGET_API_URL)); - - // Per-job, per-run unique name so the CI cleanup can target only this job's - // project (never a sibling matrix job's). - const runId = process.env["GITHUB_RUN_ID"] ?? String(Date.now()); - const name = `cli-e2e-live-${TARGET}-${runId}-${randomUUID().slice(0, 8)}`; - - // Generated here (not a shared export) and routed through provide() so the - // password reaches tests only via inject(), never an importable module const. - const dbPassword = generateDbPassword(); - const projectRef = await createTestProject(TARGET_API_URL, orgId, name, dbPassword); - - // Once the project exists, any later setup failure must still delete it — - // setup returns before the teardown closure, so Vitest cannot clean up. - let anonKey: string; - let functionsUrl: string; - let dbUrl: string; - try { - await waitForProjectReady(TARGET_API_URL, projectRef); - anonKey = await getAnonKey(TARGET_API_URL, projectRef); - functionsUrl = `https://${projectRef}.${PROJECT_HOST}/functions/v1`; - // IPv4 session-mode pooler — the direct host is IPv6-only (unreachable from - // IPv4-only CI runners); the pooler is IPv4 and session mode supports pg_dump. - dbUrl = await getPoolerSessionUrl(TARGET_API_URL, projectRef, dbPassword); - // Seed a private bucket via the Storage API so the storage live tests have - // something to cp/ls/rm against (cleaned up with the project on teardown). - const serviceRoleKey = await getServiceRoleKey(TARGET_API_URL, projectRef); - await createStorageBucket(PROJECT_HOST, projectRef, serviceRoleKey, STORAGE_BUCKET); - } catch (err) { - // Delete the half-provisioned project, but never mask the original failure. - if (!KEEP_PROJECT) { - await deleteTestProject(TARGET_API_URL, projectRef, { throwOnError: true }).catch( - (cleanupErr) => console.error("Failed to delete project after setup failure:", cleanupErr), - ); - } - throw err; - } - - provide("projectRef", projectRef); - provide("anonKey", anonKey); - provide("functionsUrl", functionsUrl); - provide("dbUrl", dbUrl); - provide("dbPassword", dbPassword); - provide("storageBucket", STORAGE_BUCKET); - - return async () => { - if (KEEP_PROJECT) { - console.log(`CLI_E2E_KEEP_PROJECT set — leaving project ${projectRef} (${name}) alive`); - return; - } - // Surface a failed teardown so a leaked staging project is visible locally - // (CI also has the always() sweep as a backstop). - await deleteTestProject(TARGET_API_URL, projectRef, { throwOnError: true }); - }; -} diff --git a/apps/cli-e2e/tests/provided-context.ts b/apps/cli-e2e/tests/provided-context.ts index a02d045184..a498890840 100644 --- a/apps/cli-e2e/tests/provided-context.ts +++ b/apps/cli-e2e/tests/provided-context.ts @@ -1,15 +1,14 @@ -// Single source of truth for Vitest's `inject()` keys across all three modes -// (replay/record use the replay-server keys; live uses the staging-project keys). -// Both global setups import this module so the augmentation is always in the -// build and `inject("…")` is typed without `as` casts. +// Single source of truth for Vitest's `inject()` keys used by the replay/record +// harness. The global setup imports this module so the augmentation is always +// in the build and `inject("…")` is typed without `as` casts. export {}; declare module "vitest" { export interface ProvidedContext { - // Shared by every mode. + // Shared by replay and record. projectRef: string; storageBucket: string; - // Replay/record only (replay server + pg/docker mocks). + // Replay/record (replay server + pg/docker mocks). replayServerUrl: string; orgId: string; pgMockPort: number; @@ -17,14 +16,5 @@ declare module "vitest" { * In record mode the relay forwards to the real Docker socket; in replay * mode it serves recorded Docker API fixtures. */ dockerHostUrl: string; - // Live only (ADR-0013): real ephemeral project wiring. - /** Legacy anon JWT for invoking deployed functions over HTTP. */ - anonKey: string; - /** https://{ref}.{CLI_E2E_PROJECT_HOST}/functions/v1 */ - functionsUrl: string; - /** IPv4 session-pooler Postgres URL for --db-url DB commands. */ - dbUrl: string; - /** DB password of the ephemeral project (for `link` → persisted pooler config). */ - dbPassword: string; } } diff --git a/apps/cli-e2e/tests/staging-project.ts b/apps/cli-e2e/tests/staging-project.ts index e0d54017fb..d30d0c8bd8 100644 --- a/apps/cli-e2e/tests/staging-project.ts +++ b/apps/cli-e2e/tests/staging-project.ts @@ -2,28 +2,20 @@ import { randomBytes } from "node:crypto"; import { createHarness, exec } from "@supabase/cli-test-helpers"; import { ACCESS_TOKEN, REGION, TARGET } from "../src/tests/env.ts"; -// Shared staging-project helpers used by both record setup (tests/setup.ts) and -// live setup (tests/live-setup.ts). -// -// `apiUrl` is whatever the CLI talks to: in record mode that is the replay -// server (so calls are captured); in live mode it is the real Management API -// (CLI_E2E_API_URL). The harness target + token come from env. +// Shared staging-project helpers used by record setup (tests/setup.ts). +// `apiUrl` is the replay server URL, which proxies calls to staging while +// recording. The harness target + token come from env. function harness(apiUrl: string) { return createHarness(TARGET, { apiUrl, accessToken: ACCESS_TOKEN }); } const PROJECT_REF_RE = /^[a-z]{20}$/; - -// Project statuses from which provisioning never recovers — fast-fail instead of -// polling to the timeout. const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); -/** A DB password for a throwaway project, used at creation and to build the live - * --db-url. Randomised per call (overridable via CLI_E2E_DB_PASSWORD) so no - * static credential is committed — the project is deleted on teardown anyway. - * Each setup generates its own and routes it through provide()/inject() rather - * than sharing a module-level export. */ +/** A DB password for a throwaway recording project. Randomised per call + * (overridable via CLI_E2E_DB_PASSWORD) so no static credential is committed — + * the project is deleted on teardown anyway. */ export function generateDbPassword(): string { return process.env["CLI_E2E_DB_PASSWORD"] ?? `cli-e2e-${randomBytes(12).toString("hex")}`; } @@ -64,8 +56,8 @@ export async function createTestProject( return ref; } -// `throwOnError` surfaces a failed deletion (live teardown uses it so a leaked -// staging project fails the run loudly; record setup keeps the lenient default). +// `throwOnError` surfaces deletion failures when a caller needs to fail loudly; +// record setup keeps the lenient default. export async function deleteTestProject( apiUrl: string, projectRef: string, @@ -100,8 +92,7 @@ export async function cleanupProjectsByName(apiUrl: string, names: string[]): Pr } } -/** Poll the real Management API until the project is ACTIVE_HEALTHY. Hits the API - * directly (not via any proxy) — this is setup-only and must not be recorded. */ +/** Poll the Management API until the recording project is ACTIVE_HEALTHY. */ export async function waitForProjectReady( apiBaseUrl: string, projectRef: string, @@ -121,155 +112,9 @@ export async function waitForProjectReady( ); } } else { - await res.body?.cancel(); // free the socket before sleeping + await res.body?.cancel(); } await new Promise((r) => setTimeout(r, 5_000)); } throw new Error(`Project ${projectRef} did not become ACTIVE_HEALTHY within ${timeoutMs}ms`); } - -interface ApiKey { - name?: string; - api_key?: string; -} - -/** Resolve a key for invoking the project's deployed functions over HTTP. - * Prefers the legacy `anon` JWT: Edge Functions default to verify_jwt=true and - * a publishable (sb_publishable_) key is NOT a JWT, so it fails the platform - * JWT check on a verified function. Falls back to the publishable key for - * projects that only issue new-style keys. Even after ACTIVE_HEALTHY the - * api-keys endpoint can briefly 4xx, so retry. */ -export async function getAnonKey( - apiBaseUrl: string, - projectRef: string, - attempts = 12, -): Promise { - for (let attempt = 1; attempt <= attempts; attempt++) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}/api-keys`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const keys = (await res.json()) as ApiKey[]; - const anonJwt = keys.find((k) => k.name === "anon" && k.api_key)?.api_key; - if (anonJwt) return anonJwt; - // Keys present but no legacy anon JWT. A publishable (sb_publishable_) key - // is NOT a JWT and 401s on the default verify_jwt=true functions, so fail - // loudly rather than proceed with a key that can't authenticate verified - // invokes (the suite would need to deploy with --no-verify-jwt instead). - if (keys.length > 0) { - throw new Error( - `Project ${projectRef} returned no anon JWT (only new-style keys); verified-function invokes require a JWT`, - ); - } - } else if (attempt < attempts) { - await res.body?.cancel(); // free the socket before sleeping - } - if (attempt === attempts) { - const detail = res.bodyUsed ? res.status : await res.text().catch(() => res.status); - throw new Error( - `Failed to resolve anon key for ${projectRef} after ${attempts} attempts: ${detail}`, - ); - } - await new Promise((r) => setTimeout(r, 10_000)); - } - // Unreachable — the loop either returns a key or throws on the last attempt. - throw new Error(`Failed to resolve anon key for ${projectRef}`); -} - -/** Service-role / secret key, used to seed a storage bucket for the live storage - * tests (the same way record setup does). Retries like getAnonKey. */ -export async function getServiceRoleKey( - apiBaseUrl: string, - projectRef: string, - attempts = 12, -): Promise { - for (let attempt = 1; attempt <= attempts; attempt++) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}/api-keys`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const keys = (await res.json()) as ApiKey[]; - const secret = - keys.find((k) => k.name === "service_role" && k.api_key)?.api_key ?? - keys.find((k) => k.api_key?.startsWith("sb_secret_"))?.api_key; - if (secret) return secret; - } else { - await res.body?.cancel(); // free the socket before sleeping - } - if (attempt === attempts) { - throw new Error(`Failed to resolve service-role key for ${projectRef}`); - } - await new Promise((r) => setTimeout(r, 10_000)); - } - throw new Error(`Failed to resolve service-role key for ${projectRef}`); -} - -/** Create a private storage bucket via the project's Storage API (host derived - * from projectHost, IPv4-reachable). Idempotent — treats an existing bucket as - * success. */ -export async function createStorageBucket( - projectHost: string, - projectRef: string, - serviceRoleKey: string, - bucket: string, -): Promise { - const res = await fetch(`https://${projectRef}.${projectHost}/storage/v1/bucket`, { - method: "POST", - headers: { Authorization: `Bearer ${serviceRoleKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ id: bucket, name: bucket, public: false }), - }); - if (!res.ok && res.status !== 409) { - throw new Error(`Failed to create bucket ${bucket}: ${res.status} ${await res.text()}`); - } -} - -interface PoolerConfig { - database_type?: string; - connection_string?: string; -} - -/** Build a SESSION-mode (port 5432) Supavisor pooler connection string for the - * project's Postgres. The direct host (db....) is IPv6-only and unreachable - * from IPv4-only CI runners, so DB commands go through the pooler, which is IPv4. - * Session mode (not the API's default transaction 6543) is required for pg_dump - * (`db dump`). - * - * Reuses the Management API's `connection_string` verbatim — it carries tenant - * routing (e.g. options=reference=... query params) that a field-reconstructed - * URL would drop — and only swaps in our password and the session port. Mirrors - * the Go connector by selecting the PRIMARY pooler config. */ -export async function getPoolerSessionUrl( - apiBaseUrl: string, - projectRef: string, - password: string, - attempts = 12, -): Promise { - for (let attempt = 1; attempt <= attempts; attempt++) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}/config/database/pooler`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const raw = (await res.json()) as PoolerConfig | PoolerConfig[]; - const configs = Array.isArray(raw) ? raw : [raw]; - const primary = configs.find((c) => c.database_type === "PRIMARY") ?? configs[0]; - if (primary?.connection_string) { - const url = new URL(primary.connection_string); - url.password = password; // overwrites the [YOUR-PASSWORD] placeholder (URL-encoded) - url.port = "5432"; // session mode (API returns the 6543 transaction port) - if (!url.searchParams.has("connect_timeout")) url.searchParams.set("connect_timeout", "30"); - return url.toString(); - } - } else if (attempt < attempts) { - await res.body?.cancel(); // free the socket before sleeping - } - if (attempt === attempts) { - const detail = res.bodyUsed ? res.status : await res.text().catch(() => res.status); - throw new Error( - `Failed to resolve pooler config for ${projectRef} after ${attempts} attempts: ${detail}`, - ); - } - await new Promise((r) => setTimeout(r, 10_000)); - } - // Unreachable — the loop either returns a URL or throws on the last attempt. - throw new Error(`Failed to resolve pooler config for ${projectRef}`); -} diff --git a/apps/cli-e2e/vitest.config.ts b/apps/cli-e2e/vitest.config.ts index 74c9117ecc..bb87f884aa 100644 --- a/apps/cli-e2e/vitest.config.ts +++ b/apps/cli-e2e/vitest.config.ts @@ -5,10 +5,7 @@ export default defineConfig({ test: { passWithNoTests: true, include: ["**/*.e2e.test.ts"], - // Live tests are *.live.e2e.test.ts and run only via vitest.live.config.ts. - // They also match the include glob, so exclude them here to keep the - // PR-blocking replay suite from globbing them. - exclude: ["**/node_modules/**", "**/*.live.e2e.test.ts"], + exclude: ["**/node_modules/**"], fileParallelism: false, maxWorkers: 1, globalSetup: ["tests/setup.ts"], diff --git a/apps/cli-e2e/vitest.live.config.ts b/apps/cli-e2e/vitest.live.config.ts deleted file mode 100644 index 5a6ea689ff..0000000000 --- a/apps/cli-e2e/vitest.live.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from "vitest/config"; - -// Live e2e project (ADR-0013): runs *.live.e2e.test.ts against a real backend. -// Separate from vitest.config.ts so the PR-blocking replay suite never globs -// live tests. The replay server is NOT started here — live-setup wires the CLI -// straight at the real Management API + Docker socket. -export default defineConfig({ - test: { - passWithNoTests: true, - include: ["**/*.live.e2e.test.ts"], - fileParallelism: false, - maxWorkers: 1, - globalSetup: ["tests/live-setup.ts"], - // Real provisioning + Docker bundling are slow; give each test plenty of room. - testTimeout: 600_000, - hookTimeout: 600_000, - // Per-test flake (a single invoke/deploy blip) retries here; provisioning / - // setup flake is handled by the CI job re-running the whole step. - retry: 2, - }, -}); diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 4b32693b00..a48cd0a384 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -471,7 +471,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - `*.unit.test.ts` belongs to the `unit` Vitest project and is the default for unit-style and other fast in-process tests. - `*.integration.test.ts` belongs to the `integration` project and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement. - `*.e2e.test.ts` belongs to the `e2e` Vitest project and is for black-box CLI subprocess tests. -- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests that run against a **real, running Supabase platform or local Docker stack** — see "Live tests" below. +- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests whose asserted command reaches a real Supabase platform or project data plane — see "Live tests" below. ### Testing policy @@ -489,19 +489,44 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that ### Live tests (`*.live.test.ts`) -Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run against a **real backend** instead of local fakes/mocks: either the real Management API (a full [supabox](https://github.com/supabase/supabox) platform stack) or a real local Docker dev stack (`supabase start`'s actual containers). They are the highest-fidelity, most expensive tier — reserved for the small set of behaviors that only a genuinely running backend can prove (auth round-trips, real Docker label filtering, real container lifecycle), not for anything an integration test can already cover with mocks. - -- **Where they run:** authored in this repo, but executed by the [`supabase/cli-e2e-ci`](https://github.com/supabase/cli-e2e-ci) harness, which builds this CLI, brings up a full supabox stack (and has a real Docker daemon, since that's how supabox itself runs), and invokes the `live` Vitest project (`nx run-many -t test:live`). They never run as part of the default unit/integration/e2e loop, and locally they no-op unless the live environment is configured (see below) — there is no need to stand up supabox yourself to develop other code. -- **Add one whenever you add or change a command whose correctness genuinely depends on a real backend** — a new Management API command, or a change to `start`/`stop`/`status`'s real Docker interaction. Colocate it with the command, same as `*.e2e.test.ts`: `src/legacy/commands//[/].live.test.ts`. -- **Gating:** every live suite must be wrapped in one of `tests/helpers/live.ts`'s `describe.skipIf` gates so the file is inert (skipped, not failed) outside the cli-e2e-ci runner: - - `describeLive` — runs whenever `SUPABASE_ACCESS_TOKEN` is set (the live env is configured at all). Reuse this even for commands that don't call the Management API themselves (e.g. `stop`/`status`) — it doubles as the "we're in the full cli-e2e-ci runner, which also has a real Docker daemon" signal. - - `describeDockerLive` — the configured-live gate composed with a `docker info` probe; use for local-stack suites whose scenarios additionally need a reachable Docker daemon at collection time. It never runs on a machine that merely exposes Docker — `SUPABASE_ACCESS_TOKEN` must still be set, so the file stays inert outside the cli-e2e-ci runner like every other live suite. - - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). - - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). -- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. -- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. -- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. -- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). +Live tests are black-box CLI subprocess tests whose asserted command reaches a +real Management API, its suite-owned project, or that project's data plane. +They are serial, explicit, and expensive; keep them to one golden path per +command. The file name selects the live Vitest project and the file imports one +extended fixture as `test` from `tests/helpers/live.ts`: + +```ts +import { expect } from "vitest"; +import { test } from "../../../../../tests/helpers/live.ts"; + +test("lists projects", async ({ cli, project }) => { + const result = await cli(["projects", "list", "--output-format", "json"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain(project.ref); +}); +``` + +Global setup requires `SUPABASE_LIVE_API_URL` and `SUPABASE_ACCESS_TOKEN`, +provisions one disposable project through the typed Effect `@supabase/api` +client, waits for `ACTIVE_HEALTHY`, resolves project wiring, writes a temporary +YAML profile, and shares it across the serial suite. Teardown deletes exactly +that project and the temporary profile. Supabox, a Docker-hosted API platform, +and staging are interchangeable; changing the URL and token retargets the +run. `SUPABASE_LIVE_KEEP_PROJECT=1` keeps the project for debugging. +`SUPABASE_LIVE_API_URL` configures the Management API only; tenant data-plane +URLs retain the profile contract `https://.`, with +`project_host` derived from the provisioned project's database host. + +Local Docker-stack lifecycle tests (`start`, `stop`, `status`, `db start`, +`db diff`, declarative sync, and `functions dev`) are `*.e2e.test.ts`, use +`runSupabase` plus the existing e2e stack cleanup, and require no platform +credentials. `functions deploy` remains live because its assertion is remote +deployment and invocation, even though Docker is a runner prerequisite. + +Setup/teardown may invoke other commands, but assertions stay focused on the +one command named by the test. The live workflow runs one serial attempt with a +20-minute bound, retains Docker preflight, and sweeps only projects owned by +that run after crashes. --- diff --git a/apps/cli/live.env.example b/apps/cli/live.env.example new file mode 100644 index 0000000000..a0ccbe76c9 --- /dev/null +++ b/apps/cli/live.env.example @@ -0,0 +1,17 @@ +# Live CLI e2e environment. The suite provisions one disposable project +# against the configured Management API URL. Supabox, Docker-hosted API, and +# staging use the same contract; only this URL and token change. Tenant data +# plane URLs remain https://., derived from the project DB +# host returned by the Management API. +SUPABASE_LIVE_API_URL=http://localhost:8080 +SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Optional provisioning/debug values. +# SUPABASE_LIVE_ORG_ID=... +# SUPABASE_LIVE_REGION=us-east-1 +# SUPABASE_LIVE_PROJECT_NAME=supabase-cli-live +# SUPABASE_LIVE_KEEP_PROJECT=1 +# NODE_EXTRA_CA_CERTS=/path/to/supabox/ca.pem + +# Run explicitly (Docker is required by the runner): +# pnpm test:live diff --git a/apps/cli/package.json b/apps/cli/package.json index 96706fd08e..ad8894dbf6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -34,6 +34,7 @@ "dev:legacy": "pnpm exec bun src/legacy/main.ts", "test": "nx run-many -t test:core test:e2e --projects=$npm_package_name", "test:core": "nx run-many -t test:unit test:integration --projects=$npm_package_name --coverage.enabled", + "test:live": "bun --bun vitest run --project live", "test:smoke": "bun run tests/smoke-test.ts", "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" diff --git a/.github/scripts/sweep-live-projects.sh b/apps/cli/scripts/sweep-live-projects.sh similarity index 80% rename from .github/scripts/sweep-live-projects.sh rename to apps/cli/scripts/sweep-live-projects.sh index 19456281ed..39774ff381 100755 --- a/.github/scripts/sweep-live-projects.sh +++ b/apps/cli/scripts/sweep-live-projects.sh @@ -3,18 +3,18 @@ # e2e job's per-run prefix). Shared by the in-run retry sweep (called best-effort # with `|| true`) and the always() cleanup step (which propagates the exit code). # -# Reads SUPABASE_ACCESS_TOKEN + CLI_E2E_API_URL from the environment. Exits +# Reads SUPABASE_ACCESS_TOKEN + SUPABASE_LIVE_API_URL from the environment. Exits # non-zero if any DELETE failed; a failed *listing* also exits non-zero (pipefail). set -o pipefail PREFIX="${1:?usage: sweep-live-projects.sh PREFIX}" : "${SUPABASE_ACCESS_TOKEN:?SUPABASE_ACCESS_TOKEN required}" -: "${CLI_E2E_API_URL:?CLI_E2E_API_URL required}" +: "${SUPABASE_LIVE_API_URL:?SUPABASE_LIVE_API_URL required}" # Capture the list in a var (not a pipe-to-while subshell) so a failed delete is # recorded in $failed; a failed listing aborts here via pipefail. refs=$(curl -fsS -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${CLI_E2E_API_URL}/v1/projects" \ + "${SUPABASE_LIVE_API_URL}/v1/projects" \ | jq -r --arg p "$PREFIX" '.[] | select(.name|startswith($p)) | .ref // .id') failed=0 @@ -22,7 +22,7 @@ for ref in $refs; do [ -n "$ref" ] || continue echo "deleting leftover project $ref" if ! curl -fsS -X DELETE -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${CLI_E2E_API_URL}/v1/projects/${ref}" >/dev/null; then + "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" >/dev/null; then echo "::error::failed to delete leftover project $ref" failed=1 fi diff --git a/apps/cli/src/legacy/commands/branches/create/create.live.test.ts b/apps/cli/src/legacy/commands/branches/create/create.live.test.ts new file mode 100644 index 0000000000..3a4b1fd4fd --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/create/create.live.test.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function cleanupBranch( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const deleted = await cli(["branches", "delete", name, "--project-ref", ref, "--yes"]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + throw new Error( + `branches delete cleanup failed (exit ${deleted.exitCode})\n${deleted.stdout}\n${deleted.stderr}`, + ); + } +} + +test("creates a preview branch", async ({ cli, project }) => { + const name = `cli-e2e-create-${randomUUID().slice(0, 8)}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const result = await cli(["branches", "create", name, "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Created preview branch"); + } catch (error) { + targetError = error; + } finally { + try { + await cleanupBranch(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts b/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts new file mode 100644 index 0000000000..3bd193d878 --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts @@ -0,0 +1,47 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +test("deletes a preview branch", async ({ cli, project }) => { + const name = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; + let mayExist = false; + let targetError: unknown; + let cleanupError: unknown; + try { + mayExist = true; + const created = await cli(["branches", "create", name, "--project-ref", project.ref]); + requireLiveSuccess(created, "branches create"); + + const removed = await cli(["branches", "delete", name, "--project-ref", project.ref, "--yes"]); + if (removed.exitCode === 0) mayExist = false; + expect(removed.exitCode, removed.stderr).toBe(0); + expect(removed.stderr).toContain("Deleted preview branch"); + } catch (error) { + targetError = error; + } finally { + if (mayExist) { + try { + const cleanup = await cli([ + "branches", + "delete", + name, + "--project-ref", + project.ref, + "--yes", + ]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + cleanupError = new Error( + `branches delete cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`, + ); + } + } catch (error) { + cleanupError = error; + } + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/branches/list/list.live.test.ts b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts index 65422ad51b..8ac0529fac 100644 --- a/apps/cli/src/legacy/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts @@ -1,30 +1,50 @@ -import { expect, test } from "vitest"; +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; -import { - describeLiveProject, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 120_000; +test("lists a preview branch for the project", async ({ cli, project }) => { + const name = `cli-e2e-list-${randomUUID().slice(0, 8)}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli(["branches", "create", name, "--project-ref", project.ref]); + requireLiveSuccess(created, "branches create setup"); -// Project-scoped read-only scenario. Skipped unless SUPABASE_LIVE_PROJECT_REF is -// set — i.e. a project has been provisioned on the stack (the cli-e2e-ci runner -// does this; a control-plane-only stack, like local macOS, skips it). -// -// Entry point for the branching lifecycle tracked in CLI-1834 -// (create / switch / delete) — extend here once a provisioned project is -// available on the full stack. -describeLiveProject("supabase branches list (live)", () => { - test("lists branches for the project", { timeout: LIVE_TIMEOUT_MS }, async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive([ + const result = await cli([ "branches", "list", + "--output", + "json", "--project-ref", - ref, + project.ref, ]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - }); + expect(result.exitCode, result.stderr).toBe(0); + const branches = JSON.parse(result.stdout) as Array<{ name?: string }>; + expect(branches.map((branch) => branch.name)).toContain(name); + } catch (error) { + targetError = error; + } finally { + try { + const deleted = await cli([ + "branches", + "delete", + name, + "--project-ref", + project.ref, + "--yes", + ]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + cleanupError = new Error( + `branches delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`, + ); + } + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); }); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts new file mode 100644 index 0000000000..2d8a044702 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts @@ -0,0 +1,115 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { describe } from "vitest"; +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../../tests/helpers/cli.ts"; + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const DIFF_COMMAND_TIMEOUT_MS = 280_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const DIFF_TEST_TIMEOUT_MS = + CLI_COMMAND_TIMEOUT_MS + + STACK_START_TIMEOUT_MS + + CLI_COMMAND_TIMEOUT_MS * 2 + + DIFF_COMMAND_TIMEOUT_MS + + LIFECYCLE_MARGIN_MS; + +// CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally +// treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, +// so a declarative schema's `REVOKE ... FROM PUBLIC` on a function was silently +// dropped from the generated migration — exit code 0, no error, just a missing +// statement. Fixed upstream in @supabase/pg-delta@1.0.0-alpha.33 +// (supabase/pg-toolbelt#357). Verified directly against this repo's build: with +// the pre-fix pin (1.0.0-alpha.32) the migration below contains only the CREATE +// FUNCTION statement; the REVOKE is silently absent. This suite uses the local +// Docker-stack e2e coverage and never calls the Management API. See AGENTS.md's +// "E2e tests" section. +describe("supabase db diff (e2e, pg-delta declarative privileges)", () => { + let project: Awaited> | undefined; + + afterEach(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "keeps REVOKE ... FROM PUBLIC on a function when diffing a declarative schema against local", + { timeout: DIFF_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-db-diff-e2e-"); + const projectDir = project.dir; + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + // Exclude the heaviest, least relevant services — `db diff` only needs the + // local Postgres container reachable, same rationale as stop/status. + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + // Minimal, deterministic repro: execute a fresh function's implicit PUBLIC + // EXECUTE grant, explicitly revoked, directly against the local database. + // `db query` is setup only; keep each statement in its own invocation + // because the legacy query command sends one prepared statement at a time. + const createFunction = await runSupabase( + [ + "db", + "query", + `create function public.probe_fn() +returns void +language sql +as $$ select 1; $$;`, + "--local", + ], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS }, + ); + requireCliSuccess(createFunction, "db query create-function setup"); + + const revoke = await runSupabase( + ["db", "query", "revoke execute on function public.probe_fn() from public;", "--local"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS }, + ); + requireCliSuccess(revoke, "db query revoke setup"); + + const diff = await runSupabase( + ["db", "diff", "--local", "--use-pg-delta", "-f", "revoke_public_execute"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: DIFF_COMMAND_TIMEOUT_MS }, + ); + expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(0); + + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + const written = + existsSync(migrationsDir) && + readdirSync(migrationsDir).find((f) => f.endsWith("_revoke_public_execute.sql")); + expect(written, `no migration written; stderr:\n${diff.stderr}`).toBeTruthy(); + const sql = readFileSync(path.join(migrationsDir, written as string), "utf8"); + + // The negative-space regression: pre-fix, exit code 0 and this file would + // exist, but silently missing the REVOKE statement (only the CREATE FUNCTION + // survives). Anchor the match to the function's own REVOKE statement — up to + // its terminating `;` — so this cannot pass on an unrelated PUBLIC mention + // elsewhere in the file. + expect(sql).toMatch( + /CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)/i, + ); + expect(sql).toMatch( + /REVOKE\s+(?:ALL|EXECUTE)\s+ON\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)\s+FROM\s+[^;]*PUBLIC[^;]*;/i, + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts deleted file mode 100644 index 78614d9aa7..0000000000 --- a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { execFile } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; -import { afterEach, expect, test } from "vitest"; - -import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; - -const execFileAsync = promisify(execFile); - -const START_TIMEOUT_MS = 280_000; -// Lifecycle allowance for scenarios that run TWO full-budget subprocesses (`start` -// then the command under test) plus init/inspection overhead — same shape as -// `start.live.test.ts`. A single shared `START_TIMEOUT_MS` test budget would let a -// slow-but-valid `start` starve the command under test before it ever runs. -const LIFECYCLE_OVERHEAD_MS = 90_000; - -// CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally -// treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, -// so a declarative schema's `REVOKE ... FROM PUBLIC` on a function was silently -// dropped from the generated migration — exit code 0, no error, just a missing -// statement. Fixed upstream in @supabase/pg-delta@1.0.0-alpha.33 -// (supabase/pg-toolbelt#357). Verified directly against this repo's build: with -// the pre-fix pin (1.0.0-alpha.32) the migration below contains only the CREATE -// FUNCTION statement; the REVOKE is silently absent. `describeLive` is reused as -// the "real local Docker stack is available" signal, same as stop/status — this -// never calls the Management API. See AGENTS.md's "Live tests" section. -describeLive("supabase db diff (live, pg-delta declarative privileges)", () => { - let projectDir: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a - // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - }); - - test( - "keeps REVOKE ... FROM PUBLIC on a function when diffing a declarative schema against local", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-db-diff-live-")); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - // `init`'s template already enables pg-delta by default (CLI-1877/#5511), but - // point `[db.migrations] schema_paths` at a declarative schema directory so - // `db diff --local` diffs against it instead of the (empty) local migration - // history. Paths are relative to `supabase/`. - const configPath = path.join(projectDir, "supabase", "config.toml"); - const config = readFileSync(configPath, "utf8"); - expect(config).toContain("schema_paths = []"); - writeFileSync( - configPath, - config.replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]'), - ); - - // Minimal, deterministic repro: a fresh function's implicit PUBLIC EXECUTE - // grant, explicitly revoked. Verified empirically against this build: pre-fix - // (pg-delta 1.0.0-alpha.32) the generated migration contains only the CREATE - // FUNCTION statement; the REVOKE is silently dropped. - const schemasDir = path.join(projectDir, "supabase", "schemas"); - mkdirSync(schemasDir, { recursive: true }); - writeFileSync( - path.join(schemasDir, "01_probe_fn.sql"), - `create function public.probe_fn() -returns void -language sql -as $$ select 1; $$; - -revoke execute on function public.probe_fn() from public; -`, - ); - - // Exclude the heaviest, least relevant services — `db diff` only needs the - // local Postgres container reachable, same rationale as stop/status. - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - const diff = await runSupabaseLive( - ["db", "diff", "--local", "--use-pg-delta", "-f", "revoke_public_execute"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(0); - - const migrationsDir = path.join(projectDir, "supabase", "migrations"); - const written = - existsSync(migrationsDir) && - readdirSync(migrationsDir).find((f) => f.endsWith("_revoke_public_execute.sql")); - expect(written, `no migration written; stderr:\n${diff.stderr}`).toBeTruthy(); - const sql = readFileSync(path.join(migrationsDir, written as string), "utf8"); - - // The negative-space regression: pre-fix, exit code 0 and this file would - // exist, but silently missing the REVOKE statement (only the CREATE FUNCTION - // survives). Anchor the match to the function's own REVOKE statement — up to - // its terminating `;` — so this cannot pass on an unrelated PUBLIC mention - // elsewhere in the file. - expect(sql).toContain("CREATE FUNCTION public.probe_fn()"); - expect(sql).toMatch( - /REVOKE\s+(?:ALL|EXECUTE)\s+ON\s+FUNCTION\s+public\.probe_fn\(\)\s+FROM\s+[^;]*PUBLIC[^;]*;/i, - ); - }, - ); -}); - -// `--use-pgadmin` is a native `docker run` of the differ container, no -// edge-runtime and no Go delegation involved. Golden-path smoke coverage only — the -// pure filtering/progress logic and the docker-run argv are covered exhaustively by -// `legacy-pgadmin-diff.unit.test.ts` and `diff.integration.test.ts`; this just proves -// the real container actually runs against a real local stack and cleans up after -// itself either way. -// -// The real, reachable outcome here is a FAILURE, not a golden diff, by design: the -// differ container joins the project's own bridge network -// (`supabase_network_`), and both diff endpoints are hardcoded loopback -// URLs from that container's own point of view — `source` (resolving to `127.0.0.1` -// for a local target) and `target` -// (`postgresql://postgres:postgres@127.0.0.1:/postgres`). Inside a -// bridge-attached container, `127.0.0.1` is the container's OWN loopback, not the -// host's — so neither the local db nor the shadow is reachable from inside the -// differ, and the container exits non-zero. See `SIDE_EFFECTS.md`'s "Network -// reachability" entry for the full static ruling. (The historical value-receiver bug -// documented there — always reporting "No schema changes found" regardless of the -// differ's actual output — only ever engages when the differ container exits 0; it -// plays no role in this failure path.) Note that a plain `--network-id host` does NOT -// rescue a golden run here: it also rewires the SHADOW container onto host -// networking, discarding its own `54320->5432` port publish that `target` depends on -// — so `source` would become reachable but `target` would not, still failing the -// diff. This suite therefore verifies the real, always-reachable failure mode -// end-to-end, plus that both the differ AND the shadow container it provisions are -// still cleaned up. -describeLive("supabase db diff (live, --use-pgadmin native differ container)", () => { - let projectDir: string | undefined; - let projectId: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a - // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - projectId = undefined; - }); - - test( - "runs the native differ container against the real stack, surfaces Go's error running container failure, and leaves no differ container behind", - { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-db-diff-pgadmin-live-")); - // No `project_id` override, so the cli resolves it from the workdir basename - // (see legacy-docker-ids.ts), same as `stop.live.test.ts`. - projectId = path.basename(projectDir); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - // Exclude the heaviest, least relevant services — `db diff --use-pgadmin` only - // needs the local Postgres container reachable, same rationale as stop/status. - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - const diff = await runSupabaseLive(["db", "diff", "--use-pgadmin"], { - cwd: projectDir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - // Both hardcoded loopback endpoints are unreachable from inside the - // bridge-attached differ container (see this suite's own header comment for the - // full, static ruling) — the differ exits non-zero and the CLI surfaces its own - // wrapper message. The differ's own exit code isn't pinned: only that the differ - // ran and failed, not the shadow/connection machinery around it. - expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(1); - expect(diff.stderr).toContain("error running container: exit "); - - // The differ is a one-shot `docker run --rm` — real Docker must agree that no - // container survives it, the same "the daemon must agree" check - // `stop.live.test.ts` runs against `com.supabase.cli.project`. - const { stdout: remainingDiffer } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - "ancestor=supabase/pgadmin-schema-diff:cli-0.0.5", - "--format", - "{{.ID}}", - ]); - expect(remainingDiffer.trim()).toBe(""); - - // This failure path exercises the shadow's `acquireUseRelease` teardown for - // real (the differ error propagates out of the `use` phase after the shadow was - // already created) — the shadow itself is created with no `--name` (Docker - // auto-generates one), unlike every real stack container, which is always named - // `supabase__`. So a leaked shadow shows up as a - // project-labeled container whose name does NOT carry that fixed prefix. - const { stdout: projectContainers } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - `label=com.supabase.cli.project=${projectId}`, - "--format", - "{{.Names}}", - ]); - const names = projectContainers - .trim() - .split("\n") - .filter((name) => name.length > 0); - expect(names.length).toBeGreaterThan(0); - expect(names.every((name) => name.startsWith("supabase_"))).toBe(true); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts index 8ebaaa8d63..8bf824a885 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts @@ -1,48 +1,12 @@ -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync } from "node:fs"; import { join } from "node:path"; -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 300_000; - -// A fresh, isolated temp workdir so the CLI writes the dump there and never touches -// the repo tree. The provisioned project ref is supplied to `--linked` via the -// `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain (flag → -// `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); `config.toml`'s -// `project_id` is NOT consulted for `--linked`. -function tempWorkdir(): string { - return mkdtempSync(join(tmpdir(), "sb-db-dump-live-")); -} - -// Data-plane: needs a provisioned project whose database is routable (the -// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project -// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB -// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is -// skipped rather than timing out on pg_dump. -describeLiveDataPlane("supabase db dump (live)", () => { - test("dumps the linked project's schema to a file", { timeout: LIVE_TIMEOUT_MS }, async () => { - const ref = requireLiveProjectRef(); - const dir = tempWorkdir(); - try { - const outFile = join(dir, "schema.sql"); - const { exitCode, stdout, stderr } = await runSupabaseLive( - ["db", "dump", "--linked", "-f", outFile], - { cwd: dir, env: { SUPABASE_PROJECT_ID: ref }, exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000 }, - ); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - // The native pg_dump container (shared `legacyStreamPgDump`) opened + wrote - // the dump file. A fresh project's public schema may be near-empty, so assert - // the file was created rather than its size. - expect(existsSync(outFile)).toBe(true); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); +test("dumps the remote schema to a file", async ({ cli, project, workspace }) => { + const outFile = join(workspace.path, "schema.sql"); + const result = await cli(["db", "dump", "--db-url", project.dbUrl, "-f", outFile]); + expect(result.exitCode, result.stderr).toBe(0); + expect(existsSync(outFile)).toBe(true); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts index 320d274d60..bdb4119e38 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts @@ -1,69 +1,68 @@ -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 300_000; +test("pulls the remote schema after a local migration is applied", async ({ + cli, + project, + workspace, +}) => { + const version = `${Date.now()}${Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, "0")}`; + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + const existingMigrations = new Set(await readdir(migrations)); + const migrationFile = join(migrations, `${version}_e2e_pull.sql`); + await writeFile(migrationFile, `create table if not exists e2e_pull_${version} (id int);\n`); -// A fresh, isolated temp workdir so the CLI writes migrations there and never -// touches the repo tree. The provisioned project ref is supplied to `--linked` via -// the `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain in both -// Go and the legacy port (flag → `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); -// `config.toml`'s `project_id` is NOT consulted for `--linked`. -function tempWorkdir(): string { - return mkdtempSync(join(tmpdir(), "sb-db-pull-live-")); -} + let targetError: unknown; + try { + const pushed = await cli(["db", "push", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(pushed, "db push setup"); -// Data-plane: needs a provisioned project whose database is routable (the -// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project -// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB -// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is -// skipped rather than timing out on the pg_dump seed. -describeLiveDataPlane("supabase db pull (live)", () => { - test( - "initial pull from the linked project (native pg_dump seed + migra diff)", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const dir = tempWorkdir(); - try { - const { stdout, stderr, exitCode } = await runSupabaseLive(["db", "pull", "--linked"], { - cwd: dir, - env: { SUPABASE_PROJECT_ID: ref }, - exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000, - // Decline the "Update remote migration history table?" prompt with a piped - // `n`: this project ref is shared across live runs, and writing a - // `schema_migrations` row here would make a later run see it as an extra - // remote migration and fail with a history conflict before pulling. The - // piped answer also exercises the native prompt's stdin scanning end to end. - stdin: "n\n", - }); - const combined = `${stdout}${stderr}`; - expect(combined).not.toContain("Unauthorized"); - // No local migrations → the native initial-migra path runs: pg_dump the remote - // schema, then append the migra diff. Assert on the durable side effect: a - // provisioned project with schema writes a `_remote_schema.sql` - // migration; a fresh empty schema reports "No schema changes found". Either - // proves the path ran end to end against the real database without hanging. - const migDir = join(dir, "supabase", "migrations"); - const wroteMigration = - existsSync(migDir) && readdirSync(migDir).some((f) => f.endsWith("_remote_schema.sql")); - expect(wroteMigration || combined.includes("No schema changes found")).toBe(true); - // The native path creates the migration file BEFORE pg_dump runs, so a failed - // dump/diff could leave a stray file behind — a written migration is only - // meaningful if the command actually succeeded. - if (wroteMigration) { - expect(exitCode).toBe(0); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - ); + const result = await cli(["db", "pull", "--db-url", project.dbUrl, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}${result.stderr}`).not.toMatch( + /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, + ); + } catch (error) { + targetError = error; + } + + const cleanupErrors: Array = []; + // Remove all migrations created by this test before resetting. This + // includes both the seed migration and the migration generated by + // `db pull`; resetting with only the generated grant statements left + // behind can reference a table that no longer exists. + let currentMigrations: ReadonlyArray = []; + try { + currentMigrations = await readdir(migrations); + } catch (error) { + cleanupErrors.push(error); + } + for (const file of currentMigrations.filter((candidate) => !existingMigrations.has(candidate))) { + try { + await unlink(join(migrations, file)); + } catch (error) { + cleanupErrors.push( + new Error( + `db pull cleanup could not remove test migration ${join(migrations, file)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + ); + } + } + + try { + const reset = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(reset, "db reset cleanup after db pull"); + } catch (error) { + cleanupErrors.push(error); + } + + throwWithCleanup(targetError, cleanupErrors); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.live.test.ts b/apps/cli/src/legacy/commands/db/push/push.live.test.ts new file mode 100644 index 0000000000..d4d4ed890f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.live.test.ts @@ -0,0 +1,34 @@ +import { mkdir, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +test("pushes a local migration to the remote database", async ({ cli, project, workspace }) => { + const version = `${Date.now()}${Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, "0")}`; + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + const migrationFile = join(migrations, `${version}_e2e_push.sql`); + await writeFile(migrationFile, `create table if not exists e2e_push_${version} (id int);\n`); + + let targetError: unknown; + const cleanupErrors: Array = []; + try { + const result = await cli(["db", "push", "--db-url", project.dbUrl, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished supabase db push"); + } catch (error) { + targetError = error; + } finally { + await unlink(migrationFile).catch((error) => cleanupErrors.push(error)); + try { + const reset = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(reset, "db reset cleanup after db push"); + } catch (error) { + cleanupErrors.push(error); + } + } + throwWithCleanup(targetError, cleanupErrors); +}); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts new file mode 100644 index 0000000000..738446318c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts @@ -0,0 +1,34 @@ +import { mkdir, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +test("resets the remote database with local migrations", async ({ cli, project, workspace }) => { + const version = `${Date.now()}${Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, "0")}`; + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + const migrationFile = join(migrations, `${version}_e2e_reset.sql`); + await writeFile(migrationFile, `create table if not exists e2e_reset_${version} (id int);\n`); + + let targetError: unknown; + const cleanupErrors: Array = []; + try { + const result = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("Resetting remote database"); + } catch (error) { + targetError = error; + } finally { + await unlink(migrationFile).catch((error) => cleanupErrors.push(error)); + try { + const reset = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(reset, "db reset cleanup"); + } catch (error) { + cleanupErrors.push(error); + } + } + throwWithCleanup(targetError, cleanupErrors); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts new file mode 100644 index 0000000000..f39d724f50 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -0,0 +1,172 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterAll, beforeAll, expect, test } from "vitest"; + +import { describe } from "vitest"; +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../../../../tests/helpers/cli.ts"; + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const SCENARIO_COMMAND_TIMEOUT_MS = 280_000; +const BEFORE_ALL_TIMEOUT_MS = CLI_COMMAND_TIMEOUT_MS + STACK_START_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const SCENARIO_TIMEOUT_MS = 900_000; +const NEXT_ENV = { SUPABASE_USE_PG_DELTA_NEXT: "true" }; + +const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); + +create table public.disposable_note ( + id bigint generated by default as identity primary key, + body text not null +); + +create view public.auth_user_emails as +select id, email +from auth.users; +`; + +function commandFailure(result: { stdout: string; stderr: string }): string { + return `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`; +} + +function migrationFiles(projectDir: string): ReadonlyArray { + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + return existsSync(migrationsDir) + ? readdirSync(migrationsDir) + .filter((file) => file.endsWith(".sql")) + .sort() + : []; +} + +describe("db schema declarative sync (e2e)", () => { + let project: Awaited> | undefined; + + beforeAll(async () => { + project = await makeTempLegacyStackProject("sb-pgdelta-next-e2e-"); + const projectDir = project.dir; + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const config = readFileSync(configPath, "utf8"); + if (!config.includes("[experimental.pgdelta]\nenabled = true")) { + throw new Error("init setup did not enable experimental pg-delta in config.toml"); + } + writeFileSync( + configPath, + config.replace( + '# declarative_schema_path = "./schemas"', + 'declarative_schema_path = "./schemas"', + ), + ); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + writeFileSync(path.join(schemasDir, "public.sql"), initialDesiredSchema); + const extensionsDir = path.join(schemasDir, "cluster", "extensions"); + mkdirSync(extensionsDir, { recursive: true }); + for (const extension of ["pg_net", "pgcrypto", "uuid-ossp"]) { + writeFileSync( + path.join(extensionsDir, `${extension}.sql`), + `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";\n`, + ); + } + + const start = await runSupabase( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + }, BEFORE_ALL_TIMEOUT_MS); + + afterAll(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "applies a representative declarative schema and converges", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + const projectDir = project?.dir; + if (projectDir === undefined) throw new Error("declarative sync project was not initialized"); + + const sync = await runSupabase( + [ + "db", + "schema", + "declarative", + "sync", + "--no-apply", + "--name", + "initial_declarative", + "--experimental", + ], + { + entrypoint: "legacy", + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, + }, + ); + expect(sync.exitCode, commandFailure(sync)).toBe(0); + + const migrations = migrationFiles(projectDir); + expect(migrations.length).toBeGreaterThan(0); + const sql = migrations + .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) + .join("\n"); + expect(sql).toContain("account_state"); + expect(sql).toContain("disposable_note"); + expect(sql).toContain("auth_user_emails"); + expect(sql).toContain("auth.users"); + expect(sql).not.toMatch( + /CREATE\s+(?:SCHEMA|TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(?:auth|storage|realtime)["']?/iu, + ); + + const reset = await runSupabase(["db", "reset", "--local", "--no-seed"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(reset, "db reset setup"); + + const converged = await runSupabase( + ["db", "schema", "declarative", "sync", "--no-apply", "--experimental"], + { + entrypoint: "legacy", + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, + }, + ); + expect(converged.exitCode, commandFailure(converged)).toBe(0); + expect(`${converged.stdout}${converged.stderr}`).toContain("No schema changes found"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts deleted file mode 100644 index d6e5a33c77..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, expect, test } from "vitest"; - -import { describeDockerLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; - -const COMMAND_TIMEOUT_MS = 280_000; -const SCENARIO_TIMEOUT_MS = 900_000; -const NEXT_ENV = { SUPABASE_USE_PG_DELTA_NEXT: "true" }; - -const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); - -create table public.disposable_note ( - id bigint generated by default as identity primary key, - body text not null -); - -create view public.auth_user_emails as -select id, email -from auth.users; -`; - -function commandFailure(result: { stdout: string; stderr: string }): string { - return `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`; -} - -function migrationFiles(projectDir: string): ReadonlyArray { - const migrationsDir = path.join(projectDir, "supabase", "migrations"); - return existsSync(migrationsDir) - ? readdirSync(migrationsDir) - .filter((file) => file.endsWith(".sql")) - .sort() - : []; -} - -describeDockerLive("pg-delta next local convergence (live)", () => { - let projectDir = ""; - - beforeAll(async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-live-")); - - const init = await runSupabaseLive(["init"], { - cwd: projectDir, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }); - expect(init.exitCode, commandFailure(init)).toBe(0); - - const configPath = path.join(projectDir, "supabase", "config.toml"); - const config = readFileSync(configPath, "utf8"); - expect(config).toContain("schema_paths = []"); - expect(config).toContain("[experimental.pgdelta]\nenabled = true"); - writeFileSync( - configPath, - config - .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') - .replace( - '# declarative_schema_path = "./schemas"', - 'declarative_schema_path = "./schemas"', - ), - ); - - const schemasDir = path.join(projectDir, "supabase", "schemas"); - mkdirSync(schemasDir, { recursive: true }); - writeFileSync(path.join(schemasDir, "public.sql"), initialDesiredSchema); - - const start = await runSupabaseLive( - [ - "start", - "--exclude", - "studio", - "--exclude", - "logflare", - "--exclude", - "vector", - "--exclude", - "gotrue", - "--exclude", - "realtime", - "--exclude", - "storage-api", - ], - { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, - ); - expect(start.exitCode, commandFailure(start)).toBe(0); - }, COMMAND_TIMEOUT_MS); - - afterAll(async () => { - if (projectDir.length === 0) return; - await runSupabaseLive(["stop", "--no-backup"], { - cwd: projectDir, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - }, COMMAND_TIMEOUT_MS); - - test( - "applies a representative declarative schema and converges", - { timeout: SCENARIO_TIMEOUT_MS }, - async () => { - expect(migrationFiles(projectDir)).toEqual([]); - - const diff = await runSupabaseLive( - ["db", "diff", "--local", "--use-pg-delta", "-f", "initial_declarative"], - { cwd: projectDir, env: NEXT_ENV, exitTimeoutMs: COMMAND_TIMEOUT_MS }, - ); - expect(diff.exitCode, commandFailure(diff)).toBe(0); - - const migrations = migrationFiles(projectDir); - expect(migrations.length).toBeGreaterThan(0); - const sql = migrations - .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) - .join("\n"); - expect(sql).toContain("account_state"); - expect(sql).toContain("disposable_note"); - expect(sql).toContain("auth_user_emails"); - expect(sql).toContain("auth.users"); - expect(sql).not.toMatch( - /CREATE\s+(?:SCHEMA|TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(?:auth|storage|realtime)["']?/iu, - ); - - const reset = await runSupabaseLive(["db", "reset", "--local", "--no-seed"], { - cwd: projectDir, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }); - expect(reset.exitCode, commandFailure(reset)).toBe(0); - - const converged = await runSupabaseLive(["db", "diff", "--local", "--use-pg-delta"], { - cwd: projectDir, - env: NEXT_ENV, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }); - expect(converged.exitCode, commandFailure(converged)).toBe(0); - expect(converged.stderr).toContain("No schema changes found"); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts new file mode 100644 index 0000000000..46ffcec0b3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; + +import { + makeTempHome, + makeTempStackProject, + runSupabase, +} from "../../../../../tests/helpers/cli.ts"; + +const DB_START_COMMAND_TIMEOUT_MS = 480_000; +const DB_START_CLEANUP_TIMEOUT_MS = 120_000; +const DB_START_TEST_TIMEOUT_MS = DB_START_COMMAND_TIMEOUT_MS + DB_START_CLEANUP_TIMEOUT_MS; + +describe("supabase db start (e2e)", () => { + test( + "boots the local database", + async () => { + const home = makeTempHome(); + const project = await makeTempStackProject("supabase-db-start-e2e-"); + try { + const started = await runSupabase(["db", "start"], { + entrypoint: "legacy", + cwd: project.dir, + home: home.dir, + exitTimeoutMs: DB_START_COMMAND_TIMEOUT_MS, + }); + expect(started.exitCode, started.stderr).toBe(0); + expect(`${started.stdout}${started.stderr}`).toMatch( + /Starting database|Initialising schema/i, + ); + } finally { + await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: project.dir, + home: home.dir, + exitTimeoutMs: DB_START_CLEANUP_TIMEOUT_MS, + }).catch(() => undefined); + } + }, + DB_START_TEST_TIMEOUT_MS, + ); +}); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts new file mode 100644 index 0000000000..9b4b06f88c --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { expect } from "vitest"; + +import { test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function cleanupFunction( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + slug: string, + ref: string, +): Promise { + const deleted = await cli(["functions", "delete", slug, "--project-ref", ref]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + throw new Error(`functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`); + } +} + +test("deletes a deployed function", async ({ cli, project, workspace }) => { + const slug = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; + const directory = `${workspace.path}/supabase/functions/${slug}`; + await mkdir(directory, { recursive: true }); + await writeFile(`${directory}/index.ts`, "Deno.serve(() => Response.json({ ok: true }));\n"); + await writeFile(`${directory}/deno.json`, '{\n "imports": {}\n}\n'); + + let targetError: unknown; + let cleanupError: unknown; + try { + const deployed = await cli([ + "functions", + "deploy", + slug, + "--project-ref", + project.ref, + "--use-api", + ]); + if (deployed.exitCode !== 0) { + throw new Error( + `functions deploy setup failed (exit ${deployed.exitCode})\nstdout:\n${deployed.stdout}\nstderr:\n${deployed.stderr}`, + ); + } + + const result = await cli(["functions", "delete", slug, "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Deleted Function"); + } catch (error) { + targetError = error; + } finally { + try { + await cleanupFunction(cli, slug, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts new file mode 100644 index 0000000000..76e83ced69 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { describe } from "vitest"; + +import { expectFunctionOk, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function cleanupFunction( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + slug: string, + ref: string, +): Promise { + const deleted = await cli(["functions", "delete", slug, "--project-ref", ref]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + throw new Error(`functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`); + } +} + +describe("functions deploy (live)", () => { + test("deploys a function that responds over HTTP", async ({ + cli, + invoke, + project, + workspace, + }) => { + const slug = `cli-e2e-deploy-${randomUUID().slice(0, 8)}`; + const directory = join(workspace.path, "supabase", "functions", slug); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "index.ts"), + `Deno.serve(() => Response.json({ case: ${JSON.stringify(slug)}, ok: true }));\n`, + ); + await writeFile(join(directory, "deno.json"), '{\n "imports": {}\n}\n'); + + let targetError: unknown; + let cleanupError: unknown; + try { + const result = await cli(["functions", "deploy", "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toMatch(/Deployed Function/i); + + expectFunctionOk(await invoke(slug), slug); + } catch (error) { + targetError = error; + } finally { + try { + await cleanupFunction(cli, slug, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/list/list.live.test.ts b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts index 2bfa93b86f..801dbd4906 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts @@ -1,52 +1,26 @@ -import { expect, test } from "vitest"; +import { describe, expect } from "vitest"; -import { - describeLive, - describeLiveProject, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 120_000; -// Project-scoped read-only scenario. Skipped unless SUPABASE_LIVE_PROJECT_REF is -// set — i.e. a project has been provisioned on the stack (the cli-e2e-ci runner -// does this; a control-plane-only stack, like local macOS, skips it). -// // This is the entry point for the broader edge-functions coverage tracked in // CLI-1834 (deploy + invoke over :443 / {ref}.supabase.red), which needs the // project's gateway reachable from the host — author those here as they become // runnable on the full stack. -describeLiveProject("supabase functions list (live)", () => { - test("lists edge functions for the project", { timeout: LIVE_TIMEOUT_MS }, async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive([ - "functions", - "list", - "--project-ref", - ref, - ]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - }); -}); - -// Project-scoped error path that needs NO provisioned project: a valid token -// with an unknown `--project-ref` must reach the live Management API, come back -// 404, and surface as a non-zero exit (not a crash, not "Unauthorized"). This -// exercises the `--project-ref` request path + error mapping on a control-plane- -// only stack, so it runs under `describeLive`, not `describeLiveProject`. -describeLive("supabase functions list — unknown project (live)", () => { - test("fails with a 404 for an unknown project ref", { timeout: LIVE_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive([ - "functions", - "list", - "--project-ref", - "a".repeat(20), // well-formed (20 lowercase chars) but nonexistent ref - ]); - const out = `${stdout}${stderr}`; - expect(exitCode).not.toBe(0); - expect(out).not.toContain("Unauthorized"); - expect(out).toContain("404"); - }); +describe("supabase functions list (live)", () => { + test( + "lists edge functions for the project", + { timeout: LIVE_TIMEOUT_MS }, + async ({ cli, project }) => { + const { exitCode, stdout, stderr } = await cli([ + "functions", + "list", + "--project-ref", + project.ref, + ]); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.live.test.ts b/apps/cli/src/legacy/commands/gen/types/types.live.test.ts new file mode 100644 index 0000000000..ac1acc30fe --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.live.test.ts @@ -0,0 +1,9 @@ +import { expect } from "vitest"; + +import { test } from "../../../../../tests/helpers/live.ts"; + +test("generates TypeScript types from the remote schema", async ({ cli, project }) => { + const result = await cli(["gen", "types", "--db-url", project.dbUrl, "--lang", "typescript"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toMatch(/export type (Database|Json)/); +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts new file mode 100644 index 0000000000..c747836609 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts @@ -0,0 +1,9 @@ +import { expect } from "vitest"; + +import { test } from "../../../../../../tests/helpers/live.ts"; + +test("reports statistics from the remote database", async ({ cli, project }) => { + const result = await cli(["inspect", "db", "db-stats", "--db-url", project.dbUrl]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Database Size"); +}); diff --git a/apps/cli/src/legacy/commands/link/link.live.test.ts b/apps/cli/src/legacy/commands/link/link.live.test.ts new file mode 100644 index 0000000000..47bf065f1d --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.live.test.ts @@ -0,0 +1,12 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { test } from "../../../../tests/helpers/live.ts"; + +test("links a project and writes its workspace cache", async ({ cli, project, workspace }) => { + const result = await cli(["link", "--project-ref", project.ref, "--skip-pooler"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect(existsSync(join(workspace.path, "supabase", ".temp", "linked-project.json"))).toBe(true); +}); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts index 377ca8c033..48fa94e903 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts @@ -1,29 +1,25 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 120_000; -// A deterministic migration to seed into the remote history and fetch back. -const VERSION = "20240101000000"; -const NAME = "cli_live_roundtrip"; -const MIGRATION_FILE = `${VERSION}_${NAME}.sql`; +// A uniquely named migration to seed into the remote history and fetch back. +const NAME = "cli_live_fetch"; -// Data-plane scenario (Postgres over the pooler) — see the note in -// `../list/list.live.test.ts`. `describeLiveDataPlane` runs this only when the -// project instance is ACTIVE_HEALTHY (the full stack with supabase-postgres-17); -// it SKIPS on the control-plane-only CI that omits it (CLI-1825). +function liveMigrationVersion(): string { + return new Date().toISOString().replace(/\D/gu, "").slice(0, 14); +} + +// Destructive data-plane scenario (Postgres over the pooler) — the setup repairs +// remote migration history and the teardown reverts that exact row. The fixture +// provisions one ACTIVE_HEALTHY project for the serial live suite. // -// Round-trip: `migration fetch` reads the remote `schema_migrations` history and -// writes each row to `supabase/migrations/_.sql`; `migration list` -// then reads those files back as the Local column. +// Golden path: `migration fetch` reads the remote `schema_migrations` history and +// writes each row to `supabase/migrations/_.sql`. // // Unlike `migration list`, `migration fetch` does NOT tolerate a missing history // table: reading the migration table has no undefined-table fallback (only @@ -32,60 +28,67 @@ const MIGRATION_FILE = `${VERSION}_${NAME}.sql`; // (`relation … does not exist`). So we first SEED one migration into the remote // history via `migration repair --status applied` (which creates the migration // table then upserts the version from the local file), establishing -// the table + a row for `fetch` to read back. The ref is supplied via -// SUPABASE_PROJECT_ID. The seed is idempotent (upsert) and the supabox stack is torn -// down per run, so it leaves no shared state behind. -describeLiveDataPlane("supabase migration fetch (live)", () => { - test( - "seeds remote history, fetches it back, and lists it (round-trip)", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const seedDir = await mkdtemp(path.join(tmpdir(), "sb-migration-seed-live-")); - const fetchDir = await mkdtemp(path.join(tmpdir(), "sb-migration-fetch-live-")); - try { - // Seed: record one migration in the remote history. `repair --status applied` - // reads the local file for the version's name/statements, so write it first. - await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); - await writeFile( - path.join(seedDir, "supabase", "migrations", MIGRATION_FILE), - "create table if not exists public.cli_live_roundtrip (id int);\n", - ); - const repaired = await runSupabaseLive( - ["migration", "repair", VERSION, "--status", "applied"], - { cwd: seedDir, env: { SUPABASE_PROJECT_ID: ref } }, - ); - expect(`${repaired.stdout}${repaired.stderr}`).not.toContain("Unauthorized"); - expect(repaired.exitCode, `stdout:\n${repaired.stdout}\nstderr:\n${repaired.stderr}`).toBe( - 0, - ); - - // Fetch into a fresh (empty) dir so no overwrite prompt fires; it reads the - // remote history and writes _.sql. - const fetched = await runSupabaseLive(["migration", "fetch"], { - cwd: fetchDir, - env: { SUPABASE_PROJECT_ID: ref }, - }); - expect(`${fetched.stdout}${fetched.stderr}`).not.toContain("Unauthorized"); - expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe(0); +// the table + a row for `fetch` to read back. The shared fixture's pooler URL is +// passed explicitly so the test does not fall back to a direct IPv6 host. +test( + "fetches a seeded remote migration into the local migrations directory", + { timeout: LIVE_TIMEOUT_MS }, + async ({ cli, project }) => { + const targetArgs = ["--db-url", project.dbUrl]; + const version = liveMigrationVersion(); + const migrationFile = `${version}_${NAME}.sql`; + const seedDir = await mkdtemp(path.join(tmpdir(), "sb-migration-seed-live-")); + const fetchDir = await mkdtemp(path.join(tmpdir(), "sb-migration-fetch-live-")); + let targetError: unknown; + const cleanupErrors: Array = []; + try { + // Seed: record one migration in the remote history. `repair --status applied` + // reads the local file for the version's name/statements, so write it first. + await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); + await writeFile( + path.join(seedDir, "supabase", "migrations", migrationFile), + "create table if not exists public.cli_live_roundtrip (id int);\n", + ); + const repairResult = await cli( + ["migration", "repair", version, "--status", "applied", ...targetArgs], + { cwd: seedDir }, + ); + requireLiveSuccess(repairResult, "migration repair setup"); - // fetch wrote the seeded migration back, under its established filename format. - const files = await readdir(path.join(fetchDir, "supabase", "migrations")); - expect(files).toContain(MIGRATION_FILE); + // Fetch into a fresh (empty) dir so no overwrite prompt fires; it reads the + // remote history and writes _.sql. + const fetched = await cli(["migration", "fetch", ...targetArgs], { cwd: fetchDir }); + expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe(0); - // The same dir feeds `migration list` as the Local column — exit 0 and the - // fetched version is reflected back. - const listed = await runSupabaseLive(["migration", "list"], { - cwd: fetchDir, - env: { SUPABASE_PROJECT_ID: ref }, - }); - expect(`${listed.stdout}${listed.stderr}`).not.toContain("Unauthorized"); - expect(listed.exitCode, `stdout:\n${listed.stdout}\nstderr:\n${listed.stderr}`).toBe(0); - expect(listed.stdout).toContain(VERSION); - } finally { - await rm(seedDir, { recursive: true, force: true }); - await rm(fetchDir, { recursive: true, force: true }); + // fetch wrote the seeded migration back, under its established filename format. + const files = await readdir(path.join(fetchDir, "supabase", "migrations")); + expect(files).toContain(migrationFile); + } catch (error) { + targetError = error; + } finally { + try { + const reverted = await cli( + ["migration", "repair", version, "--status", "reverted", ...targetArgs], + { cwd: seedDir }, + ); + if ( + reverted.exitCode !== 0 && + !/not found|does not exist/i.test(`${reverted.stdout}\n${reverted.stderr}`) + ) { + cleanupErrors.push( + new Error(`migration repair cleanup failed:\n${reverted.stdout}\n${reverted.stderr}`), + ); + } + } catch (error) { + cleanupErrors.push(error); } - }, - ); -}); + await rm(seedDir, { recursive: true, force: true }).catch((error) => + cleanupErrors.push(error), + ); + await rm(fetchDir, { recursive: true, force: true }).catch((error) => + cleanupErrors.push(error), + ); + } + throwWithCleanup(targetError, cleanupErrors); + }, +); diff --git a/apps/cli/src/legacy/commands/migration/list/list.live.test.ts b/apps/cli/src/legacy/commands/migration/list/list.live.test.ts index 358baff4d2..d2cc786163 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.live.test.ts @@ -1,53 +1,9 @@ -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 120_000; - -// Data-plane scenario: unlike `functions`/`branches` list (Management-API -// reads), `migration list` connects to the project's *Postgres* over the pooler. -// `describeLiveDataPlane` runs this only when the project instance is -// ACTIVE_HEALTHY — i.e. the full stack with supabase-postgres-17. The current -// cli-e2e-ci CI omits it (CLI-1825), so the project record exists but its DB is -// unreachable, and this suite SKIPS there rather than failing (see the gate's -// note). It activates automatically once the data-plane is provisioned. -// -// The `--linked` default mints a temp login role via the Management API, then -// reads `supabase_migrations.schema_migrations`. On a freshly provisioned -// project the history table is absent, which the handler maps to an empty list -// (an undefined-table error), so the command still exits 0. The ref is -// supplied via SUPABASE_PROJECT_ID (migration commands resolve the linked ref -// from env / config.toml / ref-file, not a `--project-ref` flag). -describeLiveDataPlane("supabase migration list (live)", () => { - test( - "lists migrations on the linked project's database", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive(["migration", "list"], { - env: { SUPABASE_PROJECT_ID: ref }, - }); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0); - }, - ); - - test( - "emits machine-readable JSON with --output-format json", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive( - ["migration", "list", "--output-format", "json"], - { env: { SUPABASE_PROJECT_ID: ref } }, - ); - expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0); - // stdout must be payload-only valid JSON in json mode (no spinner/log noise). - expect(() => JSON.parse(stdout)).not.toThrow(); - }, - ); +test("lists migrations from the remote database", async ({ cli, project }) => { + const result = await cli(["migration", "list", "--db-url", project.dbUrl]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).not.toContain("Unauthorized"); }); diff --git a/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts b/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts index 515e8a855d..2b4217489a 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts @@ -1,52 +1,19 @@ -import { expect, test } from "vitest"; -import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; +import { expect } from "vitest"; +import { test } from "../../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 60_000; -// Harness smoke for the `live` Vitest project: the canonical example of a live -// test. It exercises the full path — built binary → SUPABASE_PROFILE resolution +// Harness smoke for the live Vitest project: the canonical example of a live +// test. It exercises the full path — built binary → temporary profile resolution // → authenticated Management API request against the running platform — with a // read-only call, so it is safe to run repeatedly and creates no resources. // -// Gated by `describeLive`: skipped unless SUPABASE_ACCESS_TOKEN is set (the -// cli-e2e-ci runner provides supabox's seeded PAT). Broader lifecycle scenarios -// (projects, functions, branching, db, storage) build on this same harness. -describeLive("supabase orgs list (live)", () => { - test( - "lists organizations for the authenticated token", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive(["orgs", "list"]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - }, - ); - - test( - "emits machine-readable JSON with --output-format json", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabaseLive([ - "orgs", - "list", - "--output-format", - "json", - ]); - expect(exitCode).toBe(0); - // stdout must be payload-only valid JSON in json mode (no spinner/log noise). - expect(() => JSON.parse(stdout)).not.toThrow(); - }, - ); - - // Negative path: a bad token must round-trip to the real Management API, come - // back 401, and surface as a non-zero exit with the upstream "Unauthorized" - // message — i.e. the cli's auth + error mapping work against the live stack, - // not just the golden path. Overrides only the token (profile stays set). - test("fails with Unauthorized for an invalid token", { timeout: LIVE_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive(["orgs", "list"], { - env: { SUPABASE_ACCESS_TOKEN: `sbp_${"0".repeat(40)}` }, - }); - expect(exitCode).not.toBe(0); - expect(`${stdout}${stderr}`).toContain("Unauthorized"); - }); -}); +test( + "lists organizations for the authenticated token", + { timeout: LIVE_TIMEOUT_MS }, + async ({ cli }) => { + const { exitCode, stdout, stderr } = await cli(["orgs", "list"]); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + }, +); diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts new file mode 100644 index 0000000000..b7b7ba7097 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts @@ -0,0 +1,19 @@ +import { expect } from "vitest"; + +import { test } from "../../../../../tests/helpers/live.ts"; + +test("lists API keys for a project", async ({ cli, project }) => { + const result = await cli([ + "projects", + "api-keys", + "--project-ref", + project.ref, + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(0); + const rows = JSON.parse(result.stdout) as Array<{ name?: string; api_key?: string }>; + expect( + rows.some((key) => key.name === "anon" || key.api_key?.startsWith("sb_publishable_")), + ).toBe(true); +}); diff --git a/apps/cli/src/legacy/commands/projects/list/list.live.test.ts b/apps/cli/src/legacy/commands/projects/list/list.live.test.ts index 8c4ca20f33..12db8ce9b7 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.live.test.ts @@ -1,33 +1,25 @@ -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 60_000; - -// Account-level read-only live scenario, alongside `orgs list`. Lists every -// project the authenticated token can access — no project ref required, so it -// runs against just the control plane (no provisioned project instance needed). -// Safe to run repeatedly; creates nothing. -describeLive("supabase projects list (live)", () => { - test("lists projects for the authenticated token", { timeout: LIVE_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive(["projects", "list"]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); +test("lists the live project for the authenticated token", async ({ cli, project }) => { + const result = await cli(["projects", "list", "--output-format", "json"]); + expect(result.exitCode, result.stderr).toBe(0); + const parsed: unknown = JSON.parse(result.stdout); + expect(parsed).toEqual(expect.objectContaining({ projects: expect.any(Array) })); + if ( + parsed === null || + typeof parsed !== "object" || + !("projects" in parsed) || + !Array.isArray(parsed.projects) + ) { + throw new Error("projects list JSON response did not contain a projects array"); + } + const refs = parsed.projects.flatMap((project) => { + if (project === null || typeof project !== "object") return []; + if ("ref" in project && typeof project.ref === "string") return [project.ref]; + if ("id" in project && typeof project.id === "string") return [project.id]; + return []; }); - - test( - "emits machine-readable JSON with --output-format json", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabaseLive([ - "projects", - "list", - "--output-format", - "json", - ]); - expect(exitCode).toBe(0); - // stdout must be payload-only valid JSON in json mode (no spinner/log noise). - expect(() => JSON.parse(stdout)).not.toThrow(); - }, - ); + expect(refs).toContain(project.ref); }); diff --git a/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts b/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts new file mode 100644 index 0000000000..1b5e062d9e --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function unsetSecret( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const cleanup = await cli(["secrets", "unset", name, "--project-ref", ref, "--yes"]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + throw new Error(`secrets unset cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`); + } +} + +test("lists a secret created on the remote project", async ({ cli, project }) => { + const name = `CLI_E2E_LIST_${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli([ + "secrets", + "set", + `${name}=live-value`, + "--project-ref", + project.ref, + ]); + requireLiveSuccess(created, "secrets set setup"); + + const result = await cli(["secrets", "list", "--output", "json", "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + const names = (JSON.parse(result.stdout) as Array<{ name: string }>).map( + (secret) => secret.name, + ); + expect(names).toContain(name); + } catch (error) { + targetError = error; + } finally { + try { + await unsetSecret(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts new file mode 100644 index 0000000000..617ad85c8c --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function unsetSecret( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const cleanup = await cli(["secrets", "unset", name, "--project-ref", ref, "--yes"]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + throw new Error(`secrets unset cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`); + } +} + +test("sets a secret on the remote project", async ({ cli, project }) => { + const name = `CLI_E2E_SET_${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const result = await cli([ + "secrets", + "set", + `${name}=live-value`, + "--project-ref", + project.ref, + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished"); + } catch (error) { + targetError = error; + } finally { + try { + await unsetSecret(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts new file mode 100644 index 0000000000..ede0a6dc6b --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts @@ -0,0 +1,47 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function unsetSecret( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const cleanup = await cli(["secrets", "unset", name, "--project-ref", ref, "--yes"]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + throw new Error(`secrets unset cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`); + } +} + +test("unsets a secret from the remote project", async ({ cli, project }) => { + const name = `CLI_E2E_UNSET_${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli([ + "secrets", + "set", + `${name}=live-value`, + "--project-ref", + project.ref, + ]); + requireLiveSuccess(created, "secrets set setup"); + + const result = await cli(["secrets", "unset", name, "--project-ref", project.ref, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished"); + } catch (error) { + targetError = error; + } finally { + try { + await unsetSecret(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts similarity index 80% rename from apps/cli/src/legacy/commands/start/start.live.test.ts rename to apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts index fcfd71f2a0..2bc0483e9c 100644 --- a/apps/cli/src/legacy/commands/start/start.live.test.ts +++ b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts @@ -5,9 +5,9 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; +import { requireCliSuccess, runSupabase } from "../../../../tests/helpers/cli.ts"; import { legacySanitizeProjectId, legacyServiceContainerName, @@ -20,21 +20,14 @@ import { dockerfileServiceImage } from "../../../shared/services/dockerfile-imag const execFileAsync = promisify(execFile); const START_TIMEOUT_MS = 280_000; -const SHORT_LIVE_TIMEOUT_MS = 30_000; +const SHORT_E2E_TIMEOUT_MS = 30_000; const LIFECYCLE_OVERHEAD_MS = 90_000; /** * `--exclude` values for the 3 heaviest/least-relevant services — same intent - * `stop.live.test.ts`/`status.live.test.ts` already documented for their own - * reduced-stack `start` call (Studio's Next.js build, the Logflare/Vector - * logging pipeline), but "logflare" here, NOT "analytics" like those two - * siblings. `LEGACY_SERVICE_CATALOG`'s `excludeKey` for the logflare service - * is "logflare" — "analytics" is only that service's *container suffix* - * (`legacy-service-catalog.ts`), never a valid `--exclude` value. The - * siblings' `--exclude analytics` is a silent no-op — harmless for their own - * coarse "is the stack up/down" assertions, but this suite's exact- - * container-set assertions need the genuinely valid key so logflare is - * actually excluded. + * as the reduced-stack `start` calls in the sibling Docker e2e suites (Studio's + * Next.js build and the Logflare/Vector logging pipeline). The legacy service + * catalog uses `logflare` as the exclusion key for that logging service. */ const EXCLUDED_SERVICE_KEYS: ReadonlySet = new Set(["studio", "logflare", "vector"]); @@ -42,10 +35,10 @@ const EXCLUDED_SERVICE_KEYS: ReadonlySet = new Set(["studio", "logflare" * Services the running-container assertion below must NOT expect to be running, even though * they are neither in `EXCLUDED_SERVICE_KEYS` nor `--exclude`d on the `start` call itself: * - `supavisor` — `db.pooler.enabled` defaults to `false` (`packages/config/src/db.ts`, - * `defaultPoolerEnabled`), and `runSupabaseLive(["init"], ...)` above writes a config.toml + * `defaultPoolerEnabled`), and `runSupabase(["init"], ...)` above writes a config.toml * with no override, so it's genuinely disabled on this test's stack, not merely unasserted. * - `imgproxy` — gated on `storage.image_transformation.enabled` (`start.gates.ts:169`), - * which defaults to `false`/absent; `runSupabaseLive(["init"], ...)` writes a config.toml + * which defaults to `false`/absent; `runSupabase(["init"], ...)` writes a config.toml * with `[storage.image_transformation]` still commented out * (`project-init.templates.ts:132-133`), so imgproxy is genuinely disabled on this test's stack. */ @@ -60,18 +53,21 @@ function splitNonEmptyLines(text: string): ReadonlyArray { // `start` is the one local-dev-stack command whose correctness genuinely // depends on a real Docker daemon — real label filtering and real container -// lifecycle, not just CLI exit codes. `describeLive` is reused purely as the -// "we're in the full cli-e2e-ci runner" signal (see stop.live.test.ts's own +// lifecycle, not just CLI exit codes. `describe` gates the +// "we're in a configured e2e runner" signal (see stop.e2e.test.ts's own // comment for why this, not a Management-API gate, is correct here). See -// AGENTS.md's "Live tests" section for the full convention. -describeLive("supabase start (live)", () => { +// AGENTS.md's "e2e tests" section for the full convention. +describe("supabase start (e2e)", () => { let projectDir: string | undefined; afterEach(async () => { if (projectDir === undefined) return; // Best-effort cleanup even if an assertion above failed mid-lifecycle — a // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: projectDir, + }).catch(() => undefined); await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); projectDir = undefined; }); @@ -80,7 +76,7 @@ describeLive("supabase start (live)", () => { "recreates a stopped real stack and preserves database data", { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-")); + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-")); // No `project_id` override, so the cli resolves it from the workdir // basename (see legacy-docker-ids.ts). Sanitizing is a no-op for a // `mkdtemp`-generated basename (already alphanumeric/`-`), but mirrors @@ -98,13 +94,15 @@ describeLive("supabase start (live)", () => { "vector", ]; - const init = await runSupabaseLive(["init"], { + const init = await runSupabase(["init"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + requireCliSuccess(init, "init setup"); - const start = await runSupabaseLive(startArgs, { + const start = await runSupabase(startArgs, { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, }); @@ -135,7 +133,7 @@ describeLive("supabase start (live)", () => { const containerIds = splitNonEmptyLines(containerIdOutput); expect(containerIds.length).toBeGreaterThan(0); await execFileAsync("docker", ["stop", "--time", "0", ...containerIds], { - timeout: SHORT_LIVE_TIMEOUT_MS, + timeout: SHORT_E2E_TIMEOUT_MS, }); const { stdout: stoppedState } = await execFileAsync("docker", [ @@ -150,7 +148,8 @@ describeLive("supabase start (live)", () => { Status: "exited", }); - const restart = await runSupabaseLive(startArgs, { + const restart = await runSupabase(startArgs, { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, }); @@ -195,11 +194,12 @@ describeLive("supabase start (live)", () => { ).toBe(!isExcluded); } - const status = await runSupabaseLive(["status"], { + const status = await runSupabase(["status"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + requireCliSuccess(status, "status setup"); }, ); @@ -207,13 +207,14 @@ describeLive("supabase start (live)", () => { "bypasses an HTTPS proxy for loopback gateway health checks", { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-proxy-")); + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-proxy-")); - const init = await runSupabaseLive(["init"], { + const init = await runSupabase(["init"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + requireCliSuccess(init, "init setup"); let proxyConnections = 0; const proxy = createServer((socket) => { @@ -237,7 +238,8 @@ describeLive("supabase start (live)", () => { : ["--exclude", entry.excludeKey], ); const proxyUrl = `http://127.0.0.1:${address.port}`; - const start = await runSupabaseLive(["start", ...excludeArgs], { + const start = await runSupabase(["start", ...excludeArgs], { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, env: { @@ -273,18 +275,19 @@ describeLive("supabase start (live)", () => { "names the container and its image when a cached image cannot be executed", { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-exec-")); + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-exec-")); const projectId = legacySanitizeProjectId(path.basename(projectDir)); const mailpitContainer = legacyServiceContainerName("inbucket", projectId); // The exact tag `start` resolves for Mailpit, so its already-cached check // finds this deliberately broken build and never reaches a registry. const mailpitImage = legacyGetRegistryImageUrl(dockerfileServiceImage("mailpit")); - const init = await runSupabaseLive(["init"], { + const init = await runSupabase(["init"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + requireCliSuccess(init, "init setup"); // A `scratch` image whose entrypoint is not an executable binary — the // kernel refuses it with exactly the "exec format error" this diagnoses. @@ -305,7 +308,8 @@ describeLive("supabase start (live)", () => { ? [] : ["--exclude", entry.excludeKey], ); - const start = await runSupabaseLive(["start", ...excludeArgs], { + const start = await runSupabase(["start", ...excludeArgs], { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, }); diff --git a/apps/cli/src/legacy/commands/status/status.e2e.test.ts b/apps/cli/src/legacy/commands/status/status.e2e.test.ts new file mode 100644 index 0000000000..5b4e47413d --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.e2e.test.ts @@ -0,0 +1,77 @@ +import { afterEach, expect, test } from "vitest"; + +import { describe } from "vitest"; +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../tests/helpers/cli.ts"; + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const STATUS_COMMAND_TIMEOUT_MS = 60_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const STATUS_TEST_TIMEOUT_MS = + CLI_COMMAND_TIMEOUT_MS + + STACK_START_TIMEOUT_MS + + STATUS_COMMAND_TIMEOUT_MS * 2 + + LIFECYCLE_MARGIN_MS; + +// See stop.e2e.test.ts for why `describe` (not a Management-API gate) is +// the right reuse here: `status` never calls the Management API, only the real +// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "e2e tests" +// section for the full convention. +describe("supabase status (e2e)", () => { + let project: Awaited> | undefined; + + afterEach(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "reports a running local stack in pretty and json modes", + { timeout: STATUS_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-status-e2e-"); + const projectDir = project.dir; + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + const pretty = await runSupabase(["status"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STATUS_COMMAND_TIMEOUT_MS, + }); + expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); + expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); + expect(pretty.stdout).toContain("Project URL"); + expect(pretty.stdout).toContain("Database"); + + const json = await runSupabase(["status", "-o", "json"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STATUS_COMMAND_TIMEOUT_MS, + }); + expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); + const parsed: unknown = JSON.parse(json.stdout); + expect(parsed).toMatchObject({ + API_URL: expect.stringContaining("http"), + DB_URL: expect.stringContaining("postgresql://"), + }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/status/status.live.test.ts b/apps/cli/src/legacy/commands/status/status.live.test.ts deleted file mode 100644 index 64569c118c..0000000000 --- a/apps/cli/src/legacy/commands/status/status.live.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, expect, test } from "vitest"; - -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; - -const START_TIMEOUT_MS = 280_000; - -// See stop.live.test.ts for why `describeLive` (not a Management-API gate) is -// the right reuse here: `status` never calls the Management API, only the real -// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "Live tests" -// section for the full convention. -describeLive("supabase status (live)", () => { - let projectDir: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - }); - - test( - "reports a running local stack in pretty and json modes", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-status-live-")); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - const pretty = await runSupabaseLive(["status"], { cwd: projectDir }); - expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); - expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); - expect(pretty.stdout).toContain("Project URL"); - expect(pretty.stdout).toContain("Database"); - - const json = await runSupabaseLive(["status", "-o", "json"], { cwd: projectDir }); - expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); - const parsed: unknown = JSON.parse(json.stdout); - expect(parsed).toMatchObject({ - API_URL: expect.stringContaining("http"), - DB_URL: expect.stringContaining("postgresql://"), - }); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts new file mode 100644 index 0000000000..6e18d79786 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts @@ -0,0 +1,175 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; + +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../tests/helpers/cli.ts"; +import { legacySanitizeProjectId } from "../../shared/legacy-docker-ids.ts"; + +const execFileAsync = promisify(execFile); + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const STOP_COMMAND_TIMEOUT_MS = 120_000; +const DOCKER_INSPECT_TIMEOUT_MS = 30_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const STOP_TEST_TIMEOUT_MS = + CLI_COMMAND_TIMEOUT_MS + + STACK_START_TIMEOUT_MS + + CLI_COMMAND_TIMEOUT_MS + + STOP_COMMAND_TIMEOUT_MS + + DOCKER_INSPECT_TIMEOUT_MS + + LIFECYCLE_MARGIN_MS; + +// `stop` never calls the Management API — it talks directly to the real local +// Docker stack `start` creates. `describe` gates +// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a +// real Docker daemon, since that's how supabox itself runs); the +// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See +// AGENTS.md's "e2e tests" section for the full convention. +describe("supabase stop (e2e)", () => { + let project: Awaited> | undefined; + let projectId: string | undefined; + + afterEach(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + projectId = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "starts a real local stack, then stops it and removes its containers", + { timeout: STOP_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-stop-e2e-"); + const projectDir = project.dir; + // No `project_id` override, so the cli resolves it from the workdir + // basename (see legacy-docker-ids.ts). + projectId = path.basename(projectDir); + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + // Exclude the heaviest, least relevant services (Next.js Studio build, the + // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care + // which services are running, only that at least one real container + // exists to stop. + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + // Sanity: confirm the stack is actually up before testing `stop` against it. + const before = await runSupabase(["status"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(before, "status setup"); + + const stop = await runSupabase(["stop"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STOP_COMMAND_TIMEOUT_MS, + }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // The real Docker daemon must agree: no container carrying this project's + // label survives `stop` — the actual behavior under test, not just the + // cli's own exit code. + const { stdout: remaining } = await execFileAsync( + "docker", + [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ], + { timeout: DOCKER_INSPECT_TIMEOUT_MS }, + ); + expect(remaining.trim()).toBe(""); + }, + ); + + test( + "stop --no-backup --debug reports real pruned containers, volumes, and network", + { timeout: STOP_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-stop-e2e-"); + const projectDir = project.dir; + // Sanitizing is a no-op for a `mkdtemp`-generated basename (already + // alphanumeric/`-`), but mirrors the port's actual resolution rather + // than assuming that stays true (same note as `start.e2e.test.ts`). + projectId = legacySanitizeProjectId(path.basename(projectDir)); + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + // `--no-backup` exercises the volume-prune branch; `--debug` turns on + // the `Pruned …:` stderr reports, which are + // backed by parsing REAL `docker`/`podman` prune stdout — the format + // assumption (`Deleted …:` headers, `Total reclaimed space:` trailer) + // that mocked integration fixtures cannot validate by construction. + const stop = await runSupabase(["stop", "--no-backup", "--debug"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STOP_COMMAND_TIMEOUT_MS, + }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // Containers: real Docker reports full hex IDs — the list must be + // non-empty, since the started stack's containers were just removed. + expect(stop.stderr).toMatch(/^Pruned containers: \[[0-9a-f][^\]]*\]$/mu); + // Volumes: the db volume always exists (db is never excluded), so the + // report must name it. Other project volumes may also appear. + const volumesLine = stop.stderr + .split("\n") + .find((line) => line.startsWith("Pruned volumes: [")); + expect(volumesLine, `stderr:\n${stop.stderr}`).toContain(`supabase_db_${projectId}`); + // Network: exactly the project network; the established label is singular + // "network", unlike the other two reports. + expect(stop.stderr).toContain(`Pruned network: [supabase_network_${projectId}]`); + + // The real Docker daemon must agree with the report: nothing carrying + // this project's label survives. + const { stdout: remaining } = await execFileAsync( + "docker", + [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ], + { timeout: DOCKER_INSPECT_TIMEOUT_MS }, + ); + expect(remaining.trim()).toBe(""); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/stop/stop.live.test.ts b/apps/cli/src/legacy/commands/stop/stop.live.test.ts deleted file mode 100644 index 12d71ed169..0000000000 --- a/apps/cli/src/legacy/commands/stop/stop.live.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; -import { afterEach, expect, test } from "vitest"; - -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; -import { legacySanitizeProjectId } from "../../shared/legacy-docker-ids.ts"; - -const execFileAsync = promisify(execFile); - -const START_TIMEOUT_MS = 280_000; - -// `stop` never calls the Management API — it talks directly to the real local -// Docker stack `start` creates. `describeLive` is reused -// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a -// real Docker daemon, since that's how supabox itself runs); the -// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See -// AGENTS.md's "Live tests" section for the full convention. -describeLive("supabase stop (live)", () => { - let projectDir: string | undefined; - let projectId: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a - // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - projectId = undefined; - }); - - test( - "starts a real local stack, then stops it and removes its containers", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); - // No `project_id` override, so the cli resolves it from the workdir - // basename (see legacy-docker-ids.ts). - projectId = path.basename(projectDir); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - // Exclude the heaviest, least relevant services (Next.js Studio build, the - // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care - // which services are running, only that at least one real container - // exists to stop. - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - // Sanity: confirm the stack is actually up before testing `stop` against it. - const before = await runSupabaseLive(["status"], { cwd: projectDir }); - expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); - - const stop = await runSupabaseLive(["stop"], { cwd: projectDir }); - expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); - expect(stop.stdout).toContain("Stopped"); - - // The real Docker daemon must agree: no container carrying this project's - // label survives `stop` — the actual behavior under test, not just the - // cli's own exit code. - const { stdout: remaining } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - `label=com.supabase.cli.project=${projectId}`, - "--format", - "{{.ID}}", - ]); - expect(remaining.trim()).toBe(""); - }, - ); - - test( - "stop --no-backup --debug reports real pruned containers, volumes, and network", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); - // Sanitizing is a no-op for a `mkdtemp`-generated basename (already - // alphanumeric/`-`), but mirrors the port's actual resolution rather - // than assuming that stays true (same note as `start.live.test.ts`). - projectId = legacySanitizeProjectId(path.basename(projectDir)); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - // `--no-backup` exercises the volume-prune branch; `--debug` turns on - // the `Pruned …:` stderr reports, which are - // backed by parsing REAL `docker`/`podman` prune stdout — the format - // assumption (`Deleted …:` headers, `Total reclaimed space:` trailer) - // that mocked integration fixtures cannot validate by construction. - const stop = await runSupabaseLive(["stop", "--no-backup", "--debug"], { cwd: projectDir }); - expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); - expect(stop.stdout).toContain("Stopped"); - - // Containers: real Docker reports full hex IDs — the list must be - // non-empty, since the started stack's containers were just removed. - expect(stop.stderr).toMatch(/^Pruned containers: \[[0-9a-f][^\]]*\]$/mu); - // Volumes: the db volume always exists (db is never excluded), so the - // report must name it. Other project volumes may also appear. - const volumesLine = stop.stderr - .split("\n") - .find((line) => line.startsWith("Pruned volumes: [")); - expect(volumesLine, `stderr:\n${stop.stderr}`).toContain(`supabase_db_${projectId}`); - // Network: exactly the project network; the established label is singular - // "network", unlike the other two reports. - expect(stop.stderr).toContain(`Pruned network: [supabase_network_${projectId}]`); - - // The real Docker daemon must agree with the report: nothing carrying - // this project's label survives. - const { stdout: remaining } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - `label=com.supabase.cli.project=${projectId}`, - "--format", - "{{.ID}}", - ]); - expect(remaining.trim()).toBe(""); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts new file mode 100644 index 0000000000..c66f82c981 --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +const STORAGE_FLAGS = ["--linked", "--experimental"]; + +async function removeObject( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + remote: string, +): Promise { + const removed = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + 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}`); + } +} + +test("copies a local file to the remote bucket", async ({ cli, project, workspace }) => { + const suffix = randomUUID().slice(0, 8); + const local = join(workspace.path, `upload-${suffix}.txt`); + const remote = `ss:///${project.storageBucket}/upload-${suffix}.txt`; + await writeFile(local, "live-e2e storage payload\n"); + + let targetError: unknown; + let cleanupError: unknown; + try { + const linked = await cli(["link", "--project-ref", project.ref], { + env: { SUPABASE_DB_PASSWORD: project.dbPassword }, + }); + requireLiveSuccess(linked, "link setup for storage cp"); + + const result = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + expect(result.exitCode, result.stderr).toBe(0); + } catch (error) { + targetError = error; + } finally { + try { + await removeObject(cli, remote); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts b/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts new file mode 100644 index 0000000000..b678ceb7df --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts @@ -0,0 +1,57 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +const STORAGE_FLAGS = ["--linked", "--experimental"]; + +async function removeObject( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + remote: string, +): Promise { + const removed = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + 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}`); + } +} + +test("lists an uploaded object", async ({ cli, project, workspace }) => { + const suffix = randomUUID().slice(0, 8); + const local = join(workspace.path, `upload-${suffix}.txt`); + const remote = `ss:///${project.storageBucket}/upload-${suffix}.txt`; + await writeFile(local, "live-e2e storage payload\n"); + + let targetError: unknown; + let cleanupError: unknown; + try { + const linked = await cli(["link", "--project-ref", project.ref], { + env: { SUPABASE_DB_PASSWORD: project.dbPassword }, + }); + requireLiveSuccess(linked, "link setup for storage ls"); + const uploaded = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + requireLiveSuccess(uploaded, "storage cp setup for storage ls"); + + const result = await cli([ + "storage", + "ls", + `ss:///${project.storageBucket}/`, + ...STORAGE_FLAGS, + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain(`upload-${suffix}.txt`); + } catch (error) { + targetError = error; + } finally { + try { + await removeObject(cli, remote); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts new file mode 100644 index 0000000000..674268a2cc --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts @@ -0,0 +1,51 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +const STORAGE_FLAGS = ["--linked", "--experimental"]; + +async function removeObject( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + remote: string, +): Promise { + const removed = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + 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}`); + } +} + +test("removes an uploaded object", async ({ cli, project, workspace }) => { + const suffix = randomUUID().slice(0, 8); + const local = join(workspace.path, `upload-${suffix}.txt`); + const remote = `ss:///${project.storageBucket}/upload-${suffix}.txt`; + await writeFile(local, "live-e2e storage payload\n"); + + let targetError: unknown; + let cleanupError: unknown; + try { + const linked = await cli(["link", "--project-ref", project.ref], { + env: { SUPABASE_DB_PASSWORD: project.dbPassword }, + }); + requireLiveSuccess(linked, "link setup for storage rm"); + const uploaded = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + requireLiveSuccess(uploaded, "storage cp setup for storage rm"); + + const result = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + expect(result.exitCode, result.stderr).toBe(0); + } catch (error) { + targetError = error; + } finally { + try { + await removeObject(cli, remote); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts new file mode 100644 index 0000000000..be5905217b --- /dev/null +++ b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts @@ -0,0 +1,189 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { + makeTempCliProject, + makeTempHome, + runSupabase, + spawnSupabase, +} from "../../../../../tests/helpers/cli.ts"; +import { cleanupRegisteredStackProjects } from "../../../../../tests/helpers/stack-e2e-cleanup.ts"; + +const FUNCTIONS_DEV_STARTUP_TIMEOUT_MS = 60_000; +const FUNCTIONS_DEV_STEP_TIMEOUT_MS = 30_000; +const FUNCTIONS_DEV_CLEANUP_TIMEOUT_MS = 30_000; +const FUNCTIONS_DEV_TEST_TIMEOUT_MS = + FUNCTIONS_DEV_STARTUP_TIMEOUT_MS + + FUNCTIONS_DEV_STEP_TIMEOUT_MS * 7 + + FUNCTIONS_DEV_CLEANUP_TIMEOUT_MS; +const FUNCTION_RESPONSE_ATTEMPT_TIMEOUT_MS = 5_000; +const FUNCTION_RESPONSE_RETRY_BACKOFF_MS = 250; +const FUNCTION_FILES_RESTART_PATTERN = /Function files changed\. Restarting edge-runtime\./; + +type SpawnedSupabase = ReturnType; + +async function assertFunctionResponse( + url: string, + init: RequestInit, + assertResponse: (response: Response, body: string) => void, + timeoutMs = FUNCTIONS_DEV_STEP_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastFailure: unknown = new Error("No response received"); + + while (Date.now() < deadline) { + const remainingMs = deadline - Date.now(); + try { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(Math.min(remainingMs, FUNCTION_RESPONSE_ATTEMPT_TIMEOUT_MS)), + }); + const body = await response.text(); + assertResponse(response, body); + return; + } catch (error) { + lastFailure = error; + // Bound request frequency while the worker catches up after a reload; + // the wall-clock deadline, rather than an attempt count, remains the guard. + const retryDelayMs = Math.min( + FUNCTION_RESPONSE_RETRY_BACKOFF_MS, + Math.max(0, deadline - Date.now()), + ); + if (retryDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + } + + throw new Error( + `Function request ${url} did not reach the expected response within ${timeoutMs}ms. ` + + `Last failure: ${lastFailure instanceof Error ? lastFailure.message : String(lastFailure)}`, + ); +} + +describe("supabase functions dev (e2e)", () => { + afterEach(cleanupRegisteredStackProjects); + + test( + "serves a function created while running and applies config and source changes", + { timeout: FUNCTIONS_DEV_TEST_TIMEOUT_MS }, + async () => { + const home = makeTempHome(); + // The next functions runtime owns managed port allocation. This project + // intentionally contains no released-port reservations from the test. + const project = await makeTempCliProject("supabase-functions-dev-e2e-"); + await mkdir(join(project.dir, "supabase"), { recursive: true }); + await writeFile( + join(project.dir, "supabase", "config.toml"), + 'project_id = "functions-dev-e2e"\n', + ); + const functionPath = join(project.dir, "supabase", "functions", "hello-world", "index.ts"); + let devProc: SpawnedSupabase | undefined; + + try { + devProc = spawnSupabase(["functions", "dev"], { + cwd: project.dir, + home: home.dir, + cleanupProcessGroupOnClose: false, + exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, + }); + + await devProc.waitForOutput( + /Edge Functions dev server is running\./, + FUNCTIONS_DEV_STARTUP_TIMEOUT_MS, + ); + const functionUrlMatch = `${devProc.stdout()}\n${devProc.stderr()}`.match( + /Functions URL:\s+(https?:\/\/[^\s/]+\/functions\/v1)/, + ); + if (functionUrlMatch?.[1] === undefined) { + throw new Error( + `Functions dev output did not include a URL.\nstdout:\n${devProc.stdout()}\nstderr:\n${devProc.stderr()}`, + ); + } + const functionUrl = `${functionUrlMatch[1]}/hello-world`; + + const functionOffset = devProc.stdout().length; + const functionRestart = devProc.waitForOutput( + FUNCTION_FILES_RESTART_PATTERN, + FUNCTIONS_DEV_STEP_TIMEOUT_MS, + functionOffset, + ); + const newResult = await runSupabase(["functions", "new", "hello-world"], { + cwd: project.dir, + home: home.dir, + exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, + }); + expect(newResult.exitCode).toBe(0); + await functionRestart; + + await assertFunctionResponse(functionUrl, {}, (response, body) => { + expect(response.status).toBe(401); + expect(body).toContain("Missing authorization header"); + }); + + const configOffset = devProc.stdout().length; + const configRestart = devProc.waitForOutput( + FUNCTION_FILES_RESTART_PATTERN, + FUNCTIONS_DEV_STEP_TIMEOUT_MS, + configOffset, + ); + await writeFile( + join(project.dir, "supabase", "config.toml"), + `project_id = "functions-dev-e2e" + +[functions.hello-world] +verify_jwt = false +`, + ); + await configRestart; + + await assertFunctionResponse( + functionUrl, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Functions Dev" }), + }, + (response, body) => { + expect(response.status).toBe(200); + expect(JSON.parse(body)).toEqual({ message: "Hello Functions Dev!" }); + }, + ); + + const sourceOffset = devProc.stdout().length; + const sourceRestart = devProc.waitForOutput( + FUNCTION_FILES_RESTART_PATTERN, + FUNCTIONS_DEV_STEP_TIMEOUT_MS, + sourceOffset, + ); + await writeFile( + functionPath, + `Deno.serve(() => { + return new Response(JSON.stringify({ message: "Updated from source edit" }), { + headers: { "content-type": "application/json" }, + }); +}); +`, + ); + await sourceRestart; + + await assertFunctionResponse( + functionUrl, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Functions Dev" }), + }, + (response, body) => { + expect(response.status).toBe(200); + expect(JSON.parse(body)).toEqual({ message: "Updated from source edit" }); + }, + ); + } finally { + devProc?.kill("SIGTERM"); + await devProc?.waitForExit().catch(() => undefined); + } + }, + ); +}); diff --git a/apps/cli/src/next/commands/functions/dev/dev.live.test.ts b/apps/cli/src/next/commands/functions/dev/dev.live.test.ts deleted file mode 100644 index ddb5670c82..0000000000 --- a/apps/cli/src/next/commands/functions/dev/dev.live.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { afterEach, expect, test } from "vitest"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { - makeTempHome, - makeTempStackProject, - runSupabase, - spawnSupabase, -} from "../../../../../tests/helpers/cli.ts"; -import { describeLive } from "../../../../../tests/helpers/live.ts"; -import { cleanupRegisteredStackProjects } from "../../../../../tests/helpers/stack-e2e-cleanup.ts"; - -const FUNCTIONS_DEV_STARTUP_TIMEOUT_MS = 60_000; -const FUNCTIONS_DEV_STEP_TIMEOUT_MS = 30_000; -const FUNCTIONS_DEV_TEST_TIMEOUT_MS = 90_000; -const FUNCTION_FILES_RESTART_PATTERN = /Function files changed\. Restarting edge-runtime\./; -const FUNCTION_FILES_RESTART_PATTERN_GLOBAL = /Function files changed\. Restarting edge-runtime\./g; - -type SpawnedSupabase = ReturnType; - -function countOutputMatches(proc: SpawnedSupabase, pattern: RegExp): number { - return [...`${proc.stdout()}\n${proc.stderr()}`.matchAll(pattern)].length; -} - -async function waitForOutputMatchCount( - proc: SpawnedSupabase, - pattern: RegExp, - expectedCount: number, -) { - const deadline = Date.now() + FUNCTIONS_DEV_STEP_TIMEOUT_MS; - - while (Date.now() < deadline) { - if (countOutputMatches(proc, pattern) >= expectedCount) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw new Error( - `Timed out waiting for ${expectedCount.toString()} occurrences of ${pattern.toString()}`, - ); -} - -async function waitForFunctionResponse( - url: string, - init: RequestInit, - assertResponse: (response: Response, body: string) => void, -) { - const deadline = Date.now() + FUNCTIONS_DEV_STEP_TIMEOUT_MS; - let lastError: unknown; - - while (Date.now() < deadline) { - try { - const response = await fetch(url, init); - const body = await response.text(); - try { - assertResponse(response, body); - return; - } catch (error) { - lastError = error; - } - } catch (error) { - lastError = error; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw lastError instanceof Error - ? lastError - : new Error(`Timed out waiting for function response: ${String(lastError)}`); -} - -// This crosses the compiled CLI, detached supervisor, full local stack, file -// watcher, and HTTP runtime boundaries. Keep the one golden path in the -// opt-in live suite instead of slowing and destabilizing ordinary e2e shards. -describeLive("supabase functions dev (live)", () => { - afterEach(cleanupRegisteredStackProjects); - - test( - "serves a function created while running and applies live config and source changes", - { timeout: FUNCTIONS_DEV_TEST_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-functions-dev-e2e-"); - const functionPath = join(project.dir, "supabase", "functions", "hello-world", "index.ts"); - const functionUrl = `http://127.0.0.1:${project.ports.apiPort}/functions/v1/hello-world`; - let devProc: ReturnType | undefined; - - try { - devProc = spawnSupabase(["functions", "dev"], { - cwd: project.dir, - home: home.dir, - cleanupProcessGroupOnClose: false, - exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, - }); - - await devProc.waitForOutput( - /Edge Functions dev server is running\./, - FUNCTIONS_DEV_STARTUP_TIMEOUT_MS, - ); - await new Promise((resolve) => setTimeout(resolve, 500)); - - const newResult = await runSupabase(["functions", "new", "hello-world"], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, - }); - expect(newResult.exitCode).toBe(0); - - await devProc.waitForOutput(FUNCTION_FILES_RESTART_PATTERN, FUNCTIONS_DEV_STEP_TIMEOUT_MS); - - await waitForFunctionResponse(functionUrl, {}, (response, body) => { - expect(response.status).toBe(401); - expect(body).toContain("Missing authorization header"); - }); - - await writeFile( - join(project.dir, "supabase", "config.toml"), - `project_id = "functions-dev-e2e" - -[functions.hello-world] -verify_jwt = false -`, - ); - - await devProc.waitForOutput( - /Edge runtime config changed\. Restarting edge-runtime\./, - FUNCTIONS_DEV_STEP_TIMEOUT_MS, - ); - - await waitForFunctionResponse( - functionUrl, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Functions Dev" }), - }, - (response, body) => { - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ message: "Hello Functions Dev!" }); - }, - ); - - const restartCount = countOutputMatches(devProc, FUNCTION_FILES_RESTART_PATTERN_GLOBAL); - await writeFile( - functionPath, - `Deno.serve(() => { - return new Response(JSON.stringify({ message: "Updated from source edit" }), { - headers: { "content-type": "application/json" }, - }); -}); -`, - ); - await waitForOutputMatchCount( - devProc, - FUNCTION_FILES_RESTART_PATTERN_GLOBAL, - restartCount + 1, - ); - - await waitForFunctionResponse( - functionUrl, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Functions Dev" }), - }, - (response, body) => { - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ message: "Updated from source edit" }); - }, - ); - } finally { - devProc?.kill("SIGTERM"); - await devProc?.waitForExit().catch(() => {}); - } - }, - ); -}); diff --git a/apps/cli/src/next/commands/start/start.e2e.test.ts b/apps/cli/src/next/commands/start/start.e2e.test.ts new file mode 100644 index 0000000000..898331795e --- /dev/null +++ b/apps/cli/src/next/commands/start/start.e2e.test.ts @@ -0,0 +1,101 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { makeTempCliProject, makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; +import { cleanupRegisteredStackProjects } from "../../../../tests/helpers/stack-e2e-cleanup.ts"; + +const START_TIMEOUT_MS = 180_000; +const COMMAND_OPTIONS = { entrypoint: "next" as const }; +const LIGHTWEIGHT_DOCKER_ARGS = [ + "start", + "--detach", + "--mode", + "docker", + "--exclude", + "realtime", + "--exclude", + "storage", + "--exclude", + "imgproxy", + "--exclude", + "mailpit", + "--exclude", + "pgmeta", + "--exclude", + "studio", + "--exclude", + "analytics", + "--exclude", + "vector", + "--exclude", + "pooler", +] as const; + +// Lazy service activation crosses the real proxy, daemon, Docker network, and +// container lifecycle boundaries, so keep one golden-path Docker e2e test. +describe("supabase start lazy lifecycle (e2e)", () => { + let project: Awaited> | undefined; + let home: ReturnType | undefined; + + afterEach(async () => { + await cleanupRegisteredStackProjects(); + project = undefined; + home = undefined; + }); + + test( + "keeps an HTTP service dormant until its first proxied request", + { timeout: START_TIMEOUT_MS + 120_000 }, + async () => { + project = await makeTempCliProject("supabase-lazy-start-e2e-"); + home = makeTempHome(); + await mkdir(join(project.dir, "supabase"), { recursive: true }); + const projectId = basename(project.dir) + .replace(/[^a-z0-9]/giu, "") + .toLowerCase(); + await writeFile( + join(project.dir, "supabase", "config.toml"), + `project_id = "${projectId}"\n`, + ); + + const started = await runSupabase([...LIGHTWEIGHT_DOCKER_ARGS], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe(0); + + const before = await runSupabase(["status"], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + }); + expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); + expect(before.stdout).toContain("auth: Dormant"); + + const apiUrlMatch = + `${started.stdout}\n${started.stderr}\n${before.stdout}\n${before.stderr}`.match( + /API URL:\s+(https?:\/\/[^\s]+)/, + ); + if (apiUrlMatch?.[1] === undefined) { + throw new Error( + `Start/status output did not include an API URL.\nstdout:\n${before.stdout}\nstderr:\n${before.stderr}`, + ); + } + + const response = await fetch(`${apiUrlMatch[1]}/auth/v1/health`, { + signal: AbortSignal.timeout(60_000), + }); + expect(response.ok).toBe(true); + + const after = await runSupabase(["status"], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + }); + expect(after.exitCode, `stdout:\n${after.stdout}\nstderr:\n${after.stderr}`).toBe(0); + expect(after.stdout).toContain("auth: Healthy"); + }, + ); +}); diff --git a/apps/cli/src/next/commands/start/start.live.test.ts b/apps/cli/src/next/commands/start/start.live.test.ts deleted file mode 100644 index f9183d5f16..0000000000 --- a/apps/cli/src/next/commands/start/start.live.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterEach, expect, test } from "vitest"; -import { makeTempHome, makeTempStackProject } from "../../../../tests/helpers/cli.ts"; -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; - -const START_TIMEOUT_MS = 180_000; -const COMMAND_OPTIONS = { entrypoint: "next" as const }; -const LIGHTWEIGHT_DOCKER_ARGS = [ - "start", - "--detach", - "--mode", - "docker", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -// Lazy service activation crosses the real proxy, daemon, Docker network, and -// container lifecycle boundaries, so keep one gated golden-path live test. -describeLive("supabase start lazy lifecycle (live)", () => { - let project: Awaited> | undefined; - let home: ReturnType | undefined; - - afterEach(async () => { - if (project !== undefined && home !== undefined) { - await runSupabaseLive(["stop", "--no-backup"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }).catch(() => undefined); - } - await project?.cleanup(); - home?.[Symbol.dispose](); - project = undefined; - home = undefined; - }); - - test( - "keeps an HTTP service dormant until its first proxied request", - { timeout: START_TIMEOUT_MS + 120_000 }, - async () => { - project = await makeTempStackProject("supabase-lazy-start-live-"); - home = makeTempHome(); - - const started = await runSupabaseLive([...LIGHTWEIGHT_DOCKER_ARGS], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe(0); - - const before = await runSupabaseLive(["status"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }); - expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); - expect(before.stdout).toContain("auth: Pending"); - - const response = await fetch(`http://127.0.0.1:${project.ports.apiPort}/auth/v1/health`, { - signal: AbortSignal.timeout(60_000), - }); - expect(response.ok).toBe(true); - - const after = await runSupabaseLive(["status"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }); - expect(after.exitCode, `stdout:\n${after.stdout}\nstderr:\n${after.stderr}`).toBe(0); - expect(after.stdout).toContain("auth: Healthy"); - }, - ); -}); diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index 4da25f32c0..a25d1f13bf 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -72,45 +72,6 @@ describe("stack e2e cleanup manager", () => { expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); }); - it("keeps cleanup best-effort when the associated home cannot be disposed", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: [], - stackDirs: [], - trackedPids: [], - }), - }), - ); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - throw permissionError("home is not removable"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); - - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("/tmp/home")); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("home is not removable")); - } finally { - warn.mockRestore(); - } - expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); - }); - it("canonicalizes symlinked project and home paths before matching stack state", async () => { const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-")); const project = join(root, "project"); @@ -151,6 +112,44 @@ describe("stack e2e cleanup manager", () => { } }); + it("preserves project and home cleanup receivers", async () => { + class ReceiverHome { + readonly dir = "/tmp/home"; + disposed = false; + + dispose() { + this.disposed = true; + } + } + + class ReceiverProject { + readonly dir = "/tmp/project"; + cleaned = false; + + async cleanup() { + this.cleaned = true; + } + } + + const home = new ReceiverHome(); + const project = new ReceiverProject(); + const manager = createStackE2eCleanupManager(cleanupEnvironment([])); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + manager.registerHome(home); + manager.registerStackProject(project); + manager.associateHome(project.dir, home.dir); + + await manager.drain(); + + expect(project.cleaned).toBe(true); + expect(home.disposed).toBe(true); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it("ignores non-stack homes", async () => { const calls: Array = []; const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); @@ -265,6 +264,102 @@ describe("stack e2e cleanup manager", () => { expect(calls).toEqual(["cleanup-project", "docker-remove"]); }); + it("removes permission-blocked associated homes with the Docker root fallback", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: async () => { + calls.push("docker-remove"); + return true; + }, + }), + ); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError(); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: async () => { + calls.push("cleanup-project"); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); + + await expect(manager.drain()).resolves.toBeUndefined(); + + expect(calls).toEqual(["cleanup-project", "dispose-home", "docker-remove"]); + }); + + it("warns when an associated home remains after permission fallback", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError(); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: async () => { + calls.push("cleanup-project"); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(manager.drain()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Failed to remove temp home")); + } finally { + warn.mockRestore(); + } + expect(calls).toEqual([ + "cleanup-project", + "dispose-home", + "docker-remove", + "chmod", + "dispose-home", + ]); + }); + + it("disposes an associated home once after all projects sharing it are cleaned", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-one", + cleanup: async () => { + calls.push("cleanup-project-one"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-two", + cleanup: async () => { + calls.push("cleanup-project-two"); + }, + }); + manager.associateHome("/tmp/project-one", "/tmp/home"); + manager.associateHome("/tmp/project-two", "/tmp/home"); + + await manager.drain(); + + expect(calls).toEqual(["cleanup-project-one", "cleanup-project-two", "dispose-home"]); + }); + it("falls back to chmod and retries cleanup when Docker cannot remove the project", async () => { const calls: Array = []; let attempts = 0; diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index 5e043a3964..51a76fbaaf 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -76,6 +76,7 @@ type RunResult = { }; const DEFAULT_EXIT_TIMEOUT_MS = 60_000; +const DEFAULT_LEGACY_STACK_CLEANUP_TIMEOUT_MS = 120_000; const OUTPUT_TAIL_LENGTH = 4_000; interface SpawnedSupabase { @@ -84,7 +85,7 @@ interface SpawnedSupabase { readonly stdout: () => string; readonly stderr: () => string; readonly kill: (signal?: NodeJS.Signals) => void; - readonly waitForOutput: (pattern: RegExp, timeoutMs?: number) => Promise; + readonly waitForOutput: (pattern: RegExp, timeoutMs?: number, startAt?: number) => Promise; readonly waitForExit: (timeoutMs?: number) => Promise; } @@ -141,6 +142,51 @@ async function makeTempProject(prefix = "supabase-project-e2e-") { }; } +/** 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 async function makeTempLegacyStackProject( + prefix = "supabase-legacy-stack-e2e-", + cleanupTimeoutMs = DEFAULT_LEGACY_STACK_CLEANUP_TIMEOUT_MS, +) { + const project = await makeTempProject(prefix); + const cleanup = async () => { + if (!existsSync(project.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 }); + return; + } + + const stopped = await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: project.dir, + exitTimeoutMs: cleanupTimeoutMs, + }); + if (stopped.exitCode !== 0) { + throw new Error( + [ + `Failed to stop legacy stack in ${project.dir} (exit code ${stopped.exitCode}).`, + `stdout:\n${stopped.stdout}`, + `stderr:\n${stopped.stderr}`, + ].join("\n"), + ); + } + + await rm(project.dir, { recursive: true, force: true }); + }; + + const stackProject = { dir: project.dir, cleanup }; + registerTempStackProject(stackProject); + return stackProject; +} + export async function makeTempStackProject(prefix = "supabase-stack-e2e-") { const project = await makeTempProject(prefix); const ports = { @@ -369,8 +415,9 @@ export function spawnSupabase( proc.kill(signal); } catch {} }, - waitForOutput: async (pattern: RegExp, timeoutMs = 60_000) => { - if (pattern.test(stdout)) { + waitForOutput: async (pattern: RegExp, timeoutMs = 60_000, startAt = 0) => { + pattern.lastIndex = 0; + if (pattern.test(stdout.slice(startAt))) { return; } if (closeResult) { @@ -402,7 +449,8 @@ export function spawnSupabase( }, timeoutMs); const onStdout = (_data: Buffer) => { - if (pattern.test(stdout)) { + pattern.lastIndex = 0; + if (pattern.test(stdout.slice(startAt))) { cleanup(); resolve(); } @@ -473,3 +521,14 @@ export async function runSupabase( const result = await spawned.waitForExit(); return { ...result, exitCode: killedByUntil ? 0 : result.exitCode }; } + +export function requireCliSuccess( + result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string }, + command: string, +): void { + if (result.exitCode !== 0) { + throw new Error( + `${command} failed (exit ${result.exitCode})\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} diff --git a/apps/cli/tests/helpers/live-env.ts b/apps/cli/tests/helpers/live-env.ts index f34cd1734d..92c2519b79 100644 --- a/apps/cli/tests/helpers/live-env.ts +++ b/apps/cli/tests/helpers/live-env.ts @@ -1,125 +1,64 @@ -/** - * Environment-only helpers for the `live` Vitest project, with **no Vitest test - * APIs imported**. Vitest evaluates `globalSetup` (live-global-setup.ts) in a - * separate context before the test workers, where importing `describe`/`test` - * is not valid — so the global setup imports the env helpers from here, while - * the test-facing pieces (`describeLive`, `runSupabaseLive`, …) live in - * `live.ts` and re-export these. - * - * Environment contract (provided by the cli-e2e-ci runner): - * - `SUPABASE_ACCESS_TOKEN` — required; the platform PAT (supabox seeds a - * deterministic `sbp_…` token into its mgmt-api database). - * - `SUPABASE_PROFILE` — selects the API base URL; defaults to `supabase-local` - * (→ `http://localhost:8080`, `project_host: supabase.red`). Note the cli does - * NOT honor `SUPABASE_API_URL` (Go parity) — the profile is the override. - * - `SUPABASE_LIVE_API_URL` — base URL the readiness check probes; defaults to - * `http://localhost:8080`. - * - `SUPABASE_LIVE_PROJECT_REF` — a provisioned project; gates project-scoped - * suites (functions, branches, db, storage). - * - `NODE_EXTRA_CA_CERTS` — trusts the supabox CA for `*.supabase.red` TLS; - * inherited by the subprocess via the parent environment. - */ +/** Environment-only live-suite configuration. */ -/** Default profile for the host runner: api_url → localhost:8080, project_host → supabase.red. */ -export const LIVE_DEFAULT_PROFILE = "supabase-local"; - -/** - * Default subprocess exit timeout for live runs. `runSupabase` otherwise caps at - * 60s, which would kill a slow-but-valid supabox call before the live tests' - * own (60–120s+) timeouts fire. Generous, but under the `live` project's 300s - * cap so the per-test timeout stays the real gate. Callers may override. - */ export const LIVE_EXIT_TIMEOUT_MS = 240_000; -/** Management API base URL probed by the live readiness check. */ -export function liveApiBaseUrl(): string { - return process.env["SUPABASE_LIVE_API_URL"] ?? "http://localhost:8080"; +export function liveApiUrl(): string { + const value = process.env["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}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`SUPABASE_LIVE_API_URL must use http:// or https://: ${value}`); + } + return url.toString().replace(/\/+$/u, ""); } -/** - * True when the environment carries a platform access token, i.e. the live - * suite is expected to run. Used to gate `describeLive` so live tests are inert - * in the default test loop. - */ -export function isLiveConfigured(): boolean { - return Boolean(process.env["SUPABASE_ACCESS_TOKEN"]); +export function liveAccessToken(): string { + const token = process.env["SUPABASE_ACCESS_TOKEN"]?.trim(); + if (token === undefined || token.length === 0) { + throw new Error("SUPABASE_ACCESS_TOKEN is required to run the live suite"); + } + return token; } -/** - * Project ref for project-scoped live scenarios (functions, branches, db, - * storage, …). The cli-e2e-ci runner sets this once a project has been - * provisioned on the stack; absent → those suites skip. Returns `undefined` - * when unset so callers can branch; use `requireLiveProjectRef` inside a - * `describeLiveProject` block where presence is already guaranteed. - */ -export function liveProjectRef(): string | undefined { - return process.env["SUPABASE_LIVE_PROJECT_REF"]; +export function validateLiveConfig(): { readonly apiUrl: string; readonly accessToken: string } { + return { apiUrl: liveApiUrl(), accessToken: liveAccessToken() }; } -/** - * The live project ref, or a thrown error if unset. Safe to call inside a - * `describeLiveProject` block (the gate guarantees it is present) and gives a - * typed `string` without a non-null assertion. - */ -export function requireLiveProjectRef(): string { - const ref = liveProjectRef(); - if (!ref) { - throw new Error( - "SUPABASE_LIVE_PROJECT_REF must be set for project-scoped live tests " + - "(the cli-e2e-ci runner sets it after provisioning a project).", - ); - } - return ref; +export function keepLiveProject(): boolean { + return process.env["SUPABASE_LIVE_KEEP_PROJECT"] === "1"; } -/** - * Whether the live project's *data-plane* — its own Postgres instance — is up - * and healthy. This is a stronger gate than `liveProjectRef()`: cli-e2e-ci - * currently builds the stack WITHOUT `supabase-postgres-17` (CLI-1825), so a - * provisioned project's *record* exists — Management-API reads (orgs / projects - * / functions / branches list) work — but the instance never reaches - * `ACTIVE_HEALTHY` and its database is unreachable. Commands that talk to the - * project Postgres (migration, db, storage) gate on this and SKIP until the full - * stack lands, then activate automatically. - * - * Probes `GET /v1/projects` (already proven reachable by `projects list`) and - * matches the live ref. Any failure or missing prerequisite returns `false` — - * "not ready" is the safe default, so a probe error skips rather than fails the - * suite. - */ -export async function liveProjectDataPlaneReady(): Promise { - const token = process.env["SUPABASE_ACCESS_TOKEN"]; - const ref = liveProjectRef(); - if (token === undefined || token.length === 0 || ref === undefined) { - return false; - } +export function liveProjectName(): string { + return process.env["SUPABASE_LIVE_PROJECT_NAME"]?.trim() || "supabase-cli-live"; +} - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - try { - const response = await fetch(`${liveApiBaseUrl()}/v1/projects`, { - headers: { Authorization: `Bearer ${token}` }, - signal: controller.signal, - }); - if (!response.ok) { - return false; - } - const projects: unknown = await response.json(); - if (!Array.isArray(projects)) { - return false; - } - return projects.some( - (candidate) => - candidate !== null && - typeof candidate === "object" && - "ref" in candidate && - candidate.ref === ref && - "status" in candidate && - candidate.status === "ACTIVE_HEALTHY", +export function liveRegion(): string { + return process.env["SUPABASE_LIVE_REGION"]?.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; +} + +/** Resolve `.` from a database host such as `db..supabase.co`. */ +export function deriveLiveProjectHost(databaseHost: string, projectRef: string): string { + const prefix = `db.${projectRef}.`; + if (!databaseHost.startsWith(prefix)) { + throw new Error( + `Cannot derive project host for ${projectRef} from database host ${databaseHost}; expected a ${prefix} name`, ); - } catch { - return false; - } finally { - clearTimeout(timeout); } + const host = databaseHost.slice(prefix.length); + if (host.length === 0 || host.includes("/")) { + throw new Error(`Cannot derive a valid project host from database host ${databaseHost}`); + } + return host; } diff --git a/apps/cli/tests/helpers/live-env.unit.test.ts b/apps/cli/tests/helpers/live-env.unit.test.ts new file mode 100644 index 0000000000..b5704a6a9c --- /dev/null +++ b/apps/cli/tests/helpers/live-env.unit.test.ts @@ -0,0 +1,44 @@ +import { afterEach, 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; +}); + +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"); + + process.env["SUPABASE_LIVE_API_URL"] = "http://localhost:8080"; + expect(() => 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({ + apiUrl: "http://localhost:8080", + accessToken: "token", + }); + process.env["SUPABASE_LIVE_API_URL"] = "not-a-url"; + expect(() => liveApiUrl()).toThrow("absolute HTTP(S) URL"); + }); + + it("derives the project host from the typed database host", () => { + expect( + deriveLiveProjectHost("db.abcdefghijklmnopqrst.supabase.co", "abcdefghijklmnopqrst"), + ).toBe("supabase.co"); + expect(() => deriveLiveProjectHost("postgres.supabase.co", "abcdefghijklmnopqrst")).toThrow( + "Cannot derive project host", + ); + }); +}); diff --git a/apps/cli/tests/helpers/live-project.ts b/apps/cli/tests/helpers/live-project.ts new file mode 100644 index 0000000000..7beededf64 --- /dev/null +++ b/apps/cli/tests/helpers/live-project.ts @@ -0,0 +1,584 @@ +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 } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +import { + deriveLiveProjectHost, + keepLiveProject, + liveApiUrl, + liveOrgId, + liveProjectName, + liveRegion, +} from "./live-env.ts"; + +const PROJECT_REF_RE = /^[a-z]{20}$/u; +const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); +const PROFILE_NAME = "supabase-cli-live"; +const POLL_INTERVAL = "5 seconds"; +const POLL_TIMEOUT = "5 minutes"; + +type Project = OperationOutput<"v1GetProject">; +type Organization = OperationOutput<"v1ListAllOrganizations">[number]; +type ApiKey = OperationOutput<"v1GetProjectApiKeys">[number]; +export type PoolerConfig = OperationOutput<"v1GetPoolerConfig">[number]; +type LiveApi = Effect.Success>; +type Region = + | "us-east-1" + | "us-east-2" + | "us-west-1" + | "us-west-2" + | "ap-east-1" + | "ap-southeast-1" + | "ap-northeast-1" + | "ap-northeast-2" + | "ap-southeast-2" + | "eu-west-1" + | "eu-west-2" + | "eu-west-3" + | "eu-north-1" + | "eu-central-1" + | "eu-central-2" + | "ca-central-1" + | "ap-south-1" + | "sa-east-1"; + +const REGIONS: ReadonlyArray = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-east-1", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1", +]; + +class LiveTransientPoll extends Data.TaggedError("LiveTransientPoll")<{ + readonly phase: string; + readonly cause?: unknown; +}> {} + +class LiveTerminalPoll extends Data.TaggedError("LiveTerminalPoll")<{ + readonly phase: string; + readonly message: string; + readonly cause?: unknown; +}> {} + +class LivePollTimeout extends Data.TaggedError("LivePollTimeout")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} timed out`; + } +} + +class LiveStorageError extends Data.TaggedError("LiveStorageError")<{ + readonly message: string; + readonly retryable: boolean; + readonly cause?: unknown; +}> {} + +function apiError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +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.succeed(region); +} + +/** HTTP statuses that can occur while a newly-created project propagates. */ +export function isTransientStorageStatus(status: number): boolean { + return status === 404 || status === 408 || status === 425 || status === 429 || status >= 500; +} + +/** Retry only transport failures and statuses plausibly caused by propagation. */ +export function isTransientLiveError(error: unknown): boolean { + if (!HttpClientError.isHttpClientError(error)) return false; + if (error.reason._tag === "TransportError") return true; + return ( + error.reason._tag === "StatusCodeError" && + isTransientStorageStatus(error.reason.response.status) + ); +} + +export function selectPrimaryPoolerConfig( + configs: ReadonlyArray, +): PoolerConfig | undefined { + return configs.find((config) => config.database_type === "PRIMARY"); +} + +export function resolvePoolerDatabaseUrl( + connectionString: string, + poolMode: PoolerConfig["pool_mode"], + password: string, +): string { + const url = new URL(connectionString); + url.password = password; + if (poolMode !== "session" && url.port === "6543") url.port = "5432"; + if (!url.searchParams.has("connect_timeout")) url.searchParams.set("connect_timeout", "30"); + return url.toString(); +} + +function classifyPollError(phase: string, cause: unknown): LiveTransientPoll | LiveTerminalPoll { + return isTransientLiveError(cause) + ? new LiveTransientPoll({ phase, cause }) + : new LiveTerminalPoll({ + phase, + message: `${phase} failed: ${apiError(cause).message}`, + cause, + }); +} + +/** Retry a transient management operation using Effect's schedule and deadline semantics. */ +export function retryLiveEffect( + phase: string, + effect: Effect.Effect, + options: { + readonly interval?: import("effect").Duration.Input; + readonly timeout?: import("effect").Duration.Input; + readonly shouldRetry?: (error: E) => boolean; + } = {}, +): Effect.Effect { + const retrying = Effect.retry(effect, { + schedule: Schedule.spaced(options.interval ?? POLL_INTERVAL), + ...(options.shouldRetry === undefined ? {} : { while: options.shouldRetry }), + }); + return Effect.timeoutOrElse(retrying, { + duration: options.timeout ?? POLL_TIMEOUT, + orElse: () => Effect.fail(new LivePollTimeout({ phase })), + }); +} + +/** Build one diagnostic while retaining every target and cleanup failure. */ +export function cleanupErrors(primary: unknown, cleanup: ReadonlyArray): AggregateError { + const errors = [primary, ...cleanup].map(apiError); + return new AggregateError(errors, "Live e2e lifecycle failed"); +} + +function timeoutLiveRequest( + phase: string, + effect: Effect.Effect, +): Effect.Effect { + return Effect.timeoutOrElse(effect, { + duration: POLL_TIMEOUT, + orElse: () => Effect.fail(new Error(`${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)}`; +} + +function databasePassword(): string { + return `supabase-cli-live-${randomBytes(12).toString("hex")}`; +} + +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); + }), + ); +} + +function createProject( + api: LiveApi, + name: string, + password: string, +): Effect.Effect { + return supportedRegion(liveRegion()).pipe( + Effect.flatMap((region) => + resolveOrganization(api).pipe( + Effect.flatMap((organization) => + timeoutLiveRequest( + "project creation", + api.v1.createAProject({ + name, + db_pass: password, + organization_slug: organization.slug, + region, + }), + ).pipe(Effect.mapError(apiError)), + ), + ), + ), + 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}`)), + ), + ); +} + +function deleteProject(api: LiveApi, ref: string): Effect.Effect { + return timeoutLiveRequest("project deletion", api.v1.deleteAProject({ ref })).pipe( + Effect.mapError(apiError), + Effect.asVoid, + ); +} + +function projectReadiness( + api: LiveApi, + ref: string, +): Effect.Effect { + return api.v1.getProject({ ref }).pipe( + Effect.mapError((cause) => classifyPollError("project readiness", cause)), + Effect.flatMap( + (project): Effect.Effect => { + if (project.status === "ACTIVE_HEALTHY") return Effect.succeed(project); + if (TERMINAL_BAD_STATUSES.has(project.status)) { + return Effect.fail( + new LiveTerminalPoll({ + phase: "project readiness", + message: `Project ${ref} entered terminal status ${project.status}`, + }), + ); + } + return Effect.fail( + new LiveTransientPoll({ + phase: "project readiness", + cause: `status=${project.status}`, + }), + ); + }, + ), + ); +} + +function waitForProject(api: LiveApi, ref: string): Effect.Effect { + return retryLiveEffect("project readiness", projectReadiness(api, ref), { + shouldRetry: (error) => 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); + return apiError(error); + }), + ); +} + +function keysReadiness( + api: LiveApi, + ref: string, +): Effect.Effect< + { anonKey: string; serviceRoleKey: string }, + LiveTransientPoll | LiveTerminalPoll, + never +> { + return api.v1.getProjectApiKeys({ ref, reveal: true }).pipe( + Effect.mapError((cause) => classifyPollError("project API keys", cause)), + Effect.flatMap((keys) => { + const keyValue = (key: ApiKey): string | undefined => key.api_key ?? undefined; + const anonKey = keys.find((key) => key.name === "anon"); + const serviceRoleKey = + keys.find((key) => key.name === "service_role") ?? + keys.find((key) => key.api_key?.startsWith("sb_secret_")); + const anon = anonKey === undefined ? undefined : keyValue(anonKey); + const service = serviceRoleKey === undefined ? undefined : keyValue(serviceRoleKey); + return anon === undefined || service === undefined + ? Effect.fail( + new LiveTransientPoll({ phase: "project API keys", cause: "keys incomplete" }), + ) + : Effect.succeed({ anonKey: anon, serviceRoleKey: service }); + }), + ); +} + +function resolveKeys( + api: LiveApi, + ref: string, +): Effect.Effect<{ anonKey: string; serviceRoleKey: string }, Error, never> { + return retryLiveEffect("project API keys", keysReadiness(api, ref), { + shouldRetry: (error) => error instanceof LiveTransientPoll, + }).pipe( + Effect.mapError((error) => + error instanceof LivePollTimeout + ? new Error(`Project ${ref} did not return API keys within ${POLL_TIMEOUT}`) + : error instanceof LiveTerminalPoll + ? new Error(error.message) + : apiError(error), + ), + ); +} + +function dbReadiness( + api: LiveApi, + ref: string, + password: string, +): Effect.Effect { + return api.v1.getPoolerConfig({ ref }).pipe( + Effect.mapError((cause): LiveTransientPoll | LiveTerminalPoll => + classifyPollError("pooler configuration", cause), + ), + Effect.flatMap( + (configs): Effect.Effect => { + const primary = selectPrimaryPoolerConfig(configs); + if (primary === undefined || primary.connection_string.trim().length === 0) { + return Effect.fail( + new LiveTransientPoll({ + phase: "pooler configuration", + cause: + primary === undefined + ? "primary pooler config missing" + : "connection string missing", + }), + ); + } + try { + return Effect.succeed( + resolvePoolerDatabaseUrl(primary.connection_string, primary.pool_mode, password), + ); + } catch (cause) { + return Effect.fail( + new LiveTerminalPoll({ + phase: "pooler configuration", + message: `pooler configuration returned an invalid connection string: ${apiError(cause).message}`, + cause, + }), + ); + } + }, + ), + ); +} + +function resolveDbUrl( + api: LiveApi, + ref: string, + password: string, +): Effect.Effect { + return retryLiveEffect("pooler configuration", dbReadiness(api, ref, password), { + shouldRetry: (error) => error instanceof LiveTransientPoll, + }).pipe( + Effect.mapError((error) => + error instanceof LivePollTimeout + ? new Error( + `Project ${ref} did not return a pooler connection string within ${POLL_TIMEOUT}`, + ) + : error instanceof LiveTerminalPoll + ? new Error(error.message) + : apiError(error), + ), + ); +} + +function createStorageBucket( + ref: string, + 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()}`, + 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, + }), + }); + 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 = new URL(dbUrl).hostname; + await writeFile( + profilePath, + [ + `name: ${PROFILE_NAME}`, + `api_url: ${JSON.stringify(liveApiUrl())}`, + `dashboard_url: ${JSON.stringify(liveApiUrl())}`, + `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, + }); +} + +function cleanupDirectory(profilePath: string): Effect.Effect { + return Effect.tryPromise({ + try: () => rm(path.dirname(profilePath), { recursive: true, force: true }), + catch: apiError, + }); +} + +function cleanupRemote( + api: LiveApi, + environment: LiveProjectEnvironment, +): Effect.Effect { + return keepLiveProject() + ? Effect.sync(() => { + 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( + `SUPABASE_LIVE_KEEP_PROJECT=1 — leaving ${ref} alive after provisioning failure`, + ); + }) + : deleteProject(api, ref); +} + +function combineCleanupExits( + exits: ReadonlyArray>, +): Effect.Effect { + const errors = exits.flatMap((exit) => (Exit.isFailure(exit) ? [Cause.squash(exit.cause)] : [])); + return errors.length === 0 + ? Effect.void + : Effect.fail(new AggregateError(errors, "Live cleanup failed")); +} + +export interface LiveProjectEnvironment { + readonly project: { + readonly ref: string; + readonly dbUrl: string; + readonly dbPassword: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly functionsUrl: string; + readonly storageBucket: string; + }; + readonly profilePath: string; +} + +/** Provision one project; the caller owns the outer Effect runtime boundary. */ +export function 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 LiveProjectEnvironment; + }); + 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( + api: LiveApi, + environment: LiveProjectEnvironment, +): 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]); + }); +} diff --git a/apps/cli/tests/helpers/live-project.unit.test.ts b/apps/cli/tests/helpers/live-project.unit.test.ts new file mode 100644 index 0000000000..4da9bd2bc0 --- /dev/null +++ b/apps/cli/tests/helpers/live-project.unit.test.ts @@ -0,0 +1,185 @@ +import { Effect } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { describe, expect, it } from "vitest"; + +import { + cleanupErrors, + isTransientLiveError, + isTransientStorageStatus, + resolvePoolerDatabaseUrl, + retryLiveEffect, + selectPrimaryPoolerConfig, + supportedRegion, + type PoolerConfig, +} from "./live-project.ts"; + +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 })); + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); +} + +function poolerConfig(overrides: Partial = {}): PoolerConfig { + return { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: false, + db_user: "postgres", + db_host: "pooler.example.com", + db_port: 6543, + db_name: "postgres", + connection_string: "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.com:6543/postgres", + connectionString: "", + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + ...overrides, + }; +} + +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( + "project readiness", + Effect.suspend(() => + Effect.sync(() => { + attempts += 1; + return attempts < 3 + ? Effect.fail(new Error("temporarily unavailable")) + : Effect.succeed("ACTIVE_HEALTHY"); + }).pipe(Effect.flatten), + ), + { interval: "1 millis", timeout: "100 millis" }, + ), + ); + + expect(result).toBe("ACTIVE_HEALTHY"); + expect(attempts).toBe(3); + }); + + it("fails a poll when its wall-clock deadline expires", async () => { + const result = Effect.runPromise( + retryLiveEffect("project keys", Effect.never, { + interval: "1 millis", + timeout: "10 millis", + }), + ); + + await expect(result).rejects.toThrow("project keys timed out"); + }); + + it("preserves both target and cleanup failures", () => { + const error = cleanupErrors(new Error("provision failed"), [ + new Error("profile cleanup failed"), + new Error("project deletion failed"), + ]); + + expect(error).toBeInstanceOf(AggregateError); + expect(error.errors).toHaveLength(3); + expect(error.errors.map((entry) => String(entry))).toEqual([ + "Error: provision failed", + "Error: profile cleanup failed", + "Error: project deletion failed", + ]); + }); + + 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); + + let attempts = 0; + const transient = statusError(503); + const result = await Effect.runPromise( + 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); + + attempts = 0; + await expect( + Effect.runPromise( + retryLiveEffect( + "project readiness", + Effect.suspend(() => { + attempts += 1; + return Effect.fail(statusError(403)); + }), + { + interval: "1 millis", + timeout: "100 millis", + shouldRetry: isTransientLiveError, + }, + ), + ), + ).rejects.toThrow(); + expect(attempts).toBe(1); + }); + + it("classifies storage responses for retry without retrying terminal client errors", () => { + expect(isTransientStorageStatus(408)).toBe(true); + expect(isTransientStorageStatus(429)).toBe(true); + expect(isTransientStorageStatus(500)).toBe(true); + expect(isTransientStorageStatus(401)).toBe(false); + expect(isTransientStorageStatus(403)).toBe(false); + expect(isTransientStorageStatus(422)).toBe(false); + }); + + it("selects the primary pooler config", () => { + const replica = poolerConfig({ identifier: "replica", database_type: "READ_REPLICA" }); + const primary = poolerConfig(); + + expect(selectPrimaryPoolerConfig([replica, primary])).toBe(primary); + }); + + it("translates transaction pooler port and encodes the password", () => { + const resolved = new URL( + resolvePoolerDatabaseUrl( + "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.com:6543/postgres", + "transaction", + "p@ss word", + ), + ); + + expect(resolved.hostname).toBe("pooler.example.com"); + expect(resolved.port).toBe("5432"); + expect(decodeURIComponent(resolved.password)).toBe("p@ss word"); + expect(resolved.searchParams.get("connect_timeout")).toBe("30"); + }); + + it("preserves the API port and timeout for session pooler mode", () => { + const resolved = new URL( + resolvePoolerDatabaseUrl( + "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.com:6543/postgres?connect_timeout=7", + "session", + "secret", + ), + ); + + expect(resolved.port).toBe("6543"); + expect(resolved.searchParams.get("connect_timeout")).toBe("7"); + }); +}); diff --git a/apps/cli/tests/helpers/live-provided-context.ts b/apps/cli/tests/helpers/live-provided-context.ts new file mode 100644 index 0000000000..deb9a63adf --- /dev/null +++ b/apps/cli/tests/helpers/live-provided-context.ts @@ -0,0 +1,18 @@ +// Vitest evaluates global setup separately from test modules. Keep this module +// side-effect-free so global setup can provide the shared live environment. +export {}; + +declare module "vitest" { + export interface ProvidedContext { + liveProject: { + readonly ref: string; + readonly dbUrl: string; + readonly dbPassword: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly functionsUrl: string; + readonly storageBucket: string; + }; + liveProfilePath: string; + } +} diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 8123bc96dd..a5d3e5e848 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -1,100 +1,160 @@ -import { execSync } from "node:child_process"; -import { describe } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; -import { runSupabase } from "./cli.ts"; -import { - isLiveConfigured, - LIVE_DEFAULT_PROFILE, - LIVE_EXIT_TIMEOUT_MS, - liveProjectDataPlaneReady, - liveProjectRef, -} from "./live-env.ts"; +import { inject, test as vitestTest } from "vitest"; -/** - * Test-facing helpers for the `live` Vitest project (`*.live.test.ts`): - * black-box CLI subprocess tests that run against a *real* Supabase platform — - * in CI a local supabox stack (see the `supabase/cli-e2e-ci` harness). - * - * This module imports Vitest test APIs (`describe`), so it must NOT be imported - * from `globalSetup` (Vitest evaluates that in a different context). The - * env-only helpers live in `./live-env.ts`; `globalSetup` imports from there. - * They are re-exported below so test files have a single import site. - */ +import { makeTempHome, runSupabase } from "./cli.ts"; +import { LIVE_EXIT_TIMEOUT_MS } from "./live-env.ts"; +import type { LiveProjectEnvironment } from "./live-project.ts"; -// Re-export the env-only helpers so `*.live.test.ts` files import everything -// from `helpers/live.ts`. -export { - isLiveConfigured, - LIVE_DEFAULT_PROFILE, - LIVE_EXIT_TIMEOUT_MS, - liveApiBaseUrl, - liveProjectDataPlaneReady, - liveProjectRef, - requireLiveProjectRef, -} from "./live-env.ts"; +export type LiveProject = LiveProjectEnvironment["project"]; +type RunOptions = NonNullable[1]>; +type RunResult = Awaited>; -/** - * `describe` that runs only when the live environment is configured. Use this - * for every live suite so the file is inert (skipped, not failed) outside the - * cli-e2e-ci runner. - */ -export const describeLive = describe.skipIf(!isLiveConfigured()); +export interface LiveWorkspace { + readonly path: string; +} -function hasDockerDaemon(): boolean { - try { - execSync("docker info", { stdio: "ignore" }); - return true; - } catch { - return false; - } +export interface InvokeResult { + readonly status: number; + readonly body: unknown; + readonly text: string; } -/** - * `describe` for local-stack live tests that additionally require a reachable - * Docker daemon. Composes the configured-live gate (`isLiveConfigured`) with a - * `docker info` probe so these suites stay inert (skipped, not failed) outside - * the cli-e2e-ci runner — a machine that merely exposes Docker must never - * launch a real stack just by collecting the live Vitest project. The - * synchronous read-only probe runs once when this helper module is collected, - * and only when the live environment is configured. - */ -export const describeDockerLive = describe.skipIf(!isLiveConfigured() || !hasDockerDaemon()); +export interface LiveFixtures { + readonly project: LiveProject; + readonly workspace: LiveWorkspace; + readonly home: ReturnType; + readonly cli: (args: string[], options?: RunOptions) => Promise; + readonly invoke: ( + slug: string, + options?: { readonly anonKey?: string; readonly payload?: unknown }, + ) => Promise; +} + +const base = vitestTest.extend({ + // eslint-disable-next-line no-empty-pattern + project: async ({}, use) => use(inject("liveProject")), + + home: async ({ task: _task }, use) => { + const home = makeTempHome(); + try { + await use(home); + } finally { + home[Symbol.dispose](); + } + }, -/** - * `describe` for project-scoped live suites: runs only when the live env is - * configured AND a project ref is available. On a control-plane-only stack - * (e.g. local macOS where project instances can't be built) these skip rather - * than fail. See `requireLiveProjectRef`. - */ -export const describeLiveProject = describe.skipIf(!isLiveConfigured() || !liveProjectRef()); + 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"], { + entrypoint: "legacy", + 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}`, + ); + } + await use({ path: directory }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, -/** - * `describe` for data-plane live suites (migration / db / storage): runs only - * when the live env is configured AND the project's own Postgres instance is - * `ACTIVE_HEALTHY`. On a control-plane-only stack — including the current - * cli-e2e-ci CI, which omits `supabase-postgres-17` (CLI-1825) — the project DB - * is unreachable, so these SKIP rather than fail. They activate automatically - * once the full data-plane is provisioned. The readiness probe runs once at - * collection time (top-level await); see `liveProjectDataPlaneReady`. - */ -export const describeLiveDataPlane = describe.skipIf(!(await liveProjectDataPlaneReady())); + cli: async ({ workspace, home }, use) => { + await use((args, options) => + runSupabase(args, { + entrypoint: "legacy", + ...options, + cwd: options?.cwd ?? workspace.path, + home: home.dir, + exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, + env: { + SUPABASE_PROFILE: inject("liveProfilePath"), + ...options?.env, + }, + }), + ); + }, -/** - * Spawn the built CLI against the live platform, injecting the profile so the - * Management API base resolves to the stack. Defaults to the `legacy` shell, - * which hosts the platform commands (orgs, projects, branches, functions, …). - */ -export function runSupabaseLive( - args: string[], - options?: Parameters[1], -): ReturnType { - return runSupabase(args, { - entrypoint: "legacy", - ...options, - exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, - env: { - SUPABASE_PROFILE: process.env["SUPABASE_PROFILE"] ?? LIVE_DEFAULT_PROFILE, - ...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 }; + }); + }, +}); + +/** The sole live fixture. The live global setup owns the shared project. */ +export const test = base; + +export function requireLiveSuccess( + result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string }, + command: string, +): void { + if (result.exitCode !== 0) { + throw new Error( + `${command} failed (exit ${result.exitCode})\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} + +/** Rethrow a target failure without discarding failures from exact cleanup. */ +export function throwWithCleanup( + primary: unknown | undefined, + cleanup: ReadonlyArray, +): void { + if (primary !== undefined) { + if (cleanup.length > 0) { + throw new AggregateError([primary, ...cleanup], "Live e2e target and cleanup failed"); + } + throw primary; + } + if (cleanup.length === 1) throw cleanup[0]; + if (cleanup.length > 1) throw new AggregateError(cleanup, "Live e2e cleanup failed"); +} + +export function expectFunctionOk( + result: InvokeResult, + slug: string, + extra?: Record, +): void { + if (result.status !== 200) { + throw new Error( + `Expected function ${slug} to return 200, got ${result.status}: ${result.text}`, + ); + } + if (typeof result.body !== "object" || result.body === null) { + throw new Error(`Expected function ${slug} to return JSON: ${result.text}`); + } + const body = result.body as Record; + if (body.case !== slug || body.ok !== true) { + throw new Error(`Unexpected response from ${slug}: ${result.text}`); + } + for (const [key, value] of Object.entries(extra ?? {})) { + if (body[key] !== value) throw new Error(`Unexpected ${key} from ${slug}: ${result.text}`); + } } diff --git a/apps/cli/tests/helpers/live.unit.test.ts b/apps/cli/tests/helpers/live.unit.test.ts new file mode 100644 index 0000000000..99ac0b4d6b --- /dev/null +++ b/apps/cli/tests/helpers/live.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { throwWithCleanup } from "./live.ts"; + +describe("throwWithCleanup", () => { + it("rethrows the primary failure when cleanup succeeds", () => { + const primary = new Error("target failed"); + + expect(() => throwWithCleanup(primary, [])).toThrow(primary); + }); + + it("throws the cleanup failure when the target succeeds", () => { + const cleanup = new Error("cleanup failed"); + + expect(() => throwWithCleanup(undefined, [cleanup])).toThrow(cleanup); + }); + + it("preserves the primary and every cleanup failure", () => { + const primary = new Error("target failed"); + const cleanup = [new Error("first cleanup failed"), new Error("second cleanup failed")]; + let thrown: unknown; + + try { + throwWithCleanup(primary, cleanup); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(AggregateError); + if (!(thrown instanceof AggregateError)) return; + expect(thrown.errors).toEqual([primary, ...cleanup]); + }); +}); diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index 7f69b7e9d4..8d571f89f7 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -139,8 +139,12 @@ function readDocumentPid(documentFile: string): number | undefined { } } -function cleanupErrorDetail(projectDir: string, error: unknown): string { - return `Failed to remove temp stack project ${projectDir}: ${ +function cleanupErrorDetail( + pathname: string, + error: unknown, + resource = "temp stack project", +): string { + return `Failed to remove ${resource} ${pathname}: ${ error instanceof Error ? error.message : String(error) }`; } @@ -222,38 +226,49 @@ async function removeProjectWithDocker(projectDir: string): Promise { return removed; } -async function cleanupProject( - project: StackProject, +async function cleanupOwnedPath( + pathname: string, + cleanup: () => void | Promise, environment: Pick< CleanupEnvironment, "removeProjectWithDocker" | "repairProjectPermissions" | "describeProjectPermissions" >, ): Promise { try { - await project.cleanup(); + await cleanup(); } catch (error) { if (!isPermissionError(error)) { throw error; } - const removedByDocker = await environment.removeProjectWithDocker(project.dir); + const removedByDocker = await environment.removeProjectWithDocker(pathname); if (removedByDocker) { return; } - environment.repairProjectPermissions(project.dir); + environment.repairProjectPermissions(pathname); try { - await project.cleanup(); + await cleanup(); } catch (retryError) { throw new Error( `${retryError instanceof Error ? retryError.message : String(retryError)}\n${environment.describeProjectPermissions( - project.dir, + pathname, )}`, ); } } } +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); const managedStacksRoot = @@ -382,12 +397,17 @@ export function createStackE2eCleanupManager( return { registerHome(home) { - homes.set(normalizeDir(home.dir), home); + const dir = normalizeDir(home.dir); + homes.set(dir, { + dir, + dispose: () => home.dispose(), + }); }, registerStackProject(project) { - projects.set(normalizeDir(project.dir), { - dir: normalizeDir(project.dir), - cleanup: project.cleanup, + const dir = normalizeDir(project.dir); + projects.set(dir, { + dir, + cleanup: () => project.cleanup(), }); }, associateHome(projectDir, homeDir) { @@ -403,9 +423,14 @@ export function createStackE2eCleanupManager( homes.clear(); const failures: Array = []; + const associatedHomes = new Map(); for (const project of pendingProjects) { - const home = project.homeDir ? pendingHomes.get(project.homeDir) : undefined; + 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 || @@ -439,18 +464,18 @@ export function createStackE2eCleanupManager( await cleanupProject(project, environment); } catch (error) { failures.push(cleanupErrorDetail(project.dir, error)); - } finally { - if (home !== undefined) { - try { - home.dispose(); - } catch (error) { - failures.push(cleanupErrorDetail(home.dir, error)); - } - } } } - // Cleanup of leaked stack projects is best-effort: assertions in the + 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 of leaked stack resources is best-effort: assertions in the // test itself have already passed by the time `drain()` runs, and CI // runners are ephemeral so a leaked temp dir doesn't affect // correctness. Surface the details so developers can still see them @@ -461,7 +486,7 @@ export function createStackE2eCleanupManager( // sandbox). if (failures.length > 0) { console.warn( - `[stack-e2e-cleanup] ${failures.length} project(s) could not be cleaned up:\n${failures.join("\n")}`, + `[stack-e2e-cleanup] ${failures.length} resource(s) could not be cleaned up:\n${failures.join("\n")}`, ); } }, diff --git a/apps/cli/tests/live-global-setup.ts b/apps/cli/tests/live-global-setup.ts index d7584e757a..40b4d31c57 100644 --- a/apps/cli/tests/live-global-setup.ts +++ b/apps/cli/tests/live-global-setup.ts @@ -1,39 +1,32 @@ -// Import from the Vitest-free env module — globalSetup runs in a context where -// importing Vitest test APIs (which `helpers/live.ts` pulls in) is not valid. -import { isLiveConfigured, liveApiBaseUrl } from "./helpers/live-env.ts"; +import type { ProvidedContext } from "vitest"; -/** - * Global setup for the `live` Vitest project. When the live environment is not - * configured the suite is skipped (via `describeLive`) and this is a no-op. - * - * When it IS configured (the cli-e2e-ci runner sets `SUPABASE_ACCESS_TOKEN`), - * fail fast with a clear message if the platform is unreachable, so a - * misconfigured stack surfaces as a setup error rather than dozens of opaque - * per-test timeouts. - */ -export async function setup(): Promise { - if (!isLiveConfigured()) { - return; - } +import { makeApiClient } from "@supabase/api/effect"; +import { Effect } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; - // Reachability gate only. Any HTTP response — including 401/404 — proves the - // Management API is up and routing, which is all this probe needs to assert. - // supabox's mgmt-api requires auth on every route and exposes no public health - // endpoint (`/v1/health` 404s; an unauthenticated request is rejected by the - // auth middleware with 401), so we deliberately do NOT require a 2xx here. - // Functional and auth coverage is the live tests' job (e.g. `orgs list`). - const probeUrl = `${liveApiBaseUrl()}/v1/organizations`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - try { - await fetch(probeUrl, { signal: controller.signal }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error( - `Live platform is not reachable at ${probeUrl}: ${reason}.\n` + - "Ensure the supabox stack is up and the host can reach mgmt-api (see cli-e2e-ci).", - ); - } finally { - clearTimeout(timeout); - } +import "./helpers/live-provided-context.ts"; +import { cleanupLiveEnvironment, provisionLiveEnvironment } from "./helpers/live-project.ts"; +import { liveAccessToken, liveApiUrl, validateLiveConfig } from "./helpers/live-env.ts"; + +type LiveSetupContext = { + provide: (key: K, value: ProvidedContext[K]) => void; +}; + +/** 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( + Effect.gen(function* () { + const api = yield* makeApiClient({ baseUrl: liveApiUrl(), accessToken: liveAccessToken() }); + const environment = yield* provisionLiveEnvironment(api); + return { api, environment }; + }).pipe(Effect.provide(FetchHttpClient.layer)), + ); + provide("liveProject", environment.project); + provide("liveProfilePath", environment.profilePath); + return async () => { + await Effect.runPromise(cleanupLiveEnvironment(api, environment)); + }; } + +export default setup; diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index a8c53dd4e5..fcfa681541 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -70,9 +70,9 @@ export default defineConfig({ { plugins: [dockerfileTextPlugin()], test: { - // Live tests run against a real platform (a supabox stack in CI) and - // are gated by `describeLive`, so they are inert unless the live env - // is configured. Never part of the default unit/integration/e2e loop. + // Live tests run against one provisioned project on the configured + // platform. They are never part of the default unit/integration/e2e + // loop; an explicit run fails fast when required configuration is absent. name: "live", include: ["**/*.live.test.ts"], fileParallelism: false, diff --git a/docs/adr/0013-live-e2e-bypasses-replay-server.md b/docs/adr/0013-live-e2e-bypasses-replay-server.md index 9b0c758818..570a35ce8d 100644 --- a/docs/adr/0013-live-e2e-bypasses-replay-server.md +++ b/docs/adr/0013-live-e2e-bypasses-replay-server.md @@ -3,131 +3,60 @@ **Status**: accepted **Date**: 2026-06-16 -## Problem Statement +## Problem -The CLI has no true end-to-end tests. `apps/cli-e2e` is a replay/record harness: -in **replay** mode it serves recorded HTTP fixtures (fast, deterministic, no -network); in **record** mode it proxies the CLI's Management API and Docker -traffic to staging only to *capture* those fixtures. Tests always assert against -replayed fixtures, never live responses. Behaviour that cannot be mocked — real -Management API calls and the real Docker bundler (e.g. `functions deploy`) — is -therefore untested. - -[CLI-1630](https://linear.app/supabase/issue/CLI-1630/set-up-proper-live-e2e-tests-for-the-cli) -adds a structured Vitest **live** suite that runs the real CLI against a real -backend (staging today, the dockerized `supabox` stack later) as a non-blocking -smoke test before a stable deploy. - -The open architectural question was *how* live mode should reach the backend. -The first instinct was to add a third runtime mode inside `replay-server.ts` -alongside `replay` and `record` — taking record mode's passthrough path -(CLI → replay server → real API) but skipping fixture I/O. That keeps the -existing Docker and storage proxies "for free." +`apps/cli-e2e` is a replay/record harness. Replay tests use recorded HTTP and +Docker fixtures, so they do not exercise a real Management API, project data +plane, or Docker bundler. The CLI needs a small, non-blocking golden-path suite +that crosses those boundaries for real. ## Decision -Live mode **does not route through the replay server**. It is a harness-wiring -mode, not a `replay-server.ts` branch. - -- Live tests reuse `createHarness`/`exec` from `@supabase/cli-test-helpers`, but - the harness is wired **directly**: `apiUrl = CLI_E2E_API_URL` (the real - Management API) and `DOCKER_HOST` points at the **real Docker socket**. -- `replay-server.ts` is untouched — no `live` branch, no live Docker or storage - proxy. -- Assertions are **outcome-based**, modeled on the manual deploy playbook: - 1. run the real CLI (`run([...])`) and assert `exitCode` / `stdout`; - 2. **invoke the deployed function over HTTP directly** and assert HTTP status + - the JSON body the function itself returns (e.g. `{case, ok:true}`). - The invoke is a direct HTTP call to `https://{ref}.{CLI_E2E_PROJECT_HOST}/functions/v1`, - not a proxied call — the replay server is nowhere in the assertion path. -- Because the assertion target is the function's own deterministic response (plus - exit codes / stdout substrings), the suite is **ID-agnostic** — no response - normalization or snapshot machinery by default. The function invoke URL and - anon key are resolved at setup from the freshly created project (anon key via - `GET /v1/projects/{ref}/api-keys`). - -The CLI target is a CI **matrix axis** (`CLI_HARNESS_TARGET`): each target runs -as its own job with `fail-fast: false`, so each implementation is independently -green/red. The pilot covers `go` (raw Go binary) and `ts-legacy` (the TS rewrite -that shells out to Go for most commands and runs native TS logic for ported -ones); `ts-next` is a later axis. - -## Rationale - -For the assertions live mode actually makes, intercepting the Management API buys -nothing — nothing inspects a proxied API body. The only thing the replay server -would do in live mode for `functions deploy` is relay Docker traffic -(CLI → relay → real socket) through its streaming/idle-timeout proxy. That -streaming relay is the most complex, most failure-prone code path in the harness, -and it would sit in front of the slowest, flakiest real operation (image pull + -bundle) for zero assertion benefit. Pointing `DOCKER_HOST` at the real socket -removes that failure surface entirely. - -Keeping `replay-server.ts` out of the live path also means live and record modes -stay decoupled: record mode's destructive fixture-tree rewrite, scenario logging, -and placeholder normalization never have to grow `isLive` guards, and a future -reader is not left wondering why a "transparent proxy" mode exists that records -nothing. - -The storage proxy (the other "free" proxy) is not exercised by the -`functions deploy` pilot, so it is not a reason to keep the server in front. If a -later live command genuinely needs host rewriting (e.g. storage on a different -host than the Management API), a scoped passthrough can be introduced *then* for -that command — YAGNI until a concrete need exists. - -The per-target matrix exists because `go` and `ts-legacy` are different code -paths reaching the same backend; running them as separate jobs gives two -independent green signals instead of one averaged result. +Live tests are collocated under `apps/cli` as `*.live.test.ts` and run directly +against a configured platform URL. They never route through the replay server. + +Local Docker-stack lifecycle tests are ordinary `*.e2e.test.ts` tests. They use +the existing e2e global setup and registered-stack cleanup and do not require a +platform token. A live test means the command under assertion reaches the +Management API, its provisioned project, or that project's data plane. Docker +is a runner prerequisite, including for live `functions deploy`; there is no +Docker-specific live fixture. + +The live global setup requires `SUPABASE_LIVE_API_URL` and +`SUPABASE_ACCESS_TOKEN`, then: + +1. Creates one uniquely named disposable project through the typed Effect + `@supabase/api` client pointed at the configured URL. +2. Waits for `ACTIVE_HEALTHY`, resolves API keys and pooler connection details, + creates a storage bucket, and derives the project host from the returned + database host. +3. Writes a temporary YAML profile containing the same API URL and project + host, and injects it into every CLI subprocess in the serial suite. +4. Deletes exactly that project and the temporary profile during teardown. + +`SUPABASE_LIVE_KEEP_PROJECT=1` skips project deletion for debugging but never +skips temporary profile cleanup. Provisioning failure attempts cleanup of the +exact project it created. + +All three supported targets—Supabox, a Docker-hosted API platform, and staging— +implement the same HTTP API contract. Retargeting a run only changes +`SUPABASE_LIVE_API_URL` and its access token. The live workflow keeps a Docker +preflight, one serial attempt, a 20-minute bound, and a scoped leftover-project +sweeper. + +`SUPABASE_LIVE_API_URL` configures the Management API only. Tenant data-plane +URLs continue to use the CLI profile contract, `https://.`; +`project_host` is derived from the provisioned project's typed database host. +This keeps tenant routing correct even when a local platform exposes its +Management API over plain HTTP. ## Consequences -### Positive - -- The live path has fewer moving parts: no proxy, no streaming relay, no fixture - guards. The Docker bundler talks to the real daemon as users' machines do. -- `replay-server.ts` and the replay/record contract are unchanged, so the - PR-blocking `e2e` suite is unaffected. -- Tests are trivial to add: drop a `deploy-e2e-foo` fixture function returning a - known body, add one `testLive` that runs deploy → invoke → asserts body. -- Retargeting from staging to `supabox` is genuinely an env swap - (`CLI_E2E_TARGET_ENV` + `CLI_E2E_API_URL` + `CLI_E2E_PROJECT_HOST` + token), - because assertions key off function output, not hostnames. - -### Negative - -- Live mode requires a working Docker daemon on the runner (enforced by a - `docker info` preflight) — unlike the replay suite, which served Docker - fixtures and needed no daemon. -- Each live run provisions and tears down a real staging project, so the suite is - inherently slower and subject to provisioning flake. Mitigated by a CI-level - re-run (up to 3×) rather than in-setup retry. -- A second wiring path now exists for the same harness (replay-via-server vs - live-direct); contributors must know which mode wires the CLI how. - -## Alternatives Considered - -1. **Third `live` branch inside `replay-server.ts`** (the initial plan): rejected. - It adds `isLive` guards throughout record-mode code, keeps the fragile Docker - stream relay in the hot path for no assertion benefit, and couples live mode to - machinery it does not use. -2. **Snapshot/normalization-first assertions**: rejected as the default. Outcome - assertions on function bodies are naturally ID-agnostic; a scoped normalizer is - added only if a future case makes CLI diagnostic output itself the assertion - target. -3. **Single CLI target**: rejected. `go` and `ts-legacy` are distinct - implementations of the same commands; one job would hide a regression in - whichever target was not chosen. -4. **One shared long-lived staging project**: rejected. State would leak between - runs and overlapping runs would collide; ephemeral per-job projects with - scoped teardown keep runs isolated. - -## Related Decisions - -- [Compiled Bun self-dispatch](../../packages/process-compose/docs/architecture.md#compiled-bun-self-dispatch): - the next CLI e2e harness runs against the compiled binary and therefore exercises its process - re-entry contract -- [ADR 0011](0011-cli-release-and-distribution-strategy.md): CLI Release & Distribution Strategy - -## See Also +The live path has no fixture proxy, host-rewrite layer, attached/managed mode, +ambient profile, project-ref gate, or capability-specific skip wrapper. The +single extended Vitest fixture is imported as `test` from +`apps/cli/tests/helpers/live.ts`; its context exposes `cli`, `project`, and an +isolated workspace. Setup and teardown may invoke other commands, but each +assertion stays focused on one command. -- [cli-e2e harness](../../apps/cli-e2e/AGENTS.md) +Replay/record behavior and fixtures in `apps/cli-e2e` remain unchanged. diff --git a/docs/adr/README.md b/docs/adr/README.md index c5ab90ea00..c0bc1cf7b5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -41,23 +41,23 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi ## ADR index -| ID | Title | Status | -| ---- | ------------------------------------------------------------------------------------------ | -------- | -| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | -| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | -| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | -| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | -| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | -| 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed | -| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | -| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | -| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | -| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | -| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | -| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | -| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | superseded | -| 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed | -| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted | +| ID | Title | Status | +| ---- | -------------------------------------------------------------------------------------------------------------- | ---------- | +| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | +| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | +| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | +| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | +| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | +| 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed | +| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | +| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | +| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | +| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | +| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | +| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | accepted | +| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | superseded | +| 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed | +| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted | ## Template