test(cli-e2e): add live e2e suite covering the CLI command matrix (#5… · cloudcompute/cli@557e3eb · GitHub
Skip to content

Commit 557e3eb

Browse files
avalleteclaude
andauthored
test(cli-e2e): add live e2e suite covering the CLI command matrix (supabase#5588)
## What Adds a **live** e2e mode to `apps/cli-e2e` and a real-staging command matrix on top of it, per [CLI-1630](https://linear.app/supabase/issue/CLI-1630/set-up-proper-live-e2e-tests-for-the-cli) and [ADR-0013](https://github.com/supabase/cli/blob/develop/docs/adr/0013-live-e2e-bypasses-replay-server.md). Live mode is a third mode (`CLI_E2E_MODE=live`) that, unlike replay/record, **does not use the replay server**. The harness points the CLI straight at the real Management API (`CLI_E2E_API_URL`) and the real Docker socket; tests assert on **real outcomes** — process exit codes, the HTTP responses of deployed functions (status + JSON body), and real DB/Storage state. This is ID-agnostic, so there are no snapshots/normalization by default. ## Changes - **`env.ts`** — `CLI_E2E_MODE` (`replay`/`record`/`live`), `isLive`, `TARGET_API_URL`, `CLI_E2E_PROJECT_HOST`; back-compat `RECORD=true` → `record`. - **`tests/staging-project.ts`** — project-lifecycle helpers extracted from `setup.ts`: create/delete an ephemeral project, resolve the anon JWT, the IPv4 **session-pooler `dbUrl`**, the service-role key, and seed a Storage bucket. Record behavior is unchanged. - **`tests/live-setup.ts`** — global setup that provisions **one ephemeral project per run** (`cli-e2e-live-{target}-{runId}-{short}`), waits `ACTIVE_HEALTHY`, and exposes `projectRef`/`anonKey`/`functionsUrl`/`dbUrl`/`storageBucket` via `inject()`; deletes the project on teardown (even on failure). Intentionally dumb — no in-setup retry. - **`src/tests/live/`** — `testLive` context (direct-wired `run`, HTTP `invoke` sending the anon JWT, a `supabase init`-generated `workspace`, `seedFunctions` to layer the `deploy-e2e-*` fixtures + their `[functions.*]` config) plus live coverage for: functions deploy (the three bundler modes + deploy-all), functions lifecycle (re-deploy + delete), database (`inspect`/`migration list`/`db dump`), db push→pull, `link`, `projects`, `gen types`, `branches`, `storage`, `secrets`. - **`vitest.live.config.ts`** + `test:e2e:live`; the default config excludes `*.live.e2e.test.ts`. - **`harness.ts`** — `projectHost` option so host-derived commands (`storage --linked` → `<ref>.<host>`, `db.<ref>.<host>`) reach the real endpoint instead of `localhost`. - **`.github/workflows/live-e2e.yml`** — `workflow_dispatch` + an hourly `@beta` `schedule`; `go` + `ts-legacy` matrix (`fail-fast: false`); `docker info` preflight; 3× retry; project cleanup scoped to the job's own prefix. - **`apps/cli/.../functions/deploy/deploy.e2e.test.ts`** — collocated integration coverage for the negative/arg-validation cases that don't belong in the live suite. - **`docs/adr/0013-…`** + README index row; `fixtures/live/functions-project/` `deploy-e2e-*` functions. ## Reviewer notes - **Why bypass the replay server.** Live mode is a deliberately different signal from the replay suite: it exercises the real subprocess, real runtime wiring, and real cross-boundary behavior (API + Docker + DB + Storage) that fixtures can't represent. The replay suite stays the fast, deterministic default. - **IPv6 → IPv4 pooler.** Staging's direct DB host (`db.<ref>.supabase.red`) is **IPv6-only by design**, and the CI runners have **no IPv6 egress** — so DB-touching commands connect through the project's **IPv4 session-mode Supavisor pooler** via `--db-url` (the CLI's own blessed fallback). Session mode (not transaction `6543`) is required for `pg_dump`. - **Authoring target is `go`** (source of truth for the port); `ts-legacy` runs the same tests to prove the shim matches. Both run as separate CI jobs (independent green/red signals). - **Trigger model.** There is **no `pull_request` trigger** — run the workflow manually on a branch for pre-merge coverage. `workflow_dispatch` (Actions branch picker; no free-form `ref` input, so the staging token never reaches arbitrary code) and `schedule` only become active once this file is on the default branch (`develop`) — classic GitHub bootstrap. The hourly run exercises the `@beta` channel: `develop` is the default branch and the beta release source, so it builds `develop` from source and runs the same matrix. 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 **both** legs pass). - **Secret / fork safety.** Uses `SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN`; never `pull_request_target`, so the token is never exposed to fork code. - **Out of scope:** `config push` surfaced a TS↔remote config-schema parity bug on `ts-legacy`, tracked separately in [CLI-1810](https://linear.app/supabase/issue/CLI-1810/config-push-fails-on-ts-legacy-ts-config-schema-rejects-remote-storage); it is intentionally not covered here. Refs: CLI-1630 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3016806 commit 557e3eb

77 files changed

Lines changed: 1808 additions & 107 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions

‎.github/workflows/live-e2e.yml‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
name: Live E2E
2+
3+
# Live e2e suite (ADR-0013). Runs the real CLI against the real staging
4+
# Management API + Docker bundler, then invokes the deployed functions over HTTP.
5+
#
6+
# Non-blocking by construction: this is a standalone workflow, NOT part of the
7+
# required-checks set, and it never runs on the default PR path of test.yml.
8+
#
9+
# Triggers:
10+
# - workflow_dispatch — manual run. The Actions UI branch picker selects the
11+
# ref (github.ref), always a same-repo branch. We deliberately take NO
12+
# free-form `ref` input: that would let a manual run check out arbitrary
13+
# (e.g. external PR) code while the staging token is in the job env.
14+
# - schedule (hourly) — exercises the `@beta` channel. `develop` is the default
15+
# branch AND the beta release source, so a scheduled run checks it out and
16+
# builds from source. The `gate` job skips the run unless the published
17+
# `supabase@beta` version changed since the last green run (an actions/cache
18+
# marker keyed on the version), so we only spend a staging project when there
19+
# is actually a new beta to test.
20+
#
21+
# Secrets: workflow_dispatch and schedule both run on trusted same-repo refs, so
22+
# the staging token is never exposed to fork code.
23+
on:
24+
workflow_dispatch:
25+
schedule:
26+
# Hourly, offset from other scheduled workflows. Cron timing is best-effort.
27+
- cron: "23 * * * *"
28+
29+
permissions:
30+
contents: read
31+
32+
jobs:
33+
# Decide whether to run. Manual dispatch always runs. Scheduled runs only fire
34+
# when the latest published `@beta` is newer than the last one we tested green.
35+
gate:
36+
name: Gate (newer @beta?)
37+
runs-on: ubuntu-latest
38+
outputs:
39+
should_run: ${{ steps.decide.outputs.should_run }}
40+
version: ${{ steps.ver.outputs.version }}
41+
steps:
42+
- name: Resolve latest @beta version
43+
id: ver
44+
run: |
45+
set -euo pipefail
46+
version="$(npm view supabase@beta version)"
47+
# Validate the shape before it becomes a cache key (defense-in-depth
48+
# against a garbage/poisoned registry value).
49+
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
50+
echo "::error::unexpected supabase@beta version '$version'"; exit 1
51+
fi
52+
echo "version=$version" >> "$GITHUB_OUTPUT"
53+
54+
# Marker presence == "this beta already tested green". lookup-only so we
55+
# download nothing; the marker is written by `finalize` after a green run.
56+
- name: Check tested marker
57+
id: cache
58+
if: github.event_name == 'schedule'
59+
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
60+
with:
61+
path: .beta-marker
62+
key: live-e2e-beta-${{ steps.ver.outputs.version }}
63+
lookup-only: true
64+
65+
- name: Decide
66+
id: decide
67+
run: |
68+
if [ "${{ github.event_name }}" != "schedule" ]; then
69+
echo "manual dispatch -> run"
70+
echo "should_run=true" >> "$GITHUB_OUTPUT"
71+
elif [ "${{ steps.cache.outputs.cache-hit }}" = "true" ]; then
72+
echo "beta ${{ steps.ver.outputs.version }} already tested green -> skip"
73+
echo "should_run=false" >> "$GITHUB_OUTPUT"
74+
else
75+
echo "new beta ${{ steps.ver.outputs.version }} -> run"
76+
echo "should_run=true" >> "$GITHUB_OUTPUT"
77+
fi
78+
79+
live-e2e:
80+
needs: gate
81+
if: needs.gate.outputs.should_run == 'true'
82+
name: Live e2e (${{ matrix.target }})
83+
runs-on: blacksmith-8vcpu-ubuntu-2404
84+
# Serialize a target against itself across runs; go and ts-legacy run in
85+
# parallel. Per-job-scoped project names mean this is mostly belt-and-braces.
86+
concurrency:
87+
group: live-e2e-${{ matrix.target }}-${{ github.ref }}
88+
cancel-in-progress: true
89+
strategy:
90+
# Each target is an independent green/red signal.
91+
fail-fast: false
92+
matrix:
93+
# go = source-of-truth Go binary; ts-legacy = the TS rewrite (shells out
94+
# to Go for most commands). Authoring target is go; ts-legacy proves the
95+
# shim matches. ts-next is a later axis.
96+
target:
97+
- go
98+
- ts-legacy
99+
# Non-secret config is job-level; the staging token is scoped to only the two
100+
# steps that need it (run + cleanup) so build/checkout/docker never see it.
101+
env:
102+
CLI_E2E_MODE: live
103+
CLI_E2E_TARGET_ENV: staging
104+
CLI_E2E_API_URL: https://api.supabase.green
105+
CLI_E2E_PROJECT_HOST: supabase.red
106+
CLI_HARNESS_TARGET: ${{ matrix.target }}
107+
steps:
108+
- name: Checkout
109+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
110+
with:
111+
persist-credentials: false
112+
113+
- name: Setup
114+
uses: ./.github/actions/setup
115+
116+
- name: Setup Go
117+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
118+
with:
119+
go-version-file: apps/cli-go/go.mod
120+
cache-dependency-path: apps/cli-go/go.sum
121+
122+
# Build the Go binary for every target: `go` runs it directly and
123+
# `ts-legacy` shells out to it for most commands.
124+
- name: Build Go CLI
125+
working-directory: apps/cli-go
126+
run: go build -o supabase-go .
127+
128+
- name: Export Go binary path
129+
run: echo "SUPABASE_GO_BINARY=${{ github.workspace }}/apps/cli-go/supabase-go" >> "$GITHUB_ENV"
130+
131+
# The ts-legacy harness runs the compiled supabase binary from apps/cli/dist.
132+
- name: Build CLI
133+
if: matrix.target == 'ts-legacy'
134+
run: pnpm exec nx run supabase:build
135+
136+
# Docker is a hard requirement for the --use-docker bundler cell.
137+
- name: Docker preflight
138+
run: docker info
139+
140+
- name: Run live e2e (retry up to 3x)
141+
env:
142+
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }}
143+
run: |
144+
PREFIX="cli-e2e-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-"
145+
# GitHub runs this step as `bash -e`; use `if cmd; then` (errexit-exempt)
146+
# so a failing attempt does not abort the step before the retry.
147+
for attempt in 1 2 3; do
148+
echo "::group::live e2e attempt ${attempt}"
149+
if [ "$attempt" -gt 1 ]; then
150+
bash .github/scripts/sweep-live-projects.sh "$PREFIX" || true
151+
fi
152+
if pnpm --filter @supabase/cli-e2e test:e2e:live; then
153+
echo "::endgroup::"
154+
exit 0
155+
fi
156+
echo "::endgroup::"
157+
echo "attempt ${attempt} failed"
158+
done
159+
exit 1
160+
161+
# Backstop: delete any project this job created that survived a crash.
162+
# The script exits non-zero (failing this step) if any delete failed.
163+
- name: Cleanup leftover projects
164+
if: always()
165+
env:
166+
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }}
167+
run: bash .github/scripts/sweep-live-projects.sh "cli-e2e-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-"
168+
169+
# Record that this beta tested green so the next scheduled run skips it. Needs
170+
# the whole matrix: the marker is saved only if BOTH go and ts-legacy passed
171+
# (a red leg leaves no marker, so the next hour re-runs the same beta).
172+
finalize:
173+
needs: [gate, live-e2e]
174+
if: github.event_name == 'schedule' && needs.live-e2e.result == 'success'
175+
name: Mark @beta tested
176+
runs-on: ubuntu-latest
177+
steps:
178+
- name: Write marker
179+
run: |
180+
mkdir -p .beta-marker
181+
echo "${{ needs.gate.outputs.version }}" > .beta-marker/version
182+
183+
- name: Save tested marker
184+
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
185+
with:
186+
path: .beta-marker
187+
key: live-e2e-beta-${{ needs.gate.outputs.version }}

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ node_modules
22
dist
33
coverage/
44
.env
5+
.env.*
6+
!.env.example
57
.claude/
68
.agents/.repos/effect-v3
79
.worktrees/
810
.supabase/
11+
# Stray `supabase` project dir created by running the CLI at the repo root
12+
# (e.g. supabase/.temp/linked-project.json). This monorepo has no top-level
13+
# Supabase project — real fixtures live under apps/cli-e2e/fixtures/.
14+
/supabase/
915
.idea/
1016

