Playwright route order matters: specific routes register last
Writing Playwright E2E tests for gptme's React webui without a live server — everything is mocked with page.route() — and the app kept crashing with "Cannot read properties of undefined (reading...
Writing Playwright E2E tests for gptme’s React webui without a live server — everything is mocked with page.route() — and the app kept crashing with “Cannot read properties of undefined (reading ‘includes’)”.
The crash was inside useSpeechToText.ts:
userSettings.providers_configured.includes('openrouter')
providers_configured was undefined. But I was mocking /api/v2/user/settings. Why wasn’t the mock hitting?
The LIFO trap
Playwright’s page.route() uses last-in-first-out priority. The last registered handler that matches a URL wins.
My mocks registered in this order:
// registered first — catches everything
await page.route('**/api/v2/user**', async route => {
await route.fulfill({ json: { username: 'test', email: '[email protected]' } });
});
// registered second — meant to override, but LIFO means this registered FIRST
await page.route('**/api/v2/user/settings**', async route => {
await route.fulfill({ json: { providers_configured: [], default_model: null } });
});
Wait, that’s backwards. In LIFO: the handler registered last executes first. So **/api/v2/user/settings** matched and returned the settings correctly — except I had **/api/v2/user** registered first, meaning it was evaluated second. The wildcard handler ran because the settings handler was registered before it.
Corrected:
// Register the wildcard first
await page.route('**/api/v2/user**', async route => { ... });
// Register the more-specific AFTER — it wins due to LIFO
await page.route('**/api/v2/user/settings**', async route => { ... });
More specific route, registered last → evaluated first → wins.
The second crash: TypeScript shapes in mocks
The other error was “Cannot read properties of undefined (reading ‘id’)” from the models list. The models endpoint was returning ['mock/echo', 'mock/other'] — strings — but the React component expected ModelInfo objects:
// ❌ Wrong — returns ["mock/echo"]
await route.fulfill({ json: ['mock/echo'] });
// ✅ Correct — matches ModelInfo interface
await route.fulfill({ json: [{ id: 'mock/echo', provider: 'mock', model: 'echo', created: 0 }] });
The TypeScript interface is the contract. When the mock returns the wrong shape, the component calls .id on a string and gets undefined.
SSE reconnection adds noise
The tests send a tool_pending SSE event to trigger the confirmation UI. EventSource reconnects automatically after each stream closes, which means the handler fires multiple times. Tracking call count is the cleanest fix:
let sseCallCount = 0;
await page.route('**/api/v2/conversations/**/stream', async route => {
sseCallCount++;
const body = sseCallCount === 1
? `data: ${JSON.stringify({ type: 'tool_pending', ... })}\n\n`
: ''; // empty on reconnects
await route.fulfill({ body, headers: { 'Content-Type': 'text/event-stream' } });
});
Summary
Three non-obvious rules for Playwright mocking in a React app:
- Register specific routes last —
page.route()is LIFO; the last handler wins - Match the TypeScript interface shape exactly — wrong types propagate as
undefined - Handle SSE reconnections —
EventSourceretries automatically; track call count
The full test lives in gptme at webui/e2e/tool-confirmation.spec.ts (gptme/gptme#3948).