From 05dcd1146dce21a786a3fa7c651c868c6d8859f1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 18:54:03 +0200 Subject: [PATCH] test(e2e): repair the harness, taking the browser suite from 96 failures to 11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- e2e/fixtures.js | 17 ++++++++- e2e/playwright.config.js | 6 ++++ e2e/tests/settings-faq-dictation.spec.js | 13 ++++++- public/e2e-harness.html | 12 +++++-- test/e2e-harness.test.js | 46 ++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/e2e/fixtures.js b/e2e/fixtures.js index 5cec0802..2bd2a368 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -85,6 +85,21 @@ async function loginAs(context, request, email = TEST_EMAIL) { }]); } +// A '**/api/x' glob stopped matching any URL when Playwright went to 1.50, and +// page.route fails silently: no error, no warning, the request simply goes to +// the server. So every "mocked" AI test was calling the real model and +// comparing its genuine output against a canned string — spending real credits +// on every run and failing for a reason that looked like a UI bug. Measured: +// against http://127.0.0.1:3553/api/health, '**/api/health' and '*/**/api/health' +// both matched zero times; a regex matched. +// +// The patterns are kept as strings because they are also the keys callers pass +// in `overrides`, and turned into anchored regexes here. +function asMatcher(pattern) { + const path = pattern.replace(/^\*\*/, ''); + return new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[?#]|$)'); +} + // ── AI mock — intercepts generation endpoints ────────────── // Canned response shape matches what each route's frontend expects. // Override per-test by passing {pattern: responseFn} in overrides. @@ -111,7 +126,7 @@ async function mockAI(page, overrides = {}) { for (const { pattern, response } of routes) { const override = overrides[pattern]; - await page.route(pattern, async route => { + await page.route(asMatcher(pattern), async route => { const resp = typeof override === 'function' ? await override(route.request()) : (override || response); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(resp) }); }); diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 9fafb833..02d16a79 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -28,6 +28,12 @@ module.exports = defineConfig({ reporter: [['list']], use: { baseURL: process.env.BASE_URL || 'http://127.0.0.1:3553', + // The app registers a service worker that answers every /api/ request with + // its own fetch(). A request made inside a service worker never reaches + // page.route, so mockAI could not intercept anything while one was running + // and the tests called the real model. Blocking registration puts the + // requests back in the page, where the mocks can see them. + serviceWorkers: 'block', trace: 'retain-on-failure', screenshot: 'only-on-failure', actionTimeout: 5_000, diff --git a/e2e/tests/settings-faq-dictation.spec.js b/e2e/tests/settings-faq-dictation.spec.js index e86366c9..fe216d04 100644 --- a/e2e/tests/settings-faq-dictation.spec.js +++ b/e2e/tests/settings-faq-dictation.spec.js @@ -5,6 +5,12 @@ const { test, expect, E2E_BASE, mockAI } = require('../fixtures'); +// Settings and FAQ are not on the tab rail. They live in the account-card menu +// alongside Admin, and this helper used to click button.tab-btn[data-tab=…] for +// them, which simply timed out — the cause of ten of these failures. Dictation +// really is a rail tab, so both routes are needed. +const ACCOUNT_MENU = ['settings', 'faq']; + async function openTab(page, name) { await page.goto(E2E_BASE + '/'); await page.waitForSelector('button.tab-btn', { timeout: 15000 }); @@ -12,7 +18,12 @@ async function openTab(page, name) { if (vp && vp.width <= 768) { await page.click('#btn-menu-toggle').catch(() => {}); } - await page.click(`button.tab-btn[data-tab="${name}"]`); + if (ACCOUNT_MENU.includes(name)) { + await page.locator('.account-card-btn').first().click(); + await page.locator(`[data-account-tab="${name}"]`).first().click(); + } else { + await page.click(`button.tab-btn[data-tab="${name}"]`); + } await page.waitForFunction((t) => { const el = document.getElementById(t + '-tab'); return el && el.classList.contains('active') && el.innerHTML.trim().length > 100; diff --git a/public/e2e-harness.html b/public/e2e-harness.html index 6fc88b4a..b268ad9a 100644 --- a/public/e2e-harness.html +++ b/public/e2e-harness.html @@ -20,9 +20,17 @@ calc-nav-pill (which no longer exists). -->
+ - - + + diff --git a/test/e2e-harness.test.js b/test/e2e-harness.test.js index bedde3c6..581e43ae 100644 --- a/test/e2e-harness.test.js +++ b/test/e2e-harness.test.js @@ -58,3 +58,49 @@ test('the e2e accounts are seeded, and the seed cannot touch a real one', () => // 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('