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:
parent
a2f9c12b2d
commit
e8c0df027e
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
30
tests/_guard.js
Normal 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 };
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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])));
|
||||
|
||||
@ -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')).
|
||||
|
||||
@ -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: [] } }));
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 } }));
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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' };
|
||||
|
||||
|
||||
@ -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/',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user