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('