This document explains how the suite is layered, how data flows through a test, which gate enforces which boundary rule, and how the committed .claude/ SDET kit and the tools/ validators mechanically keep the architecture from eroding.
It is the deep companion to the short contract in ../CLAUDE.md and the constraints in CONSTRAINTS.md.
The repository is organised into six layers, pinned in ../tests-config.json. Imports flow in one direction only; a reverse import (e.g. a page object importing a spec) is a build failure, not a code-review nit.
flowchart TD
specs["specs/<br/>AAA tests, tags, assertions"]
fixtures["fixtures/<br/>mergeTests composition + DI"]
pages["pages/<br/>page objects: verb actions"]
factories["factories/<br/>Fishery + Faker, deterministic"]
support["support/<br/>purchase flow + boundary oracle (pure)"]
infra["infra/<br/>zod env loader, helpers"]
specs --> fixtures
specs --> support
fixtures --> pages
fixtures --> factories
fixtures --> infra
pages --> support
pages --> infra
factories --> infra
| Layer | Path | Responsibility | Must not |
|---|---|---|---|
infra/ |
tests/infra |
zod-validated env access, cross-cutting helpers | import from any layer above |
support/ |
tests/support |
pure test orchestration: payment-boundary oracle, purchase flow + page interfaces | import concrete page objects; contain expect |
pages/ |
tests/pages |
verb actions over private locators (BasePage + 6 POs) |
contain expect; import specs |
factories/ |
tests/factories |
type-safe synthetic data via Fishery + Faker | touch the network, Date.now, Math.random, top-level mutables |
fixtures/ |
tests/fixtures |
compose page objects + factories into injected deps | hold business logic |
specs/ |
tests/specs |
the actual tests — Arrange / Act / Assert | inline locators, raw fetch/axios, waitForTimeout |
There is no components/, api/, clients/, generated/, or data/ layer. Those were removed during the reorganization; this project is black-box UI E2E with no API contract under our control (see CONSTRAINTS.md §2.1).
The support/ layer depends on page objects only through interfaces (OfferLandingPage, PaymentStep) it declares itself — Dependency Inversion, so purchaseVpn is reusable across the RU and EN sites without knowing their concrete classes.
A representative happy-path spec (Scenario A — Sign Up) flows like this:
sequenceDiagram
participant Spec as spec (signup-flow.spec.ts)
participant Fix as fixture (mergeTests)
participant Fac as factory (user.factory)
participant PO as page object (Signup/Payment)
participant Site as live site (account.freevpnplanet.com)
Spec->>Fix: request injected pages + data
Fix->>Fac: build synthetic user (SEED=1234)
Fac-->>Fix: { email@yopmail.com, password, … }
Fix->>PO: construct page objects (origin-aware)
Fix-->>Spec: { homePage, signupPage, paymentMethodsPage, user }
Spec->>PO: actions (open → login → signup → plan → method)
PO->>Site: goto / click / programmatic input events
Site-->>PO: navigation to payment provider boundary
Spec->>Spec: poll reachedPaymentProvider(url) (STOP — no Pay)
Key invariants visible above:
- No
baseURL. Three origins are in play, so each page object carries an absolute URL derived fromenv.BASE_URL_*.goto()is always explicit. - Determinism. All synthetic data comes from Faker seeded once in
../tests/factories/_seed.ts(SEED=1234), so the same email/password is produced every run. - The payment boundary is a hard stop. Specs poll the pure oracle
reachedPaymentProvider(url, origins)in../tests/support/payment-boundary.tsand never submit card/crypto data (CONSTRAINTS.md§2.3). The oracle has no card-form interaction by construction.
The rules are not aspirational. Each is enforced by a script under ../tools/, wired into npm run validate, .husky/pre-commit, and CI. Exit code 0 = pass; non-zero fails the build.
| Boundary rule | Enforced by | Where it runs |
|---|---|---|
Specs don't import locators directly; *Page classes live only under tests/pages/ |
tools/validate-layout.sh |
npm run validate, pre-commit, CI |
BasePage contains no expect; no raw axios/fetch in specs |
tools/validate-layout.sh |
npm run validate, pre-commit, CI |
No waitForTimeout, no XPath, no inline page-object construction, no stray console.log |
tools/lint-ui-spec.ts |
npm run validate, pre-commit, CI |
Factories stay pure: no network, no Date.now/Math.random, no top-level mutable state |
tools/factory-rules.ts |
npm run validate, CI |
Every fixture calls await use(...); no built-in name collisions; scope sanity |
tools/fixture-rules.ts |
npm run validate, CI |
Repo health: tooling present, required files exist, .claude/ kit intact, no .env committed |
tools/verify.sh |
npm run verify |
All scripts read their paths from ../tests-config.json, so a layout change in one place propagates to every gate. See ../tools/README.md for the full table.
The kit is a committed, reusable engineering asset, not prompt history. It has three composable tiers plus a guardrail layer.
flowchart LR
subgraph commands["Slash commands — one-shot pipelines"]
c1["/test-new"]
c2["/test-fix"]
c3["/flake-hunt"]
end
subgraph agents["Subagents — isolated context"]
a1["test-design-agent"]
a2["flaky-detective"]
end
subgraph skills["Skills — single-responsibility"]
s1["requirements-to-test-design"]
s2["gherkin-test-case-author"]
s3["playwright-test-author-ui/api"]
s4["test-data-factory-builder"]
s5["fixture-architect"]
s6["test-code-reviewer"]
end
c1 --> s1 --> s2 --> s3
s3 --> s4
s3 --> s5
s4 --> s6
s5 --> s6
c1 -.delegates.-> a1
c3 -.delegates.-> a2
- Skills (
.claude/skills/) are the building blocks — each does one thing (design, author, review, analyze). - Subagents (
.claude/agents/) run a skill in an isolated context so a long design or flake hunt doesn't pollute the main conversation. - Slash commands (
.claude/commands/) chain skills/agents into repeatable pipelines (/test-new,/test-fix, …).
Two independent mechanisms keep the architecture honest regardless of who (human or agent) makes a change:
- Claude Code hooks (
.claude/settings.json) — guard the agent's actions in real time:PreToolUse Bash→guard-bash.shblocks destructive shell commands.PreToolUse Edit|Write→guard-paths.shblocks writes totests/api/generated, snapshots, and.env.PostToolUse Edit|Write→typecheck-touched.shtypechecks touched files immediately (formatting/lint are handled later, bylint-staged).Stop→ echoes a smoke-test reminder.
- Git-level validators (
../tools/) — guard every commit, agent-authored or not, via.husky/pre-commitand CI.
The skills carry portable copies of the same validators (e.g. .claude/skills/playwright-test-author-ui/scripts/lint-ui-spec.ts); the copies in tools/ are this repository's wired-in, active gates. The kit is the template; tools/ is the running instance.
For this black-box project, CONSTRAINTS.md §4 disables the OpenAPI-oriented capabilities (api-client-from-openapi, playwright-test-author-api, /spec-sync, contract-drift-watch). They remain committed to demonstrate that the workflow supports contract testing when a repo owns a spec — they are simply switched off here.
The test code is comment-free; intent is carried by names. The few facts a name cannot convey live here, so the knowledge is not lost.
BasePage.toggleHiddenControl(locator)— the offer/gateway radios and the terms checkbox are native inputs that are visually hidden behind custom-styled cards and sit outside the layout viewport. A normal.check()can't reach them, so the helper setscheckedand dispatches a bubblingchangeevent to drive the site's JS state.BasePage.fillReactively(locator, value)— Site A's/order/form (PrimeVue) only enables the Next + payment buttons afterinput,change, andblurfire; Playwright's native.fill()does not reliably emitchangeheadless. The helper dispatches the full event chain.SignupPage.fillEmailwaits for the plan toggle first — that toggle renders only after the Vue form hydrates, so its visibility is a reliable "listeners are attached" marker; without the wait, events fire on an un-hydrated input when arriving from/login/.reachedPaymentProvider(url, origins)— the boundary oracle. Success = navigated off our origins to a provider (or stayed in-house with agateway=invoice param), and the URL is not a*/failed|error|declinedpage. It never inspects card forms.- Known gaps (surfaced as
test.fixme/ omitted on purpose), seeux-findings.md: the RU site exposes no crypto gateway on/payment/; Site A's email validator is permissive (accepts addresses with no@), so that negative case is documented as a defect rather than asserted as passing.