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 bdb4119e38..6597aaa639 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,68 +1,69 @@ -import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, unlink } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; -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")}`; +// `db pull` exits non-zero when the diff comes back empty (the in-sync +// finding, see IN_SYNC_SUGGESTION in pull.handler.ts), so the journey seeds a remote-only +// marker table through `db query` — no local migration and no history row. +// The marker cannot exist in the freshly provisioned shadow, so the diff is +// never empty regardless of engine and the pull deterministically writes it. +test("pulls the remote schema into an initial migration", async ({ cli, project, workspace }) => { + const marker = `e2e_pull_${randomUUID().slice(0, 8)}`; 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`); let targetError: unknown; + const cleanupErrors: Array = []; try { - const pushed = await cli(["db", "push", "--db-url", project.dbUrl, "--yes"]); - requireLiveSuccess(pushed, "db push setup"); + const seeded = await cli([ + "db", + "query", + `create table if not exists ${marker} (id int)`, + "--db-url", + project.dbUrl, + ]); + requireLiveSuccess(seeded, "db query setup for db pull"); 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, + + expect(result.stderr, result.stderr).toContain("Schema written to"); + const generated = (await readdir(migrations)).filter((file) => !existingMigrations.has(file)); + expect(generated.length, result.stderr).toBeGreaterThan(0); + const pulled = await Promise.all( + generated.map((file) => readFile(join(migrations, file), "utf8")), ); + expect(pulled.join("\n"), result.stderr).toContain(marker); } 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))) { + } finally { + // Remove the generated migration before resetting so the reset replays an + // empty local set and restores the baseline schema, dropping the marker. + let currentMigrations: ReadonlyArray = []; try { - await unlink(join(migrations, file)); + currentMigrations = await readdir(migrations); } catch (error) { - cleanupErrors.push( - new Error( - `db pull cleanup could not remove test migration ${join(migrations, file)}: ${ - error instanceof Error ? error.message : String(error) - }`, - ), - ); + cleanupErrors.push(error); + } + for (const file of currentMigrations.filter( + (candidate) => !existingMigrations.has(candidate), + )) { + try { + await unlink(join(migrations, file)); + } catch (error) { + cleanupErrors.push(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); } } - - 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/services/services.live.test.ts b/apps/cli/src/legacy/commands/services/services.live.test.ts new file mode 100644 index 0000000000..eaf190f516 --- /dev/null +++ b/apps/cli/src/legacy/commands/services/services.live.test.ts @@ -0,0 +1,23 @@ +import { expect } from "vitest"; + +import { requireLiveSuccess, test } from "../../../../tests/helpers/live.ts"; + +test("merges remote versions from the linked live project into services output", async ({ + cli, + project, +}) => { + const linked = await cli(["link", "--project-ref", project.ref, "--skip-pooler"]); + requireLiveSuccess(linked, "link setup for services"); + + // One remote-backed invocation is the live golden path; cross-format + // rendering is integration-tested with fixed remote data. + const json = await cli(["services", "-o", "json"]); + expect(json.exitCode, json.stderr).toBe(0); + const rows = JSON.parse(json.stdout) as Array<{ name: string; local: string; remote: string }>; + expect(rows, json.stdout).toHaveLength(10); + const postgres = rows.find((row) => row.name === "supabase/postgres"); + if (postgres === undefined) { + throw new Error(`supabase/postgres row missing from services json:\n${json.stdout}`); + } + expect(postgres.remote.length, json.stdout).toBeGreaterThan(0); +}); 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 index c66f82c981..7b0b247f2c 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts @@ -3,22 +3,13 @@ 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}`); - } -} +import { + removeStorageLiveObject, + requireLiveSuccess, + storageLiveFlags, + test, + throwWithCleanup, +} from "../../../../../tests/helpers/live.ts"; test("copies a local file to the remote bucket", async ({ cli, project, workspace }) => { const suffix = randomUUID().slice(0, 8); @@ -34,13 +25,13 @@ test("copies a local file to the remote bucket", async ({ cli, project, workspac }); requireLiveSuccess(linked, "link setup for storage cp"); - const result = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + const result = await cli(["storage", "cp", local, remote, ...storageLiveFlags]); expect(result.exitCode, result.stderr).toBe(0); } catch (error) { targetError = error; } finally { try { - await removeObject(cli, remote); + await removeStorageLiveObject(cli, remote); } catch (error) { cleanupError = error; } 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 index b678ceb7df..ea67ee203e 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts @@ -3,22 +3,13 @@ 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}`); - } -} +import { + removeStorageLiveObject, + requireLiveSuccess, + storageLiveFlags, + test, + throwWithCleanup, +} from "../../../../../tests/helpers/live.ts"; test("lists an uploaded object", async ({ cli, project, workspace }) => { const suffix = randomUUID().slice(0, 8); @@ -33,14 +24,14 @@ test("lists an uploaded object", async ({ cli, project, workspace }) => { env: { SUPABASE_DB_PASSWORD: project.dbPassword }, }); requireLiveSuccess(linked, "link setup for storage ls"); - const uploaded = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + const uploaded = await cli(["storage", "cp", local, remote, ...storageLiveFlags]); requireLiveSuccess(uploaded, "storage cp setup for storage ls"); const result = await cli([ "storage", "ls", `ss:///${project.storageBucket}/`, - ...STORAGE_FLAGS, + ...storageLiveFlags, ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain(`upload-${suffix}.txt`); @@ -48,7 +39,7 @@ test("lists an uploaded object", async ({ cli, project, workspace }) => { targetError = error; } finally { try { - await removeObject(cli, remote); + await removeStorageLiveObject(cli, remote); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.live.test.ts b/apps/cli/src/legacy/commands/storage/mv/mv.live.test.ts new file mode 100644 index 0000000000..96b21fa1a6 --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/mv/mv.live.test.ts @@ -0,0 +1,56 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { + removeStorageLiveObject, + requireLiveSuccess, + storageLiveFlags, + test, + throwWithCleanup, +} from "../../../../../tests/helpers/live.ts"; + +test("moves an uploaded object to a new path", async ({ cli, project, workspace }) => { + const suffix = randomUUID().slice(0, 8); + const local = join(workspace.path, `mv-src-${suffix}.txt`); + const source = `ss:///${project.storageBucket}/mv-src-${suffix}.txt`; + const destination = `ss:///${project.storageBucket}/mv-dst-${suffix}.txt`; + await writeFile(local, "live-e2e storage payload\n"); + + let targetError: unknown; + const cleanupErrors: Array = []; + try { + const linked = await cli(["link", "--project-ref", project.ref], { + env: { SUPABASE_DB_PASSWORD: project.dbPassword }, + }); + requireLiveSuccess(linked, "link setup for storage mv"); + const uploaded = await cli(["storage", "cp", local, source, ...storageLiveFlags]); + requireLiveSuccess(uploaded, "storage cp setup for storage mv"); + + const moved = await cli(["storage", "mv", source, destination, ...storageLiveFlags]); + expect(moved.exitCode, moved.stderr).toBe(0); + expect(moved.stderr, moved.stderr).toContain("Moving object:"); + + const listed = await cli([ + "storage", + "ls", + `ss:///${project.storageBucket}/`, + ...storageLiveFlags, + ]); + requireLiveSuccess(listed, "storage ls proof for storage mv"); + expect(listed.stdout).toContain(`mv-dst-${suffix}.txt`); + expect(listed.stdout).not.toContain(`mv-src-${suffix}.txt`); + } catch (error) { + targetError = error; + } finally { + for (const remote of [destination, source]) { + try { + await removeStorageLiveObject(cli, remote); + } catch (error) { + cleanupErrors.push(error); + } + } + } + throwWithCleanup(targetError, cleanupErrors); +}); 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 index 674268a2cc..e2ec238ada 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts @@ -3,22 +3,13 @@ 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}`); - } -} +import { + removeStorageLiveObject, + requireLiveSuccess, + storageLiveFlags, + test, + throwWithCleanup, +} from "../../../../../tests/helpers/live.ts"; test("removes an uploaded object", async ({ cli, project, workspace }) => { const suffix = randomUUID().slice(0, 8); @@ -33,16 +24,16 @@ test("removes an uploaded object", async ({ cli, project, workspace }) => { env: { SUPABASE_DB_PASSWORD: project.dbPassword }, }); requireLiveSuccess(linked, "link setup for storage rm"); - const uploaded = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + const uploaded = await cli(["storage", "cp", local, remote, ...storageLiveFlags]); requireLiveSuccess(uploaded, "storage cp setup for storage rm"); - const result = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + const result = await cli(["storage", "rm", remote, "--yes", ...storageLiveFlags]); expect(result.exitCode, result.stderr).toBe(0); } catch (error) { targetError = error; } finally { try { - await removeObject(cli, remote); + await removeStorageLiveObject(cli, remote); } catch (error) { cleanupError = error; } diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 9b87dc3465..d17ea1bbb1 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -122,6 +122,28 @@ export function requireLiveSuccess( } } +/** Flags every storage live test passes: the suite links the shared project + * and the storage command family is experimental-gated. */ +export const storageLiveFlags: ReadonlyArray = ["--linked", "--experimental"]; + +/** + * Best-effort exact-object cleanup for storage live tests: removes one owned + * remote object, tolerating an already-removed target so teardown stays + * idempotent across the moved/renamed paths a test may leave behind. + */ +export async function removeStorageLiveObject( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + remote: string, +): Promise { + const removed = await cli(["storage", "rm", remote, "--yes", ...storageLiveFlags]); + if ( + removed.exitCode !== 0 && + !/not found|does not exist/i.test(`${removed.stdout}\n${removed.stderr}`) + ) { + throw new Error(`storage rm cleanup failed:\n${removed.stdout}\n${removed.stderr}`); + } +} + /** Rethrow a target failure without discarding failures from exact cleanup. */ export function throwWithCleanup(primary: unknown, cleanup: ReadonlyArray): void { if (primary !== undefined) {