diff --git a/README.md b/README.md index 692b59b..6244dc3 100644 --- a/README.md +++ b/README.md @@ -962,11 +962,14 @@ bun-mariadb/ bun run test ``` -Unit tests need no database. The MariaDB integration tests run only when `BUN_MARIADB_TEST_DB` is set (plus the usual `BUN_MARIADB_SERVER_*` variables). Point it at a throwaway database: the tests create and drop tables. +Starts a throwaway MariaDB container (Docker required, image `mariadb:11.8` by default), runs every test file against it, then removes the container. Extra arguments are passed to `bun test`, e.g. `bun run test tests/db.test.ts`. -```bash -BUN_MARIADB_TEST_DB=bun_mariadb_test bun run test -``` +| Variable | Description | +| ------------------------ | --------------------------------------------------------------------------- | +| `BUN_MARIADB_TEST_IMAGE` | Docker image to use (MariaDB 11.7+ for `VECTOR`) | +| `BUN_MARIADB_TEST_DB` | Skip Docker and use this existing database (with `BUN_MARIADB_SERVER_*`). The tests create and drop tables, so use a throwaway database. | + +`bun run test:unit` (or plain `bun test`) runs without a database; the integration tests are skipped. --- diff --git a/package.json b/package.json index 6334eae..d012c07 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dev": "tsc --watch", "compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json && chmod +x dist/commands/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "bun test tests", + "test": "bun run scripts/test.ts", + "test:unit": "bun test", "prepublishOnly": "bun run compile" }, "devDependencies": { diff --git a/scripts/test.ts b/scripts/test.ts new file mode 100644 index 0000000..3e361dd --- /dev/null +++ b/scripts/test.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env bun +/** + * Run the full test suite against a throwaway MariaDB Docker container. + * + * bun run test # start container, run all tests, remove container + * bun run test tests/db # extra args are passed to `bun test` + * + * Env: + * BUN_MARIADB_TEST_IMAGE Docker image (default: mariadb:11.8, needs 11.7+ for VECTOR) + * BUN_MARIADB_TEST_DB If already set, skip Docker and use the configured + * BUN_MARIADB_SERVER_* connection instead + */ +import { SQL } from "bun"; + +const IMAGE = process.env.BUN_MARIADB_TEST_IMAGE || "mariadb:11.8"; +const DB_NAME = "bun_mariadb_test"; +const PASSWORD = "bun_mariadb_test_pw"; +const CONTAINER = `bun-mariadb-test-${process.pid}`; +const READY_TIMEOUT_MS = 120_000; + +const testArgs = process.argv.slice(2); + +async function run(cmd: string[], opts: { quiet?: boolean } = {}) { + const proc = Bun.spawn(cmd, { + stdout: opts.quiet ? "pipe" : "inherit", + stderr: opts.quiet ? "pipe" : "inherit", + }); + const stdout = opts.quiet ? await new Response(proc.stdout).text() : ""; + const exitCode = await proc.exited; + return { exitCode, stdout: stdout.trim() }; +} + +async function runTests(env: Record): Promise { + const proc = Bun.spawn(["bun", "test", ...testArgs], { + stdout: "inherit", + stderr: "inherit", + env: { ...process.env, ...env }, + }); + return proc.exited; +} + +// Already pointed at a database: just run the tests +if (process.env.BUN_MARIADB_TEST_DB) { + process.exit(await runTests({})); +} + +if ((await run(["docker", "info"], { quiet: true })).exitCode !== 0) { + console.error( + "Docker is not available. Start Docker, or set BUN_MARIADB_TEST_DB and BUN_MARIADB_SERVER_* to use an existing database.", + ); + process.exit(1); +} + +let removed = false; +async function removeContainer() { + if (removed) return; + removed = true; + await run(["docker", "rm", "-f", CONTAINER], { quiet: true }); +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, async () => { + await removeContainer(); + process.exit(130); + }); +} + +let exitCode = 1; + +try { + console.log(`Starting ${IMAGE} (${CONTAINER}) ...`); + + const started = await run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + CONTAINER, + "-e", + `MARIADB_ROOT_PASSWORD=${PASSWORD}`, + "-e", + `MARIADB_DATABASE=${DB_NAME}`, + // Random free host port, bound to localhost only + "-p", + "127.0.0.1::3306", + IMAGE, + ], + { quiet: true }, + ); + + if (started.exitCode !== 0) { + throw new Error(`docker run failed (exit ${started.exitCode})`); + } + + const portOut = await run(["docker", "port", CONTAINER, "3306/tcp"], { + quiet: true, + }); + const port = portOut.stdout.split("\n")[0]?.split(":").pop(); + + if (!port) { + throw new Error(`Could not read the container's mapped port`); + } + + /** + * Wait for a TCP connection. The image's init phase runs a temporary + * server with networking disabled, so a successful TCP query means the + * real server is up. + */ + const deadline = Date.now() + READY_TIMEOUT_MS; + let ready = false; + + while (Date.now() < deadline) { + const client = new SQL({ + adapter: "mariadb", + hostname: "127.0.0.1", + port: Number(port), + username: "root", + password: PASSWORD, + database: DB_NAME, + connectionTimeout: 2, + }); + + try { + await client.unsafe("SELECT 1"); + ready = true; + } catch { + await Bun.sleep(1000); + } finally { + await client.close().catch(() => {}); + } + + if (ready) break; + } + + if (!ready) { + throw new Error(`MariaDB did not become ready within ${READY_TIMEOUT_MS / 1000}s`); + } + + console.log(`MariaDB ready on 127.0.0.1:${port}\n`); + + exitCode = await runTests({ + BUN_MARIADB_TEST_DB: DB_NAME, + BUN_MARIADB_SERVER_HOST: "127.0.0.1", + BUN_MARIADB_SERVER_PORT: port, + BUN_MARIADB_SERVER_USERNAME: "root", + BUN_MARIADB_SERVER_PASSWORD: PASSWORD, + }); +} catch (error: any) { + console.error(`Test setup failed: ${error.message}`); + exitCode = 1; +} finally { + await removeContainer(); +} + +process.exit(exitCode);