From b4a3d6f984cce745f002e6f77d5780846dc2e201 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 11 Jun 2026 16:26:57 -0700 Subject: [PATCH 1/2] test/docker: BUN_TEST_SERVICE_ env override for ensure() Lets DB-dependent tests run in environments that have the services reachable but no docker CLI, without touching any test file. ensure(service) now resolves in order: BUN_TEST_SERVICE_ env var -> BUN_DOCKER_COORDINATOR socket (existing) -> docker compose. The env value is host[:PORT] for single-port services or host:INT=EXT,INT=EXT for multi-port (redis_unified, minio); host alone defaults each port to itself. The per-service ports/tls/users that were inlined in ensure()'s switch move to a serviceMeta table so the env path builds an identical ServiceInfo. describeWithContainer no longer bails on !isDockerEnabled() before the service is resolvable; it skips only when no env var, no coordinator and no docker. isDockerEnabled() itself is unchanged. down() becomes a no-op when compose was never started. --- test/docker/index.ts | 179 +++++++++++++++++++++++++------------------ test/harness.ts | 157 +++++++++++++++++++------------------ 2 files changed, 187 insertions(+), 149 deletions(-) diff --git a/test/docker/index.ts b/test/docker/index.ts index 031812c0dee3..26d29bab022d 100644 --- a/test/docker/index.ts +++ b/test/docker/index.ts @@ -31,6 +31,98 @@ export interface ServiceInfo { users?: Record; } +const serviceMeta: Record = { + postgres_plain: { ports: [5432] }, + postgres_tls: { + ports: [5432], + tls: { + cert: join(__dirname, "../js/sql/docker-tls/server.crt"), + key: join(__dirname, "../js/sql/docker-tls/server.key"), + }, + }, + postgres_auth: { + ports: [5432], + users: { + bun_sql_test: "", + bun_sql_test_md5: "bun_sql_test_md5", + bun_sql_test_scram: "bun_sql_test_scram", + }, + }, + mysql_plain: { ports: [3306] }, + mysql_native_password: { ports: [3306] }, + mysql_tls: { + ports: [3306], + tls: { + ca: join(__dirname, "../js/sql/mysql-tls/ssl/ca.pem"), + cert: join(__dirname, "../js/sql/mysql-tls/ssl/server-cert.pem"), + key: join(__dirname, "../js/sql/mysql-tls/ssl/server-key.pem"), + }, + }, + redis_plain: { ports: [6379] }, + redis_unified: { + ports: [6379, 6380], + tls: { + cert: join(__dirname, "../js/valkey/docker-unified/server.crt"), + key: join(__dirname, "../js/valkey/docker-unified/server.key"), + }, + users: { + default: "", + testuser: "test123", + readonly: "readonly", + writeonly: "writeonly", + }, + }, + minio: { ports: [9000, 9001] }, + autobahn: { ports: [9002] }, + squid: { ports: [3128] }, +}; + +function serviceFromEnv(service: ServiceName): ServiceInfo | null { + const raw = process.env["BUN_TEST_SERVICE_" + service]; + if (!raw) return null; + + const meta = serviceMeta[service]; + const ports: Record = {}; + const colon = raw.indexOf(":"); + let host: string; + + if (colon === -1) { + host = raw; + for (const p of meta.ports) ports[p] = p; + } else { + host = raw.slice(0, colon); + if (!host) { + throw new Error(`BUN_TEST_SERVICE_${service}: missing host in "${raw}"`); + } + const spec = raw.slice(colon + 1); + if (spec.includes("=")) { + for (const pair of spec.split(",")) { + const m = /^(\d+)=(\d+)$/.exec(pair); + if (!m) { + throw new Error(`BUN_TEST_SERVICE_${service}: malformed port mapping "${pair}" in "${raw}"`); + } + ports[Number(m[1])] = Number(m[2]); + } + for (const p of meta.ports) if (!(p in ports)) ports[p] = p; + } else { + if (!/^\d+$/.test(spec)) { + throw new Error(`BUN_TEST_SERVICE_${service}: malformed port spec "${spec}" in "${raw}"`); + } + if (meta.ports.length !== 1) { + throw new Error( + `BUN_TEST_SERVICE_${service}: single port in "${raw}" is ambiguous for a ${meta.ports.length}-port service; use cport=hport pairs`, + ); + } + ports[meta.ports[0]] = Number(spec); + } + } + + const info: ServiceInfo = { host, ports }; + if (meta.tls) info.tls = meta.tls; + if (meta.users) info.users = meta.users; + return info; +} + interface DockerComposeOptions { projectName?: string; composeFile?: string; @@ -40,6 +132,7 @@ class DockerComposeHelper { private projectName: string; private composeFile: string; private upPromises: Map> = new Map(); + private composeStarted = false; constructor(options: DockerComposeOptions = {}) { this.projectName = @@ -158,6 +251,8 @@ class DockerComposeHelper { `Failed to start service ${service}: ${stderr}\n` + `--- ps ---\n${ps.stdout}\n--- logs ---\n${logs.stdout}`, ); } + + this.composeStarted = true; } async port(service: ServiceName, targetPort: number): Promise { @@ -251,6 +346,9 @@ class DockerComposeHelper { } async ensure(service: ServiceName): Promise { + const viaEnv = serviceFromEnv(service); + if (viaEnv) return viaEnv; + const viaCoordinator = await this.ensureViaCoordinator(service); if (viaCoordinator !== null) { return viaCoordinator; @@ -270,84 +368,16 @@ class DockerComposeHelper { throw error; } + const meta = serviceMeta[service]; const info: ServiceInfo = { host: this.testHost, ports: {}, }; - - // Get ports based on service type - switch (service) { - case "postgres_plain": - case "postgres_tls": - case "postgres_auth": - info.ports[5432] = await this.port(service, 5432); - - if (service === "postgres_tls") { - info.tls = { - cert: join(__dirname, "../js/sql/docker-tls/server.crt"), - key: join(__dirname, "../js/sql/docker-tls/server.key"), - }; - } - - if (service === "postgres_auth") { - info.users = { - bun_sql_test: "", - bun_sql_test_md5: "bun_sql_test_md5", - bun_sql_test_scram: "bun_sql_test_scram", - }; - } - break; - - case "mysql_plain": - case "mysql_native_password": - case "mysql_tls": - info.ports[3306] = await this.port(service, 3306); - - if (service === "mysql_tls") { - info.tls = { - ca: join(__dirname, "../js/sql/mysql-tls/ssl/ca.pem"), - cert: join(__dirname, "../js/sql/mysql-tls/ssl/server-cert.pem"), - key: join(__dirname, "../js/sql/mysql-tls/ssl/server-key.pem"), - }; - } - break; - - case "redis_plain": - info.ports[6379] = await this.port(service, 6379); - break; - - case "redis_unified": - info.ports[6379] = await this.port(service, 6379); - info.ports[6380] = await this.port(service, 6380); - // For Redis unix socket, we need to use docker volume mapping - // This won't work as expected without additional configuration - // info.socketPath = "/tmp/redis/redis.sock"; - info.tls = { - cert: join(__dirname, "../js/valkey/docker-unified/server.crt"), - key: join(__dirname, "../js/valkey/docker-unified/server.key"), - }; - info.users = { - default: "", - testuser: "test123", - readonly: "readonly", - writeonly: "writeonly", - }; - break; - - case "minio": - info.ports[9000] = await this.port(service, 9000); - info.ports[9001] = await this.port(service, 9001); - break; - - case "autobahn": - info.ports[9002] = await this.port(service, 9002); - // Docker compose --wait should handle readiness - break; - - case "squid": - info.ports[3128] = await this.port(service, 3128); - break; + for (const p of meta.ports) { + info.ports[p] = await this.port(service, p); } + if (meta.tls) info.tls = meta.tls; + if (meta.users) info.users = meta.users; return info; } @@ -429,6 +459,9 @@ class DockerComposeHelper { if (process.env.BUN_KEEP_DOCKER === "1") { return; } + if (!this.composeStarted) { + return; + } const { exitCode } = await this.exec(["down", "-v"]); if (exitCode !== 0) { diff --git a/test/harness.ts b/test/harness.ts index ba13c607199b..70f73b5803d2 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -959,91 +959,96 @@ export async function describeWithContainer( }, fn: (container: { port: number; host: string; ready: Promise }) => void, ) { - // Skip if Docker is not available - if (!isDockerEnabled()) { - describe.todo(label); + // Check if this is one of our docker-compose services + const services: Record = { + "postgres_plain": 5432, + "postgres_tls": 5432, + "postgres_auth": 5432, + "mysql_plain": 3306, + "mysql_native_password": 3306, + "mysql_tls": 3306, + "mysql:8": 3306, // Map mysql:8 to mysql_plain + "mysql:9": 3306, // Map mysql:9 to mysql_native_password + "redis_plain": 6379, + "redis_unified": 6379, + "minio": 9000, + "autobahn": 9002, + }; + + const servicePort = services[image]; + if (!servicePort) { + // No fallback - if the image isn't in docker-compose, it should fail + describe(label, () => { + throw new Error( + `Image "${image}" is not configured in docker-compose.yml. All test containers must use docker-compose.`, + ); + }); return; } - (concurrent && Bun.version !== "1.2.22" ? describe.concurrent : describe)(label, () => { - // Check if this is one of our docker-compose services - const services: Record = { - "postgres_plain": 5432, - "postgres_tls": 5432, - "postgres_auth": 5432, - "mysql_plain": 3306, - "mysql_native_password": 3306, - "mysql_tls": 3306, - "mysql:8": 3306, // Map mysql:8 to mysql_plain - "mysql:9": 3306, // Map mysql:9 to mysql_native_password - "redis_plain": 6379, - "redis_unified": 6379, - "minio": 9000, - "autobahn": 9002, - }; + // Map mysql:8 and mysql:9 based on environment variables + let actualService = image; + if (image === "mysql:8" || image === "mysql:9") { + if (env.MYSQL_ROOT_PASSWORD === "bun") { + actualService = "mysql_native_password"; // Has password "bun" + } else if (env.MYSQL_ALLOW_EMPTY_PASSWORD === "yes") { + actualService = "mysql_plain"; // No password + } else { + actualService = "mysql_plain"; // Default to no password + } + } - const servicePort = services[image]; - if (servicePort) { - // Map mysql:8 and mysql:9 based on environment variables - let actualService = image; - if (image === "mysql:8" || image === "mysql:9") { - if (env.MYSQL_ROOT_PASSWORD === "bun") { - actualService = "mysql_native_password"; // Has password "bun" - } else if (env.MYSQL_ALLOW_EMPTY_PASSWORD === "yes") { - actualService = "mysql_plain"; // No password - } else { - actualService = "mysql_plain"; // Default to no password - } - } + // Skip only when no env override, no coordinator, and docker is unavailable. + // isDockerEnabled() may throw when docker is required but absent, so the + // env-override and coordinator checks must short-circuit before it. + if (!process.env["BUN_TEST_SERVICE_" + actualService] && !process.env.BUN_DOCKER_COORDINATOR && !isDockerEnabled()) { + describe.todo(label); + return; + } - // Create a container descriptor with stable references and a ready promise - let readyResolver: () => void; - let readyRejecter: (error: any) => void; - const readyPromise = new Promise((resolve, reject) => { - readyResolver = resolve; - readyRejecter = reject; - }); + (concurrent && Bun.version !== "1.2.22" ? describe.concurrent : describe)(label, () => { + // Create a container descriptor with stable references and a ready promise + let readyResolver: () => void; + let readyRejecter: (error: any) => void; + const readyPromise = new Promise((resolve, reject) => { + readyResolver = resolve; + readyRejecter = reject; + }); - // Internal state that will be updated when container is ready - let _host = "127.0.0.1"; - let _port = 0; + // Internal state that will be updated when container is ready + let _host = "127.0.0.1"; + let _port = 0; - // Container descriptor with live getters and ready promise - const containerDescriptor = { - get host() { - return _host; - }, - get port() { - return _port; - }, - ready: readyPromise, - }; + // Container descriptor with live getters and ready promise + const containerDescriptor = { + get host() { + return _host; + }, + get port() { + return _port; + }, + ready: readyPromise, + }; - // Kick off `ensure()` at describe-define time so a file with multiple - // describeWithContainer blocks starts all of its containers in parallel. - // up() de-duplicates in-flight calls per service, so two describes for - // the same service share one `compose up`. beforeAll just awaits the - // result so test failures still surface there. - const startPromise = import("./docker/index.ts").then(h => h.ensure(actualService as any)); - // Surface any rejection through `ready`; without a handler the runner - // would see an unhandled rejection before beforeAll re-throws it. - startPromise.catch(readyRejecter!); - - beforeAll(async () => { - const info = await startPromise; - _host = info.host; - _port = info.ports[servicePort]; - console.log(`Container ready via docker-compose: ${image} at ${_host}:${_port}`); - readyResolver!(); - }); + // Kick off `ensure()` at describe-define time so a file with multiple + // describeWithContainer blocks starts all of its containers in parallel. + // up() de-duplicates in-flight calls per service, so two describes for + // the same service share one `compose up`. beforeAll just awaits the + // result so test failures still surface there. + const startPromise = import("./docker/index.ts").then(h => h.ensure(actualService as any)); + // Surface any rejection through `ready`; without a handler the runner + // would see an unhandled rejection before beforeAll re-throws it. + startPromise.catch(readyRejecter!); + + beforeAll(async () => { + const info = await startPromise; + _host = info.host; + _port = info.ports[servicePort]; + console.log(`Container ready via docker-compose: ${image} at ${_host}:${_port}`); + readyResolver!(); + }); - fn(containerDescriptor); - return; - } - // No fallback - if the image isn't in docker-compose, it should fail - throw new Error( - `Image "${image}" is not configured in docker-compose.yml. All test containers must use docker-compose.`, - ); + fn(containerDescriptor); }); } From 00a63f5726c4fa3987501197a5501d6e605b7f17 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 11 Jun 2026 16:35:50 -0700 Subject: [PATCH 2/2] harness: drop the Bun 1.2.22 describe.concurrent guard --- test/harness.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/harness.ts b/test/harness.ts index 70f73b5803d2..1790a9e78ba8 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1006,7 +1006,7 @@ export async function describeWithContainer( return; } - (concurrent && Bun.version !== "1.2.22" ? describe.concurrent : describe)(label, () => { + (concurrent ? describe.concurrent : describe)(label, () => { // Create a container descriptor with stable references and a ready promise let readyResolver: () => void; let readyRejecter: (error: any) => void;