diff --git a/apps/cli/scripts/sweep-live-projects.integration.test.ts b/apps/cli/scripts/sweep-live-projects.integration.test.ts new file mode 100644 index 0000000000..ac124dce97 --- /dev/null +++ b/apps/cli/scripts/sweep-live-projects.integration.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +type Scenario = { + lists: Array | ((deletes: ReadonlyArray) => unknown); + deletes: Record; + deleteStatuses?: Record>; +}; + +async function runSweep(scenario: Scenario) { + const directory = await mkdtemp(path.join(tmpdir(), "sweep-live-projects-")); + directories.push(directory); + const fakeSleep = path.join(directory, "sleep"); + await writeFile(fakeSleep, "#!/bin/sh\nexit 0\n"); + await chmod(fakeSleep, 0o755); + + let listIndex = 0; + const deletes: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + const ref = url.pathname.split("/").pop() ?? ""; + if (request.method === "GET" && url.pathname === "/v1/projects") { + const value = + typeof scenario.lists === "function" + ? scenario.lists(deletes) + : scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; + return typeof value === "number" + ? new Response("temporary failure", { status: value }) + : Response.json(value); + } + if (request.method === "DELETE" && url.pathname.startsWith("/v1/projects/")) { + deletes.push(ref); + const statuses = scenario.deleteStatuses?.[ref]; + const result: { status: number; body?: unknown } = + statuses === undefined + ? (scenario.deletes[ref] ?? { status: 404 }) + : { + status: + statuses[ + Math.min( + deletes.filter((value) => value === ref).length - 1, + statuses.length - 1, + ) + ] ?? 500, + }; + return new Response(result.body === undefined ? null : JSON.stringify(result.body), { + status: result.status, + headers: { "x-request-id": `delete-${ref}` }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + try { + const child = Bun.spawn(["bash", script, "e2e-"], { + env: { + ...globalThis.process.env, + PATH: `${directory}:${globalThis.process.env.PATH}`, + SUPABASE_ACCESS_TOKEN: "test-token", + SUPABASE_LIVE_API_URL: `http://127.0.0.1:${server.port}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const timeout = setTimeout(() => child.kill(), 10_000); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + clearTimeout(timeout); + return { exitCode, stdout, stderr, deletes }; + } finally { + await server.stop(true); + } +} + +const active = (ref: string, name = `e2e-${ref}`) => ({ ref, name, status: "ACTIVE" }); + +describe.skipIf(process.platform === "win32")("sweep-live-projects.sh", { timeout: 40_000 }, () => { + test("accepts refused deletion when a fresh authenticated list shows absence", async () => { + const result = await runSweep({ + lists: [[active("gone")], []], + deletes: { gone: { status: 403, body: { message: "already removed" } } }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["gone"]); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); + }); + + test("accepts a terminal status and converges after a stale list", async () => { + const result = await runSweep({ + lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], + deletes: { stale: { status: 202 } }, + }); + expect(result.exitCode).toBe(0); + }); + + test("retries a transient read failure but rejects malformed evidence", async () => { + const transient = await runSweep({ + lists: [503, [active("retry")], []], + deletes: { retry: { status: 202 } }, + }); + expect(transient.exitCode).toBe(0); + + const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.deletes).toEqual([]); + }); + + test("fails terminal listing errors without retrying or deleting", async () => { + const forbidden = await runSweep({ lists: [403, []], deletes: {} }); + expect(forbidden.exitCode).not.toBe(0); + expect(forbidden.deletes).toEqual([]); + + const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); + expect(unavailable.exitCode).not.toBe(0); + expect(unavailable.deletes).toEqual([]); + + const malformedReconciliation = await runSweep({ + lists: [[active("bad-evidence")], { projects: [] }], + deletes: { "bad-evidence": { status: 403 } }, + }); + expect(malformedReconciliation.exitCode).not.toBe(0); + }); + + test("attempts every owned project and fails if one remains active", async () => { + const result = await runSweep({ + lists: [ + [active("stuck"), active("other"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], + [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], + ], + deletes: { stuck: { status: 403 }, other: { status: 204 } }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.deletes).toEqual(["stuck", "other"]); + }); + + test("retries a transient deletion after fresh evidence still shows the project", async () => { + const result = await runSweep({ + lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), + deletes: {}, + deleteStatuses: { flaky: [503, 204] }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["flaky", "flaky"]); + expect(result.stderr).toContain( + "delete retry completed for project flaky (HTTP 204 request delete-flaky)", + ); + }); +}); diff --git a/apps/cli/scripts/sweep-live-projects.sh b/apps/cli/scripts/sweep-live-projects.sh index 633c58d3b6..5598b8168c 100755 --- a/apps/cli/scripts/sweep-live-projects.sh +++ b/apps/cli/scripts/sweep-live-projects.sh @@ -1,67 +1,112 @@ #!/usr/bin/env bash -# Delete every staging project whose name starts with the given prefix (the live -# e2e job's per-run prefix). Runs as the live e2e job's always() cleanup step, -# which propagates the exit code. -# -# Reads SUPABASE_ACCESS_TOKEN + SUPABASE_LIVE_API_URL from the environment. Exits -# non-zero if any DELETE failed and the project still reads as live; a failed -# *listing* also exits non-zero. +# Delete every staging project whose name starts with the live e2e prefix. set -eo pipefail PREFIX="${1:?usage: sweep-live-projects.sh PREFIX}" : "${SUPABASE_ACCESS_TOKEN:?SUPABASE_ACCESS_TOKEN required}" : "${SUPABASE_LIVE_API_URL:?SUPABASE_LIVE_API_URL required}" -# Retry into a file, not a pipe: curl rewinds a file between attempts, but a -# pipe keeps an already-flushed body and the retry would duplicate the listing. listing=$(mktemp) -project=$(mktemp) -trap 'rm -f "$listing" "$project"' EXIT -curl -fsS --retry 3 --retry-connrefused --max-time 60 --retry-max-time 120 \ - -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - -o "$listing" "${SUPABASE_LIVE_API_URL}/v1/projects" -# Capture the list in a var (not a pipe-to-while subshell) so a failed delete is -# recorded in $failed; a failed listing aborts above via errexit. -projects=$(jq -r --arg p "$PREFIX" '.[] | select(.name|startswith($p)) | "\(.ref // .id) \(.status)"' "$listing") +headers=$(mktemp) +response=$(mktemp) +trap 'rm -f "$listing" "$headers" "$response"' EXIT -# The suite's teardown can delete a project after the listing was taken, so a -# refused DELETE only counts once the project still reads as live afterwards -# (12 bounded reads, 5s apart); a removed project is refused or GOING_DOWN. -gone() { - local attempt code status +api_request() { + local method=$1 url=$2 output=$3 + : > "$headers" + : > "$output" + API_CODE=$(curl -sS --max-time 15 -X "$method" -D "$headers" -o "$output" \ + -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" -w '%{http_code}' "$url") || API_CODE=000 + API_REQUEST_ID=$(awk 'tolower($0) ~ /^x-request-id:/ { sub(/^[^:]*:[[:space:]]*/, ""); gsub(/[[:space:]]+$/, ""); print; exit }' "$headers" | tr -cd '[:alnum:]_.:/-' | cut -c1-120) +} + +fetch_listing_once() { + api_request GET "${SUPABASE_LIVE_API_URL}/v1/projects" "$listing" + if [ "$API_CODE" != 200 ]; then + echo "project listing failed (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + case "$API_CODE" in 000|408|429|5??) return 1 ;; esac + return 2 + fi + if ! jq -e 'type == "array" and all(.[]; (.name | type == "string") and ((.ref // .id) | type == "string") and ((.status // "") | type == "string"))' "$listing" >/dev/null; then + echo "project listing was malformed (HTTP 200${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + return 2 + fi +} + +initial_attempt=1 +while true; do + if fetch_listing_once; then + initial_result=0 + break + else + initial_result=$? + fi + [ "$initial_result" -eq 2 ] && break + [ "$initial_attempt" -lt 3 ] || break + initial_attempt=$((initial_attempt + 1)) + sleep 5 +done +if [ "$initial_result" -ne 0 ]; then + echo "::error::unable to obtain a complete authenticated project listing" >&2 + exit 1 +fi + +projects=$(jq -r --arg p "$PREFIX" '.[] | select(.name | startswith($p)) | "\(.ref // .id) \(.status // "")"' "$listing") + +# Reconcile through fresh authenticated list responses. A DELETE 403/400 or a +# single-project 404 is not evidence that a project is gone. +reconcile() { + local ref=$1 attempt for attempt in $(seq 12); do - # curl leaves the output file untouched when no body arrives. - : > "$project" - code=$(curl -sS --max-time 10 -o "$project" -w '%{http_code}' \ - -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${SUPABASE_LIVE_API_URL}/v1/projects/$1") || code=000 - status=$(jq -r '.status // empty' "$project" 2>/dev/null || true) - case "$code:$status" in - 403:* | 404:* | 200:GOING_DOWN | 200:REMOVED) - echo "project $1 already gone ($code${status:+ $status})" + if fetch_listing_once; then + if jq -e --arg ref "$ref" 'any(.[]; ((.ref // .id) == $ref) and ((.status // "") != "GOING_DOWN") and ((.status // "") != "REMOVED"))' "$listing" >/dev/null; then + echo "project $ref still active (list HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + if [ "$delete_attempt" -lt 3 ]; then + case "$delete_code" in + 000|408|425|429|5??) + delete_attempt=$((delete_attempt + 1)) + echo "retrying transient delete for project $ref (attempt $delete_attempt)" >&2 + sleep 5 + api_request DELETE "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" "$response" + delete_code=$API_CODE + echo "delete retry completed for project $ref (HTTP $delete_code${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + ;; + esac + fi + else + echo "project $ref reconciled as absent or terminal (list HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" return 0 - ;; - esac + fi + elif [ "$?" -eq 2 ]; then + echo "project $ref reconciliation evidence was unavailable or malformed" >&2 + return 1 + else + echo "project $ref reconciliation read failed (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + fi [ "$attempt" -lt 12 ] && sleep 5 done - echo "project $1 still reads $code${status:+ $status}" return 1 } failed=0 while read -r ref status; do [ -n "$ref" ] || continue - # Already deleted by the suite; a second DELETE is refused. case "$status" in GOING_DOWN | REMOVED) echo "skipping project $ref ($status)" continue ;; esac - echo "deleting leftover project $ref" - if ! curl -fsS -X DELETE -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" >/dev/null && ! gone "$ref"; then - echo "::error::failed to delete leftover project $ref" + delete_attempt=1 + echo "deleting leftover project $ref (attempt $delete_attempt)" + api_request DELETE "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" "$response" + delete_code=$API_CODE + case "$API_CODE" in + 2??) echo "delete accepted for project $ref (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" ;; + *) echo "delete failed for project $ref (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 ;; + esac + if ! reconcile "$ref"; then + echo "::error::project $ref remains active or cleanup evidence was unavailable" >&2 failed=1 fi done <<< "$projects" diff --git a/apps/cli/src/commands/branches/create/create.live.test.ts b/apps/cli/src/commands/branches/create/create.live.test.ts index 58c0417108..847099bcec 100644 --- a/apps/cli/src/commands/branches/create/create.live.test.ts +++ b/apps/cli/src/commands/branches/create/create.live.test.ts @@ -1,28 +1,55 @@ import { randomUUID } from "node:crypto"; -import { Cause, Effect, Exit } from "effect"; +import { Cause, Effect, Exit, Schema } from "effect"; import { expect } from "vitest"; -import { test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; -import { awaitBranch, removeBranch } from "../../../../tests/helpers/branches-live.ts"; +import { throwWithCleanup, test } from "../../../../tests/helpers/live.ts"; +import { + awaitLiveBranchEffect, + awaitLiveBranchRemovedEffect, + removeLiveBranchByNameEffect, +} from "../../../../tests/helpers/branches-live.ts"; -// Not wired to the test `signal`: cleanup runs through the plain-promise `cli` path, so an -// interrupt would abandon an in-flight `branches delete` rather than stop it, leaving the -// branch behind. Its own exit timeout bounds the wait instead. -test("creates a preview branch", ({ cli, cliEffect, project }) => +const CreatedBranchRef = Schema.Struct({ project_ref: Schema.String }); +const CreatedBranch = Schema.Struct({ message: Schema.String, project_ref: Schema.String }); + +test("creates a preview branch", ({ cliEffect, project }) => Effect.runPromise( Effect.gen(function* () { const name = `cli-e2e-create-${randomUUID().slice(0, 8)}`; + let branchRef: string | undefined; const target = Effect.gen(function* () { - const result = yield* cliEffect(["branches", "create", name, "--project-ref", project.ref]); + const result = yield* cliEffect([ + "branches", + "create", + name, + "--project-ref", + project.ref, + "--output-format", + "json", + ]); expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("Created preview branch"); - yield* awaitBranch(cli, project, name); + const refBody = yield* Schema.decodeEffect(Schema.fromJsonString(CreatedBranchRef))( + result.stdout, + ); + branchRef = refBody.project_ref.length > 0 ? refBody.project_ref : undefined; + const body = yield* Schema.decodeEffect(Schema.fromJsonString(CreatedBranch))( + result.stdout, + ); + expect(body).toMatchObject({ + message: "Created preview branch", + project_ref: expect.any(String), + }); + expect(branchRef).toBeDefined(); + yield* awaitLiveBranchEffect(cliEffect, project, name); }); const targetExit = yield* Effect.exit(target); - const cleanupExit = yield* Effect.exit(removeBranch(cli, project, name)); + const cleanupExit = + branchRef === undefined + ? yield* Effect.exit(removeLiveBranchByNameEffect(cliEffect, project, name)) + : yield* Effect.exit(awaitLiveBranchRemovedEffect(cliEffect, project, branchRef)); return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], diff --git a/apps/cli/src/commands/branches/delete/delete.live.test.ts b/apps/cli/src/commands/branches/delete/delete.live.test.ts index 5ec5c0996d..cc495b8e17 100644 --- a/apps/cli/src/commands/branches/delete/delete.live.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.live.test.ts @@ -3,30 +3,24 @@ import { randomUUID } from "node:crypto"; import { Cause, Effect, Exit } from "effect"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; -import { awaitBranch, removeBranch } from "../../../../tests/helpers/branches-live.ts"; +import { throwWithCleanup, test } from "../../../../tests/helpers/live.ts"; +import { + awaitLiveBranchEffect, + awaitLiveBranchRemovedEffect, + createLiveBranchEffect, +} from "../../../../tests/helpers/branches-live.ts"; -// Not wired to the test `signal`: cleanup runs through the plain-promise `cli` path, so an -// interrupt would abandon an in-flight `branches delete` rather than stop it, leaving the -// branch behind. Its own exit timeout bounds the wait instead. -test("deletes a preview branch", ({ cli, cliEffect, project }) => +test("deletes a preview branch", ({ cliEffect, project }) => Effect.runPromise( Effect.gen(function* () { const name = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; - let mayExist = false; + let branchRef: string | undefined; + let deletionAcknowledged = false; const target = Effect.gen(function* () { - mayExist = true; - const created = yield* cliEffect([ - "branches", - "create", - name, - "--project-ref", - project.ref, - ]); - requireLiveSuccess(created, "branches create"); - yield* awaitBranch(cli, project, name); - + const ref = yield* createLiveBranchEffect(cliEffect, project, name); + branchRef = ref; + yield* awaitLiveBranchEffect(cliEffect, project, name); const removed = yield* cliEffect([ "branches", "delete", @@ -35,21 +29,23 @@ test("deletes a preview branch", ({ cli, cliEffect, project }) => project.ref, "--yes", ]); - if (removed.exitCode === 0) mayExist = false; + deletionAcknowledged = removed.exitCode === 0; expect(removed.exitCode, removed.stderr).toBe(0); expect(removed.stderr).toContain("Deleted preview branch"); + yield* awaitLiveBranchRemovedEffect(cliEffect, project, ref, deletionAcknowledged); + branchRef = undefined; }); const targetExit = yield* Effect.exit(target); - const cleanupExit = mayExist - ? yield* Effect.exit(removeBranch(cli, project, name)) - : undefined; + const cleanupExit = + branchRef === undefined + ? yield* Effect.exit(Effect.succeed(true)) + : yield* Effect.exit( + awaitLiveBranchRemovedEffect(cliEffect, project, branchRef, deletionAcknowledged), + ); return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, - cleanupErrors: - cleanupExit !== undefined && Exit.isFailure(cleanupExit) - ? [Cause.squash(cleanupExit.cause)] - : [], + cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], }; }), ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); diff --git a/apps/cli/src/commands/branches/disable/disable.live.test.ts b/apps/cli/src/commands/branches/disable/disable.live.test.ts index e654defda5..5243068392 100644 --- a/apps/cli/src/commands/branches/disable/disable.live.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.live.test.ts @@ -3,37 +3,28 @@ import { randomUUID } from "node:crypto"; import { Cause, Effect, Exit } from "effect"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; +import { throwWithCleanup, test } from "../../../../tests/helpers/live.ts"; import { - awaitBranch, - awaitBranchesRemoved, - removeBranch, + awaitLiveBranchEffect, + awaitLiveBranchRemovedEffect, + awaitLiveBranchesRemovedEffect, + createLiveBranchEffect, } from "../../../../tests/helpers/branches-live.ts"; -// Not wired to the test `signal`: cleanup runs through the plain-promise `cli` path, so an -// interrupt would abandon an in-flight `branches delete` rather than stop it, leaving the -// branch behind. Its own exit timeout bounds the wait instead. -test("disables preview branching", ({ cli, cliEffect, project }) => +test("disables preview branching", ({ cliEffect, project }) => Effect.runPromise( Effect.gen(function* () { const name = `cli-e2e-disable-${randomUUID().slice(0, 8)}`; - let mayExist = false; + let branchRef: string | undefined; + let deletionAcknowledged = false; const target = Effect.gen(function* () { // The platform 422s `branches disable` while any non-default branch exists, so this // creates then deletes one first, waiting for it to fully disappear. Sibling tests // re-enable branching by creating their own branch first. - mayExist = true; - const created = yield* cliEffect([ - "branches", - "create", - name, - "--project-ref", - project.ref, - ]); - requireLiveSuccess(created, "branches create"); - yield* awaitBranch(cli, project, name); - + const ref = yield* createLiveBranchEffect(cliEffect, project, name); + branchRef = ref; + yield* awaitLiveBranchEffect(cliEffect, project, name); const removed = yield* cliEffect([ "branches", "delete", @@ -42,9 +33,11 @@ test("disables preview branching", ({ cli, cliEffect, project }) => project.ref, "--yes", ]); - if (removed.exitCode === 0) mayExist = false; - requireLiveSuccess(removed, "branches delete"); - yield* awaitBranchesRemoved(cli, project); + deletionAcknowledged = removed.exitCode === 0; + expect(removed.exitCode, removed.stderr).toBe(0); + yield* awaitLiveBranchRemovedEffect(cliEffect, project, ref, deletionAcknowledged); + yield* awaitLiveBranchesRemovedEffect(cliEffect, project); + branchRef = undefined; const disabled = yield* cliEffect(["branches", "disable", "--project-ref", project.ref]); expect(disabled.exitCode, disabled.stderr).toBe(0); @@ -52,15 +45,15 @@ test("disables preview branching", ({ cli, cliEffect, project }) => }); const targetExit = yield* Effect.exit(target); - const cleanupExit = mayExist - ? yield* Effect.exit(removeBranch(cli, project, name)) - : undefined; + const cleanupExit = + branchRef === undefined + ? yield* Effect.exit(Effect.succeed(true)) + : yield* Effect.exit( + awaitLiveBranchRemovedEffect(cliEffect, project, branchRef, deletionAcknowledged), + ); return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, - cleanupErrors: - cleanupExit !== undefined && Exit.isFailure(cleanupExit) - ? [Cause.squash(cleanupExit.cause)] - : [], + cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], }; }), ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); diff --git a/apps/cli/src/commands/branches/get/get.live.test.ts b/apps/cli/src/commands/branches/get/get.live.test.ts index 4357d41d65..fffeede252 100644 --- a/apps/cli/src/commands/branches/get/get.live.test.ts +++ b/apps/cli/src/commands/branches/get/get.live.test.ts @@ -1,40 +1,25 @@ import { randomUUID } from "node:crypto"; -import { Cause, Effect, Exit, Schema } from "effect"; +import { Cause, Effect, Exit } from "effect"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; -import { awaitBranch, removeBranch } from "../../../../tests/helpers/branches-live.ts"; +import { throwWithCleanup, test } from "../../../../tests/helpers/live.ts"; +import { + awaitLiveBranchEffect, + awaitLiveBranchRemovedEffect, + createLiveBranchEffect, +} from "../../../../tests/helpers/branches-live.ts"; -const CreatedBranch = Schema.Struct({ project_ref: Schema.String }); - -// Not wired to the test `signal`: cleanup runs through the plain-promise `cli` path, so an -// interrupt would abandon an in-flight `branches delete` rather than stop it, leaving the -// branch behind. Its own exit timeout bounds the wait instead. -test("gets a preview branch by name", ({ cli, cliEffect, project }) => +test("gets a preview branch by name", ({ cliEffect, project }) => Effect.runPromise( Effect.gen(function* () { const name = `cli-e2e-get-${randomUUID().slice(0, 8)}`; let branchRef: string | undefined; const target = Effect.gen(function* () { - const created = yield* cliEffect([ - "branches", - "create", - name, - "--project-ref", - project.ref, - "--output-format", - "json", - ]); - requireLiveSuccess(created, "branches create"); - const ref = (yield* Schema.decodeEffect(Schema.fromJsonString(CreatedBranch))( - created.stdout, - )).project_ref; + const ref = yield* createLiveBranchEffect(cliEffect, project, name); branchRef = ref; - expect(ref, created.stdout).toBeTruthy(); - yield* awaitBranch(cli, project, name); - + yield* awaitLiveBranchEffect(cliEffect, project, name); const result = yield* cliEffect(["branches", "get", name, "--project-ref", project.ref]); expect(result.exitCode, result.stderr).toBe(0); // The pretty table prints the branch password and JWT secret, so failures @@ -50,7 +35,10 @@ test("gets a preview branch by name", ({ cli, cliEffect, project }) => }); const targetExit = yield* Effect.exit(target); - const cleanupExit = yield* Effect.exit(removeBranch(cli, project, branchRef ?? name)); + const cleanupExit = + branchRef === undefined + ? yield* Effect.exit(Effect.succeed(true)) + : yield* Effect.exit(awaitLiveBranchRemovedEffect(cliEffect, project, branchRef)); return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], diff --git a/apps/cli/src/commands/branches/list/list.live.test.ts b/apps/cli/src/commands/branches/list/list.live.test.ts index 32b144799e..aa5cabd8e0 100644 --- a/apps/cli/src/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/commands/branches/list/list.live.test.ts @@ -3,30 +3,24 @@ import { randomUUID } from "node:crypto"; import { Cause, Effect, Exit, Schema } from "effect"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; -import { awaitBranch, removeBranch } from "../../../../tests/helpers/branches-live.ts"; +import { throwWithCleanup, test } from "../../../../tests/helpers/live.ts"; +import { + awaitLiveBranchListedEffect, + awaitLiveBranchRemovedEffect, + createLiveBranchEffect, +} from "../../../../tests/helpers/branches-live.ts"; const ListedBranches = Schema.Array(Schema.Struct({ name: Schema.optional(Schema.String) })); -// Not wired to the test `signal`: cleanup runs through the plain-promise `cli` path, so an -// interrupt would abandon an in-flight `branches delete` rather than stop it, leaving the -// branch behind. Its own exit timeout bounds the wait instead. -test("lists a preview branch for the project", ({ cli, cliEffect, project }) => +test("lists a preview branch for the project", ({ cliEffect, project }) => Effect.runPromise( Effect.gen(function* () { const name = `cli-e2e-list-${randomUUID().slice(0, 8)}`; + let branchRef: string | undefined; const target = Effect.gen(function* () { - const created = yield* cliEffect([ - "branches", - "create", - name, - "--project-ref", - project.ref, - ]); - requireLiveSuccess(created, "branches create setup"); - yield* awaitBranch(cli, project, name); - + branchRef = yield* createLiveBranchEffect(cliEffect, project, name); + yield* awaitLiveBranchListedEffect(cliEffect, project, name); const result = yield* cliEffect([ "branches", "list", @@ -43,7 +37,10 @@ test("lists a preview branch for the project", ({ cli, cliEffect, project }) => }); const targetExit = yield* Effect.exit(target); - const cleanupExit = yield* Effect.exit(removeBranch(cli, project, name)); + const cleanupExit = + branchRef === undefined + ? yield* Effect.exit(Effect.succeed(true)) + : yield* Effect.exit(awaitLiveBranchRemovedEffect(cliEffect, project, branchRef)); return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], diff --git a/apps/cli/src/commands/branches/update/update.live.test.ts b/apps/cli/src/commands/branches/update/update.live.test.ts index a91998ec67..f87713b17a 100644 --- a/apps/cli/src/commands/branches/update/update.live.test.ts +++ b/apps/cli/src/commands/branches/update/update.live.test.ts @@ -3,15 +3,14 @@ import { randomUUID } from "node:crypto"; import { Cause, Effect, Exit, Schema } from "effect"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; -import { awaitBranch, removeBranch } from "../../../../tests/helpers/branches-live.ts"; +import { throwWithCleanup, test } from "../../../../tests/helpers/live.ts"; +import { + awaitLiveBranchEffect, + awaitLiveBranchRemovedEffect, + createLiveBranchEffect, +} from "../../../../tests/helpers/branches-live.ts"; -const CreatedBranch = Schema.Struct({ project_ref: Schema.String }); - -// Not wired to the test `signal`: cleanup runs through the plain-promise `cli` path, so an -// interrupt would abandon an in-flight `branches delete` rather than stop it, leaving the -// branch behind. Its own exit timeout bounds the wait instead. -test("renames a preview branch", ({ cli, cliEffect, project }) => +test("renames a preview branch", ({ cliEffect, project }) => Effect.runPromise( Effect.gen(function* () { const name = `cli-e2e-update-${randomUUID().slice(0, 8)}`; @@ -19,24 +18,8 @@ test("renames a preview branch", ({ cli, cliEffect, project }) => let branchRef: string | undefined; const target = Effect.gen(function* () { - const created = yield* cliEffect([ - "branches", - "create", - name, - "--project-ref", - project.ref, - "--output-format", - "json", - ]); - requireLiveSuccess(created, "branches create"); - const ref = (yield* Schema.decodeEffect(Schema.fromJsonString(CreatedBranch))( - created.stdout, - )).project_ref; - branchRef = ref; - expect(ref, created.stdout).toBeTruthy(); - yield* awaitBranch(cli, project, name); - - // `--output json` keeps stdout payload-only and sends the confirmation to stderr. + branchRef = yield* createLiveBranchEffect(cliEffect, project, name); + yield* awaitLiveBranchEffect(cliEffect, project, name); const updated = yield* cliEffect([ "branches", "update", @@ -54,11 +37,14 @@ test("renames a preview branch", ({ cli, cliEffect, project }) => updated.stdout, ); expect(payload).toMatchObject({ name: renamed }); - yield* awaitBranch(cli, project, renamed); + yield* awaitLiveBranchEffect(cliEffect, project, renamed); }); const targetExit = yield* Effect.exit(target); - const cleanupExit = yield* Effect.exit(removeBranch(cli, project, branchRef ?? name)); + const cleanupExit = + branchRef === undefined + ? yield* Effect.exit(Effect.succeed(true)) + : yield* Effect.exit(awaitLiveBranchRemovedEffect(cliEffect, project, branchRef)); return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], diff --git a/apps/cli/tests/helpers/branches-live.integration.test.ts b/apps/cli/tests/helpers/branches-live.integration.test.ts new file mode 100644 index 0000000000..2c5a429ecc --- /dev/null +++ b/apps/cli/tests/helpers/branches-live.integration.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Fiber } from "effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { + awaitLiveBranchEffect, + awaitLiveBranchListedEffect, + awaitLiveBranchRemovedEffect, + createLiveBranchEffect, + type BranchCli, +} from "./branches-live.ts"; +import type { LiveProject } from "./live.ts"; + +const project: LiveProject = { + ref: "abcdefghijklmnopqrst", + dbUrl: "postgresql://postgres:password@example.com:5432/postgres", + dbPassword: "password", + anonKey: "anon", + serviceRoleKey: "service-role", + functionsUrl: "https://example.com/functions", + storageBucket: "bucket", +}; +const branchRef = "zyxwvutsrqponmlkjihg"; +const branch = { name: "feature-x", project_ref: branchRef, is_default: false }; +const defaultBranch = { name: "main", project_ref: project.ref, is_default: true }; + +type State = { + readonly calls: string[]; + listed: boolean; + getReady: boolean; + deleteAttempts: number; +}; + +function result(stdout = "", stderr = "", exitCode = 0) { + return { stdout, stderr, exitCode }; +} + +function statefulCli(state: State): BranchCli { + return (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "create") { + return result(JSON.stringify({ ...branch, project_ref: branchRef })); + } + if (args[1] === "get") { + return state.getReady + ? result(JSON.stringify(branch)) + : result("", "Request failed with status 404", 1); + } + if (args[1] === "list") { + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + } + if (args[1] === "delete") { + state.deleteAttempts += 1; + if (state.deleteAttempts === 1) return result("", "Request failed with status 404", 1); + state.listed = false; + return result(JSON.stringify(branch)); + } + return result("", "unexpected command", 1); + }); +} + +describe("live branch lifecycle helpers", () => { + it.effect("waits for LIST visibility after name lookup succeeds", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: false, getReady: true, deleteAttempts: 0 }; + const cli = statefulCli(state); + const listed = yield* awaitLiveBranchListedEffect(cli, project, branch.name).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("2 seconds"); + state.listed = true; + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(listed); + expect(state.calls.filter((call) => call.includes("branches list")).length).toBeGreaterThan( + 1, + ); + expect(state.calls.some((call) => call.includes("branches get"))).toBe(false); + }), + ); + + it.effect("does not treat early LIST absence as deletion after a DELETE 404", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: false, getReady: false, deleteAttempts: 0 }; + const cli = statefulCli(state); + const ref = yield* createLiveBranchEffect(cli, project, branch.name); + expect(ref).toBe(branchRef); + const cleanup = yield* awaitLiveBranchRemovedEffect(cli, project, ref).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("2 seconds"); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(cleanup); + expect(state.deleteAttempts).toBe(2); + const firstDelete = state.calls.findIndex((call) => call.includes("branches delete")); + const secondDelete = state.calls.findIndex( + (call, index) => index > firstDelete && call.includes("branches delete"), + ); + expect( + state.calls.slice(firstDelete, secondDelete).some((call) => call.includes("branches list")), + ).toBe(false); + expect(state.calls.at(-1)).toContain("branches list"); + expect(state.listed).toBe(false); + }), + ); + + it.effect("waits for LIST absence after deletion was acknowledged", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: true, getReady: true, deleteAttempts: 0 }; + const cli: BranchCli = (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "list") { + if (state.calls.filter((call) => call.includes("branches list")).length > 1) { + state.listed = false; + } + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + } + return result(); + }); + const cleanup = yield* awaitLiveBranchRemovedEffect(cli, project, branchRef, true).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(cleanup); + expect(state.listed).toBe(false); + }), + ); + + it.effect("fails a cleanup authorization error without retrying", () => + Effect.gen(function* () { + let attempts = 0; + const cli: BranchCli = () => + Effect.sync(() => { + attempts += 1; + return result("", "Request failed with status 403: not found", 1); + }); + const exit = yield* Effect.exit(awaitLiveBranchRemovedEffect(cli, project, branchRef)); + if (!Exit.isFailure(exit)) throw new Error("expected cleanup authorization failure"); + expect(String(exit.cause)).toContain("status 403"); + expect(attempts).toBe(1); + }), + ); + + it.effect("fails malformed LIST payloads instead of treating them as branch absence", () => + Effect.gen(function* () { + let attempts = 0; + const cli: BranchCli = () => + Effect.sync(() => { + attempts += 1; + return result(JSON.stringify([{}])); + }); + const exit = yield* Effect.exit(awaitLiveBranchListedEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected malformed list failure"); + expect(String(exit.cause)).toContain("unexpected payload"); + expect(attempts).toBe(1); + }), + ); + + it.effect("honors the readiness deadline", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: false, getReady: false, deleteAttempts: 0 }; + const waiting = yield* awaitLiveBranchEffect(statefulCli(state), project, branch.name).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("60 seconds"); + const exit = yield* Fiber.await(waiting); + if (!Exit.isFailure(exit)) throw new Error("expected readiness timeout"); + expect(String(exit.cause)).toContain("timed out"); + }), + ); + + it.effect("cleans up an owned name when a successful create payload is malformed", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: true, getReady: false, deleteAttempts: 0 }; + const cli: BranchCli = (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "create") + return result(JSON.stringify({ message: "Created preview branch" })); + if (args[1] === "delete") { + state.listed = false; + return result(); + } + if (args[1] === "list") + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(); + }); + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected malformed create failure"); + expect(String(exit.cause)).toContain("unexpected payload"); + expect(state.calls.some((call) => call.includes(`branches delete ${branch.name}`))).toBe( + true, + ); + expect(state.listed).toBe(false); + }), + ); + + it.effect("cleans up an owned name when create exits after starting", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: true, getReady: false, deleteAttempts: 0 }; + const cli: BranchCli = (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "create") return result("", "create failed", 1); + if (args[1] === "delete") { + state.listed = false; + return result(); + } + if (args[1] === "list") + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(); + }); + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected create failure"); + expect(String(exit.cause)).toContain("create failed"); + expect(state.calls).toContain( + `branches delete ${branch.name} --project-ref ${project.ref} --yes`, + ); + expect(state.listed).toBe(false); + }), + ); + + it.effect("cleans up an owned name when create invocation fails", () => + Effect.gen(function* () { + const calls: string[] = []; + const cli: BranchCli = (args) => { + calls.push(args.join(" ")); + if (args[1] === "create") return Effect.fail(new Error("invocation failed")); + if (args[1] === "delete") return Effect.succeed(result()); + if (args[1] === "list") return Effect.succeed(result(JSON.stringify([defaultBranch]))); + return Effect.succeed(result()); + }; + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected invocation failure"); + expect(String(exit.cause)).toContain("invocation failed"); + expect(calls.some((call) => call.startsWith(`branches delete ${branch.name}`))).toBe(true); + }), + ); + + it.effect("preserves create and cleanup failures", () => + Effect.gen(function* () { + const cli: BranchCli = (args) => + args[1] === "create" + ? Effect.fail(new Error("create invocation failed")) + : Effect.succeed(result("", "cleanup forbidden", 1)); + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected aggregate failure"); + const failure = Cause.squash(exit.cause); + if (!(failure instanceof AggregateError)) throw new Error("expected aggregate failure cause"); + expect(failure.message).toBe("Branch create and cleanup failed"); + const causes = failure.errors.map(String).join("\n"); + expect(causes).toContain("create invocation failed"); + expect(causes).toContain("cleanup forbidden"); + }), + ); + + it.effect("keeps conservative by-name cleanup bounded when create never appears", () => + Effect.gen(function* () { + const cli: BranchCli = (args) => + args[1] === "create" + ? Effect.fail(new Error("create invocation failed")) + : args[1] === "delete" + ? Effect.succeed(result("", "Request failed with status 404", 1)) + : Effect.succeed(result(JSON.stringify([defaultBranch]))); + const cleanup = yield* createLiveBranchEffect(cli, project, branch.name).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("120 seconds"); + const exit = yield* Fiber.await(cleanup); + if (!Exit.isFailure(exit)) throw new Error("expected bounded cleanup failure"); + const failure = Cause.squash(exit.cause); + if (!(failure instanceof AggregateError)) throw new Error("expected aggregate failure cause"); + const causes = failure.errors.map(String).join("\n"); + expect(causes).toContain("create invocation failed"); + expect(causes).toContain("branch removal feature-x timed out"); + }), + ); + + it.effect("uses one deadline across delayed deletion and LIST absence", () => + Effect.gen(function* () { + const cli: BranchCli = (args) => + args[1] === "delete" + ? Effect.sleep("60 seconds").pipe(Effect.as(result())) + : Effect.succeed(result(JSON.stringify([defaultBranch, branch]))); + const waiting = yield* awaitLiveBranchRemovedEffect(cli, project, branchRef).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("120 seconds"); + const exit = yield* Fiber.await(waiting); + if (!Exit.isFailure(exit)) throw new Error("expected removal timeout"); + expect(String(exit.cause)).toContain("timed out"); + }), + ); +}); diff --git a/apps/cli/tests/helpers/branches-live.ts b/apps/cli/tests/helpers/branches-live.ts index 3eaaac87bf..1682231d7b 100644 --- a/apps/cli/tests/helpers/branches-live.ts +++ b/apps/cli/tests/helpers/branches-live.ts @@ -1,42 +1,352 @@ -import { Data, Effect } from "effect"; - -import { - awaitLiveBranch, - awaitLiveBranchesRemoved, - type LiveFixtures, - type LiveProject, - removeLiveBranch, -} from "./live.ts"; - -/** Typed live failures; `message` is a field so vitest can serialize the error. */ -class BranchesLiveError extends Data.TaggedError("BranchesLiveError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -const liveFailure = (error: unknown): BranchesLiveError => - new BranchesLiveError({ - message: error instanceof Error ? error.message : String(error), - cause: error, - }); +import { Data, Duration, Effect, Schedule, Schema } from "effect"; -export function awaitBranch(cli: LiveFixtures["cli"], project: LiveProject, branch: string) { - return Effect.tryPromise({ - try: () => awaitLiveBranch(cli, project, branch), - catch: liveFailure, - }); +import type { LiveProject } from "./live.ts"; + +type BranchCliOptions = { readonly exitTimeoutMs?: number }; +type BranchCliResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +/** The Effect edge used by branch lifecycle helpers. */ +export type BranchCli = ( + args: string[], + options?: BranchCliOptions, +) => Effect.Effect; + +const COMMAND_TIMEOUT = 20_000; +const POLL_INTERVAL = "2 seconds"; +const READINESS_TIMEOUT = Duration.seconds(60); +const REMOVAL_TIMEOUT = Duration.seconds(120); +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const BranchListItem = Schema.Struct({ + name: Schema.String, + project_ref: Schema.String, + is_default: Schema.Boolean, +}); +const BranchList = Schema.Array(BranchListItem); + +class BranchCommandFailed extends Data.TaggedError("BranchCommandFailed")<{ + readonly phase: string; + readonly exitCode: number; + readonly stderr: string; +}> { + override get message(): string { + return `${this.phase} failed (exit ${this.exitCode})${this.stderr.length === 0 ? "" : `\nstderr:\n${this.stderr}`}`; + } } -export function awaitBranchesRemoved(cli: LiveFixtures["cli"], project: LiveProject) { - return Effect.tryPromise({ - try: () => awaitLiveBranchesRemoved(cli, project), - catch: liveFailure, - }); +class BranchNotReady extends Data.TaggedError("BranchNotReady")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} is not ready`; + } +} + +class BranchPollTimedOut extends Data.TaggedError("BranchPollTimedOut")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} timed out`; + } +} + +class BranchPayloadInvalid extends Data.TaggedError("BranchPayloadInvalid")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} returned an unexpected payload`; + } +} + +type BranchError = + | E + | BranchCommandFailed + | BranchNotReady + | BranchPollTimedOut + | BranchPayloadInvalid + | AggregateError; + +function boundedStderr(stderr: string): string { + return stderr.length <= 2_000 ? stderr : stderr.slice(stderr.length - 2_000); +} + +function command( + cli: BranchCli, + args: string[], + phase: string, + timeout?: number, +): Effect.Effect, never> { + const response = timeout === undefined ? cli(args) : cli(args, { exitTimeoutMs: timeout }); + return response.pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.succeed(result) + : Effect.fail( + new BranchCommandFailed({ + phase, + exitCode: result.exitCode, + stderr: boundedStderr(result.stderr), + }), + ), + ), + ); +} + +function poll( + phase: string, + effect: Effect.Effect, never>, + timeout: Duration.Duration, +): Effect.Effect, never> { + return Effect.timeoutOrElse( + Effect.retry(effect, { + schedule: Schedule.spaced(POLL_INTERVAL), + while: (error) => error instanceof BranchNotReady, + }), + { + duration: timeout, + orElse: () => Effect.fail(new BranchPollTimedOut({ phase })), + }, + ); +} + +function isNotFound(result: BranchCliResult): boolean { + return /\b(?:status|http(?: status)?|code)\D{0,12}404\b/iu.test(result.stderr); +} + +function branchRefFromCreate( + result: BranchCliResult, +): Effect.Effect { + return Schema.decodeEffect(UnknownFromJsonString)(result.stdout).pipe( + Effect.mapError(() => new BranchPayloadInvalid({ phase: "branches create" })), + Effect.flatMap((body) => + typeof body === "object" && + body !== null && + "project_ref" in body && + typeof body.project_ref === "string" && + body.project_ref.length > 0 + ? Effect.succeed(body.project_ref) + : Effect.fail(new BranchPayloadInvalid({ phase: "branches create" })), + ), + ); +} + +/** Creates one named branch and captures its immutable reference before polling readiness. */ +export function createLiveBranchEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect, never> { + const created = command( + cli, + ["branches", "create", name, "--project-ref", project.ref, "--output-format", "json"], + "branches create", + undefined, + ); + return created.pipe( + Effect.flatMap((result) => branchRefFromCreate(result)), + Effect.catch((primary) => + removeLiveBranchByNameEffect(cli, project, name).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.fail(primary), + onFailure: (cleanup) => + Effect.fail(new AggregateError([primary, cleanup], "Branch create and cleanup failed")), + }), + ), + ), + ); +} + +function getBranchReady( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect, never> { + const response: Effect.Effect = cli( + ["branches", "get", name, "--project-ref", project.ref], + { + exitTimeoutMs: COMMAND_TIMEOUT, + }, + ); + return response.pipe( + Effect.flatMap((result): Effect.Effect, never> => { + if (result.exitCode === 0) return Effect.succeed(true as const); + if (isNotFound(result)) + return Effect.fail(new BranchNotReady({ phase: `branches get ${name}` })); + return Effect.fail( + new BranchCommandFailed({ + phase: `branches get ${name}`, + exitCode: result.exitCode, + stderr: boundedStderr(result.stderr), + }), + ); + }), + ); +} + +/** Waits until name lookup observes a created or renamed branch. */ +export function awaitLiveBranchEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect, never> { + return poll( + `branches get ${name}`, + Effect.suspend(() => getBranchReady(cli, project, name)), + READINESS_TIMEOUT, + ); +} + +function listBranches( + cli: BranchCli, + project: LiveProject, + phase: string, +): Effect.Effect>, BranchError, never> { + return command( + cli, + ["branches", "list", "--output", "json", "--project-ref", project.ref], + phase, + COMMAND_TIMEOUT, + ).pipe( + Effect.flatMap((result) => + Schema.decodeEffect(Schema.fromJsonString(BranchList))(result.stdout).pipe( + Effect.mapError(() => new BranchPayloadInvalid({ phase })), + ), + ), + ); +} + +/** Waits until the list endpoint contains the named branch. */ +export function awaitLiveBranchListedEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect, never> { + const read = listBranches(cli, project, `branches list ${name}`).pipe( + Effect.flatMap((branches) => + branches.some((branch) => branch["name"] === name) + ? Effect.succeed(true as const) + : Effect.fail(new BranchNotReady({ phase: `branches list ${name}` })), + ), + ); + return poll( + `branches list ${name}`, + Effect.suspend(() => read), + READINESS_TIMEOUT, + ); } -export function removeBranch(cli: LiveFixtures["cli"], project: LiveProject, branch: string) { - return Effect.tryPromise({ - try: () => removeLiveBranch(cli, project, branch), - catch: liveFailure, +function deleteBranch( + cli: BranchCli, + project: LiveProject, + target: string, + phase: string, +): Effect.Effect, never> { + const response: Effect.Effect = cli( + ["branches", "delete", target, "--project-ref", project.ref, "--yes"], + { + exitTimeoutMs: COMMAND_TIMEOUT, + }, + ); + return response.pipe( + Effect.flatMap((result): Effect.Effect, never> => { + if (result.exitCode === 0) return Effect.succeed(true as const); + if (isNotFound(result)) return Effect.fail(new BranchNotReady({ phase })); + return Effect.fail( + new BranchCommandFailed({ + phase, + exitCode: result.exitCode, + stderr: boundedStderr(result.stderr), + }), + ); + }), + ); +} + +function branchIsListed( + cli: BranchCli, + project: LiveProject, + target: string, + field: "project_ref" | "name", +): Effect.Effect, never> { + return listBranches(cli, project, "branches list while awaiting branch removal").pipe( + Effect.map((branches) => branches.some((branch) => branch[field] === target)), + ); +} + +function removeBranchEffect( + cli: BranchCli, + project: LiveProject, + target: string, + field: "project_ref" | "name", + deletionAcknowledged: boolean, +): Effect.Effect, never> { + const waitForList = Effect.retry( + Effect.suspend(() => + branchIsListed(cli, project, target, field).pipe( + Effect.flatMap((listed) => + listed + ? Effect.fail(new BranchNotReady({ phase: `branches list removal ${target}` })) + : Effect.succeed(true as const), + ), + ), + ), + { + schedule: Schedule.spaced(POLL_INTERVAL), + while: (error) => error instanceof BranchNotReady, + }, + ); + const acknowledged = deletionAcknowledged + ? waitForList + : Effect.retry( + Effect.suspend(() => deleteBranch(cli, project, target, `branches delete ${target}`)), + { + schedule: Schedule.spaced(POLL_INTERVAL), + while: (error) => error instanceof BranchNotReady, + }, + ).pipe(Effect.flatMap(() => waitForList)); + return Effect.timeoutOrElse(acknowledged, { + duration: REMOVAL_TIMEOUT, + orElse: () => Effect.fail(new BranchPollTimedOut({ phase: `branch removal ${target}` })), }); } + +/** Deletes an owned branch by its unique test name and confirms LIST absence. */ +export function removeLiveBranchByNameEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect, never> { + return removeBranchEffect(cli, project, name, "name", false); +} + +/** Confirms deletion using the owned ref, then waits for LIST to omit that ref. */ +export function awaitLiveBranchRemovedEffect( + cli: BranchCli, + project: LiveProject, + branchRef: string, + deletionAcknowledged = false, +): Effect.Effect, never> { + return removeBranchEffect(cli, project, branchRef, "project_ref", deletionAcknowledged); +} + +/** Waits for LIST to contain only the default project. */ +export function awaitLiveBranchesRemovedEffect( + cli: BranchCli, + project: LiveProject, +): Effect.Effect, never> { + const read = listBranches(cli, project, "branches list while awaiting branch removal").pipe( + Effect.flatMap((branches) => + branches.some((branch) => branch["is_default"] !== true) + ? Effect.fail(new BranchNotReady({ phase: "branches list while awaiting branch removal" })) + : Effect.succeed(true as const), + ), + ); + return poll( + "branches list while awaiting branch removal", + Effect.suspend(() => read), + REMOVAL_TIMEOUT, + ); +} diff --git a/apps/cli/tests/helpers/live-project.integration.test.ts b/apps/cli/tests/helpers/live-project.integration.test.ts new file mode 100644 index 0000000000..be267fa325 --- /dev/null +++ b/apps/cli/tests/helpers/live-project.integration.test.ts @@ -0,0 +1,139 @@ +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { provisionLiveEnvironment } from "./live-project.ts"; + +const ref = "abcdefghijklmnopqrst"; +function jsonResponse( + request: HttpClientRequest.HttpClientRequest, + status: number, + body: unknown, + headers: Record = {}, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }), + ); +} + +const project = { + id: "1", + ref, + organization_id: "org-id", + organization_slug: "org-slug", + name: "live-test", + region: "us-east-1", + created_at: "2026-01-01T00:00:00.000Z", + status: "ACTIVE_HEALTHY" as const, + database: { + host: `db.${ref}.supabase.green`, + version: "17", + postgres_engine: "17", + release_channel: "stable", + }, +}; + +type ApiKeyScenario = { + name: string; + status: number; + body: unknown; + headers: Record; +}; + +describe("live project provisioning", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + { + name: "terminal API key rejection", + status: 403, + body: { message: "authorization denied", access_token: "secret-value" }, + headers: { "x-request-id": "req-live-403" }, + }, + { + name: "malformed HTTP 200 API key response", + status: 200, + body: "sentinel-secret", + headers: {}, + }, + ])("redacts $name while cleaning up", async ({ status, body, headers }) => { + vi.stubEnv("SUPABASE_LIVE_API_URL", "https://api.supabase.green"); + vi.stubEnv("SUPABASE_ACCESS_TOKEN", "test-token"); + vi.stubEnv("SUPABASE_LIVE_ORG_ID", "org-slug"); + vi.stubEnv("SUPABASE_LIVE_REGION", "us-east-1"); + vi.stubEnv("SUPABASE_LIVE_KEEP_PROJECT", "0"); + + const requests: string[] = []; + const ownedProjects = new Set(); + const client = await Effect.runPromise( + makeApiClient({ baseUrl: "https://api.supabase.green", accessToken: "test-token" }).pipe( + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}`); + if (request.method === "GET" && url.pathname === "/v1/organizations") { + return Effect.succeed( + jsonResponse(request, 200, [{ id: "org-id", slug: "org-slug", name: "Test" }]), + ); + } + if (request.method === "POST" && url.pathname === "/v1/projects") { + ownedProjects.add(ref); + return Effect.succeed( + jsonResponse(request, 201, { ...project, database: undefined }), + ); + } + if (request.method === "GET" && url.pathname === `/v1/projects/${ref}`) { + return Effect.succeed(jsonResponse(request, 200, project)); + } + if (request.method === "GET" && url.pathname === `/v1/projects/${ref}/api-keys`) { + return Effect.succeed(jsonResponse(request, status, body, headers)); + } + if (request.method === "DELETE" && url.pathname === `/v1/projects/${ref}`) { + ownedProjects.delete(ref); + return Effect.succeed( + jsonResponse(request, 200, { id: 1, ref, name: "live-test" }), + ); + } + return Effect.die(`unexpected request ${request.method} ${url.pathname}`); + }), + ), + ), + ), + ); + + let failure: unknown; + try { + await Effect.runPromise(provisionLiveEnvironment(client)); + } catch (error) { + failure = error; + } + if (status === 403) { + expect(String(failure)).toMatch( + /project API keys failed: GET \/v1\/projects\/abcdefghijklmnopqrst\/api-keys returned HTTP 403.*x-request-id=req-live-403.*response body omitted/, + ); + } else { + expect(String(failure)).toContain( + "project API keys failed: management API response schema validation failed", + ); + } + expect(requests.filter((request) => request.includes("/api-keys"))).toHaveLength(1); + expect(requests).toContain("DELETE /v1/projects/abcdefghijklmnopqrst"); + expect(ownedProjects).toEqual(new Set()); + const message = String(failure); + expect(message).not.toContain("test-token"); + expect(message).not.toContain("secret-value"); + expect(message).not.toContain("authorization denied"); + expect(message).not.toContain("sentinel-secret"); + }); +}); diff --git a/apps/cli/tests/helpers/live-project.ts b/apps/cli/tests/helpers/live-project.ts index 1e2a6a8ef9..ecff21050d 100644 --- a/apps/cli/tests/helpers/live-project.ts +++ b/apps/cli/tests/helpers/live-project.ts @@ -4,7 +4,7 @@ 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 { Cause, Data, Effect, Exit, Schedule, Schema } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { getDomain } from "tldts"; @@ -98,6 +98,45 @@ function apiError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +const DIAGNOSTIC_HEADER_NAMES = [ + "x-request-id", + "x-correlation-id", + "cf-ray", + "traceparent", +] as const; + +function boundedHeaderValue(value: string | undefined): string | undefined { + if (value === undefined || value.length === 0 || !/^[\x20-\x7e]+$/u.test(value)) return undefined; + return value.length <= 128 ? value : `${value.slice(0, 125)}...`; +} + +/** Formats management API failures without exposing credentials or response bodies. */ +function liveApiErrorMessage(phase: string, cause: unknown): string { + if (Schema.isSchemaError(cause)) { + return `${phase} failed: management API response schema validation failed`; + } + if (!HttpClientError.isHttpClientError(cause)) { + return `${phase} failed: management API request failed`; + } + if (cause.reason._tag !== "StatusCodeError") { + return `${phase} failed: management API request failed (${cause.reason._tag})`; + } + + const { request, response } = cause.reason; + let path = request.url; + try { + path = new URL(request.url).pathname; + } catch { + path = ""; + } + const requestIds = DIAGNOSTIC_HEADER_NAMES.flatMap((name) => { + const value = boundedHeaderValue(response.headers[name] ?? request.headers[name]); + return value === undefined ? [] : [`${name}=${value}`]; + }); + const context = requestIds.length === 0 ? "" : `, ${requestIds.join(", ")}`; + return `${phase} failed: ${request.method} ${path} returned HTTP ${response.status}${context} (response body omitted)`; +} + export function supportedRegion(value: string): Effect.Effect { const region = REGIONS.find((candidate) => candidate === value); return region === undefined @@ -153,7 +192,7 @@ function classifyPollError(phase: string, cause: unknown): LiveTransientPoll | L ? new LiveTransientPoll({ phase, cause }) : new LiveTerminalPoll({ phase, - message: `${phase} failed: ${apiError(cause).message}`, + message: liveApiErrorMessage(phase, cause), cause, }); } diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 539bf30f0b..397fa18da7 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -175,23 +175,6 @@ export async function removeStorageLiveObject( } } -/** Exact cleanup for branches live tests by name or ref; deleting an already-removed branch is tolerated. */ -export async function removeLiveBranch( - cli: LiveFixtures["cli"], - project: LiveProject, - branch: string, -): Promise { - const removed = await cli(["branches", "delete", branch, "--project-ref", project.ref, "--yes"]); - if ( - removed.exitCode !== 0 && - !/not found|does not exist|status 404\b/i.test(`${removed.stdout}\n${removed.stderr}`) - ) { - throw new Error( - `branches delete cleanup for ${branch} failed (exit ${removed.exitCode})\n${removed.stdout}\n${removed.stderr}`, - ); - } -} - /** Flags for experimental-gated live tests that address the shared project by * ref rather than linking it (contrast `storageLiveFlags`). */ export function experimentalProjectLiveFlags(project: LiveProject): ReadonlyArray { @@ -268,83 +251,6 @@ export async function expectPostgresConfigLiveOverride( await expect.poll(read, { interval: 2_000, timeout: 60_000, message: label }).toBe(expected); } -/** - * Waits until `branches get` resolves `branch` on the live project. `branches - * create` and `update --name` return before the platform can look the branch - * up, so a caller that acts on it next does one fail-fast read (aborting on - * anything but a 404) and then polls (2s apart, 60s deadline, each attempt - * bounded). Reports stderr only since `get` prints secrets on stdout. - */ -export async function awaitLiveBranch( - cli: LiveFixtures["cli"], - project: LiveProject, - branch: string, -): Promise { - const read = async (): Promise => { - const proof = await cli(["branches", "get", branch, "--project-ref", project.ref], { - exitTimeoutMs: 20_000, - }); - if (proof.exitCode !== 0 && !/status 404\b/u.test(proof.stderr)) { - requireCliSuccess({ ...proof, stdout: "" }, `branches get ${branch}`); - } - return proof.exitCode === 0 - ? "found" - : `not found (exit ${proof.exitCode})\nstderr:\n${proof.stderr}`; - }; - if ((await read()) === "found") return; - await expect - .poll(read, { - interval: 2_000, - timeout: 60_000, - message: `branches get ${branch} still does not find the branch`, - }) - .toBe("found"); -} - -/** - * Waits until `branches list` shows no non-default branch on the live project. - * `branches delete` returns before the platform finishes tearing the branch - * down, and `branches disable` is refused ("Please delete all non-default - * branches before disabling branching.") while any non-default branch still - * exists, so a caller that needs an empty branching setup does one fail-fast - * read and then polls the list (2s apart, 120s deadline, each attempt bounded). - */ -export async function awaitLiveBranchesRemoved( - cli: LiveFixtures["cli"], - project: LiveProject, -): Promise { - const label = "branches list while awaiting branch removal"; - const read = async (): Promise> => { - const listed = await cli( - ["branches", "list", "--output", "json", "--project-ref", project.ref], - { exitTimeoutMs: 20_000 }, - ); - requireCliSuccess(listed, label); - let branches: unknown; - try { - branches = JSON.parse(listed.stdout); - } catch { - branches = undefined; - } - if (!Array.isArray(branches)) { - throw new Error( - `${label}: unexpected branches list payload\nstdout:\n${listed.stdout}\nstderr:\n${listed.stderr}`, - ); - } - return branches - .filter((branch: { is_default: boolean }) => !branch.is_default) - .map((branch: { name: string }) => branch.name); - }; - if ((await read()).length === 0) return; - await expect - .poll(read, { - interval: 2_000, - timeout: 120_000, - message: "non-default preview branches still exist", - }) - .toEqual([]); -} - /** * Unique migration version for a live test: a sortable `YYYYMMDDHHMMSS` UTC * stamp plus four random digits, so it always orders after any conventional