From 735ea29dcc00933d15d81839098c5813b0d71523 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 12:33:51 +0200 Subject: [PATCH 1/4] test(stack): qualify complete slim Docker graph --- .../tests/createStack-docker.e2e.test.ts | 568 ++++++++++++------ 1 file changed, 398 insertions(+), 170 deletions(-) diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index f2882266a0..87dbd2bc47 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -1,20 +1,75 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; import { createStack, type StackHandle } from "../src/node.ts"; import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; -import { DEFAULT_VERSIONS } from "../src/versions.ts"; +import { SERVICE_NAMES, type ServiceName } from "../src/versions.ts"; import { setupTestTable } from "./helpers/e2e.ts"; -const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 180_000; -const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 90_000; +const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 240_000; +const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 180_000; const STACK_DOCKER_E2E_SETUP_TIMEOUT_MS = dependencyTimeoutSecondsForServices(["postgres"]) * 1000 + STACK_DOCKER_E2E_SETUP_OVERHEAD_MS; -const ANALYTICS_COLD_START_TEST_TIMEOUT_MS = activationTimeoutSecondsForService("analytics") * 1000; + +const EXPECTED_SLIM_DOCKER_IMAGES: Readonly> = { + postgres: "ghcr.io/supabase/cli/postgres:17.6.1.165", + postgrest: "ghcr.io/supabase/cli/postgrest:v16.2", + auth: "ghcr.io/supabase/cli/auth:v2.196.0", + "edge-runtime": "ghcr.io/supabase/cli/edge-runtime:v1.74.3", + realtime: "ghcr.io/supabase/cli/realtime:v2.129.9", + storage: "ghcr.io/supabase/cli/storage:v1.71.0", + imgproxy: "ghcr.io/supabase/cli/imgproxy:v3.8.0", + mailpit: "ghcr.io/supabase/cli/mailpit:v1.30.2", + pgmeta: "ghcr.io/supabase/cli/pgmeta:v0.98.0", + studio: "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", + analytics: "ghcr.io/supabase/cli/analytics:v1.50.6", + vector: "ghcr.io/supabase/vector:0.53.0-alpine", + pooler: "ghcr.io/supabase/supavisor:2.9.7", +}; + +const EAGER_SERVICES: ReadonlyArray = [ + "postgres", + "realtime", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", +]; + +const LAZY_SERVICES: ReadonlyArray = [ + "postgrest", + "auth", + "edge-runtime", + "storage", + "imgproxy", +]; + +const ownedDockerContainerName = (service: ServiceName, identity: string): string => + `supabase-${service}-${identity}`; + +const ownedDockerContainers = ( + identity: string, +): ReadonlyArray<{ readonly name: string; readonly image: string }> => + SERVICE_NAMES.flatMap((service) => { + const name = ownedDockerContainerName(service, identity); + const output = execFileSync( + "docker", + ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}\t{{.Image}}"], + { stdio: ["ignore", "pipe", "pipe"] }, + ) + .toString() + .trim(); + if (output === "") return []; + return output.split(/\r?\n/).map((line) => { + const [containerName, image] = line.split("\t"); + return { name: containerName ?? name, image: image ?? "" }; + }); + }); function hasDockerDaemon(): boolean { try { @@ -39,8 +94,34 @@ dockerDescribe("createStack e2e (docker mode)", () => { stack = await createStack({ mode: "docker", jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + servicePolicies: { + postgres: "eager", + postgrest: "lazy", + auth: "lazy", + "edge-runtime": "lazy", + realtime: "eager", + storage: "lazy", + imgproxy: "lazy", + mailpit: "eager", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + pooler: "eager", + }, postgres: { dataDir }, + postgrest: {}, + auth: {}, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, analytics: {}, + vector: {}, + pooler: {}, }); try { @@ -68,11 +149,11 @@ dockerDescribe("createStack e2e (docker mode)", () => { afterAll(async () => { await stack?.dispose(); - // Verify all Docker containers are cleaned up after dispose - const remaining = execSync(`docker ps -q --filter name=supabase-.*-${apiPort}`) - .toString() - .trim(); - expect(remaining).toBe(""); + // Verify all exact owned Docker containers are cleaned up, including stopped containers. + if (apiPort !== undefined) { + const remaining = ownedDockerContainers(apiPort).map((container) => container.name); + expect(remaining).toEqual([]); + } try { rmSync(dataDir, { recursive: true, force: true }); @@ -80,175 +161,322 @@ dockerDescribe("createStack e2e (docker mode)", () => { }, 30_000); test( - "runs the core services in Docker containers and serves health endpoints", + "qualifies the complete slim Docker graph through one public user journey", { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, async () => { - await Promise.all([stack.startService("postgrest"), stack.startService("auth")]); - - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - expect(runningImages).toContain( - `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, - ); - expect(runningImages).toContain(`ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`); - expect(runningImages).toContain(`ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`); - - const [proxyRes, authRes] = await Promise.all([ - fetch(`${stack.url}/health`), - fetch(`${stack.url}/auth/v1/health`), - ]); - expect(proxyRes.status).toBe(200); - expect(await proxyRes.text()).toBe("OK"); - expect(authRes.status).toBe(200); - expect(await authRes.json()).toEqual( - expect.objectContaining({ description: expect.any(String) }), - ); - }, - ); - - test( - "runs the edge runtime in Docker and serves the functions placeholder through the local gateway", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const functionsRes = await fetch(`${stack.url}/functions/v1/test`); - await stack.serviceReady("edge-runtime"); - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - const states = await stack.getStatus(); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, - ); - expect(states).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "edge-runtime", status: "Healthy" }), - ]), - ); - expect(functionsRes.status).toBe(501); - expect(await functionsRes.json()).toEqual({ - code: "FUNCTIONS_NOT_CONFIGURED", - message: "Edge Functions are not configured for this local stack yet.", - }); - }, - ); - - test( - "cold-starts analytics through lazy service activation", - { timeout: ANALYTICS_COLD_START_TEST_TIMEOUT_MS }, - async () => { - expect(await stack.getServiceStatus("analytics")).toEqual( - expect.objectContaining({ status: "Dormant" }), - ); - - await stack.startService("analytics"); - - const [runningImages, states] = await Promise.all([ - Promise.resolve(execSync("docker ps --format '{{.Image}}'").toString()), - stack.getStatus(), - ]); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/analytics:${DEFAULT_VERSIONS.analytics}`, - ); - expect(states).toEqual( - expect.arrayContaining([expect.objectContaining({ name: "analytics", status: "Healthy" })]), - ); - }, - ); + try { + const initialStates = await stack.getStatus(); + expect(initialStates.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); - test( - "supports the docker auth signup and session golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = "test-password-123"; - - const signUp = await supabase.auth.signUp({ - email: testEmail, - password: testPassword, - }); - expect(signUp.error).toBeNull(); - expect(signUp.data.user?.email).toBe(testEmail); - expect(signUp.data.session).toBeDefined(); - - const signIn = await supabase.auth.signInWithPassword({ - email: testEmail, - password: testPassword, - }); - expect(signIn.error).toBeNull(); - expect(signIn.data.user?.email).toBe(testEmail); - expect(signIn.data.session?.access_token).toBeTruthy(); - - const currentUser = await supabase.auth.getUser(); - expect(currentUser.error).toBeNull(); - expect(currentUser.data.user?.email).toBe(testEmail); - }, - ); + const [storageRes, pgmetaRes, analyticsRes] = await Promise.all([ + fetch(`${stack.url}/storage/v1/status`), + fetch(`${stack.url}/pg/health`), + fetch(`${stack.url}/analytics/v1/health`), + ]); + expect(storageRes.status, "storage status").toBe(200); + expect(pgmetaRes.status, "pgmeta status").toBe(200); + expect(analyticsRes.status, "analytics status").toBe(200); + + const functionsRes = await fetch(`${stack.url}/functions/v1/test`); + expect(functionsRes.status).toBe(501); + expect(await functionsRes.json()).toEqual({ + code: "FUNCTIONS_NOT_CONFIGURED", + message: "Edge Functions are not configured for this local stack yet.", + }); - test( - "supports a full docker PostgREST CRUD golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const seeded = await supabase.from("todos").select("*").order("id"); - expect(seeded.error).toBeNull(); - expect(seeded.data).toHaveLength(2); - - const inserted = await supabase - .from("todos") - .insert({ title: "E2E test todo" }) - .select() - .single(); - expect(inserted.error).toBeNull(); - expect(inserted.data?.title).toBe("E2E test todo"); - - const updated = await supabase - .from("todos") - .update({ completed: true }) - .eq("title", "E2E test todo") - .select() - .single(); - expect(updated.error).toBeNull(); - expect(updated.data?.completed).toBe(true); - - const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); - expect(deleted.error).toBeNull(); - - const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); - expect(remaining.data).toHaveLength(0); - }, - ); + const testEmail = `test-${Date.now()}@example.com`; + const testPassword = "test-password-123"; + const signUp = await supabase.auth.signUp({ + email: testEmail, + password: testPassword, + }); + expect(signUp.error).toBeNull(); + expect(signUp.data.user?.email).toBe(testEmail); + expect(signUp.data.session).toBeDefined(); - test( - "restarts the Studio graph with its Pgmeta dependency", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const graphDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-graph-")); - let graphStack: StackHandle | undefined; - try { - graphStack = await createStack({ - mode: "docker", - postgres: { dataDir: graphDataDir }, - pgmeta: {}, - studio: {}, + const signIn = await supabase.auth.signInWithPassword({ + email: testEmail, + password: testPassword, }); - await graphStack.start(); - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), + expect(signIn.error).toBeNull(); + expect(signIn.data.user?.email).toBe(testEmail); + expect(signIn.data.session?.access_token).toBeTruthy(); + + const currentUser = await supabase.auth.getUser(); + expect(currentUser.error).toBeNull(); + expect(currentUser.data.user?.email).toBe(testEmail); + + const seeded = await supabase.from("todos").select("*").order("id"); + expect(seeded.error).toBeNull(); + expect(seeded.data).toHaveLength(2); + + const todoTitle = `E2E test todo ${Date.now()}`; + const inserted = await supabase + .from("todos") + .insert({ title: todoTitle }) + .select() + .single(); + expect(inserted.error).toBeNull(); + expect(inserted.data?.title).toBe(todoTitle); + + const updated = await supabase + .from("todos") + .update({ completed: true }) + .eq("title", todoTitle) + .select() + .single(); + expect(updated.error).toBeNull(); + expect(updated.data?.completed).toBe(true); + + const healthyStates = await stack.getStatus(); + expect(healthyStates).toHaveLength(SERVICE_NAMES.length); + expect(healthyStates.every((state) => state.status === "Healthy")).toBe(true); + + const ownedContainers = ownedDockerContainers(apiPort); + expect(ownedContainers.map((container) => container.name).toSorted()).toEqual( + SERVICE_NAMES.map((service) => ownedDockerContainerName(service, apiPort)).toSorted(), ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), + for (const service of SERVICE_NAMES) { + const containerName = ownedDockerContainerName(service, apiPort); + const container = ownedContainers.find((candidate) => candidate.name === containerName); + expect(container?.image, `${service} image`).toBe(EXPECTED_SLIM_DOCKER_IMAGES[service]); + } + + const primaryOwnedNamesBeforeSibling = ownedContainers.map((container) => container.name); + const primaryDbPort = new URL(stack.dbUrl).port; + const primaryIsolationTitle = `isolation-primary-${crypto.randomUUID()}`; + const siblingIsolationTitle = `isolation-sibling-${crypto.randomUUID()}`; + const primaryIsolationInsert = await supabase + .from("todos") + .insert({ title: primaryIsolationTitle }) + .select() + .single(); + expect(primaryIsolationInsert.error).toBeNull(); + expect(primaryIsolationInsert.data?.title).toBe(primaryIsolationTitle); + + const siblingDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-sibling-")); + const siblingStartedServices: ReadonlyArray = [...EAGER_SERVICES, "postgrest"]; + let sibling: StackHandle | undefined; + let siblingApiPort: string | undefined; + let siblingJourneyError: unknown; + let siblingDisposeError: unknown; + let siblingContainerCleanupError: unknown; + let siblingDataDirCleanupError: unknown; + try { + sibling = await createStack({ + mode: "docker", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + servicePolicies: { + postgres: "eager", + postgrest: "lazy", + auth: "lazy", + "edge-runtime": "lazy", + realtime: "eager", + storage: "lazy", + imgproxy: "lazy", + mailpit: "eager", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + pooler: "eager", + }, + postgres: { dataDir: siblingDataDir }, + postgrest: {}, + auth: {}, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: {}, + vector: {}, + pooler: {}, + }); + const siblingIdentity = new URL(sibling.url).port; + siblingApiPort = siblingIdentity; + await sibling.start(); + + const siblingDbPort = new URL(sibling.dbUrl).port; + expect(siblingIdentity).not.toBe(apiPort); + expect(siblingDbPort).not.toBe(primaryDbPort); + await setupTestTable(parseInt(siblingDbPort)); + const siblingSupabase = createClient(sibling.url, sibling.publishableKey); + await sibling.startService("postgrest"); + + const [primaryBeforeDispose, siblingStates] = await Promise.all([ + stack.getStatus(), + sibling.getStatus(), + ]); + expect(primaryBeforeDispose.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); + expect(primaryBeforeDispose.every((state) => state.status === "Healthy")).toBe(true); + expect(primaryBeforeDispose.map(({ name, status }) => ({ name, status }))).toEqual( + healthyStates.map(({ name, status }) => ({ name, status })), + ); + expect(siblingStates.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); + expect(siblingStates).toEqual( + expect.arrayContaining( + siblingStartedServices.map((name) => + expect.objectContaining({ name, status: "Healthy" }), + ), + ), + ); + expect(siblingStates).toEqual( + expect.arrayContaining( + LAZY_SERVICES.filter((name) => name !== "postgrest").map((name) => + expect.objectContaining({ name, status: "Dormant" }), + ), + ), + ); + + const siblingIsolationInsert = await siblingSupabase + .from("todos") + .insert({ title: siblingIsolationTitle }) + .select() + .single(); + expect(siblingIsolationInsert.error).toBeNull(); + expect(siblingIsolationInsert.data?.title).toBe(siblingIsolationTitle); + + const [siblingCannotReadPrimary, primaryCannotReadSibling] = await Promise.all([ + siblingSupabase.from("todos").select("title").eq("title", primaryIsolationTitle), + supabase.from("todos").select("title").eq("title", siblingIsolationTitle), + ]); + expect(siblingCannotReadPrimary.error).toBeNull(); + expect(siblingCannotReadPrimary.data).toEqual([]); + expect(primaryCannotReadSibling.error).toBeNull(); + expect(primaryCannotReadSibling.data).toEqual([]); + + const siblingOwnedBeforeDispose = ownedDockerContainers(siblingIdentity); + expect(siblingOwnedBeforeDispose.map((container) => container.name).toSorted()).toEqual( + siblingStartedServices + .map((service) => ownedDockerContainerName(service, siblingIdentity)) + .toSorted(), + ); + } catch (error) { + siblingJourneyError = error; + let siblingStates: ReadonlyArray = []; + let siblingLogs: ReadonlyArray = []; + if (sibling !== undefined) { + const activeSibling = sibling; + [siblingStates, siblingLogs] = await Promise.all([ + activeSibling.getStatus().catch(() => []), + Promise.all( + SERVICE_NAMES.map((service) => + activeSibling.logHistory(service, 10).catch(() => []), + ), + ), + ]); + } + siblingJourneyError = new Error( + `Sibling Docker isolation journey failed: ${String(error)}\nstatus=${JSON.stringify(siblingStates)}\nlogs=${JSON.stringify(siblingLogs)}`, + ); + } finally { + try { + if (sibling !== undefined) { + await sibling.dispose(); + } + } catch (error) { + siblingDisposeError = error; + } + + try { + if (siblingApiPort !== undefined) { + const remaining = ownedDockerContainers(siblingApiPort).map( + (container) => container.name, + ); + expect(remaining).toEqual([]); + } + } catch (error) { + siblingContainerCleanupError = error; + } + + try { + rmSync(siblingDataDir, { recursive: true, force: true }); + } catch (error) { + siblingDataDirCleanupError = error; + } + } + + if (siblingJourneyError !== undefined) { + throw siblingJourneyError; + } + if (siblingDisposeError !== undefined) { + throw siblingDisposeError; + } + if (siblingContainerCleanupError !== undefined) { + throw siblingContainerCleanupError; + } + if (siblingDataDirCleanupError !== undefined) { + throw siblingDataDirCleanupError; + } + + expect( + ownedDockerContainers(apiPort) + .map((container) => container.name) + .toSorted(), + ).toEqual(primaryOwnedNamesBeforeSibling.toSorted()); + const primaryAfterSiblingDispose = await supabase + .from("todos") + .select("title") + .eq("title", primaryIsolationTitle) + .single(); + expect(primaryAfterSiblingDispose.error).toBeNull(); + expect(primaryAfterSiblingDispose.data?.title).toBe(primaryIsolationTitle); + const deletedIsolation = await supabase + .from("todos") + .delete() + .eq("title", primaryIsolationTitle); + expect(deletedIsolation.error).toBeNull(); + + const beforeRestart = await stack.getStatus(); + await stack.stop(); + await stack.start(); + const afterRestart = await stack.getStatus(); + expect(afterRestart.map((state) => state.name).toSorted()).toEqual( + beforeRestart.map((state) => state.name).toSorted(), ); - - await graphStack.stop(); - await graphStack.start(); - - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), + expect(afterRestart).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "pgmeta", status: "Healthy" }), + expect.objectContaining({ name: "studio", status: "Healthy" }), + ]), + ); + expect(afterRestart).toEqual( + expect.arrayContaining( + EAGER_SERVICES.map((name) => expect.objectContaining({ name, status: "Healthy" })), + ), + ); + expect(afterRestart).toEqual( + expect.arrayContaining( + LAZY_SERVICES.map((name) => expect.objectContaining({ name, status: "Stopped" })), + ), ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), + await stack.startService("postgrest"); + + const persisted = await supabase.from("todos").select("*").eq("title", todoTitle).single(); + expect(persisted.error).toBeNull(); + expect(persisted.data?.completed).toBe(true); + + const deleted = await supabase.from("todos").delete().eq("title", todoTitle); + expect(deleted.error).toBeNull(); + } catch (error) { + const [states, logs] = await Promise.all([ + stack.getStatus().catch(() => []), + Promise.all( + SERVICE_NAMES.map((service) => stack.logHistory(service, 10).catch(() => [])), + ), + ]); + throw new Error( + `Complete Docker graph journey failed: ${String(error)}\nstatus=${JSON.stringify(states)}\nlogs=${JSON.stringify(logs)}`, ); - } finally { - await graphStack?.dispose(); - rmSync(graphDataDir, { recursive: true, force: true }); } }, ); From 4683e0d172da08e123489360b3e2e9b5d78c7d18 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 12:46:42 +0200 Subject: [PATCH 2/4] test(stack): satisfy Effect lint in Docker journey --- packages/stack/tests/createStack-docker.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index 865284f7e1..b22d6598f3 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -248,8 +248,8 @@ dockerDescribe("createStack e2e (docker mode)", () => { const primaryOwnedNamesBeforeSibling = ownedContainers.map((container) => container.name); const primaryDbPort = new URL(stack.dbUrl).port; - const primaryIsolationTitle = `isolation-primary-${crypto.randomUUID()}`; - const siblingIsolationTitle = `isolation-sibling-${crypto.randomUUID()}`; + const primaryIsolationTitle = "isolation-primary"; + const siblingIsolationTitle = "isolation-sibling"; const primaryIsolationInsert = await supabase .from("todos") .insert({ title: primaryIsolationTitle }) From 6e332dd54f2ec309d6e2982acedc363301421466 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 13:01:12 +0200 Subject: [PATCH 3/4] test(stack): budget complete Docker journey --- packages/stack/tests/createStack-docker.e2e.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index b22d6598f3..ad7a26b7c9 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -11,10 +11,12 @@ import { dependencyTimeoutSecondsForServices } from "../src/services/health-budg import { SERVICE_NAMES, type ServiceName } from "../src/versions.ts"; import { setupTestTable } from "./helpers/e2e.ts"; -const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 240_000; const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 180_000; -const STACK_DOCKER_E2E_SETUP_TIMEOUT_MS = +const STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS = dependencyTimeoutSecondsForServices(["postgres"]) * 1000 + STACK_DOCKER_E2E_SETUP_OVERHEAD_MS; +const STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS = 240_000; +const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = + 2 * STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS + STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS; const EXPECTED_SLIM_DOCKER_IMAGES: Readonly> = { postgres: "ghcr.io/supabase/cli/postgres:17.6.1.165", @@ -146,7 +148,7 @@ dockerDescribe("createStack e2e (docker mode)", () => { apiPort = new URL(stack.url).port; supabase = createClient(stack.url, stack.publishableKey); - }, STACK_DOCKER_E2E_SETUP_TIMEOUT_MS); + }, STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS); afterAll(async () => { await stack?.dispose(); From 61098438f065bb97b67fd48e8507d0ed3b415c72 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 10:32:18 +0200 Subject: [PATCH 4/4] test(stack): harden Docker journey teardown --- .../tests/createStack-docker.e2e.test.ts | 122 +++++++++++------- 1 file changed, 73 insertions(+), 49 deletions(-) diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index ad7a26b7c9..bb6b191463 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -6,9 +6,15 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { dockerContainerName } from "../src/StackIdentity.ts"; import { createStack, type StackHandle } from "../src/node.ts"; import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; -import { SERVICE_NAMES, type ServiceName } from "../src/versions.ts"; +import { + DEFAULT_VERSIONS, + SERVICE_NAMES, + dockerImageForService, + type ServiceName, +} from "../src/versions.ts"; import { setupTestTable } from "./helpers/e2e.ts"; const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 180_000; @@ -17,22 +23,11 @@ const STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS = const STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS = 240_000; const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 2 * STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS + STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS; - -const EXPECTED_SLIM_DOCKER_IMAGES: Readonly> = { - postgres: "ghcr.io/supabase/cli/postgres:17.6.1.165", - postgrest: "ghcr.io/supabase/cli/postgrest:v16.2", - auth: "ghcr.io/supabase/cli/auth:v2.196.0", - "edge-runtime": "ghcr.io/supabase/cli/edge-runtime:v1.74.3", - realtime: "ghcr.io/supabase/cli/realtime:v2.129.9", - storage: "ghcr.io/supabase/cli/storage:v1.71.0", - imgproxy: "ghcr.io/supabase/cli/imgproxy:v3.8.0", - mailpit: "ghcr.io/supabase/cli/mailpit:v1.30.2", - pgmeta: "ghcr.io/supabase/cli/pgmeta:v0.98.0", - studio: "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", - analytics: "ghcr.io/supabase/cli/analytics:v1.50.6", - vector: "ghcr.io/supabase/vector:0.53.0-alpine", - pooler: "ghcr.io/supabase/supavisor:2.9.7", -}; +const STACK_DOCKER_E2E_AFTER_ALL_SHUTDOWN_TIMEOUT_MS = 60_000; +const STACK_DOCKER_E2E_AFTER_ALL_INSPECTION_OVERHEAD_MS = 30_000; +const STACK_DOCKER_E2E_AFTER_ALL_TIMEOUT_MS = + STACK_DOCKER_E2E_AFTER_ALL_SHUTDOWN_TIMEOUT_MS + + STACK_DOCKER_E2E_AFTER_ALL_INSPECTION_OVERHEAD_MS; const EAGER_SERVICES: ReadonlyArray = [ "postgres", @@ -53,14 +48,11 @@ const LAZY_SERVICES: ReadonlyArray = [ "imgproxy", ]; -const ownedDockerContainerName = (service: ServiceName, identity: string): string => - `supabase-${service}-${identity}`; - const ownedDockerContainers = ( identity: string, ): ReadonlyArray<{ readonly name: string; readonly image: string }> => SERVICE_NAMES.flatMap((service) => { - const name = ownedDockerContainerName(service, identity); + const name = dockerContainerName(service, identity); const output = execFileSync( "docker", ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}\t{{.Image}}"], @@ -75,6 +67,13 @@ const ownedDockerContainers = ( }); }); +const forceRemoveOwnedDockerContainers = (containerNames: ReadonlyArray): void => { + if (containerNames.length === 0) return; + try { + execFileSync("docker", ["rm", "-f", ...containerNames], { stdio: "ignore" }); + } catch {} +}; + function hasDockerDaemon(): boolean { try { execSync("docker info", { stdio: "ignore" }); @@ -151,18 +150,42 @@ dockerDescribe("createStack e2e (docker mode)", () => { }, STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS); afterAll(async () => { - await stack?.dispose(); + const teardownFailures: unknown[] = []; - // Verify all exact owned Docker containers are cleaned up, including stopped containers. - if (apiPort !== undefined) { - const remaining = ownedDockerContainers(apiPort).map((container) => container.name); - expect(remaining).toEqual([]); + try { + await stack?.dispose(); + } catch (error) { + teardownFailures.push(error); + } + + try { + // Verify all exact owned Docker containers are cleaned up, including stopped containers. + if (apiPort !== undefined) { + const remaining = ownedDockerContainers(apiPort).map((container) => container.name); + if (remaining.length > 0) { + forceRemoveOwnedDockerContainers(remaining); + teardownFailures.push( + new Error(`Owned Docker containers remained after dispose: ${remaining.join(", ")}`), + ); + } + } + } catch (error) { + teardownFailures.push(error); } try { rmSync(dataDir, { recursive: true, force: true }); - } catch {} - }, 30_000); + } catch (error) { + teardownFailures.push(error); + } + + if (teardownFailures.length === 1) { + throw teardownFailures[0]; + } + if (teardownFailures.length > 1) { + throw new AggregateError(teardownFailures, "Docker e2e teardown failed"); + } + }, STACK_DOCKER_E2E_AFTER_ALL_TIMEOUT_MS); test( "qualifies the complete slim Docker graph through one public user journey", @@ -240,12 +263,14 @@ dockerDescribe("createStack e2e (docker mode)", () => { const ownedContainers = ownedDockerContainers(apiPort); expect(ownedContainers.map((container) => container.name).toSorted()).toEqual( - SERVICE_NAMES.map((service) => ownedDockerContainerName(service, apiPort)).toSorted(), + SERVICE_NAMES.map((service) => dockerContainerName(service, apiPort)).toSorted(), ); for (const service of SERVICE_NAMES) { - const containerName = ownedDockerContainerName(service, apiPort); + const containerName = dockerContainerName(service, apiPort); const container = ownedContainers.find((candidate) => candidate.name === containerName); - expect(container?.image, `${service} image`).toBe(EXPECTED_SLIM_DOCKER_IMAGES[service]); + expect(container?.image, `${service} image`).toBe( + dockerImageForService(service, DEFAULT_VERSIONS[service]), + ); } const primaryOwnedNamesBeforeSibling = ownedContainers.map((container) => container.name); @@ -361,7 +386,7 @@ dockerDescribe("createStack e2e (docker mode)", () => { const siblingOwnedBeforeDispose = ownedDockerContainers(siblingIdentity); expect(siblingOwnedBeforeDispose.map((container) => container.name).toSorted()).toEqual( siblingStartedServices - .map((service) => ownedDockerContainerName(service, siblingIdentity)) + .map((service) => dockerContainerName(service, siblingIdentity)) .toSorted(), ); } catch (error) { @@ -396,7 +421,12 @@ dockerDescribe("createStack e2e (docker mode)", () => { const remaining = ownedDockerContainers(siblingApiPort).map( (container) => container.name, ); - expect(remaining).toEqual([]); + if (remaining.length > 0) { + forceRemoveOwnedDockerContainers(remaining); + siblingContainerCleanupError = new Error( + `Sibling owned Docker containers remained after dispose: ${remaining.join(", ")}`, + ); + } } } catch (error) { siblingContainerCleanupError = error; @@ -409,17 +439,17 @@ dockerDescribe("createStack e2e (docker mode)", () => { } } - if (siblingJourneyError !== undefined) { - throw siblingJourneyError; - } - if (siblingDisposeError !== undefined) { - throw siblingDisposeError; + const siblingFailures = [ + siblingJourneyError, + siblingDisposeError, + siblingContainerCleanupError, + siblingDataDirCleanupError, + ].filter((failure) => failure !== undefined); + if (siblingFailures.length === 1) { + throw siblingFailures[0]; } - if (siblingContainerCleanupError !== undefined) { - throw siblingContainerCleanupError; - } - if (siblingDataDirCleanupError !== undefined) { - throw siblingDataDirCleanupError; + if (siblingFailures.length > 1) { + throw new AggregateError(siblingFailures, "Sibling Docker isolation cleanup failed"); } expect( @@ -447,12 +477,6 @@ dockerDescribe("createStack e2e (docker mode)", () => { expect(afterRestart.map((state) => state.name).toSorted()).toEqual( beforeRestart.map((state) => state.name).toSorted(), ); - expect(afterRestart).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "pgmeta", status: "Healthy" }), - expect.objectContaining({ name: "studio", status: "Healthy" }), - ]), - ); expect(afterRestart).toEqual( expect.arrayContaining( EAGER_SERVICES.map((name) => expect.objectContaining({ name, status: "Healthy" })),