pediatric-ai-scribe-v3/test/e2e-harness.test.js
Daniel 05dcd1146d test(e2e): repair the harness, taking the browser suite from 96 failures to 11
Three separate reasons tests were failing, none of them a defect in the app.

The calculators. e2e-harness.html loaded calculators.js and drugs-loader.js with
`defer` after they were split into ES modules; index.html was updated at the
time and this page was not. A module parsed as a classic script throws "Cannot
use import statement outside a module" before a line runs, so no click handler
was ever attached: the pills rendered from static HTML and did nothing. Only the
first calculator appeared to pass, because it carries `active` in the markup and
needs no click. That was 52 failures.

Settings and FAQ. Both moved from the tab rail into the account-card menu; the
helper still clicked button.tab-btn[data-tab=…] and timed out. Ten more.

The AI mocks, which had stopped intercepting for two independent reasons and so
were calling the real model on every run — spending credits and comparing
genuine output against strings like "MOCK HPI from dictation". A '**/api/x' glob
matches no URL on Playwright 1.50, and page.route fails silently when nothing
matches; measured against a real URL, that glob and '*/**/api/x' both matched
zero times where a regex matched. Fixing that alone was not enough: the app
registers a service worker that answers every /api/ request with its own
fetch(), and a request made inside a service worker never reaches page.route.
Blocking registration in the config puts them back in the page. The mocked
dictation test now finishes in 1.6s rather than 7.5s, which is what a real model
call costs.

Whole suite: 204 passed / 96 failed in 15.8 minutes, now 289 passed / 11 failed
in 6.8. The remaining eleven are spread across nine specs with no shared cause
and are not touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 18:54:03 +02:00

106 lines
5.9 KiB
JavaScript

// ============================================================
// E2E HARNESS
// ============================================================
// The browser suite is the only thing that exercises the app the way a person
// uses it, so the two ways it silently stops doing that are worth pinning.
//
// Both were found the same day: every browser-driving spec was failing, and had
// been, because the harness could not sign in at all.
// ============================================================
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
test('the browser suite runs against a loopback origin, because the app needs a secure context', () => {
// AccountBoundary mints a session generation with crypto.randomUUID() on
// every sign-in. On a non-loopback http origin the browser provides no
// crypto.randomUUID at all, so that call throws, the boot handler's catch
// swallows it, and every test lands on the login screen with a valid session
// in hand. Measured: isSecureContext false and randomUUID undefined on
// http://pediatric-ai-scribe-e2e:3000, both true on http://127.0.0.1:3553.
assert.match(read('public/js/accountBoundary.js'), /crypto\.randomUUID\(\)/,
'if this call is gone, the loopback requirement may have gone with it');
const config = read('e2e/playwright.config.js');
const fixtures = read('e2e/fixtures.js');
const runner = read('scripts/e2e.sh');
// host.docker.internal is a hostname, not loopback, and was the old default
// in all three places.
for (const [name, src] of [['config', config], ['fixtures', fixtures], ['runner', runner]]) {
assert.ok(!/host\.docker\.internal/.test(src), name + ' must not default to a non-loopback origin');
}
assert.match(config, /127\.0\.0\.1:3553/, 'playwright baseURL is loopback');
assert.match(fixtures, /127\.0\.0\.1:3553/, 'the fixtures authenticate against loopback');
assert.match(runner, /--network=host/, 'which needs the host network to reach the published port');
assert.match(read('docker-compose.e2e.yml'), /http:\/\/127\.0\.0\.1:3553/, 'and CORS has to allow it');
});
test('the e2e accounts are seeded, and the seed cannot touch a real one', () => {
const seed = read('e2e/seed.js');
// Both roles. Without an admin account nothing under /api/admin could be
// tested through a real request, which is how the Search Sources screen came
// to be checked by reading its markup instead.
assert.match(seed, /role: 'user'/);
assert.match(seed, /role: 'admin'/);
// It resets passwords and grants the admin role, so the domain guard is the
// part that matters: a mistyped environment variable must not be able to
// reach a real account.
assert.match(seed, /refusing to seed/);
assert.match(seed, /TEST_DOMAIN = '@ped-ai\.test'/);
assert.match(seed, /email\.slice\(-TEST_DOMAIN\.length\) !== TEST_DOMAIN/);
// Reconciles rather than only creating, so a leftover account with the wrong
// role cannot fail the suite for a reason unrelated to the code.
assert.match(seed, /UPDATE users SET password = \?, name = \?, role = \?, email_verified = true, disabled = false/);
// And the runner seeds before it tests, so nobody has to remember to.
assert.match(read('scripts/e2e.sh'), /node e2e\/seed\.js/);
});
test('the harness loads the calculators the way the app does', () => {
// calculators.js and drugs-loader.js are ES modules. Loaded with `defer` they
// are parsed as classic scripts and throw "Cannot use import statement outside
// a module" before a line runs, so no click handler is ever attached: the
// pills render from static HTML and do nothing. Measured in the browser, and
// it was 52 of the 96 e2e failures. Only the first calculator "passed",
// because it carries `active` in the markup and needs no click.
const harness = read('public/e2e-harness.html');
const app = read('public/index.html');
for (const file of ['calculators.js', 'drugs-loader.js']) {
const inApp = new RegExp('<script type="module" src="/js/' + file.replace('.', '\\.') + '"');
assert.match(app, inApp, file + ' is a module in the app');
assert.match(harness, inApp, 'so the harness must load it the same way');
}
assert.doesNotMatch(harness, /<script defer src="\/js\/(calculators|drugs-loader)\.js"/);
});
test('the AI mocks actually intercept, rather than calling the real model', () => {
const fixtures = read('e2e/fixtures.js');
// Two separate reasons the mocks were silently doing nothing, both measured.
//
// One: a '**/api/x' glob matches no URL on Playwright 1.50, and page.route
// fails silently when nothing matches.
assert.match(fixtures, /function asMatcher\(pattern\)/);
assert.match(fixtures, /await page\.route\(asMatcher\(pattern\)/);
// Two: the app registers a service worker that answers every /api/ request
// with its own fetch(), and a request made inside a service worker never
// reaches page.route. Blocking registration puts them back in the page.
assert.match(read('public/sw.js'), /url\.pathname\.startsWith\('\/api\/'\)/,
'if the worker stops handling /api this guard can be revisited');
assert.match(read('e2e/playwright.config.js'), /serviceWorkers: 'block'/);
});
test('Settings and FAQ are opened the way the app actually offers them', () => {
// Neither is on the tab rail; both live in the account-card menu with Admin.
const index = read('public/index.html');
for (const name of ['settings', 'faq']) {
assert.doesNotMatch(index, new RegExp('data-tab="' + name + '"'), name + ' is not a rail tab');
assert.match(index, new RegExp('data-account-tab="' + name + '"'));
}
const spec = read('e2e/tests/settings-faq-dictation.spec.js');
assert.match(spec, /const ACCOUNT_MENU = \['settings', 'faq'\];/);
assert.match(spec, /\[data-account-tab="\$\{name\}"\]/);
});