1117
# Local dev registry (verdaccio storage, generated config, auth tokens)

‎apps/cli-e2e/.env.example‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# cli-e2e environment — copy to `.env.local` (gitignored) and fill in.
2+
# Only the live/record modes need real values; replay mode (the default) needs none.
3+
4+
# Mode: replay (default, no creds) | record (capture fixtures) | live (ADR-0013).
5+
CLI_E2E_MODE=live
6+
7+
# Backend the live/record suite targets. Only `staging` is wired today.
8+
CLI_E2E_TARGET_ENV=staging
9+
10+
# CLI target under test: go (source-of-truth binary) | ts-legacy (the rewrite) | ts-next.
11+
CLI_HARNESS_TARGET=go
12+
13+
# Staging Management API token. Either name works (the suite also reads
14+
# SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN). Required in record/live mode.
15+
SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
16+
17+
# For the `go` target, point at a freshly built binary so newly-added commands
18+
# resolve (the system `supabase` may be stale):
19+
# cd apps/cli-go && go build -o /tmp/supabase-test-binary .
20+
SUPABASE_GO_BINARY=/tmp/supabase-test-binary
21+
22+
# --- Optional overrides (sensible defaults in src/tests/env.ts) ---
23+
# Management API base + per-project host (default to staging: api.supabase.green / supabase.red).
24+
# CLI_E2E_API_URL=https://api.supabase.green
25+
# CLI_E2E_PROJECT_HOST=supabase.red
26+
# DB password for the ephemeral project (default: random per run).
27+
# CLI_E2E_DB_PASSWORD=
28+
# Skip org resolution / region / pick a specific org.
29+
# CLI_E2E_ORG_ID=
30+
# CLI_E2E_REGION=us-east-1
31+
# Leave the ephemeral live project alive after the run (debugging).
32+
# CLI_E2E_KEEP_PROJECT=1

‎apps/cli-e2e/.prettierignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Live e2e fixtures are real Deno Edge Function projects (deno.json, jsr:/npm:
2+
# imports, Deno globals) — test data the CLI deploys, not workspace source.
3+
# oxfmt reads this file by default; keep it from formatting the fixtures.
4+
fixtures/

‎apps/cli-e2e/AGENTS.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ In **record mode**: global setup resolves the org, deletes any orphaned test pro
152152

153153
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.
154154

155+
## Live mode (ADR-0013)
156+
157+
`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**.
158+
159+
- 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.
160+
- 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.
161+
- 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.
162+
- **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.
163+
- **Authoring target is `go`** (source of truth for the port); `ts-legacy` runs the same tests to prove the shim matches. Both run as separate CI jobs.
164+
- 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.
165+
- **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 same `[go, ts-legacy]` matrix. 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 **both** legs pass), so a staging project is spent only when there is a new beta to test. Because the marker is written only on a fully-green matrix, a chronically-failing `@beta` keeps re-running every hour until it goes green or a newer beta supersedes it (intended — the failure stays visible).
166+
155167
## Running the suite
156168

157169
```sh
@@ -162,8 +174,19 @@ pnpm nx run @supabase/cli-e2e:test:go # go binary target
162174
# Record (requires staging access)
163175
SUPABASE_ACCESS_TOKEN=sbp_... SUPABASE_STAGING_URL=https://api.supabase.green \
164176
pnpm nx run @supabase/cli-e2e:record
177+
178+
# Live (requires staging access; creates + deletes a real project; needs Docker).
179+
# For the `go` target, build the binary first so newly-added commands resolve
180+
# (the system `supabase` may be stale) — mirrors what CI does.
181+
cd apps/cli-go && go build -o /tmp/supabase-test-binary . && cd -
182+
SUPABASE_GO_BINARY=/tmp/supabase-test-binary CLI_HARNESS_TARGET=go \
183+
SUPABASE_ACCESS_TOKEN=sbp_... \
184+
pnpm --filter @supabase/cli-e2e test:e2e:live
165185
```
166186

187+
See `apps/cli-e2e/.env.example` for the full set of live/record env vars (copy to
188+
a gitignored `.env.local`).
189+
167190
After recording, replay must pass with no changes between the two commands.
168191

169192
### Sharding (replay only)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Per-function config appended onto the `supabase init`-generated config.toml by
2+
# seedFunctions() for the functions deploy tests (the import-map, custom
3+
# entrypoint, static-file, and no-jwt fixtures need these). Everything else runs
4+
# against the bare generated config.
5+
6+
[functions."deploy-e2e-root-map"]
7+
import_map = "./import_map.json"
8+
9+
[functions."deploy-e2e-custom-entry"]
10+
entrypoint = "./functions/deploy-e2e-custom-entry/handler.ts"
11+
12+
[functions."deploy-e2e-static-in-fn"]
13+
static_files = ["./functions/deploy-e2e-static-in-fn/static/*.txt"]
14+
15+
[functions."deploy-e2e-static-asset"]
16+
static_files = ["./assets/*.svg", "./functions/deploy-e2e-static-asset/assets/*.svg"]
17+
18+
[functions."deploy-e2e-no-jwt"]
19+
verify_jwt = false
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const greet = () => "hello";
Lines changed: 3 additions & 0 deletions

0 commit comments

Comments
 (0)