11.3 — The config, decoded
Every Playwright question that begins “why does my test…” ends in this file. So before locators, before actions, playwright.config.ts gets the 8.6 treatment: every line decoded, until nothing in it is mystery furniture.
You’ve done this twice (package.json in 8.6; the CI file comes in 11.16). Same detective method, richest file yet.
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.BASE_URL
?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
});The method is 8.6’s, verbatim: read every line, pin its job to the board, trust nothing you can’t explain. This file is the suite’s CONTRACT — the runner consults it before touching a single test, so understanding it means understanding every run you’ll ever start.
The deeper story, with the real names for things — this part is what turns “I saw it” into “I can explain it.”
Where env values come from in practice: teams load a local .env file at the top of the config (import "dotenv/config" — 9.4’s layered sources), while CI injects the same names from its vault (11.16). The config reads process.env.* either way — one reading site, two suppliers.
Settings cascade: the config’s use{} is the base layer, a project (11.12) can override it, a file can (test.use({…})), and a single test can too. Most-specific wins — the same mental model as CSS specificity (7.x), applied to test settings.
Two more residents you’ll meet in real configs: projects (the cross-browser array — all of 11.12) and webServer (start the app before testing — also 11.12). And globalSetup/globalTeardown for run-once work — 11.11 uses the projects flavor of that idea for login.
Job note: when a suite behaves differently on CI than locally, diff the EFFECTIVE config first — nearly always it’s a process.env.CI ? … : … line doing exactly what it says (retries masking a flake, workers changing order). You now read those lines at a glance; that instinct alone will save you afternoons.
⌨️ the config resolver
The config asks the environment two questions (9.4’s plumbing). Build the resolver that answers them, and print the effective settings for a local run and a CI run.
requirements:
- Write
resolveRetries(env):2whenenv.CIis set,0otherwise (a ternary reads best). - Write
resolveBaseURL(env):env.BASE_URLwith the honest default"http://localhost:3000"(8.4’s operator, exactly as the real config uses it). - Print one line per world with a template literal:
local: 0 retries → http://localhost:3000for an empty env, andci: 2 retries → https://staging.shop.comfor{ CI: "true", BASE_URL: "https://staging.shop.com" }.
when you press RUN, the console must show exactly:
✏️ Quick check 1
timeout: 30_000 and expect.timeout: 5_000 — which one is the budget for a SINGLE retrying assertion?
✏️ Quick check 2
retries is 2 on CI but 0 locally. Why zero at your desk? (short phrase)
✏️ Quick check 3
With baseURL set, what does page.goto("/") navigate to?
🗣️ Now teach it back
Give the full config briefing: the two nested timeouts, why retries and workers read process.env.CI, what the use block is (and how baseURL gets aimed), and the artifacts trio’s policy logic.
Write it as if your friend is sitting next to you. Saved to your journal — future-you will use these notes to teach others.