diff --git a/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts index 2d8a044702..2fe5a90ae5 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts @@ -5,6 +5,7 @@ import { afterEach, expect, test } from "vitest"; import { describe } from "vitest"; import { makeTempLegacyStackProject, + overrideStackPorts, requireCliSuccess, runSupabase, } from "../../../../../tests/helpers/cli.ts"; @@ -53,6 +54,7 @@ describe("supabase db diff (e2e, pg-delta declarative privileges)", () => { exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, }); requireCliSuccess(init, "init setup"); + await overrideStackPorts(projectDir); // Exclude the heaviest, least relevant services — `db diff` only needs the // local Postgres container reachable, same rationale as stop/status. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts index f39d724f50..947664f7fc 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, expect, test } from "vitest"; import { describe } from "vitest"; import { makeTempLegacyStackProject, + overrideStackPorts, requireCliSuccess, runSupabase, } from "../../../../../../../tests/helpers/cli.ts"; @@ -57,6 +58,7 @@ describe("db schema declarative sync (e2e)", () => { exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, }); requireCliSuccess(init, "init setup"); + await overrideStackPorts(projectDir); const configPath = path.join(projectDir, "supabase", "config.toml"); const config = readFileSync(configPath, "utf8"); diff --git a/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts index 2bc0483e9c..5cf11eee2c 100644 --- a/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts @@ -7,7 +7,11 @@ import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, test } from "vitest"; -import { requireCliSuccess, runSupabase } from "../../../../tests/helpers/cli.ts"; +import { + overrideStackPorts, + requireCliSuccess, + runSupabase, +} from "../../../../tests/helpers/cli.ts"; import { legacySanitizeProjectId, legacyServiceContainerName, @@ -100,6 +104,7 @@ describe("supabase start (e2e)", () => { exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); requireCliSuccess(init, "init setup"); + await overrideStackPorts(projectDir); const start = await runSupabase(startArgs, { entrypoint: "legacy", @@ -229,6 +234,7 @@ describe("supabase start (e2e)", () => { if (address === null || typeof address === "string") { throw new Error("Failed to allocate a proxy port"); } + await overrideStackPorts(projectDir); const excludeArgs = LEGACY_SERVICE_CATALOG.flatMap((entry) => entry.excludeKey === undefined || @@ -288,6 +294,7 @@ describe("supabase start (e2e)", () => { exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); requireCliSuccess(init, "init setup"); + await overrideStackPorts(projectDir); // A `scratch` image whose entrypoint is not an executable binary — the // kernel refuses it with exactly the "exec format error" this diagnoses. diff --git a/apps/cli/src/legacy/commands/status/status.e2e.test.ts b/apps/cli/src/legacy/commands/status/status.e2e.test.ts index 5b4e47413d..a68fc37254 100644 --- a/apps/cli/src/legacy/commands/status/status.e2e.test.ts +++ b/apps/cli/src/legacy/commands/status/status.e2e.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, test } from "vitest"; import { describe } from "vitest"; import { makeTempLegacyStackProject, + overrideStackPorts, requireCliSuccess, runSupabase, } from "../../../../tests/helpers/cli.ts"; @@ -44,6 +45,7 @@ describe("supabase status (e2e)", () => { exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, }); requireCliSuccess(init, "init setup"); + await overrideStackPorts(projectDir); const start = await runSupabase( ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], diff --git a/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts index 6e18d79786..12bcfaa3ae 100644 --- a/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { makeTempLegacyStackProject, + overrideStackPorts, requireCliSuccess, runSupabase, } from "../../../../tests/helpers/cli.ts"; @@ -59,6 +60,7 @@ describe("supabase stop (e2e)", () => { exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, }); requireCliSuccess(init, "init setup"); + await overrideStackPorts(projectDir); // Exclude the heaviest, least relevant services (Next.js Studio build, the // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care @@ -122,6 +124,7 @@ describe("supabase stop (e2e)", () => { exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, }); requireCliSuccess(init, "init setup"); + await overrideStackPorts(projectDir); const start = await runSupabase( ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], diff --git a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts index be5905217b..6a6bb997ec 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -11,6 +11,7 @@ import { import { cleanupRegisteredStackProjects } from "../../../../../tests/helpers/stack-e2e-cleanup.ts"; const FUNCTIONS_DEV_STARTUP_TIMEOUT_MS = 60_000; +const FUNCTIONS_URL_PATTERN = /Functions URL:\s+(https?:\/\/[^\s/]+\/functions\/v1)/; const FUNCTIONS_DEV_STEP_TIMEOUT_MS = 30_000; const FUNCTIONS_DEV_CLEANUP_TIMEOUT_MS = 30_000; const FUNCTIONS_DEV_TEST_TIMEOUT_MS = @@ -93,9 +94,8 @@ describe("supabase functions dev (e2e)", () => { /Edge Functions dev server is running\./, FUNCTIONS_DEV_STARTUP_TIMEOUT_MS, ); - const functionUrlMatch = `${devProc.stdout()}\n${devProc.stderr()}`.match( - /Functions URL:\s+(https?:\/\/[^\s/]+\/functions\/v1)/, - ); + await devProc.waitForOutput(FUNCTIONS_URL_PATTERN, FUNCTIONS_DEV_STARTUP_TIMEOUT_MS); + const functionUrlMatch = devProc.stdout().match(FUNCTIONS_URL_PATTERN); if (functionUrlMatch?.[1] === undefined) { throw new Error( `Functions dev output did not include a URL.\nstdout:\n${devProc.stdout()}\nstderr:\n${devProc.stderr()}`, @@ -104,18 +104,31 @@ describe("supabase functions dev (e2e)", () => { const functionUrl = `${functionUrlMatch[1]}/hello-world`; const functionOffset = devProc.stdout().length; - const functionRestart = devProc.waitForOutput( - FUNCTION_FILES_RESTART_PATTERN, - FUNCTIONS_DEV_STEP_TIMEOUT_MS, - functionOffset, - ); const newResult = await runSupabase(["functions", "new", "hello-world"], { cwd: project.dir, home: home.dir, exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, }); expect(newResult.exitCode).toBe(0); - await functionRestart; + // The dev server prints its startup lines before the file watcher has + // subscribed and exposes no watcher-ready signal, so a write that lands + // in that window is silently lost. Re-touch the created function until + // the dev server reports it, bounded by the step deadline. + const functionRestartDeadline = Date.now() + FUNCTIONS_DEV_STEP_TIMEOUT_MS; + for (;;) { + try { + await devProc.waitForOutput(FUNCTION_FILES_RESTART_PATTERN, 5_000, functionOffset); + break; + } catch (error) { + if ( + Date.now() >= functionRestartDeadline || + (error instanceof Error && error.message.startsWith("Process exited")) + ) { + throw error; + } + await writeFile(functionPath, await readFile(functionPath, "utf8")); + } + } await assertFunctionResponse(functionUrl, {}, (response, body) => { expect(response.status).toBe(401); diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index 51a76fbaaf..27de78f8f3 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -131,6 +131,33 @@ function pickFreePort(): Promise { }); } +/** + * Rewrites every active port assignment in an `init`-generated + * `supabase/config.toml` with a freshly allocated free port, so stacks started + * from default configs cannot collide on host ports with other e2e stacks on + * the same runner. Commented-out port lines are left untouched. + */ +export async function overrideStackPorts(projectDir: string) { + const configPath = path.join(projectDir, "supabase", "config.toml"); + const config = await readFile(configPath, "utf8"); + const assigned = new Set(); + const lines: string[] = []; + for (const line of config.split("\n")) { + const match = /^(\s*(?:port|smtp_port|pop3_port|inspector_port|shadow_port) = )\d+$/.exec(line); + if (match === null) { + lines.push(line); + continue; + } + let port = await pickFreePort(); + while (assigned.has(port)) { + port = await pickFreePort(); + } + assigned.add(port); + lines.push(`${match[1]}${port}`); + } + await writeFile(configPath, lines.join("\n")); +} + async function makeTempProject(prefix = "supabase-project-e2e-") { const projectDir = await mkdtemp(path.join(tmpdir(), prefix));