Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand Down
58 changes: 48 additions & 10 deletions docs/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,28 +444,66 @@ 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 <pg-container> psql -U authgd -d postgres \
-c "CREATE DATABASE authgd_test_mine OWNER authgd;"
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-<worktree>` 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```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 |
Expand Down
134 changes: 134 additions & 0 deletions e2e/env.ts
Original file line number Diff line number Diff line change
@@ -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 <APP_PORT>` 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";
8 changes: 3 additions & 5 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof testDb>["db"]) {
Expand Down Expand Up @@ -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 };
}
Loading
Loading