test(e2e): stage 2 — auth-gated pages (11 tabs × 2 viewports, 22 tests)
Adds a second containerized instance of the app with Turnstile + SMTP disabled so Playwright can log in without a bot challenge. - docker-compose.e2e.yml: pediatric-ai-scribe-e2e on port 3553. Shares postgres + pgdata with main so seeded test users (*@ped-ai.test) persist. - Test user: e2e-user@ped-ai.test (created once via /api/auth/register against the e2e container — SMTP is off so register auto-verifies). - Tests log in once per worker via /api/auth/login (module-scoped token cache) then inject the ped_auth cookie into each test's browser context. This avoids the 10-per-15-min login rate-limit. - Mobile viewport opens the sidebar via #btn-menu-toggle before clicking tab buttons (which are hidden behind the hamburger <=768px). Coverage: encounter, wellvisit, chart, vaxschedule, catchup, learning, dictation, settings, calculators, faq + landing-page-after-login. Each test clicks the tab, waits for the lazy component to render (>100 chars), and asserts a known anchor string is present. Total suite: 128 tests passing (53 desktop + 53 mobile Bedside/top-calcs + 11 desktop + 11 mobile auth-gated).
This commit is contained in:
parent
c7cc62e979
commit
176edd9fed
2 changed files with 147 additions and 0 deletions
40
docker-compose.e2e.yml
Normal file
40
docker-compose.e2e.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Bring up with:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
|
||||||
|
#
|
||||||
|
# Tear down with:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.e2e.yml down pediatric-scribe-e2e
|
||||||
|
|
||||||
|
services:
|
||||||
|
pediatric-scribe-e2e:
|
||||||
|
build: .
|
||||||
|
image: ped-ai-local:latest
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3553:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# Disable Turnstile bot-check so tests can log in without a challenge
|
||||||
|
TURNSTILE_SECRET_KEY: ""
|
||||||
|
# Disable SMTP so register auto-verifies the user and returns a session
|
||||||
|
SMTP_HOST: ""
|
||||||
|
volumes:
|
||||||
|
- scribe-logs-e2e:/app/data/logs
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
container_name: pediatric-ai-scribe-e2e
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
scribe-logs-e2e:
|
||||||
107
e2e/tests/auth-gated-smoke.spec.js
Normal file
107
e2e/tests/auth-gated-smoke.spec.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Smoke tests for pages behind the auth wall. Runs against the separate
|
||||||
|
// `pediatric-ai-scribe-e2e` container (port 3553 on host, 3000 internal) which
|
||||||
|
// has TURNSTILE_SECRET_KEY="" + SMTP_HOST="" so tests can log in without a
|
||||||
|
// bot challenge and register auto-verifies.
|
||||||
|
//
|
||||||
|
// Each test logs in via the API (no UI interaction needed) and injects the
|
||||||
|
// session cookie into the browser context.
|
||||||
|
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const E2E_BASE_INTERNAL = 'http://pediatric-ai-scribe-e2e:3000';
|
||||||
|
const E2E_BASE_EXTERNAL = 'http://host.docker.internal:3553'; // only used when running Playwright on host
|
||||||
|
const E2E_BASE = process.env.E2E_AUTH_BASE_URL || E2E_BASE_INTERNAL;
|
||||||
|
|
||||||
|
const TEST_EMAIL = 'e2e-user@ped-ai.test';
|
||||||
|
const TEST_PASSWORD = 'E2E-testPassword123!';
|
||||||
|
|
||||||
|
// Module-scoped token cache — one login per Playwright worker (config uses
|
||||||
|
// workers:1, so effectively one login for the whole run). Without this we
|
||||||
|
// hit the 10/15min login rate-limit on the first pass.
|
||||||
|
let _tokenCache = null;
|
||||||
|
|
||||||
|
async function getAuthToken(request) {
|
||||||
|
if (_tokenCache) return _tokenCache;
|
||||||
|
const r = await request.post(E2E_BASE + '/api/auth/login', {
|
||||||
|
data: { email: TEST_EMAIL, password: TEST_PASSWORD },
|
||||||
|
});
|
||||||
|
if (!r.ok()) {
|
||||||
|
const text = await r.text();
|
||||||
|
throw new Error(`Login failed (status ${r.status()}): ${text}`);
|
||||||
|
}
|
||||||
|
const body = await r.json();
|
||||||
|
if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body));
|
||||||
|
_tokenCache = body.token;
|
||||||
|
return _tokenCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAs(context, request) {
|
||||||
|
const token = await getAuthToken(request);
|
||||||
|
const url = new URL(E2E_BASE);
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: 'ped_auth',
|
||||||
|
value: token,
|
||||||
|
domain: url.hostname,
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Auth-gated pages — main tabs', () => {
|
||||||
|
test.beforeEach(async ({ context, request }) => {
|
||||||
|
await loginAs(context, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Landing page shows tab navigation after login', async ({ page }) => {
|
||||||
|
await page.goto(E2E_BASE + '/');
|
||||||
|
await expect(page.locator('button.tab-btn').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each auth-gated tab test: activate the tab, assert its container becomes
|
||||||
|
// visible + contains the expected anchor string. We use getTabPanel helpers
|
||||||
|
// because components load lazily from /components/<tab>.html.
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'encounter', anchor: /New Encounter|encounter|SOAP/i },
|
||||||
|
{ name: 'wellvisit', anchor: /Well Visit|well visit/i },
|
||||||
|
{ name: 'chart', anchor: /Chart|visits|patients/i },
|
||||||
|
{ name: 'vaxschedule', anchor: /Vaccine|schedule|dose/i },
|
||||||
|
{ name: 'catchup', anchor: /Catch-up|catch up|schedule/i },
|
||||||
|
{ name: 'learning', anchor: /Learning|quiz|topic/i },
|
||||||
|
{ name: 'dictation', anchor: /Dictation|record|transcrib/i },
|
||||||
|
{ name: 'settings', anchor: /Setting|profile|preferences|account/i },
|
||||||
|
{ name: 'calculators', anchor: /Pediatric Calculator|BP Percentile|BMI/i },
|
||||||
|
{ name: 'faq', anchor: /FAQ|question|answer/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, anchor } of tabs) {
|
||||||
|
test(`${name} tab loads content`, async ({ page, viewport }) => {
|
||||||
|
await page.goto(E2E_BASE + '/');
|
||||||
|
// On mobile the sidebar is hidden behind a hamburger. Open it so the
|
||||||
|
// tab buttons become interactable.
|
||||||
|
if (viewport && viewport.width <= 768) {
|
||||||
|
await page.click('#btn-menu-toggle').catch(() => {});
|
||||||
|
}
|
||||||
|
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
|
||||||
|
const tabBtn = page.locator(`button.tab-btn[data-tab="${name}"]`);
|
||||||
|
const isHidden = await tabBtn.evaluate((el) => el.classList.contains('hidden')).catch(() => true);
|
||||||
|
test.skip(isHidden, `Tab "${name}" is hidden for this user role`);
|
||||||
|
await tabBtn.click();
|
||||||
|
// Wait for lazy component load to complete
|
||||||
|
await page.waitForFunction(
|
||||||
|
(t) => {
|
||||||
|
const el = document.getElementById(t + '-tab');
|
||||||
|
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
|
||||||
|
},
|
||||||
|
name,
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
await expect(page.locator(`#${name}-tab`)).toContainText(anchor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue