test: e2e runs against its own throwaway database, not production's
The e2e stack shared production's Postgres — same server, same database, same table. Seeded robots sat in `users` beside real clinicians, and anything a test wrote, or a migration under test changed, landed on real data. Nothing about "run the tests" should be able to reach an account belonging to a person. Now it has a Postgres and a Redis of its own, both on tmpfs: created empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one command that recreates the stack, seeds it, runs the browser and leaves the app up at 127.0.0.1:3553 so it can be clicked around in, with the report served at :3554. Two bugs fell out of it immediately, both of which only a database that did not already exist could have found: The schema could not be built from nothing. The entrypoint migrated before the app created its baseline tables, so the first migration failed on saved_encounters not existing. It never showed because every database this has ever run against already had the baseline. Then, one layer down, 1777800000000_generated-images creates a table with a foreign key to learning_content — which the baseline stopped creating when Learning Hub was removed. Restoring into a brand-new database could not have booted. The entrypoint now stands aside when the database is empty and lets the app do it in the order it already gets right, and the foreign key is only created where its target is. All 20 migrations replay from empty, producing the same 23 tables production has. Configuration lives in the database, so a throwaway one starts at defaults — 14 settings against production's 49. That is why every model picker was empty: models.custom did not exist. The tests were right and the environment was incomplete, so the seed now states what the suite depends on, with fictional model ids: a test should not pass because of a setting somebody changed on the live system last week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
38b8fd584a
commit
5a666f5ca5
8 changed files with 284 additions and 102 deletions
|
|
@ -1,21 +1,68 @@
|
|||
# E2E test environment — runs a second instance of the app on port 3553 with
|
||||
# Turnstile disabled so Playwright can log in without the bot challenge.
|
||||
# Shares the postgres + pgdata volume with production so seeded e2e test users
|
||||
# (email pattern *@ped-ai.test) persist across test runs.
|
||||
# E2E test environment — a whole second copy of the app, on its own throwaway
|
||||
# database, with its own throwaway Redis.
|
||||
#
|
||||
# Bring up with:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
|
||||
# docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v postgres-e2e redis-e2e pediatric-scribe-e2e
|
||||
#
|
||||
# Tear down with:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.e2e.yml down pediatric-scribe-e2e
|
||||
# Normally you want scripts/e2e.sh, which does both around a test run.
|
||||
#
|
||||
# It used to share production's Postgres — same server, same database, same
|
||||
# table. Seeded robots sat in `users` next to real clinicians, and anything a
|
||||
# test wrote, or a migration under test changed, landed on real data. Nothing
|
||||
# about "run the tests" should be able to reach an account belonging to a
|
||||
# person. Now the stack has a database of its own, held in a tmpfs: it exists
|
||||
# in RAM, it is created empty on every `up`, and it is gone on `down`. The
|
||||
# schema is rebuilt each time by the container's own migrations, which also
|
||||
# means every run proves the migrations still work from nothing.
|
||||
|
||||
services:
|
||||
# ── Throwaway Postgres ────────────────────────────────────────────────
|
||||
# Same pinned image as production, so an e2e pass says something about what
|
||||
# production will do. PGDATA points at a subdirectory because initdb wants a
|
||||
# 0700 directory of its own and a tmpfs mountpoint is not one.
|
||||
postgres-e2e:
|
||||
image: pgvector/pgvector:pg16@sha256:00ba258a66dac104fd5171074a0084462a64a1369d8513f3d0a634e2f24d15bc
|
||||
container_name: pedscribe-db-e2e
|
||||
environment:
|
||||
POSTGRES_DB: pedscribe_e2e
|
||||
POSTGRES_USER: pedscribe
|
||||
POSTGRES_PASSWORD: e2e-throwaway
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
tmpfs:
|
||||
# In RAM, so there is no volume to forget to clean up and nothing to
|
||||
# survive a reboot. 1G is far more than a seeded test run uses.
|
||||
- /var/lib/postgresql/data:size=1g
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pedscribe -d pedscribe_e2e"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
restart: "no"
|
||||
|
||||
# ── Throwaway Redis ───────────────────────────────────────────────────
|
||||
# Sessions and rate-limit counters. Persistence off in both directions: no
|
||||
# RDB snapshots, no AOF, and /data on tmpfs, so a run cannot inherit state
|
||||
# from the one before it.
|
||||
redis-e2e:
|
||||
image: redis:8-alpine@sha256:d146f83b1e0f02fc27c26a50cee39338c736674c5959db84363e6ae3cd9e02d2
|
||||
container_name: ped-ai-redis-e2e
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
tmpfs:
|
||||
- /data:size=64m
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
restart: "no"
|
||||
|
||||
# ── The app under test ────────────────────────────────────────────────
|
||||
pediatric-scribe-e2e:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GIT_REVISION: ${GIT_REVISION:-unknown}
|
||||
image: ped-ai-local:latest
|
||||
image: ped-ai-e2e:latest
|
||||
ports:
|
||||
- "127.0.0.1:3553:3000"
|
||||
networks:
|
||||
|
|
@ -27,6 +74,13 @@ services:
|
|||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# These four are the isolation. The entrypoint applies OpenBao secrets
|
||||
# only for keys docker has not already set, so anything named here wins
|
||||
# over the vault — which is exactly what that rule was written for.
|
||||
DATABASE_URL: postgresql://pedscribe:e2e-throwaway@postgres-e2e:5432/pedscribe_e2e
|
||||
REDIS_URL: redis://redis-e2e:6379
|
||||
# Never mail a real person from a test run.
|
||||
SMTP_HOST: ""
|
||||
# Disable Turnstile entirely — both server-side verification AND the
|
||||
# client-side widget. Without clearing the SITE_KEY the frontend tries
|
||||
# to initialise the Turnstile iframe against the prod domain and
|
||||
|
|
@ -34,8 +88,9 @@ services:
|
|||
# flags as an uncaught exception.
|
||||
TURNSTILE_SECRET_KEY: ""
|
||||
TURNSTILE_SITE_KEY: ""
|
||||
# Disable SMTP so register auto-verifies the user and returns a session
|
||||
SMTP_HOST: ""
|
||||
# A key of its own. Rows here are throwaway, and binding them to the
|
||||
# production key would be the one piece of production that leaked in.
|
||||
DATA_ENCRYPTION_KEY: "e2e0000000000000000000000000000000000000000000000000000000000e2e"
|
||||
# Raise the login rate-limit so Playwright multi-worker runs don't
|
||||
# trip the production 10/15min cap. Only affects this e2e container.
|
||||
LOGIN_RATE_LIMIT_MAX: "500"
|
||||
|
|
@ -50,16 +105,33 @@ services:
|
|||
volumes:
|
||||
- scribe-logs-e2e:/app/data/logs
|
||||
depends_on:
|
||||
postgres:
|
||||
postgres-e2e:
|
||||
condition: service_healthy
|
||||
redis-e2e:
|
||||
condition: service_healthy
|
||||
container_name: pediatric-ai-scribe-e2e
|
||||
restart: unless-stopped
|
||||
# Not unless-stopped: this is a test rig, not a service. It should not come
|
||||
# back on its own after a reboot, and it should not outlive a `down`.
|
||||
restart: "no"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# ── The last run's report ─────────────────────────────────────────────
|
||||
# Playwright writes a self-contained HTML report; this serves it so there is
|
||||
# a link to open rather than a directory to find. Traces and screenshots of
|
||||
# failures are in there, which is the part worth looking at on a phone.
|
||||
e2e-report:
|
||||
image: nginx:alpine
|
||||
container_name: pediatric-ai-scribe-e2e-report
|
||||
ports:
|
||||
- "127.0.0.1:3554:80"
|
||||
volumes:
|
||||
- ./e2e/playwright-report:/usr/share/nginx/html:ro
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
scribe-logs-e2e:
|
||||
|
|
|
|||
|
|
@ -93,6 +93,28 @@ if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# A database with nothing in it is the one case where migrating here is
|
||||
# wrong. The schema has two layers: src/db/database.js creates the baseline
|
||||
# tables on first connect, and the migrations are written to layer on top —
|
||||
# the earliest of them alters saved_encounters, which only the baseline
|
||||
# creates. Run first against an empty database and they fail on a table that
|
||||
# does not exist yet.
|
||||
#
|
||||
# So: empty database, stand aside and let the app do it, which it already
|
||||
# does in the right order (initDatabase, then runMigrations). Existing
|
||||
# database, migrate here exactly as before, so a deploy still cannot put new
|
||||
# code in front of an old schema. Unreachable, carry on into the loop below,
|
||||
# which is what already handles a Postgres still opening its socket.
|
||||
#
|
||||
# This is why restoring into a brand-new database could not boot.
|
||||
if [ "$(node scripts/schema-state.js 2>/dev/null)" = "empty" ]; then
|
||||
echo "[entrypoint] database is empty — the app will create the baseline and migrate on top of it"
|
||||
RUN_MIGRATIONS=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||
|
||||
_MIGRATE_ATTEMPT=1
|
||||
_MIGRATE_MAX=${MIGRATION_ATTEMPTS:-10}
|
||||
while : ; do
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ module.exports = defineConfig({
|
|||
fullyParallel: false,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['list']],
|
||||
// list for the terminal, html for afterwards. The html report is a
|
||||
// self-contained directory with the trace and screenshot of every failure in
|
||||
// it; docker-compose.e2e.yml serves it at 127.0.0.1:3554 so it is a link
|
||||
// rather than a path. open:'never' because this runs in a container that has
|
||||
// no browser to open it with.
|
||||
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://127.0.0.1:3553',
|
||||
// The app registers a service worker that answers every /api/ request with
|
||||
|
|
|
|||
39
e2e/seed.js
39
e2e/seed.js
|
|
@ -46,6 +46,44 @@ var ACCOUNTS = [
|
|||
{ email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin' + TEST_DOMAIN, name: 'E2E Admin', role: 'admin' }
|
||||
];
|
||||
|
||||
// ── Configuration ─────────────────────────────────────────────────────
|
||||
// Settings live in the database, so a throwaway database starts at defaults
|
||||
// rather than at whatever production happens to be configured with. That is
|
||||
// the point — a test should not pass because of a setting somebody changed on
|
||||
// the live system last week — but it does mean anything the suite depends on
|
||||
// has to be stated here.
|
||||
//
|
||||
// This is what made the model pickers empty when the e2e stack stopped sharing
|
||||
// production's database: models.custom did not exist, so there was nothing to
|
||||
// put in the <select>. The tests were right; the environment was incomplete.
|
||||
//
|
||||
// Fictional ids on purpose. Nothing here reaches a gateway — the specs mock
|
||||
// the model calls — and a real model name would invite someone to believe a
|
||||
// green run says something about that model.
|
||||
var SETTINGS = {
|
||||
'models.custom': JSON.stringify([
|
||||
{ id: 'e2e-model-a', name: 'E2E Model A' },
|
||||
{ id: 'e2e-model-b', name: 'E2E Model B' }
|
||||
]),
|
||||
'models.default': 'e2e-model-a',
|
||||
'models.disabled': '[]',
|
||||
'stt.model': 'e2e-stt',
|
||||
'tts.model': 'e2e-tts',
|
||||
'tts.voice': 'e2e-voice',
|
||||
// Registration closed and invite-only, which is what production runs and
|
||||
// what the auth-screen spec asserts the sign-in page reflects.
|
||||
'registration_enabled': 'true',
|
||||
'registration_invite_only': 'true'
|
||||
};
|
||||
|
||||
async function seedSettings() {
|
||||
var keys = Object.keys(SETTINGS);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
await db.setSetting(keys[i], SETTINGS[keys[i]]);
|
||||
}
|
||||
console.log('settings seeded (' + keys.length + ' keys)');
|
||||
}
|
||||
|
||||
async function seed(account) {
|
||||
var email = String(account.email || '').toLowerCase().trim();
|
||||
if (email.slice(-TEST_DOMAIN.length) !== TEST_DOMAIN) {
|
||||
|
|
@ -75,6 +113,7 @@ async function seed(account) {
|
|||
(async function () {
|
||||
try {
|
||||
for (var i = 0; i < ACCOUNTS.length; i++) await seed(ACCOUNTS[i]);
|
||||
await seedSettings();
|
||||
console.log('e2e accounts ready');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
// ============================================================
|
||||
// LEARNING HUB — search, category pills, feed rendering.
|
||||
// Quiz flow is gated by having quiz content; just verify the UI
|
||||
// scaffolding works without requiring a specific topic to exist.
|
||||
// ============================================================
|
||||
|
||||
const { test, expect, E2E_BASE } = require('../fixtures');
|
||||
|
||||
async function openTab(page) {
|
||||
await page.goto(E2E_BASE + '/');
|
||||
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
|
||||
const vp = page.viewportSize();
|
||||
if (vp && vp.width <= 768) {
|
||||
await page.click('#btn-menu-toggle').catch(() => {});
|
||||
}
|
||||
await page.click('button.tab-btn[data-tab="learning"]');
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById('learning-tab');
|
||||
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
|
||||
}, { timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('Learning Hub — navigation + search', () => {
|
||||
|
||||
test('search input + categories + feed all render', async ({ authedPage: _, page }) => {
|
||||
await openTab(page);
|
||||
await expect(page.locator('#lh-search')).toBeVisible();
|
||||
await expect(page.locator('#lh-categories')).toBeVisible();
|
||||
await expect(page.locator('#lh-feed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('typing in search filters the feed (even if zero matches)', async ({ authedPage: _, page }) => {
|
||||
await openTab(page);
|
||||
// Wait for feed to render some content or be flagged as empty
|
||||
await expect.poll(async () =>
|
||||
(await page.locator('#lh-feed').innerText()).trim().length,
|
||||
{ timeout: 10000 }).toBeGreaterThan(0);
|
||||
const initialHtml = await page.locator('#lh-feed').innerHTML();
|
||||
|
||||
// Type a very specific string that likely won't match any topic
|
||||
await page.fill('#lh-search', 'xyzzy-unlikely-topic-name');
|
||||
// Feed should update — either to empty state or different filtered list
|
||||
await expect.poll(async () =>
|
||||
(await page.locator('#lh-feed').innerHTML()) !== initialHtml,
|
||||
{ timeout: 3000 }).toBe(true);
|
||||
});
|
||||
|
||||
test('clicking a category pill (if present) does not crash the UI', async ({ authedPage: _, page }) => {
|
||||
await openTab(page);
|
||||
const pills = page.locator('#lh-categories button, #lh-categories .category-pill');
|
||||
const count = await pills.count();
|
||||
test.skip(count === 0, 'No category pills rendered — nothing to test');
|
||||
await pills.first().click();
|
||||
// Feed must still be visible and have some content after filtering
|
||||
await expect(page.locator('#lh-feed')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,16 @@
|
|||
// Durable jobs and private asset grants. No external calls or corpus changes.
|
||||
//
|
||||
// generated_image_links pointed at learning_content, which the baseline created
|
||||
// at the time. Learning Hub has since been retired: the baseline no longer
|
||||
// creates that table, and 1780800000000_retire-learning-hub drops this one.
|
||||
// Replaying the chain into an empty database therefore failed here, on a
|
||||
// foreign key to a table that no longer exists — which is what stopped a
|
||||
// brand-new database from ever booting.
|
||||
//
|
||||
// The reference is now created only where the target is. Databases that
|
||||
// already ran this migration are untouched: node-pg-migrate records it as
|
||||
// applied and never runs it again. Fresh ones get the table without the key,
|
||||
// and lose it entirely a few migrations later, which is the same end state.
|
||||
exports.up = pgm => pgm.sql(`
|
||||
CREATE TABLE generated_image_jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
|
|
@ -21,9 +33,16 @@ exports.up = pgm => pgm.sql(`
|
|||
CREATE INDEX generated_image_claim ON generated_image_jobs(stage, created_at);
|
||||
CREATE TABLE generated_image_links (
|
||||
asset_id UUID NOT NULL REFERENCES generated_image_jobs(id) ON DELETE CASCADE,
|
||||
content_id INTEGER NOT NULL REFERENCES learning_content(id) ON DELETE CASCADE,
|
||||
content_id INTEGER NOT NULL,
|
||||
PRIMARY KEY(asset_id, content_id)
|
||||
);
|
||||
DO $links$ BEGIN
|
||||
IF to_regclass('public.learning_content') IS NOT NULL THEN
|
||||
ALTER TABLE generated_image_links
|
||||
ADD CONSTRAINT generated_image_links_content_id_fkey
|
||||
FOREIGN KEY (content_id) REFERENCES learning_content(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $links$;
|
||||
CREATE FUNCTION guard_generated_image_job() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (NEW.owner_id, NEW.workflow, NEW.idempotency_key, NEW.input_hash, NEW.prompt_cipher, NEW.model, NEW.prompt_revision, NEW.budget, NEW.prompt_units)
|
||||
|
|
|
|||
109
scripts/e2e.sh
109
scripts/e2e.sh
|
|
@ -1,45 +1,100 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs Playwright smoke tests inside the official Playwright container against
|
||||
# the running PedScribe app. Usage: npm run e2e (or ./scripts/e2e.sh)
|
||||
# End-to-end tests: a real browser, against a real copy of the whole app, on a
|
||||
# database that did not exist a minute ago.
|
||||
#
|
||||
# scripts/e2e.sh # fresh stack, run every spec, leave it up
|
||||
# scripts/e2e.sh auth-screen # only specs matching a pattern
|
||||
# scripts/e2e.sh --down # tear the stack down and stop
|
||||
# scripts/e2e.sh --no-reset # reuse the running stack and its data
|
||||
#
|
||||
# Every run recreates the database from empty, so nothing carries over between
|
||||
# runs and the migrations are proved from nothing each time. The stack is left
|
||||
# running afterwards on purpose: http://127.0.0.1:3553 is then a working copy
|
||||
# of the app you can click around in, and http://127.0.0.1:3554 is the report.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.50.0-noble"
|
||||
COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.e2e.yml)
|
||||
SERVICES=(postgres-e2e redis-e2e pediatric-scribe-e2e)
|
||||
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v1.50.0-noble"
|
||||
APP_URL="http://127.0.0.1:3553"
|
||||
REPORT_URL="http://127.0.0.1:3554"
|
||||
|
||||
# --- PREFLIGHT: static reference linter ---
|
||||
# Catches the class of bug where a JS file reaches for an id that no
|
||||
# HTML element (or dynamic id assignment anywhere in the repo) ever
|
||||
# produces — the lightbox + adminMilestones dead-code bugs were both
|
||||
# this shape and both went undetected until someone tripped over them
|
||||
# in the real app. Fails the build before tests even start.
|
||||
RESET=true
|
||||
GREP=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--down)
|
||||
echo "==> Tearing down the e2e stack"
|
||||
"${COMPOSE[@]}" rm -sfv "${SERVICES[@]}" e2e-report >/dev/null 2>&1 || true
|
||||
echo " gone (its database was in RAM, so nothing is left on disk)"
|
||||
exit 0 ;;
|
||||
--no-reset) RESET=false ;;
|
||||
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
*) GREP="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Static reference lint ─────────────────────────────────────────────
|
||||
# Catches the class of bug where a JS file reaches for an id that no HTML
|
||||
# element (or dynamic id assignment anywhere in the repo) ever produces — the
|
||||
# lightbox and adminMilestones dead-code bugs were both this shape and both
|
||||
# went undetected until someone tripped over them in the real app. Cheap, so
|
||||
# it runs first and fails before anything is built.
|
||||
echo "==> Static reference lint"
|
||||
docker run --rm -v "$PWD:/work" -w /work node:20-alpine \
|
||||
node scripts/lint-references.js
|
||||
docker run --rm -v "$PWD:/work" -w /work node:20-alpine node scripts/lint-references.js
|
||||
|
||||
# --- SEED: the accounts the fixtures log in as ---
|
||||
# Idempotent, and the only place the admin account comes from. Run inside the
|
||||
# app container because that is where the database credentials are: the
|
||||
# entrypoint exports them from OpenBao into the Node process and nowhere else.
|
||||
# Non-fatal, so a run against a stack that is already seeded is not blocked by
|
||||
# a container that happens not to be up.
|
||||
E2E_CONTAINER="${E2E_CONTAINER:-pediatric-ai-scribe-e2e}"
|
||||
echo "==> Seeding e2e accounts"
|
||||
if docker exec "$E2E_CONTAINER" node e2e/seed.js; then
|
||||
:
|
||||
# ── A stack with nothing in it ────────────────────────────────────────
|
||||
# rm -sfv, not `down`: `down` on a merged compose file would take production's
|
||||
# services with it. This names only the e2e ones. Their database and Redis are
|
||||
# tmpfs, so removing the containers is what makes the data ephemeral.
|
||||
if [ "$RESET" = true ]; then
|
||||
echo "==> Recreating the e2e stack (fresh database)"
|
||||
"${COMPOSE[@]}" rm -sfv "${SERVICES[@]}" >/dev/null 2>&1 || true
|
||||
GIT_REVISION="$(git rev-parse HEAD 2>/dev/null || echo unknown)" \
|
||||
"${COMPOSE[@]}" up -d --build --wait "${SERVICES[@]}"
|
||||
else
|
||||
echo " seed skipped ($E2E_CONTAINER not running or not seedable); tests will fail on login if the accounts are missing" >&2
|
||||
echo "==> Reusing the running e2e stack"
|
||||
GIT_REVISION="$(git rev-parse HEAD 2>/dev/null || echo unknown)" \
|
||||
"${COMPOSE[@]}" up -d --wait "${SERVICES[@]}"
|
||||
fi
|
||||
|
||||
# What is actually being tested. A stale image here would make a green run
|
||||
# meaningless, which is the failure mode worth naming out loud.
|
||||
RUNNING="$(curl -fsS --max-time 10 "$APP_URL/api/build" | sed -n 's/.*"buildId":"\([^"]*\)".*/\1/p' || true)"
|
||||
echo " testing revision ${RUNNING:-<unknown>}"
|
||||
|
||||
# ── Seed ──────────────────────────────────────────────────────────────
|
||||
# The accounts the fixtures log in as. The database is empty every run, so
|
||||
# unlike before this is not optional and a failure here is fatal: tests that
|
||||
# cannot log in fail in a way that looks like the app is broken.
|
||||
echo "==> Seeding e2e accounts"
|
||||
"${COMPOSE[@]}" exec -T pediatric-scribe-e2e node e2e/seed.js
|
||||
|
||||
# ── The browser ───────────────────────────────────────────────────────
|
||||
# Host network and a loopback URL, because the browser only treats loopback as
|
||||
# a secure context over plain http, and the app cannot sign in without one.
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3553}"
|
||||
|
||||
echo "==> Playwright"
|
||||
set +e
|
||||
docker run --rm --ipc=host \
|
||||
--network=host \
|
||||
-v "$PWD/e2e":/work \
|
||||
-w /work \
|
||||
-e BASE_URL="$BASE_URL" \
|
||||
-e BASE_URL="$APP_URL" \
|
||||
-e CI=true \
|
||||
"$IMAGE" \
|
||||
sh -c "npm install --no-audit --no-fund --silent && npx playwright test"
|
||||
"$PLAYWRIGHT_IMAGE" \
|
||||
sh -c "npm install --no-audit --no-fund --silent && npx playwright test ${GREP:+--grep \"$GREP\"}"
|
||||
STATUS=$?
|
||||
set -e
|
||||
|
||||
# The report is worth serving whether the run passed or failed — a pass is
|
||||
# where you check that a spec did what you thought it did.
|
||||
"${COMPOSE[@]}" up -d e2e-report >/dev/null 2>&1 || true
|
||||
|
||||
echo
|
||||
if [ "$STATUS" -eq 0 ]; then echo "==> ✅ e2e passed"; else echo "==> ❌ e2e failed (exit $STATUS)"; fi
|
||||
echo " app $APP_URL (a working copy, throwaway data)"
|
||||
echo " report $REPORT_URL (traces and screenshots of any failure)"
|
||||
echo " stop scripts/e2e.sh --down"
|
||||
exit "$STATUS"
|
||||
|
|
|
|||
27
scripts/schema-state.js
Normal file
27
scripts/schema-state.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Prints one word: is this database already carrying the app's schema?
|
||||
//
|
||||
// ready the baseline tables exist; migrations can be applied on top
|
||||
// empty nothing here yet
|
||||
// unreachable could not connect or could not tell
|
||||
//
|
||||
// Used by docker-entrypoint.sh to decide whether it may migrate before the app
|
||||
// starts. The schema has two layers — src/db/database.js creates the baseline
|
||||
// with CREATE TABLE IF NOT EXISTS on first connect, and the migrations layer on
|
||||
// top of it. Against an empty database the migrations fail, because the
|
||||
// earliest of them alters a table only the baseline creates.
|
||||
//
|
||||
// Never exits non-zero: an answer of "unreachable" is for the caller to handle,
|
||||
// and a database still opening its socket is not an error worth stopping on.
|
||||
const { Client } = require('pg');
|
||||
|
||||
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
||||
client.connect()
|
||||
.then(() => client.query("select to_regclass('public.users') is not null as ready"))
|
||||
.then((result) => {
|
||||
process.stdout.write(result.rows[0] && result.rows[0].ready ? 'ready' : 'empty');
|
||||
return client.end();
|
||||
})
|
||||
.catch(() => {
|
||||
process.stdout.write('unreachable');
|
||||
try { client.end(); } catch (e) { /* already down */ }
|
||||
});
|
||||
Loading…
Reference in a new issue