From e8c0df027e1169b71194b23f399c9a1e689fd6f6 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 4 Oct 2026 21:00:25 +0200 Subject: [PATCH] test: fail any spec that would write to the live API Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ architecture/qa.md | 4 +++- tests/_guard.js | 30 ++++++++++++++++++++++++++++++ tests/archetype-gallery.spec.js | 2 +- tests/audiobook-emotion.spec.js | 2 +- tests/audiobook-spans.spec.js | 4 +++- tests/bundle.spec.js | 2 +- tests/characters-scope.spec.js | 3 ++- tests/characters.spec.js | 2 +- tests/constrained.spec.js | 2 +- tests/design-chips.spec.js | 2 +- tests/i18n.spec.js | 2 +- tests/inline-tags.spec.js | 2 +- tests/lazy-loading.spec.js | 2 +- tests/magazine.spec.js | 3 ++- tests/rehearser.spec.js | 2 +- tests/seed-autopick.spec.js | 2 +- tests/smoke.spec.js | 2 +- tests/vocal-isolation.spec.js | 2 +- tests/voice-bundle.spec.js | 2 +- tests/voice-health.spec.js | 2 +- tests/voice-provenance.spec.js | 2 +- tests/voice-sources-new.spec.js | 2 +- 23 files changed, 59 insertions(+), 21 deletions(-) create mode 100644 tests/_guard.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e41aea7..ff5e818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi --- ## [Unreleased] +### Changed +- Playwright specs now import `test` from `tests/_guard.js`, whose auto fixture aborts and fails any unmocked non-GET request to `/api/**` so a spec can no longer modify live settings. See `architecture/qa.md`. --- diff --git a/architecture/qa.md b/architecture/qa.md index 062cb8f..57ec601 100644 --- a/architecture/qa.md +++ b/architecture/qa.md @@ -28,7 +28,9 @@ npm run qa -- --list ## Test isolation -Playwright `baseURL` defaults to the live container (`http://localhost:7890`, override with `BASE_URL`). The specs mock every `/api/**` route (the live server only serves static files and the page), so no live data is written; use a throwaway instance via `BASE_URL` when in doubt. `--fast` never touches it. The secret scan prints only `file:line pattern-name`, never the match. +Playwright `baseURL` defaults to the live container (`http://localhost:7890`, override with `BASE_URL`). The specs mock every `/api/**` route (the live server only serves static files and the page), so no live data is written; use a throwaway instance via `BASE_URL` when in doubt. + +**Rule: specs must never write to the live API.** Every spec imports `test`/`expect` from `tests/_guard.js` (not from `@playwright/test`). Its auto fixture registers `page.route('**/api/**')` first; Playwright consults the most recently registered route first, so spec mocks win and only unmocked requests reach the guard. Any non-GET/HEAD/OPTIONS request that reaches it is aborted and the test fails at teardown naming `METHOD url`. Fix by adding a `page.route` mock in the spec, never by weakening the guard. Unmocked GETs are tolerated. Default inert mocks for app-init writes (design-preset sync, voice-source scrape) live in `_guard.js` and lose to any spec route. Limit: `request`/`page.request` (APIRequestContext) bypass page routes and are not covered. `--fast` never touches it. The secret scan prints only `file:line pattern-name`, never the match. ## Isolation and coverage diff --git a/tests/_guard.js b/tests/_guard.js new file mode 100644 index 0000000..db83c27 --- /dev/null +++ b/tests/_guard.js @@ -0,0 +1,30 @@ +// Single import path for all specs: `const { test, expect } = require('./_guard');` +// Auto fixture that makes a spec fail if it would send a write (non-GET/HEAD/OPTIONS) +// to /api/** on the live app. It is registered first, and Playwright consults the +// most recently registered route first, so spec mocks (page.route in the spec/beforeEach) +// win; only requests no spec mock fulfilled (or route.fallback()'d) reach this handler. +const base = require('@playwright/test'); + +const test = base.test.extend({ + // Registered after the guard, before any spec route: the app POSTs its design presets on every + // page load (voice-clone.js syncDesignPresetsToServer); answer it inertly. Spec mocks still win. + initWriteMocks: [async ({ page, liveWriteGuard }, use) => { + await page.route('**/api/voice-design-presets', (r) => (r.request().method() === 'POST' ? r.fulfill({ json: {} }) : r.fallback())); + // POST /api/voice-sources is the scrape triggered on opening the Studio section. + await page.route('**/api/voice-sources', (r) => (r.request().method() === 'POST' ? r.fulfill({ json: { total: 0, errors: [], sources: [], voices: [] } }) : r.fallback())); + await use(); + }, { auto: true }], + liveWriteGuard: [async ({ page }, use) => { + const violations = []; + await page.route('**/api/**', (route) => { + const { method, url } = { method: route.request().method(), url: route.request().url() }; + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return route.fallback(); + violations.push(`${method} ${url}`); + return route.abort('blockedbyclient'); + }); + await use(); + base.expect(violations, `Unmocked write(s) to the live API blocked; add a page.route mock in the spec:\n${violations.join('\n')}`).toEqual([]); + }, { auto: true }], +}); + +module.exports = { test, expect: base.expect }; diff --git a/tests/archetype-gallery.spec.js b/tests/archetype-gallery.spec.js index 2fed027..fb30f95 100644 --- a/tests/archetype-gallery.spec.js +++ b/tests/archetype-gallery.spec.js @@ -1,6 +1,6 @@ // @ts-check // Voice archetype gallery; every /api route is mocked (no live writes, no generation). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const items = Array.from({ length: 24 }, (_, i) => ({ id: 'v' + i, name_de: 'Stimme ' + i, name_en: 'Voice ' + i, instruct: 'Tiefe Stimme ' + i, diff --git a/tests/audiobook-emotion.spec.js b/tests/audiobook-emotion.spec.js index e545f52..11614e5 100644 --- a/tests/audiobook-emotion.spec.js +++ b/tests/audiobook-emotion.spec.js @@ -1,7 +1,7 @@ // @ts-check // Audiobook emotion path: span-cast segment emotion -> script -> rehearser line -> instruct -> /api/tts-preview. // Routes are mocked; nothing here reaches the live TTS API. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const WAV = Buffer.from('UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA=', 'base64'); diff --git a/tests/audiobook-spans.spec.js b/tests/audiobook-spans.spec.js index 9e4967b..0783455 100644 --- a/tests/audiobook-spans.spec.js +++ b/tests/audiobook-spans.spec.js @@ -3,7 +3,7 @@ // section, the pure segmenter works in the real page, and the engine switch honours // localStorage. The LLM-driven flow itself is covered by the unit tests and the // scored regression runs (architecture/audiobook-attribution.md). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); async function openReader(page) { await page.goto('/', { waitUntil: 'domcontentloaded' }); @@ -54,6 +54,7 @@ async function seedCast(page, firstPass, book = BOOK) { async function mockSpanEndpoint(page, plan) { const calls = []; + await page.route('**/api/rehearsals*', route => route.fulfill({ json: { id: 'reh-test' } })); // the warm-up request must never reach the real model server await page.route('**/api/attribute-dialogue', route => route.fulfill({ json: { segments: [] } })); await page.route('**/api/attribute-spans', route => { @@ -127,6 +128,7 @@ test('verify leaves the cast untouched when the endpoint fails, and declines a c await seedCast(page, { 'Na los!': 'Linosch', 'Vielen Dank!': 'Alrik' }); const before = await page.evaluate(() => JSON.stringify(window._audiobook.segments.map(s => [s.spanId, s.speaker]))); await page.route('**/api/attribute-dialogue', route => route.fulfill({ json: { segments: [] } })); + await page.route('**/api/rehearsals*', route => route.fulfill({ json: { id: 'reh-test' } })); await page.route('**/api/attribute-spans', route => route.fulfill({ status: 503, json: { detail: 'down' } })); await page.evaluate(() => window.audiobookVerifySpans(undefined, undefined, {})); const after = await page.evaluate(() => JSON.stringify(window._audiobook.segments.map(s => [s.spanId, s.speaker]))); diff --git a/tests/bundle.spec.js b/tests/bundle.spec.js index edeb65f..9b59878 100644 --- a/tests/bundle.spec.js +++ b/tests/bundle.spec.js @@ -1,7 +1,7 @@ // @ts-check // Verifies the opt-in production bundle (npm run minify โ†’ static/dist/main.min.js): // the app must load and behave identically through the single-bundle path. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); // Block the service worker so request visibility is deterministic (the SW can serve // cached responses without a network request, hiding them from page.on('request')). diff --git a/tests/characters-scope.spec.js b/tests/characters-scope.spec.js index 83ccbc8..c4248c3 100644 --- a/tests/characters-scope.spec.js +++ b/tests/characters-scope.spec.js @@ -1,6 +1,6 @@ // @ts-check // Global cast view (sidebar) vs. per-book characters inside Studio. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const mk = (book, n, prefix) => Array.from({ length: n }, (_, i) => ({ id: `${prefix}-${i}`, book, name: `${prefix}Hero${i}`, tags: '', voice: null, @@ -14,6 +14,7 @@ const CHARACTERS = [...mk(BOOK_A, 3, 'Al'), ...mk(BOOK_B, 4, 'Be'), ...mk(BOOK_C async function setup(page, { resume } = {}) { await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } })); await page.route('**/api/voices*', route => route.fulfill({ json: [] })); + await page.route('**/api/reader/docs/*/progress', route => route.fulfill({ json: { ok: true } })); await page.route('**/api/settings*', route => route.fulfill({ json: {} })); await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } })); await page.route('**/api/reader/docs/book-b', route => route.fulfill({ json: { id: 'book-b', title: BOOK_B, sentences: [] } })); diff --git a/tests/characters.spec.js b/tests/characters.spec.js index 7a796fc..f838785 100644 --- a/tests/characters.spec.js +++ b/tests/characters.spec.js @@ -1,7 +1,7 @@ // @ts-check // Character-library interaction coverage uses deterministic API fixtures so it // can run without a populated host voice directory or a configured provider. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const VOICES = [ { id: 'de_alice', name: 'Alice', enabled: true, has_ref: true, lang: 'de', gender: 'F', rating: 3 }, diff --git a/tests/constrained.spec.js b/tests/constrained.spec.js index 8c9bd77..dbc3865 100644 --- a/tests/constrained.spec.js +++ b/tests/constrained.spec.js @@ -1,7 +1,7 @@ // @ts-check // Lightweight constrained-device regression: the browser's 4 GB profile must // reduce shared batch concurrency before multiple audio/image operations run. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); test('4 GB browser profile caps shared batch concurrency at two', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); diff --git a/tests/design-chips.spec.js b/tests/design-chips.spec.js index 4680b8d..3c9793c 100644 --- a/tests/design-chips.spec.js +++ b/tests/design-chips.spec.js @@ -1,6 +1,6 @@ // @ts-check // Design-prompt attribute chips; all /api routes are mocked (no live writes). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); test('chips edit only their own phrase', async ({ page }) => { await page.route('**/api/**', route => route.request().method() === 'GET' diff --git a/tests/i18n.spec.js b/tests/i18n.spec.js index 6db0e90..57c25f4 100644 --- a/tests/i18n.spec.js +++ b/tests/i18n.spec.js @@ -1,6 +1,6 @@ // @ts-check // Verifies the language picker + German translation of the UI chrome. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); test('defaults to English and shows the language picker in Settings', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); diff --git a/tests/inline-tags.spec.js b/tests/inline-tags.spec.js index d530e21..4561cfd 100644 --- a/tests/inline-tags.spec.js +++ b/tests/inline-tags.spec.js @@ -1,6 +1,6 @@ // @ts-check // Inline-tags settings switch + syntax hints (all /api routes mocked). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); async function boot(page, lang) { /** @type {any[]} */ const patches = []; diff --git a/tests/lazy-loading.spec.js b/tests/lazy-loading.spec.js index fec68db..22ba86d 100644 --- a/tests/lazy-loading.spec.js +++ b/tests/lazy-loading.spec.js @@ -1,6 +1,6 @@ // @ts-check // Section-specific feature code should not be fetched until its section is opened. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); test('heavy feature modules load on demand', async ({ page }) => { const requests = []; diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 5bfc51e..37ff123 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -4,7 +4,7 @@ // (matching routes/magazine.py's actual response shapes) without needing a // live PyMuPDF/tesseract ingestion โ€” that pipeline has its own coverage in // tests/test_magazine.py and tests/test_magazine_routes.py. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const DOC_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -944,6 +944,7 @@ test('dropping a PDF file onto mag-dropzone triggers creation without browser na return route.fulfill({ json: { id: DOC_ID } }); }); await page.route(`**/api/reader/docs/${DOC_ID}/source*`, route => route.fulfill({ json: { ok: true } })); + await page.route('**/api/magazine/*/ingest*', route => route.fulfill({ json: { ok: true } })); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached' }); diff --git a/tests/rehearser.spec.js b/tests/rehearser.spec.js index fb7392a..02e53ac 100644 --- a/tests/rehearser.spec.js +++ b/tests/rehearser.spec.js @@ -1,7 +1,7 @@ // @ts-check // Functional safety net for the Script Rehearser โ€” guards the parser/cast pipeline // while rehearser.js is split into smaller modules (#8). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const SCRIPT = `FADE IN diff --git a/tests/seed-autopick.spec.js b/tests/seed-autopick.spec.js index 604fd86..c523e27 100644 --- a/tests/seed-autopick.spec.js +++ b/tests/seed-autopick.spec.js @@ -1,6 +1,6 @@ // @ts-check // Seed Finder "Auto-pick best": all /api routes mocked, nothing is ever saved or generated for real. -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const WAV = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA='; // empty 16-bit wav diff --git a/tests/smoke.spec.js b/tests/smoke.spec.js index 4a7620a..8dbe1b1 100644 --- a/tests/smoke.spec.js +++ b/tests/smoke.spec.js @@ -1,5 +1,5 @@ // @ts-check -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); // Console/page errors that are environmental (offline backends, missing engines) and // must NOT fail the smoke test. Real JS exceptions (ReferenceError, etc.) still fail. diff --git a/tests/vocal-isolation.spec.js b/tests/vocal-isolation.spec.js index 9ecc8b2..b9e4956 100644 --- a/tests/vocal-isolation.spec.js +++ b/tests/vocal-isolation.spec.js @@ -1,6 +1,6 @@ // @ts-check // "Remove background music" in the URL import card (all /api routes mocked). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); async function open(page, available) { await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } })); diff --git a/tests/voice-bundle.spec.js b/tests/voice-bundle.spec.js index 030ef99..9995f41 100644 --- a/tests/voice-bundle.spec.js +++ b/tests/voice-bundle.spec.js @@ -1,6 +1,6 @@ // @ts-check // Voice bundle export/import in the library toolbar (mocked routes only; never touches the live library). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); async function setup(page, calls) { await page.route('**/api/voices/import', async route => { diff --git a/tests/voice-health.spec.js b/tests/voice-health.spec.js index 6b4d14e..6a610d5 100644 --- a/tests/voice-health.spec.js +++ b/tests/voice-health.spec.js @@ -1,6 +1,6 @@ // @ts-check // Audio problems in the routing test result and the library voice panel (mocked routes only). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const PROBLEMS = [ { kind: 'silence', start_s: 2.0, end_s: 4.0, detail: '2.0s of silence' }, diff --git a/tests/voice-provenance.spec.js b/tests/voice-provenance.spec.js index 91aea2c..2cfbccf 100644 --- a/tests/voice-provenance.spec.js +++ b/tests/voice-provenance.spec.js @@ -1,6 +1,6 @@ // @ts-check // Source & consent panel in the voice inspector (all /api routes mocked; no live writes). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const VOICE = { id: 'EN_F_Test', path: '/voices/EN_F_Test.wav', file_type: 'wav', duration: 8, lang: 'EN', gender: 'F', enabled: true, has_ref: true, transcript: 'hi' }; diff --git a/tests/voice-sources-new.spec.js b/tests/voice-sources-new.spec.js index f58b70b..5e438de 100644 --- a/tests/voice-sources-new.spec.js +++ b/tests/voice-sources-new.spec.js @@ -1,6 +1,6 @@ // @ts-check // LibriVox / Wikimedia search + import in "Get Voices Online" (mocked routes only). -const { test, expect } = require('@playwright/test'); +const { test, expect } = require('./_guard'); const ITEMS = [{ source: 'librivox', title: 'Pride - Ch 1', reader: 'Ann', author: 'Jane Austen', language: 'English', duration: 600, licence: 'Public domain (LibriVox, USA)', licence_url: 'https://librivox.org/pages/public-domain/', source_url: 'https://librivox.org/b/',