diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ffef27b..31f599d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,9 @@ jobs: # equivalent for CI, which never needs the non-test `authgd` database. POSTGRES_DB: authgd_test # Host 5433 matches docker-compose.dev.yml, so the TEST_DATABASE_URL - # default baked into tests/helpers/db.ts and playwright.config.ts is - # correct with no env override. CI uses the same URL developers do. + # default baked into tests/helpers/db.ts and e2e/env.ts is correct with + # no env override. e2e/env.ts keeps this default specifically for CI; + # locally it derives a per-worktree database instead. ports: - 5433:5432 options: >- diff --git a/docs/ops.md b/docs/ops.md index 33c7a164..9e761982 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -444,21 +444,25 @@ This is not obvious, and it stops people running the tests. | Database | Used by | Destructive operations | |---|---|---| | `authgd` | `npm run dev`, `npm run worker`, `npm run db:migrate` | none automatic | -| `authgd_test` | `npm test`, `npm run test:e2e` | `TRUNCATE` between every test | +| `authgd_test` | `npm test` | `TRUNCATE` between every test | `authgd_test` is created by `scripts/init-test-db.sql` at container init. The -test helpers connect to it explicitly (`tests/helpers/db.ts`, -`playwright.config.ts`), so the `TRUNCATE ... CASCADE` the suites run between -tests physically cannot reach `authgd`. Run the tests freely. +test helpers connect to it explicitly (`tests/helpers/db.ts`), so the +`TRUNCATE ... CASCADE` the suite runs between tests physically cannot reach +`authgd`. Run the tests freely. -**Never run `npm test` and `npm run test:e2e` at the same time.** They share -`authgd_test`, and Playwright is pinned to `workers: 1` for the same reason. -Symptoms of a collision are rows vanishing mid-test — assertion failures like -`expected [] to deeply equal [1, 2]` that move around between runs. +`npm run test:e2e` does not appear above: it provisions a database of its own, +in its own container, and never touches either of these. See +[`npm run test:e2e` isolates itself](#npm-run-teste2e-isolates-itself) below. + +**Two `npm test` runs at once will fight**, because they share `authgd_test`. +Symptoms are rows vanishing mid-test — assertion failures like +`expected [] to deeply equal [1, 2]` that move around between runs. Playwright +is pinned to `workers: 1` for the same reason within its own suite. The same applies across git worktrees: two checkouts running `npm test` -simultaneously fight over the same database. If you need to run tests while -another checkout is using it, point yours somewhere private: +simultaneously fight over that one database. If you need to run the unit tests +while another checkout is using it, point yours somewhere private: ```bash docker exec psql -U authgd -d postgres \ @@ -466,6 +470,40 @@ docker exec psql -U authgd -d postgres \ TEST_DATABASE_URL=postgres://authgd:authgd@localhost:5433/authgd_test_mine npm test ``` +#### `npm run test:e2e` isolates itself + +The e2e suite is the exception: it needs none of the above. `e2e/env.ts` hashes +the worktree's absolute path into a dev-server port and a database port, and +`e2e/provision.ts` starts a Postgres container named `authgd-e2e-` on +that port before the dev server boots. Both `playwright.config.ts` and +`e2e/helpers.ts` read the resulting URL from that one module, so the server and +the seeding code cannot end up pointed at different databases. + +Concurrent worktrees therefore each get their own port and their own database, +and `npm run test:e2e` remains the only command you need. + +```bash +npm run test:e2e # provisions on first run, reuses afterwards +npm run test:e2e:clean # remove this worktree's container when you're done +``` + +The container is kept between runs on purpose — a throwaway one would pay for +`initdb` plus a full migration every time, and would strand any reused dev +server against a database that no longer exists. Nothing reclaims it +automatically, so `test:e2e:clean` is the tidy-up. + +Two overrides exist, both optional: + +| Variable | Effect | +|---|---| +| `TEST_DATABASE_URL` | Use this database and skip provisioning entirely. | +| `E2E_PORT` / `E2E_DB_PORT` | Pin a port, e.g. after a hash collision. | + +If something that is not this worktree's own dev server already holds the port, +the run **aborts** rather than attaching to it. Attaching is what used to make a +sibling worktree's server answer your tests and return a green suite that never +touched your branch. + ### What works on fakes, and what needs real credentials | Works with `.env.example` as-is | Needs real credentials | diff --git a/e2e/env.ts b/e2e/env.ts new file mode 100644 index 00000000..b998c53f --- /dev/null +++ b/e2e/env.ts @@ -0,0 +1,134 @@ +import { createHash } from "node:crypto"; +import { basename, dirname } from "node:path"; + +/** + * The one place the e2e harness decides *which port* and *which database* a run + * uses. + * + * Both halves of the harness import from here: + * + * - `playwright.config.ts` — configures the dev server (its port, and the + * `DATABASE_URL` it reads through). + * - `e2e/helpers.ts` — connects the *test* process to the database it seeds, + * and mints session cookies scoped to the server's origin. + * + * They used to hold independent copies of both values. Overriding one and not + * the other seeded one database while the pages read another, which surfaces as + * every assertion failing on missing content — indistinguishable from a real + * regression. Deriving both here makes that state unrepresentable. + */ + +/** Worktree root: the parent of this file's directory. */ +export const WORKTREE_ROOT = dirname(__dirname); + +export const IS_CI = !!process.env.CI; + +/** + * False inside a Playwright worker process. + * + * Playwright re-imports `playwright.config.ts` in every worker, so anything + * with a side effect at module load runs once per worker as well as once in the + * runner. Provisioning and the port guard are both runner-only: two processes + * migrating the same database concurrently fails on `CREATE SCHEMA`, and a + * worker evaluating the port guard could kill the very server it is about to + * test against. + */ +export const IS_RUNNER = process.env.TEST_WORKER_INDEX === undefined; + +/** + * A port that is stable for a given worktree and (almost always) different + * between worktrees. + * + * Keying on the absolute worktree path rather than a counter or a random value + * gives the two properties that matter together: repeated runs in one worktree + * reuse the same port and the same container, while two worktrees checked out + * side by side do not collide. `salt` separates the app port from the database + * port so they cannot derive the same number. + * + * Collisions are possible (two paths can hash into one slot). They are not + * silent: `playwright.config.ts` refuses to run against a server it cannot + * prove is its own, and provisioning fails loudly if the database port is held + * by something else. Both messages name the override to set. + */ +function portFor(salt: string, base: number, span: number): number { + const digest = createHash("sha256").update(`${salt}\0${WORKTREE_ROOT}`).digest(); + return base + (digest.readUInt16BE(0) % span); +} + +/** + * Short, filesystem-safe worktree identifier used to name the Postgres + * container. The hash suffix keeps two worktrees whose directories share a + * basename (`.../a/authGD` and `.../b/authGD`) from claiming the same one. + */ +export const WORKTREE_SLUG = `${basename(WORKTREE_ROOT) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 24)}-${createHash("sha256").update(WORKTREE_ROOT).digest("hex").slice(0, 6)}`; + +/** + * Reads a port override, or falls back to the derived default when unset. + * + * A malformed override throws rather than falling back. Silently substituting + * the derived port for `E2E_PORT=311l` would put the run on a port the operator + * did not ask for — and they set the override precisely because the default was + * wrong for them, so the fallback is the one outcome guaranteed to be unhelpful. + */ +function portOverride(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `[e2e] ${name}=${JSON.stringify(raw)} is not a valid port. ` + + `Set an integer between 1 and 65535, or unset it to use the ` + + `port derived from this worktree (${fallback}).`, + ); + } + return port; +} + +/** Dev server port. `E2E_PORT` overrides, e.g. to dodge a hash collision. */ +export const APP_PORT = portOverride("E2E_PORT", portFor("app", 3200, 400)); + +export const BASE_URL = `http://localhost:${APP_PORT}`; + +/** Host port for the per-worktree Postgres container. `E2E_DB_PORT` overrides. */ +export const DB_PORT = portOverride("E2E_DB_PORT", portFor("db", 5600, 300)); + +export const CONTAINER_NAME = `authgd-e2e-${WORKTREE_SLUG}`; + +/** + * The database URL both halves of the harness use. + * + * Precedence, and why: + * + * 1. An explicit `TEST_DATABASE_URL` always wins — it is the documented + * escape hatch, and setting it also disables provisioning. + * 2. Under CI, the historical shared default. `.github/workflows/ci.yml` + * stands up a Postgres *service* on host 5433 and deliberately sets no + * override, so this value must stay exactly what it has always been. + * 3. Otherwise, this worktree's own container. + * + * Rule 2 is load-bearing: making the per-worktree URL the unconditional default + * would leave CI pointing at a database no one started. + */ +export const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + (IS_CI + ? "postgres://authgd:authgd@localhost:5433/authgd_test" + : `postgres://authgd:authgd@localhost:${DB_PORT}/authgd_test`); + +/** True when this run is responsible for standing up its own database. */ +export const SHOULD_PROVISION = IS_RUNNER && !IS_CI && !process.env.TEST_DATABASE_URL; + +/** + * Environment marker `playwright.config.ts` puts into the dev server it starts, + * so the guard can later prove a process on this port is one the harness owns. + * + * A matching cwd is not proof: a developer's own `next dev -p ` in + * this worktree looks identical through /proc. The guard restarts servers it + * owns, and restarting means SIGTERM — so ownership has to be conclusive, not + * inferred. + */ +export const MANAGED_ENV_KEY = "E2E_MANAGED_WORKTREE"; diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 3788314d..58c15dd7 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -2,12 +2,10 @@ import { createHash, randomBytes } from "node:crypto"; import { sql } from "drizzle-orm"; import { createDb } from "../src/db"; import { account, character, session } from "../src/db/schema"; - -const TEST_URL = - process.env.TEST_DATABASE_URL ?? "postgres://authgd:authgd@localhost:5433/authgd_test"; +import { BASE_URL, TEST_DATABASE_URL } from "./env"; export function testDb() { - return createDb(TEST_URL); + return createDb(TEST_DATABASE_URL); } export async function resetDb(db: ReturnType["db"]) { @@ -78,5 +76,5 @@ export async function sessionCookieFor( accountId, expiresAt: new Date(Date.now() + 60 * 60 * 1000), }); - return { name: "authgd_session", value: raw, url: "http://localhost:3111" }; + return { name: "authgd_session", value: raw, url: BASE_URL }; } diff --git a/e2e/provision.ts b/e2e/provision.ts new file mode 100644 index 00000000..b35cd97b --- /dev/null +++ b/e2e/provision.ts @@ -0,0 +1,232 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + CONTAINER_NAME, + DB_PORT, + SHOULD_PROVISION, + TEST_DATABASE_URL, + WORKTREE_ROOT, +} from "./env"; + +/** + * Stands up a Postgres container dedicated to this worktree. + * + * Everything here is synchronous on purpose. Playwright starts `webServer` + * during plugin setup, which runs *before* `globalSetup` + * (`createGlobalSetupTasks` in the runner orders plugins first), so a + * `globalSetup` hook cannot guarantee a migrated database exists before + * `next dev` boots, and cannot inspect the port ahead of the server that is + * about to bind it. Config module load is the only point early enough, and + * config loading is synchronous. + * + * The container is deliberately *not* torn down between runs. A throwaway + * container per run would cost a fresh initdb plus a full migration every time, + * and it would leave a reused dev server holding a pool against a database that + * no longer exists — the exact stale-server failure this harness has to prevent. + * Keeping it warm means a reused server always finds the same live database. + */ + +const IMAGE = "postgres:16-alpine"; +const STAMP_DIR = join(WORKTREE_ROOT, "tmp", "e2e"); + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Every docker call is bounded. A daemon that accepts the connection and then + * stops answering would otherwise hang config load itself — before Playwright + * has a test to time out, so the run would sit there silently with no output. + * A timeout surfaces as an ordinary failed result, which callers already handle. + */ +const DOCKER_TIMEOUT_MS = 30_000; + +function docker(args: string[]): { ok: boolean; stdout: string; stderr: string } { + const res = spawnSync("docker", args, { + encoding: "utf8", + timeout: DOCKER_TIMEOUT_MS, + }); + if (res.error && "code" in res.error && res.error.code === "ETIMEDOUT") { + return { + ok: false, + stdout: "", + stderr: + `docker ${args.join(" ")} did not return within ` + + `${DOCKER_TIMEOUT_MS / 1000}s and was killed`, + }; + } + return { + ok: res.status === 0, + stdout: (res.stdout ?? "").trim(), + stderr: (res.stderr ?? "").trim(), + }; +} + +function requireDocker(): void { + if (docker(["version", "--format", "{{.Server.Version}}"]).ok) return; + throw new Error( + `[e2e] Docker is not available, so the per-worktree test database cannot be ` + + `started.\n` + + `Either start Docker, or point the suite at a database you manage:\n` + + ` TEST_DATABASE_URL=postgres://user:pass@host:port/db npm run test:e2e`, + ); +} + +/** Host port this container currently publishes for 5432, if it exists. */ +function publishedPort(): number | null { + const res = docker([ + "inspect", + "-f", + '{{range $p := index .NetworkSettings.Ports "5432/tcp"}}{{$p.HostPort}}{{end}}', + CONTAINER_NAME, + ]); + if (!res.ok || !res.stdout) return null; + return Number(res.stdout) || null; +} + +function containerState(): "missing" | "running" | "stopped" { + const res = docker(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME]); + if (!res.ok) return "missing"; + return res.stdout === "true" ? "running" : "stopped"; +} + +function waitForReady(): void { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if ( + docker(["exec", CONTAINER_NAME, "pg_isready", "-U", "authgd", "-d", "authgd_test"]) + .ok + ) { + return; + } + sleepSync(500); + } + throw new Error( + `[e2e] Postgres container ${CONTAINER_NAME} did not become ready within 60s.\n` + + `Inspect it with: docker logs ${CONTAINER_NAME}`, + ); +} + +function createContainer(): void { + const res = docker([ + "run", + "-d", + "--name", + CONTAINER_NAME, + "-e", + "POSTGRES_USER=authgd", + "-e", + "POSTGRES_PASSWORD=authgd", + "-e", + "POSTGRES_DB=authgd_test", + "-p", + `${DB_PORT}:5432`, + IMAGE, + ]); + if (res.ok) return; + + // A hash collision, or an unrelated service, already owns the port. Say so + // rather than letting the suite fail later on a database it never reached. + if (/port is already allocated|address already in use/i.test(res.stderr)) { + docker(["rm", "-f", CONTAINER_NAME]); + throw new Error( + `[e2e] Host port ${DB_PORT} is already in use, so the test database for ` + + `this worktree could not start.\n` + + `Find the holder with: docker ps --filter publish=${DB_PORT}\n` + + `Then re-run with an explicit port: E2E_DB_PORT= npm run test:e2e`, + ); + } + throw new Error(`[e2e] Failed to start ${CONTAINER_NAME}:\n${res.stderr}`); +} + +/** + * Migrations are the slow part, so they run only when something actually + * changed: a new container, or an edit under drizzle/. The stamp lives in + * gitignored tmp/ and is keyed on the container id, so a recreated container + * always re-migrates even if the migration set is untouched. + */ +function migrationStamp(): string { + const containerId = docker(["inspect", "-f", "{{.Id}}", CONTAINER_NAME]).stdout; + let journal = ""; + try { + journal = readFileSync( + join(WORKTREE_ROOT, "drizzle", "meta", "_journal.json"), + "utf8", + ); + } catch { + // No journal readable — fall through to a stamp that never matches, which + // makes the run migrate rather than assume it is up to date. + journal = String(Date.now()); + } + return createHash("sha256").update(`${containerId}\0${journal}`).digest("hex"); +} + +function migrate(): void { + const res = spawnSync("npx", ["tsx", "src/db/migrate.ts"], { + cwd: WORKTREE_ROOT, + encoding: "utf8", + env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL }, + }); + if (res.status !== 0) { + throw new Error( + `[e2e] Migrating ${TEST_DATABASE_URL} failed:\n${res.stdout ?? ""}${res.stderr ?? ""}`, + ); + } +} + +export interface ProvisionResult { + /** + * True when the database was created or restarted during this call. The + * caller uses it to decide whether a dev server left over from a previous run + * may still be holding connections to a database that no longer exists. + */ + recreated: boolean; +} + +export function ensureTestDatabase(): ProvisionResult { + // CI stands up its own Postgres service, and an explicit TEST_DATABASE_URL + // means the developer is managing the database themselves. Both opt out. + if (!SHOULD_PROVISION) return { recreated: false }; + + requireDocker(); + + let recreated = false; + let state = containerState(); + + // An existing container published on a different host port would leave the + // URL in env.ts pointing nowhere. Recreate it rather than silently disagree. + if (state !== "missing" && publishedPort() !== DB_PORT) { + docker(["rm", "-f", CONTAINER_NAME]); + state = "missing"; + } + + if (state === "missing") { + createContainer(); + recreated = true; + } else if (state === "stopped") { + const res = docker(["start", CONTAINER_NAME]); + if (!res.ok) + throw new Error(`[e2e] Failed to start ${CONTAINER_NAME}:\n${res.stderr}`); + recreated = true; + } + + waitForReady(); + + const stampFile = join(STAMP_DIR, `${CONTAINER_NAME}.stamp`); + const stamp = migrationStamp(); + let applied = ""; + try { + applied = readFileSync(stampFile, "utf8"); + } catch { + applied = ""; + } + if (applied !== stamp) { + migrate(); + mkdirSync(STAMP_DIR, { recursive: true }); + writeFileSync(stampFile, stamp); + } + + return { recreated }; +} diff --git a/e2e/server-guard.ts b/e2e/server-guard.ts new file mode 100644 index 00000000..84f8b166 --- /dev/null +++ b/e2e/server-guard.ts @@ -0,0 +1,165 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, readlinkSync } from "node:fs"; +import { + APP_PORT, + BASE_URL, + IS_CI, + IS_RUNNER, + MANAGED_ENV_KEY, + TEST_DATABASE_URL, + WORKTREE_ROOT, +} from "./env"; + +/** + * Decides whether an already-running dev server on this worktree's port may be + * reused. + * + * Why this exists: `reuseExistingServer` used to be a flat `!process.env.CI` + * against a hardcoded port 3111. When a sibling worktree happened to hold 3111, + * Playwright attached to it and the suite exercised *that* worktree's code + * against *that* worktree's database — and passed. A false green is far worse + * than a false red, because nothing prompts you to look. + * + * Per-worktree ports make that collision unlikely but not impossible (hashes + * collide, and people set E2E_PORT by hand). So reuse is granted only when the + * process holding the port can be *proved* to belong to this worktree and to be + * reading this run's database. Anything unproven is a hard error naming the + * override, never a silent attach. + */ + +interface PortOwner { + pid: number; + cwd: string | null; + databaseUrl: string | null; + /** Worktree recorded by the harness that started this process, if any. */ + managedBy: string | null; +} + +function listenerPid(port: number): number | null { + const probes: Array<[string, string[]]> = [ + ["ss", ["-lntpH", `sport = :${port}`]], + ["lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"]], + ]; + for (const [cmd, args] of probes) { + try { + const out = execFileSync(cmd, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const pid = /pid=(\d+)/.exec(out)?.[1] ?? out.trim().split("\n")[0]; + if (pid && Number(pid)) return Number(pid); + } catch { + // Probe unavailable or found nothing; try the next one. + } + } + return null; +} + +/** Reads the owner's identity from /proc. Returns nulls where unreadable. */ +function describeOwner(pid: number): PortOwner { + let cwd: string | null = null; + let databaseUrl: string | null = null; + let managedBy: string | null = null; + try { + cwd = readlinkSync(`/proc/${pid}/cwd`); + } catch { + cwd = null; + } + try { + const environ = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0"); + const read = (key: string): string | null => + environ.find((e) => e.startsWith(`${key}=`))?.slice(key.length + 1) ?? null; + databaseUrl = read("DATABASE_URL"); + managedBy = read(MANAGED_ENV_KEY); + } catch { + databaseUrl = null; + managedBy = null; + } + return { pid, cwd, databaseUrl, managedBy }; +} + +function isPortFree(port: number): boolean { + return listenerPid(port) === null; +} + +function stop(pid: number): void { + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (isPortFree(APP_PORT)) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); + } + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already gone. + } +} + +const OVERRIDE_HINT = `Re-run on a port of your choosing:\n E2E_PORT= npm run test:e2e`; + +/** + * @param dbRecreated whether provisioning just created or restarted the + * database. A server that predates a recreated database holds a pool against + * storage that may no longer exist, so it is never reused. + */ +export function resolveServerReuse(dbRecreated: boolean): boolean { + // Only the runner starts servers. A worker re-importing the config must not + // probe the port, and must never reach stop() — that would kill the server + // the suite is mid-way through using. + if (!IS_RUNNER) return true; + + // CI starts from a clean runner and must never attach to anything. This is + // the same answer the old `!process.env.CI` gave. + if (IS_CI) return false; + + const pid = listenerPid(APP_PORT); + if (pid === null) return true; // Nothing to attach to; Playwright starts its own. + + const owner = describeOwner(pid); + + if (owner.cwd !== WORKTREE_ROOT) { + throw new Error( + `[e2e] ${BASE_URL} is held by pid ${pid}, which does not belong to this ` + + `worktree.\n` + + ` this worktree: ${WORKTREE_ROOT}\n` + + ` port holder: ${owner.cwd ?? ""}\n` + + `Refusing to attach: the suite would test that process's code against ` + + `its database and report a pass that never touched this branch.\n` + + OVERRIDE_HINT, + ); + } + + // Ours by directory, but not started by this harness — a hand-run `next dev` + // on the same port, say. Reuse is unsafe (its env is unknown) and so is + // restarting it: SIGTERM to a process someone is deliberately running is + // exactly the collateral damage this guard exists to avoid. Say so instead. + if (owner.managedBy !== WORKTREE_ROOT) { + throw new Error( + `[e2e] ${BASE_URL} is held by pid ${pid}, which runs from this worktree ` + + `but was not started by the e2e harness (no ${MANAGED_ENV_KEY} marker).\n` + + `Refusing to reuse it — its configuration is unknown — and refusing to ` + + `stop it, since you may be running it on purpose.\n` + + `Stop it yourself, or leave it alone and re-run elsewhere:\n` + + ` E2E_PORT= npm run test:e2e`, + ); + } + + // Ours, but pointed at a different database than this run will seed — the + // two-sources-of-truth bug in server form. Restart it. + if (owner.databaseUrl !== TEST_DATABASE_URL) { + stop(pid); + return false; + } + + if (dbRecreated) { + stop(pid); + return false; + } + + return true; +} diff --git a/package.json b/package.json index 9decfb9c..1dd199cb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "smoke:wanderer": "tsx --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/wanderer-smoke.ts", "typecheck": "tsc --noEmit", "test:e2e": "playwright test", + "test:e2e:clean": "tsx scripts/e2e-clean.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", diff --git a/playwright.config.ts b/playwright.config.ts index a5629597..f48c7d3e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,14 +1,34 @@ import { defineConfig } from "@playwright/test"; +import { + APP_PORT, + BASE_URL, + CONTAINER_NAME, + MANAGED_ENV_KEY, + SHOULD_PROVISION, + TEST_DATABASE_URL, + WORKTREE_ROOT, +} from "./e2e/env"; +import { ensureTestDatabase } from "./e2e/provision"; +import { resolveServerReuse } from "./e2e/server-guard"; -const TEST_URL = - process.env.TEST_DATABASE_URL ?? "postgres://authgd:authgd@localhost:5433/authgd_test"; +// Provisioning runs at config load, not in globalSetup: Playwright starts +// `webServer` during plugin setup, which the runner orders *before* global +// setup files. By the time a globalSetup hook ran, `next dev` would already be +// up and the port already bound. +const { recreated } = ensureTestDatabase(); + +if (SHOULD_PROVISION) { + console.log(`[e2e] ${CONTAINER_NAME} → ${TEST_DATABASE_URL}`); +} // Full config env: getConfig() validates lazily per request, so the dev server // needs every required var even though e2e never talks to EVE/Discord/Wanderer. const env = { - DATABASE_URL: TEST_URL, + // The same constant e2e/helpers.ts seeds through — see e2e/env.ts. These two + // must never be able to disagree. + DATABASE_URL: TEST_DATABASE_URL, TOKEN_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString("base64"), - APP_BASE_URL: "http://localhost:3111", + APP_BASE_URL: BASE_URL, ALLIANCE_ID: "99000001", BOOTSTRAP_ADMIN_CHARACTER_IDS: "", EVE_SSO_CLIENT_ID: "cid", @@ -30,17 +50,23 @@ const env = { // e2e never exercises an external integration, so nothing here depends on // live behavior — and dry-run is the correct default for a browsable app. SYNC_MODE: "dry-run", + // Lets the guard prove, on a later run, that a server on this port is one we + // started and may therefore restart. See e2e/server-guard.ts. + [MANAGED_ENV_KEY]: WORKTREE_ROOT, }; export default defineConfig({ testDir: "e2e", workers: 1, // shared test database — never parallelize - use: { baseURL: "http://localhost:3111" }, + use: { baseURL: BASE_URL }, webServer: { - command: "npx next dev -p 3111", - url: "http://localhost:3111/login", + command: `npx next dev -p ${APP_PORT}`, + url: `${BASE_URL}/login`, env, - reuseExistingServer: !process.env.CI, + // Not a flat boolean: reuse is granted only when the process already on + // this port proves it belongs to this worktree and reads this run's + // database. See e2e/server-guard.ts for why a flat `!CI` was unsafe. + reuseExistingServer: resolveServerReuse(recreated), timeout: 60_000, }, }); diff --git a/scripts/e2e-clean.ts b/scripts/e2e-clean.ts new file mode 100644 index 00000000..47356379 --- /dev/null +++ b/scripts/e2e-clean.ts @@ -0,0 +1,44 @@ +import { spawnSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { CONTAINER_NAME, WORKTREE_ROOT } from "../e2e/env"; + +/** + * Removes the Postgres container `npm run test:e2e` provisions for this + * worktree, plus the stamp that records which migrations it has had applied. + * + * The suite deliberately keeps its container warm between runs, so nothing + * reclaims it automatically. Run this when you are finished with a worktree — + * or whenever you want the next run to rebuild the database from scratch. + */ +const removed = spawnSync("docker", ["rm", "-f", CONTAINER_NAME], { + encoding: "utf8", + timeout: 30_000, +}); + +const stdout = (removed.stdout ?? "").trim(); +const stderr = (removed.stderr ?? "").trim(); + +// `docker rm -f` reports a missing container two different ways depending on +// version: exit 0 with an empty stdout and a warning, or a non-zero exit with +// "No such container". Both are the goal state. Everything else has to be loud — +// reporting a removal when the daemon was simply unreachable would leave a live +// container behind under a message saying it was gone. +const absent = + (removed.status === 0 && stdout === "") || /no such container/i.test(stderr); + +if (removed.status === 0 && stdout !== "") { + console.log(`removed container ${CONTAINER_NAME}`); +} else if (absent) { + console.log(`no container named ${CONTAINER_NAME}`); +} else { + throw new Error( + `[e2e] could not remove ${CONTAINER_NAME}: ` + + `${stderr || removed.error?.message || `docker exited ${removed.status}`}\n` + + `The container may still be running. Leaving the migration stamp in place.`, + ); +} + +rmSync(join(WORKTREE_ROOT, "tmp", "e2e", `${CONTAINER_NAME}.stamp`), { + force: true, +});