test: fail any spec that would write to the live API

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 21:00:25 +02:00
parent a2f9c12b2d
commit e8c0df027e
23 changed files with 59 additions and 21 deletions

View File

@ -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`.
---

View File

@ -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

30
tests/_guard.js Normal file
View File

@ -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 };

View File

@ -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,

View File

@ -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');

View File

@ -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])));

View File

@ -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')).

View File

@ -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: [] } }));

View File

@ -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 },

View File

@ -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' });

View File

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

View File

@ -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' });

View File

@ -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 = [];

View File

@ -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 = [];

View File

@ -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' });

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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 } }));

View File

@ -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 => {

View File

@ -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' },

View File

@ -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' };

View File

@ -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/',