import { defineConfig, devices } from '@playwright/test' import { STORAGE_STATE } from './e2e/paths' const PORT = Number(process.env.E2E_PORT ?? 3210) const COLLAB_PORT = Number(process.env.GW_COLLAB_PORT ?? 3211) // localhost, not 127.0.0.1. Browsers treat localhost as the one insecure // origin where WebAuthn still works, and they refuse an IP address as a // Relying Party ID outright -- so the passkey tests cannot run against the // loopback address, however equivalent the two look. const baseURL = `http://localhost:${PORT}` /** * E2E is the narrow top of the pyramid: only flows that cannot be proven a * level down. Schedule arithmetic, ordering and duration parsing are unit * tested; what E2E adds is proof that the whole pipeline survives a real * browser -- server render, CSS tokens, responsive collapse, accessibility tree. * * See docs/testing-conventions.md for the flow list. */ export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, // Capped rather than "one per core". Every authenticated test now seeds its // own workshop through the collaboration room, and a dozen rooms opening at // once is what turned a green suite into a differently-red one on every run. // Fewer workers is also FASTER here -- less contention, less retrying. workers: process.env.CI ? 2 : 4, reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], use: { baseURL, /** * Pinned, and this is load-bearing rather than tidy. * * The application resolves its language from Accept-Language when there is * no session and no cookie. Playwright sends the host's locale, which in a * CI container is en-US -- so without this line the suite would render in * English and every German accessible name in e2e/ would stop matching at * once, with a failure that looks like broken markup rather than a language * switch. German is the source text; the suite asserts against it. * * English is covered deliberately, by e2e/locale.spec.ts, which sets its * own locale rather than relying on the environment. * * `locale` and not an explicit Accept-Language header: Playwright derives * the header from this, and a header set here would override -- silently -- * the locale that project sets for itself. */ locale: 'de-DE', // Only on a retry: traces and video on every run turn a fast suite into a // slow one and bury the interesting artefact among hundreds of boring ones. trace: 'on-first-retry', video: 'on-first-retry', screenshot: 'only-on-failure', }, projects: [ // What is left of the database-free half: the door. Everything about the // agenda itself moved -- the derivations into component tests, the layout // and interaction assertions into the authenticated projects below -- // when the public demo page that used to host them was removed. { name: 'desktop', use: { ...devices['Desktop Chrome'] }, testMatch: /(entry|setup)\.spec\.ts/, }, { name: 'mobile', use: { ...devices['Pixel 5'] }, testMatch: /(entry|setup)\.spec\.ts/ }, /** * The one project that is deliberately NOT German. * * Everything else pins de-DE so the suite keeps asserting the source text. * This one sets its own locale and no cookie, which is the only way to * exercise the Accept-Language leg of the resolution -- the leg a signed-out * first-time visitor actually arrives on. */ { name: 'locale', testMatch: /locale\.spec\.ts/, use: { ...devices['Desktop Chrome'], locale: 'en-US' }, }, // Everything past the login needs a database, so the authenticated tests // are skipped where there is none rather than failing confusingly. ...(process.env.DATABASE_URL ? [ { name: 'setup', testMatch: /auth\.setup\.ts/ }, { name: 'authenticated', testMatch: /(workshop|agenda)\.spec\.ts/, use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE }, dependencies: ['setup'], }, // The reading view is the most-used screen of this product per // workshop -- a facilitator reads the agenda on a phone, in the room. // It gets its own project rather than a viewport tweak inside one // test, and it needs a signed-in session now that the agenda it reads // is a real one. // // agenda.spec.ts deliberately does NOT run here. Its dragging and // in-row editing skip themselves below 1024px because the editor does // not mount there, and what remains -- horizontal overflow, the // footer -- has its own assertions in reading-view.spec.ts. Running // it anyway meant seeding a workshop through a layout that has no // editor, which is what timed out. // The website's pictures, taken from the running product. Its own // project because it writes files and is not part of a test run -- // the spec skips itself unless GW_CAPTURE is set. { name: 'marketing', testMatch: /marketing\.capture\.ts/, use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE }, dependencies: ['setup'], }, { name: 'authenticated-mobile', testMatch: /reading-view\.spec\.ts/, use: { ...devices['Pixel 5'], storageState: STORAGE_STATE }, dependencies: ['setup'], }, ] : []), ], webServer: [ { // Builds first, then runs the standalone server -- byte for byte the // artifact the Docker image ships. Testing against `next dev` would prove // the wrong thing, and `next start` does not work with output: standalone // at all (see scripts/start-standalone.mjs). // In CI the build is its own step, so a failing build reads as a failing // build rather than as a mysterious webServer timeout. command: process.env.E2E_SKIP_BUILD ? `PORT=${PORT} pnpm start` : `pnpm build && PORT=${PORT} pnpm start`, // No proxy in the harness, so the browser is told where the // collaboration server actually is. env: { // Where the app thinks it lives. Magic-link verification redirects // against this, so getting it from the ambient environment means a // developer's .env.local can send the browser to a port that is not // listening -- which reads as "the login is broken". GW_APP_URL: baseURL, // localhost, like baseURL: the session cookie is set for the host the // page was loaded from, and a socket to 127.0.0.1 is a different host // as far as the browser is concerned -- it would carry no cookie, and // the document would sit at "offline" with nothing in the log. GW_COLLAB_URL: `ws://localhost:${COLLAB_PORT}/collab`, // The address the app itself uses when an MCP write joins a room. // Separate from the one above because the browser goes through a // proxy in a real deployment and this process is inside it. GW_COLLAB_INTERNAL_URL: `ws://localhost:${COLLAB_PORT}/collab`, // Set here so the footer assertion has something to recognise. It also // proves the thing a component test cannot: that the layout is rendered // per request and not prerendered into the build, which in the Docker // image would freeze the version at whatever the builder stage knew. // // Both, because they are read by different things: GW_VERSION by // /api/health, GW_BUILD by the footer. See displayVersion in // src/lib/version.ts. GW_VERSION: 'e2e', GW_BUILD: 'e2e', }, // The root, not /api/health. Health is honest about needing a database -- // it returns 503 without one -- while "is the server listening" and "can // this container serve the app" are different questions, and the harness // is asking the first one. A redirect answers it just as well, which is // what `/` is now that the public demo page is gone. url: `${baseURL}/`, reuseExistingServer: !process.env.CI, timeout: 180_000, stdout: 'ignore', stderr: 'pipe', }, // The collaboration server. Without it a signed-in editor has nowhere to // write, so the authenticated tests would be asserting against a page that // silently keeps everything in the browser. ...(process.env.DATABASE_URL ? [ { command: 'node dist/collab-server.mjs', url: `http://127.0.0.1:${COLLAB_PORT}/health`, reuseExistingServer: !process.env.CI, timeout: 60_000, stdout: 'ignore' as const, stderr: 'pipe' as const, env: { GW_COLLAB_PORT: String(COLLAB_PORT), // The socket refuses an upgrade from a foreign Origin, and it // works out what "foreign" means from GW_APP_URL. In the image // this process shares an environment with the app; here it is a // separate one, so it has to be told -- otherwise it compares // the browser's Origin against the default localhost:3000 and // every authenticated test sees a document stuck at "offline". GW_APP_URL: baseURL, // Short enough that a test can wait for a write instead of // guessing at one. GW_COLLAB_PERSIST_MS: '50', GW_COLLAB_MATERIALIZE_MS: '150', GW_COLLAB_GRACE_MS: '200', }, }, ] : []), ], })