Build and maintain Playwright tests in hycommerce-e2e
This is the working manual for the Schippers Test Automation Suite: a Playwright + TypeScript
project that exercises the Hycommerce storefronts (NL, BE, FR, DE) across Chrome, Firefox,
Safari (WebKit) and Edge. It covers the layered architecture, environment setup, how to read
and extend an existing test, how to write a brand-new one, and how the suite runs in CI and
reports back through this dashboard.
Every test in this repo is assembled from four layers that each have exactly one job.
Keeping the boundaries clean is what lets the same flow logic run against four countries
and four browsers without duplicating code.
13
smoke specs
7
e2e journeys
10
integration CSV fixtures
17
Playwright projects
The layered model
test-cases
tests/*/test-cases/ — the spec files Playwright discovers. Each test(...) owns the tag, the fixtures it needs, and the pass/fail assertion. No selectors live here.
step-definitions
tests/*/step-definitions/ — reusable flow logic (runCartBadgeCountSmoke, runPurchaseFlowForStore…) composed from page-object calls, wrapped in test.step() via reportStep.
page-objects
src/page-objects/{common,smoke,e2e}/ and src/page-objects/components/ — selectors and low-level actions only, one class per page or reusable UI component, all extending BasePage.
test-data
tests/test-data/{smoke,e2e}/ — per-country inputs, timeouts and copy patterns, usually resolved from env vars with a per-country fallback (getCountryEnv).
Smoke, e2e and integration
Smoke (tests/smoke/) — fast, focused checks of one storefront surface at a time (cart, PDP, navigation, footer…). These run in parallel and make up the bulk of the suite (13 spec files).
e2e (tests/e2e/) — broader, multi-page journeys: full purchase flows, real identity-provider login, HyCare sales-admin reordering, registration. Several run serial and/or --workers=1 because they share authenticated state.
Integration (tests/integration/) — a single data-driven spec that replays the Hycommerce storefront API contract from CSV fixtures, with no browser at all (it uses Playwright's request context, project integration).
tsconfig.json maps @/* to ./src/* (plus the narrower
@page-objects/* and @test-data/* aliases). Always import internal
modules through the alias instead of relative ../../ chains:
import { test, expect } from '@/fixtures/pages.fixture';
import { CartPage } from '@/page-objects/common/cart.page-object';
import { cartData, getCartData } from '@test-data/smoke/cart.data';
02Environment & setup
The suite talks to real acceptance storefronts behind Cloudflare Access, so setup is
mostly about installing browsers and supplying the right credentials and service-token
headers before the first run.
Install dependencies and browsers
npm install then npm run install:browsers (installs Firefox,
WebKit — used as safari — Chrome and Edge channels for Playwright).
Run npm run install:chrome / npm run install:edge separately
if you want the locally installed branded builds instead of Playwright's own channel
(installing Edge may prompt for your macOS password).
Create your .env
cp .env.example .env, then fill in the required values. playwright.config.ts
loads .env.example first for safe defaults and then overrides with .env
if it exists.
Set the required storefront and Cloudflare variables
BASE_URL (NL), BASE_URL_BE, BASE_URL_FR, BASE_URL_DE,
plus CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET for the
Cloudflare Access service token. Without the last two, playwright.config.ts
prints a warning and requests will not carry the bypass headers.
Add optional flow configuration as needed
PLAYWRIGHT_COUNTRIES / PLAYWRIGHT_BROWSERS to narrow the project
matrix, TEST_PRODUCT_PATH / TEST_PRODUCT_NAME_CONTAINS /
TEST_SEARCH_QUERY for storefront content, ACCEPTANCE_LOGIN_EMAIL/PASSWORD,
HYCARE_SALES_ADMIN_EMAIL/PASSWORD and the per-country HYCARE_*
values for authenticated e2e journeys, and REPORT_DATABASE_URL if you want
local runs to persist into the same Postgres history CI uses.
Run something to confirm it works
npm run test:smoke:cart is a good smoke check — it only needs
BASE_URL/BASE_URL_BE and the Cloudflare pair, and finishes quickly.
Required environment values
Variable
Used for
BASE_URL
NL storefront base URL (default project baseURL)
BASE_URL_BE / _FR / _DE
Per-country storefront base URLs for the -be, -fr, -de projects
CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET
Cloudflare Access service-token headers sent on every request
ACCEPTANCE_LOGIN_EMAIL / _PASSWORD
Shared credentials for Login, MS-Schippers and HyCare authenticated flows
HYCARE_SALES_ADMIN_EMAIL / _PASSWORD
Isolated sales-admin account for the HyCare impersonation + reorder journey
Per-country HyCare client/impersonation lookup data
HYCARE_PRODUCT_SKU / HYCARE_PRODUCT_SEARCH_TERM
Product used in the HyCare reorder flow
REPORT_DATABASE_URL
Postgres connection string for persistent run history (use a pooled Supabase connection in CI)
Cloudflare Access bypass.playwright.config.ts sets
CF-Access-Client-Id and CF-Access-Client-Secret globally under
use.extraHTTPHeaders, so every request — browser navigation and API calls alike —
carries the service-token bypass automatically. You never need to add these headers in a
page object or test.
03Anatomy of a test
Walking through the real Cart smoke suite shows how the four layers fit
together end to end.
1. The test-case — tests/smoke/test-cases/cart.smoke.spec.ts
Imports the fixtures, the step-definition functions, and the test data. Each test is one line of orchestration:
import { test } from '@/fixtures/pages.fixture';
import {
runCartAutoOpensSmoke,
runCartBadgeCountSmoke,
// …other step-definition functions
} from '../step-definitions/cart.steps';
import { cartData, getCartData } from '@test-data/smoke/cart.data';
test.describe('Cart', () => {
test.describe.configure({ mode: 'parallel' });
test.setTimeout(cartData.timeoutMs);
// TcSmokeAcct001: cart auto opens when item is added.
test('TcSmokeAcct001 - cart auto opens when item added', { tag: ['@quick-smoke'] }, async ({
page,
productPage,
cartPage,
baseURL,
}) => {
await runCartAutoOpensSmoke({ page, productPage, cartPage }, getCartData(baseURL));
});
});
Naming convention:Tc<Suite><Area><Number> - lowercase descriptive title,
e.g. TcSmokeAcct001, TcSmokeCart009, TcSmokeNavg003. The
numeric ID stays stable so historical runs in the Reports dashboard can be tracked across
renames of the descriptive part. The second options argument, { tag: ['@quick-smoke'] },
is how a test opts into a suite — see Tags, suites & selective runs.
2. The step-definition — tests/smoke/step-definitions/cart.steps.ts
Owns the reusable flow logic and reports readable steps into the trace/report via reportStep:
reportStep, attachVisitedUrls and attachSmokeContext come from
src/support/reporting/smoke.report.ts — thin wrappers around Playwright's
test.step() and test.info().attach() so every smoke run leaves a
structured trail (visited URLs, cart counts, product path) that shows up in the Reports
dashboard under each test's attachments.
3. The test data — tests/test-data/smoke/cart.data.ts
function getCartData(baseURL?: string) {
const country = getStorefrontCountry(baseURL) ?? 'nl';
const countryConfig = manualTestData.smoke.cart.byCountry[country];
return {
timeoutMs: 180_000,
maxAddToCartAttempts: 3,
cartOpenTimeoutMs: 12_000,
quantitySyncTimeoutMs: 20_000,
productPath: getCountryEnv('TEST_PRODUCT_PATH', country, countryConfig.productPath),
productNameContains: getCountryEnv('TEST_PRODUCT_NAME_CONTAINS', country, countryConfig.productNameContains),
cartPath: countryConfig.cartPath,
supportsAnonymousCart: countryConfig.supportsAnonymousCart,
quantityInputSelector: 'input[aria-label="Input Number"]:visible',
increaseButtonSelector: 'button[title="Increase"]:visible',
decreaseButtonSelector: 'button[title="Decrease"]:visible',
checkoutButtonPattern:
/verder naar bestellen|afrekenen|checkout|continuer la commande|zur kasse|.../i,
// …timeouts and localized text patterns for nl/be/fr/de
};
}
Test data resolves country from the fixture's baseURL, then layers
getCountryEnv('KEY', country, fallback) — a country-suffixed env var, then the
bare env var (NL only), then a hardcoded fallback from manualTestData
(tests/test-data/manual-test-data.ts / .json). This is why the same
cart spec runs unmodified against NL, BE, FR and DE.
4. The page object — src/page-objects/common/cart.page-object.ts
class CartPage extends BasePage {
async open(path = '/winkelwagen'): Promise<void> {
await this.goto(path);
await this.closeBlockingOverlays();
await this.waitForCartMergeDialogToClose().catch(() => {});
await expect(this.page).toHaveURL(this.cartUrlPattern);
}
async expectCartHasItems(): Promise<void> {
await expect
.poll(() => this.hasItems(), {
timeout: 20_000,
message: 'Expected the cart to contain at least one line item.',
})
.toBeTruthy();
}
}
Every page object extends BasePage (src/page-objects/common/base.page-object.ts),
which supplies a resilient goto(), clickFirstVisible() with a DOM-click
fallback, and closeBlockingOverlays() for cookie/privacy/merge-cart dialogs — see
Selectors & stability.
Fixtures — how the layers get wired together
src/fixtures/test.fixture.ts extends the base Playwright test with an
autocookieConsent fixture that hides the country-selection modal, retries
flaky page.goto() navigations, blocks the Trengo chat widget by default, and
auto-dismisses the cookie banner on every domcontentloaded event.
src/fixtures/pages.fixture.ts extends test.fixture.ts further and injects
one fixture per page object / component (homePage, cartPage, checkoutPage,
loginModal, siteHeader…), each a plain new SomePage(page).
import { test, expect } from '@/fixtures/pages.fixture';
// or, for page-level-only tests with no page objects:
import { test, expect } from '@/fixtures/test.fixture';
Use pages.fixture whenever a test needs page objects or components; use the lighter test.fixture for page-level-only checks.
04Writing a new smoke test
Follow the same order every time: data, then selectors, then flow, then the test case
itself. That order keeps you from hardcoding a locale string or a selector directly into
the spec file.
Add or extend a test-data file
In tests/test-data/smoke/<area>.data.ts, add a get<Area>Data(baseURL?)
function that resolves the storefront country and returns timeouts, paths and any
localized text patterns — follow the cart.data.ts pattern
(getStorefrontCountry + getCountryEnv + a manualTestData fallback).
Add selectors and actions to a page object
Put storefront selectors in the right src/page-objects/{common,smoke,e2e}/
folder — common if e2e might reuse it, smoke if it's smoke-only.
Extend BasePage, prefer getByRole/getByText/[data-testid]
locators, and keep any new component (modal, header widget) in
src/page-objects/components/.
Write the step-definition
In tests/smoke/step-definitions/<area>.steps.ts, compose page-object calls
into a run<Scenario>Smoke(context, data) function. Wrap the meaningful
actions in reportStep('…', async () => { … }) and finish with
attachSmokeContext('<scenario>-smoke-context', { … }) so the Reports
dashboard has something useful to show for the run.
Wire fixtures if you added a page object
If the page object is brand new, register it as a fixture in
src/fixtures/pages.fixture.ts (add to PageFixtures and the
test.extend block) so specs can request it by name.
Add the test case with the right tag
In tests/smoke/test-cases/<area>.smoke.spec.ts, add
test('TcSmoke<Area><NNN> - description', { tag: ['@quick-smoke'] }, async ({ … }) => { … }).
Pick the tag deliberately — see Tags, suites & selective runs —
and add locale-scoping tags (@nl-only, @be-only, @not-fr)
if the flow doesn't apply to every storefront.
Run it locally, then confirm typecheck and the wider suite
npx playwright test tests/smoke/test-cases/<area>.smoke.spec.ts --project=chrome
npm run typecheck
Then run the suite script that matches your tag (e.g. npm run test:quick:smoke)
to confirm the new test is actually picked up by the grep filter.
05Writing e2e & integration tests
e2e journeys and the integration spec both live outside the smoke tree because they carry
different constraints: real authentication, multi-page state, or no browser at all.
e2e journeys
e2e specs (tests/e2e/test-cases/) chain several page objects together — listing →
product → cart → checkout, or a full identity-provider login. Where a flow depends on
shared authenticated state (impersonation, a single sales-admin session), the
describe block runs serial and the npm script pins
--workers=1:
test.describe('Order', () => {
test.describe.configure({ mode: 'serial', timeout: orderData.timeoutMs });
test(
'OrderE2E001 - sales admin can impersonate a client and place a HyCare reorder',
{ tag: ['@hycare', '@full-regression', '@requires-auth', '@nl-only'] },
async ({
page,
accountLoginPage,
identityProviderPage,
siteHeader,
salesAdminPage,
hyCareOrderPage,
baseURL,
}) => {
/* … */
},
);
});
identityProviderPage (src/page-objects/common/identity-provider.page-object.ts)
drives the real Schippers identity provider login page (not a mocked auth step), asserting
the URL matches login.schippers.cloud / *.auth.hycommerce.schippers.cloud
before filling the password and submitting. The matching npm script cross-browsers the
journey while keeping it serial:
"test:hycare:nl:cross-browser": "playwright test tests/e2e/test-cases/order.e2e.spec.ts --project=firefox --project=chrome --project=safari --project=edge --workers=1"
HyCare currently ships NL-only test data, so playwright.config.ts excludes
tests/e2e/test-cases/order.e2e.spec.ts from the BE/FR/DE projects
(nlOnlyHyCareSpecs) until matching customer data exists for those storefronts.
Integration: the data-driven API contract spec
tests/integration/test-cases/api-endpoints.integration.spec.ts is a single spec
file that generates one Playwright test() per row of every CSV file in
tests/integration/testdata/ — no page objects, no browser, just
request context calls against the Hycommerce API:
To add a new contract check, add a row to an existing CSV (or a new *.csv file —
it's picked up automatically) rather than editing the spec. Run the whole set with
npm run test:integration, which targets the dedicated integration
Playwright project (no browser use.baseURL beyond the default storefront origin).
06Tags, suites & selective runs
Every test carries one suite tag and, where relevant, one or more scoping tags. The suite
tags decide which npm script picks the test up; the scoping tags decide which
country/browser projects it runs under.
Suite tags
Tag
npm script
What it runs
@quick-smoke
npm run test:quick:smoke / npm run test:smoke:chrome
Lean smoke subset for a fast storefront health check
@full-smoke
npm run test:full-smoke / npm run test:full-smoke:chrome
The full smoke spec set (superset of @quick-smoke)
@quick-regression
npm run test:quick:regression / :chrome
Critical smoke checks plus core e2e coverage
@full-regression
npm run test:full:regression / :chrome
Full Chrome NL+BE regression: smoke, e2e (incl. HyCare, My Account) and the integration API contract spec
@integration
npm run test:integration
Data-driven API contract checks against tests/integration/testdata/*.csv (project integration)
Scoping tags
Tag
Effect
@nl-only
Excluded from the BE, FR and DE projects via grepInvert in playwright.config.ts
@be-only
Excluded from the NL project's grepInvert (and thus effectively BE-scoped)
@not-fr
Excluded from the fr project only
@requires-auth
Marks a flow that needs ACCEPTANCE_LOGIN_* / HYCARE_* credentials configured — informational, doesn't change project scoping
@my-account
My Schippers account-area coverage, typically paired with @requires-auth
@hycare
HyCare sales-admin impersonation and reorder flow (NL-only test data today)
Direct Playwright invocations
npx playwright test --grep @quick-smoke --project=chrome --project=chrome-be
npx playwright test --grep @quick-regression --project=chrome --project=chrome-be
npx playwright test --grep @full-regression --project=chrome --project=chrome-be
npm run test:smoke:cart
npm run test:hycare:nl:cross-browser
npm run test:storefront-journeys
Browser projects are named <browser> for NL and <browser>-<country>
for BE/FR/DE (e.g. chrome, chrome-be, firefox-fr,
edge-de) — 4 browsers × 4 countries, plus the standalone integration
project. Narrow either axis with PLAYWRIGHT_BROWSERS / PLAYWRIGHT_COUNTRIES
or with explicit --project flags.
07Selectors & stability best practices
These tests run against live acceptance storefronts in four languages, so resilience isn't
optional — a hardcoded English label or a one-shot waitForTimeout will flake
the first time content or network timing shifts.
Do prefer getByRole(), getByText() and
[data-testid="…"] over positional CSS or XPath. Where copy is localized,
match with a single regex covering all four locales — see checkoutButtonPattern
and loginGatePattern in tests/test-data/smoke/cart.data.ts — rather
than four separate hardcoded strings.
Do reuse the explicit wait helpers in src/support/waits/page.wait.ts
(waitForPageStable, waitForNavigationPanel, waitForSearchResults)
and Playwright's expect.poll() for state synchronization instead of a bare
page.waitForTimeout(). BasePage.goto() and
CartPage.open() already retry on known-flaky navigation errors
(ERR_CONNECTION_CLOSED, ERR_ABORTED, frame-detached, etc.) — don't
re-implement that retry loop per page object.
Do let the framework handle cookie consent and blocking overlays. The
cookieConsent auto-fixture in src/fixtures/test.fixture.ts calls
acceptCookieBannerWithRetry (from src/utils/cookie-consent.ts) on
every navigation and DOM-ready event, and BasePage.closeBlockingOverlays()
dismisses privacy/cookie/merge-cart dialogs across nl/be/fr/de copy. New page objects get
this for free by extending BasePage — don't write a bespoke "click accept
cookies" step in a new test.
Watch for content drift. Because there's no mocked backend, a failing
assertion can mean the storefront copy or price genuinely changed, not that the test is
wrong. Before "fixing" a selector, check whether the CSV/regex/test-data value is simply
stale.
Don't put business-rule assertions inside a page object. Page objects may
expose narrow, poll-based readiness helpers (expectCartHasItems,
expectProceedToCheckoutVisible) that describe UI state, but the decision of
whether a scenario passes or fails belongs in the step-definition / test-case, where the
expected business behaviour is visible and reviewable. Also avoid raw XPath except as a
last-resort escape hatch (see CartPage.clickQuantityStepper's
ancestor-or-self:: fallback) — try role/text/test-id locators first.
08Maintaining tests & CI
Every local or CI run produces this same dashboard, so the Reports page is both your
results view and your primary triage tool.
Reporter & dashboard
scripts/reporting/dashboard.reporter.js is registered as a Playwright reporter in
playwright.config.ts. After every run it writes a static site to
playwright-report/ (this Manual page, the Reports overview, and the
build-copied Coverage page) and retains run history in .report-history/. If
REPORT_DATABASE_URL is set, the same run is also persisted to Postgres, and
npm run report:rebuild can rehydrate the latest stored runs and regenerate the
site without re-running any tests.
Pull requests into main — typecheck plus a quick-smoke and quick-regression gate on Chrome/NL
Each of the four deploy workflows follows the same pipeline:
Validates the Postgres report-history connection (npm run report:test-db).
Resolves the selected browsers, countries and environment override from the workflow inputs.
Runs the corresponding Playwright command.
Persists the latest run to Postgres.
Rebuilds the static dashboard and uploads it as a workflow artifact.
Deploys playwright-report/ to Azure Static Web Apps and writes the live dashboard URL to the job summary.
Manual workflow dispatch defaults to chromium on NL with the
current environment (other choices: acc, tst, dev).
Required secrets: AZURE_WEBBAPP_DEPLOYMENT_TOKEN, BASE_URL,
BASE_URL_BE, CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET,
REPORT_DATABASE_URL.
Triaging a flaky live-site failure
Open the failed test in the Reports dashboard — check the failure screenshot, trace, and any attachSmokeContext payload (visited URLs, cart counts, etc.) first.
Reproduce locally against the same browser/country project: npx playwright test <spec> --project=<browser>[-<country>] --headed.
Decide what changed: storefront copy/price drift, a genuine regression, or infra reachability (Cloudflare Access creds, acceptance environment downtime).
If it's a real timing flake, tighten the wait (a more specific expect.poll() condition or locator) rather than adding blind retries — the base config runs with retries: 0 outside CI-specific overrides.
If it's content drift, update the test-data pattern/value (and tests/test-data/manual-test-data.ts/.json if it's a shared fallback) rather than the assertion logic.
Maintenance checklist
Run npm run typecheck before pushing — tsconfig.json covers playwright.config.ts, src/**/*.ts and tests/**/*.ts.
Run npm run validate:test-data after touching tests/test-data/manual-test-data.json to make sure every country entry is complete.
Keep .env.example in sync whenever a test introduces a new environment variable.
Double-check the suite tag and any scoping tags (@nl-only/@be-only/@not-fr) match where the flow is actually expected to run.
When adding a page object, confirm it's registered in src/fixtures/pages.fixture.ts if any spec needs to request it as a fixture.
Prefer updating an existing CSV row over hand-editing the integration spec when an API contract changes.
Where to go next. Check the Coverage page to
see which storefront surfaces and My Schippers account areas already have automated
coverage before adding a new test. If you're using an AI coding assistant to help write
specs, point it at this Manual and the repository README so generated tests follow the
same layered conventions described here.
09Building tests with GitHub Copilot
This repo is set up so GitHub Copilot writes tests that already follow the layered
conventions above. There are three files to know, a recommended mode and model, and a
one-command way to scaffold a new test.
The files Copilot uses
.github/copilot-instructions.md
Loaded automatically by Copilot for every request in this repo — the
golden rules, layering, tag table and do-NOT list. You don't attach it; Copilot always
sees it. Keep it accurate.
docs/AI-TEST-AUTHORING-GUIDE.md
The deep guide: copy-paste skeletons, e2e/integration recipes, selector rules and the
full do-NOT list. Attach it in chat with #file:docs/AI-TEST-AUTHORING-GUIDE.md
when authoring.
.github/prompts/new-smoke-test.prompt.md
A reusable prompt file. Type /new-smoke-test in Copilot Chat
to scaffold a new smoke test (data → page object → step-definition → spec) that already
obeys every rule.
Recommended model
Use Gemini 2.5 Pro as the first choice, or GPT-5 as an
equally good alternative — pick it from the model dropdown in the Copilot Chat input.
A large-context, strong-coding model matters here because authoring a correct test means
holding the instructions file, the authoring guide and several page objects / step-defs in
context at once; Gemini 2.5 Pro's large context window does that comfortably. Avoid the
small "fast" models for authoring — they tend to inline selectors, skip a layer, or forget
the tag.
Which Copilot features to use
Agent mode — the create/edit mode that can touch several files and run the
test. Use it for building tests (Ask mode is only for questions).
Context references — #file:<path> to pin a specific file,
#codebase (or @workspace) to let Copilot search the repo, and
#selection for highlighted code.
Prompt files — /new-smoke-test runs the reusable prompt above.
Requires the chat.promptFiles setting (on by default in current Copilot).
Workflow
Open the area's existing trio
Open the nearest *.smoke.spec.ts, its *.steps.ts and
*.data.ts, plus the page object, so Copilot has concrete patterns to mirror.
Pick Agent mode + Gemini 2.5 Pro (or GPT-5)
Select the model in the chat input's model picker and switch the mode to Agent.
Run /new-smoke-test (or write your own prompt)
Answer the area / behaviour / tag inputs. Copilot scaffolds data → page object →
step-definition → spec across the right folders.
Make Copilot verify
Have it run npm run typecheck and the single test on Chrome, and iterate until
green. Review the diff against the golden rules before committing.
Example prompts
/new-smoke-test
area: product comparison
behaviour: adding a third product keeps the comparison bar stable and removable
tag: @full-smoke
#file:docs/AI-TEST-AUTHORING-GUIDE.md #codebase
Add a @quick-smoke test that asserts the PDP breadcrumb links back to its category
listing. Follow the layering: selectors only on the product page object, assertions in
the step-definition, wiring only in the spec. Then typecheck and run it on Chrome.
The same setup works for OpenAI Codex and other assistants. Point them at
docs/AI-TEST-AUTHORING-GUIDE.md and .github/copilot-instructions.md;
the conventions and definition-of-done are identical regardless of the tool.