test(e2e): kill cold-start flakiness with an /api/v1 fallback + one retry
A full parallel run intermittently failed the first few tests: unmocked /api/v1 calls fell through to the dev proxy, whose backend isn't running under E2E, so each incurred a slow ECONNREFUSED round-trip + Vite error logging that piled up across 8 workers at cold start. - Add e2e/fixtures.ts: a shared `test` whose auto-use fixture installs an `**/api/v1/**` fallback (fast 503, logged) for any endpoint a spec didn't mock. Registered before the test body, so per-test routes still win; only genuine gaps land here. Turns silent mock-gap hangs into instant, attributable failures — and cuts the full run ~96s → ~25s. - Point all 22 specs at ./fixtures instead of @playwright/test. - retries: 1 in the config as a safety net for the residual Vite cold-compile timing (re-runs on the now-warm server). Two consecutive full runs: 110 passed, 0 flaky, no retries consumed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// E2E for the admin Analysis section: coverage overview + badges, drill
|
// E2E for the admin Analysis section: coverage overview + badges, drill
|
||||||
// manga → chapter → page, the page-detail modal, and the enqueue actions.
|
// manga → chapter → page, the page-detail modal, and the enqueue actions.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// E2E for the admin Crawler "History" tab: the Live/History toggle, the
|
// E2E for the admin Crawler "History" tab: the Live/History toggle, the
|
||||||
// searchable/filterable job log, and inline requeue of a dead job. The
|
// searchable/filterable job log, and inline requeue of a dead job. The
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Mocks the auth endpoints at the network level so the journey is
|
// Mocks the auth endpoints at the network level so the journey is
|
||||||
// deterministic and doesn't require a live backend.
|
// deterministic and doesn't require a live backend.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Regression spec for the reader-back-loop bug: previously the reader's
|
// Regression spec for the reader-back-loop bug: previously the reader's
|
||||||
// back arrow was a plain `<a href="/manga/{id}">`, which PUSHED a new
|
// back arrow was a plain `<a href="/manga/{id}">`, which PUSHED a new
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
const mangaId = '22222222-2222-2222-2222-222222222222';
|
const mangaId = '22222222-2222-2222-2222-222222222222';
|
||||||
const userFixture = {
|
const userFixture = {
|
||||||
|
|||||||
43
frontend/e2e/fixtures.ts
Normal file
43
frontend/e2e/fixtures.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { test as base, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export { expect };
|
||||||
|
export type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Playwright `test` for the E2E suite.
|
||||||
|
*
|
||||||
|
* Adds an auto-use fallback for the `/api` surface: any request a spec
|
||||||
|
* forgot to mock is fulfilled here with a fast 503 instead of falling
|
||||||
|
* through to the SvelteKit dev proxy. That proxy targets a backend that
|
||||||
|
* isn't running under E2E (`BACKEND_URL` in `.env`), so an unmocked call
|
||||||
|
* otherwise incurs a proxy round-trip + Vite error logging — which, under
|
||||||
|
* 8 parallel workers at cold start, piles up and flakes the first tests.
|
||||||
|
*
|
||||||
|
* Because this route is registered during fixture setup (before the test
|
||||||
|
* body runs), any `page.route(...)` a spec registers later takes
|
||||||
|
* precedence — only genuinely-unmocked calls land here. The 503 also
|
||||||
|
* surfaces mock gaps loudly (a clear warning + a fast, attributable
|
||||||
|
* failure) instead of a 30s "element never appeared" timeout.
|
||||||
|
*/
|
||||||
|
export const test = base.extend<{ apiFallback: void }>({
|
||||||
|
apiFallback: [
|
||||||
|
async ({ page }, use) => {
|
||||||
|
// Scope to `/api/v1/**` (the real backend surface). A broader
|
||||||
|
// `**/api/**` would also swallow Vite's own dev module requests
|
||||||
|
// under `/src/lib/api/*.ts` and break the app bundle.
|
||||||
|
await page.route('**/api/v1/**', (route) => {
|
||||||
|
const { pathname } = new URL(route.request().url());
|
||||||
|
console.warn(`[e2e] unmocked API call: ${route.request().method()} ${pathname}`);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: { code: 'e2e_unmocked', message: `unmocked in test: ${pathname}` }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await use();
|
||||||
|
},
|
||||||
|
{ auto: true }
|
||||||
|
]
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
const userFixture = {
|
const userFixture = {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
import { SORT_FIELD_LABELS } from '../src/lib/mangaSort';
|
import { SORT_FIELD_LABELS } from '../src/lib/mangaSort';
|
||||||
|
|
||||||
// These E2E tests run against the SvelteKit dev server, which proxies /api
|
// These E2E tests run against the SvelteKit dev server, which proxies /api
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Phase 5: Account becomes an inset-grouped hub on mobile (Profile /
|
// Phase 5: Account becomes an inset-grouped hub on mobile (Profile /
|
||||||
// Preferences / Change password + red Log out at the bottom) and
|
// Preferences / Change password + red Log out at the bottom) and
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Mobile chrome contract: the AppBar + BottomNav are visible on phone
|
// Mobile chrome contract: the AppBar + BottomNav are visible on phone
|
||||||
// viewports and the desktop header is hidden. On desktop, the reverse.
|
// viewports and the desktop header is hidden. On desktop, the reverse.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Phase 2: the catalog (/) gets a mobile chrome — search input full
|
// Phase 2: the catalog (/) gets a mobile chrome — search input full
|
||||||
// width, Filter and Sort as chip buttons that open bottom sheets, and
|
// width, Filter and Sort as chip buttons that open bottom sheets, and
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Phase 3: the manga detail page gains a mobile hero (blurred backdrop
|
// Phase 3: the manga detail page gains a mobile hero (blurred backdrop
|
||||||
// + transparent app bar), a sticky bottom CTA whose wording reflects
|
// + transparent app bar), a sticky bottom CTA whose wording reflects
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Phase 4: the reader gains a mobile chrome — invisible tap zones for
|
// Phase 4: the reader gains a mobile chrome — invisible tap zones for
|
||||||
// prev/next/toggle, a chapter-jump bottom sheet, a reader-settings
|
// prev/next/toggle, a chapter-jump bottom sheet, a reader-settings
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// E2E for the per-page collection + tag flow added in v0.61.0. Mocks
|
// E2E for the per-page collection + tag flow added in v0.61.0. Mocks
|
||||||
// the entire API so the spec runs without a backend. The same
|
// the entire API so the spec runs without a backend. The same
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Guards the title-on-nav behavior: without this, a stale title from
|
// Guards the title-on-nav behavior: without this, a stale title from
|
||||||
// the last manga / author page lingers when the user navigates to a
|
// the last manga / author page lingers when the user navigates to a
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Network-level mocks for the private-mode UX. The backend integration
|
// Network-level mocks for the private-mode UX. The backend integration
|
||||||
// tests (api_private_mode.rs) cover the actual gate; here we only
|
// tests (api_private_mode.rs) cover the actual gate; here we only
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
const userFixture = {
|
const userFixture = {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
const mangaId = '33333333-3333-3333-3333-333333333333';
|
const mangaId = '33333333-3333-3333-3333-333333333333';
|
||||||
const chapter1Id = 'c1111111-3333-3333-3333-333333333333';
|
const chapter1Id = 'c1111111-3333-3333-3333-333333333333';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
const mangaId = '22222222-2222-2222-2222-222222222222';
|
const mangaId = '22222222-2222-2222-2222-222222222222';
|
||||||
const chapterId = 'c2222222-2222-2222-2222-222222222222';
|
const chapterId = 'c2222222-2222-2222-2222-222222222222';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// E2E for the `?page=N` deep-link path in both reader modes. The
|
// E2E for the `?page=N` deep-link path in both reader modes. The
|
||||||
// single-mode case has been working since v0.x; the continuous-mode
|
// single-mode case has been working since v0.x; the continuous-mode
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
const mangaId = '11111111-1111-1111-1111-111111111111';
|
const mangaId = '11111111-1111-1111-1111-111111111111';
|
||||||
const chapterId = 'c1111111-1111-1111-1111-111111111111';
|
const chapterId = 'c1111111-1111-1111-1111-111111111111';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// E2E for the /search page shipped in v0.62.0. Five scenarios against
|
// E2E for the /search page shipped in v0.62.0. Five scenarios against
|
||||||
// mocked endpoints — no backend needed.
|
// mocked endpoints — no backend needed.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
const userFixture = {
|
const userFixture = {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const E2E_PORT = 5174;
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: 'e2e',
|
testDir: 'e2e',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
// The specs are deterministic in isolation, but a full parallel run
|
||||||
|
// has a cold-start window: Vite compiles each route on first request,
|
||||||
|
// so the first tests across 8 workers contend on the dev server and
|
||||||
|
// can trip the timeout. One retry re-runs those on the now-warm server.
|
||||||
|
// (The `/api/v1` fallback in e2e/fixtures.ts removes the other flake
|
||||||
|
// source — unmocked calls stalling against the dead dev proxy.)
|
||||||
|
retries: 1,
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.E2E_BASE_URL ?? `http://localhost:${E2E_PORT}`,
|
baseURL: process.env.E2E_BASE_URL ?? `http://localhost:${E2E_PORT}`,
|
||||||
trace: 'retain-on-failure'
|
trace: 'retain-on-failure'
|
||||||
|
|||||||
Reference in New Issue
Block a user