E2E testing guide
Audience: developers & AI agents writing or running the Playwright suite · Scope: the local stack, the projects, the test accounts, the settings/env, common specs & patterns, and how to add a test · Last reviewed: 2026-07-31
Part of Testing. New to the vocabulary? Start with concepts.md. Install & run:
tests/README.md.
TL;DR — The scripted suite lives in tests/README.md and drives
https://betacalco.ddev.site with a real browser. Two site quirks trip up automation — the
self-signed cert (ignore HTTPS errors) and the Cookiebot consent banner (replay real consent from a
saved storageState, never disable it). Logged-in specs run as one automation account; anonymous specs
authorise the follow-up page with a minted grant token. Pipedrive writes are captured, not sent.
The local stack
- Front-end:
https://betacalco.ddev.site— a self-signed cert, so Playwright must ignore HTTPS errors (use: { ignoreHTTPSErrors: true }, already set inplaywright.config.ts; evencurlneeds-k). See ../configbox/environment.md. - Backend (Joomla):
…/administrator— does not load the front-end Cookiebot script, so no consent banner on backend pages.
Cookie consent (Cookiebot) — replay it, don't disable it
The site loads Cookiebot; consent lives in a cookie named CookieConsent, and the front-end tracking
gate reads it (trackingmanager.js → hasConsent('statistics'|'marketing') — see
../features/tracking.md). Because the consent choice changes site behaviour
(which pixels load and fire), don't strip Cookiebot out for tests — that tests a different site. Instead
grant real consent once and replay it: the setup project (setup/consent.setup.ts) clicks the real
"Allow all" and saves the browser storageState to tests/.auth/consent.json; every project loads that
state, so the genuine CookieConsent cookie is already present and the banner never shows.
Hand-setting the cookie is fragile — Cookiebot's own widget may still render without its full
stamp/ver/utcfields. Capturing real consent viastorageStateis the robust route.
The projects (how a run is wired)
playwright.config.ts runs single-worker, not parallel (shared capture file + one seeded quote), 180 s
timeout. Projects chain via dependencies / teardown:
| Project | Does | Depends on |
|---|---|---|
setup | grants Cookiebot consent → .auth/consent.json | — |
setup-agent | logs in as the automation account → .auth/agent.json | setup |
setup-seed | seeds a realistic Decorative quote (drives the configurator) → .auth/seed-quote.json | setup-agent |
cleanup-seed | deletes the seeded quote (teardown of setup-seed) | — |
anon | the not-logged-in specs (specs/anon/**), replaying consent only | setup, setup-seed |
agent | the logged-in specs (specs/agent/**), replaying the agent session | setup-agent |
backend | Joomla-backend specs (specs/backend/**) as the automation admin; each spec logs in itself via support/backendLogin.ts (password + generated TOTP — no consent, no stored state) | — |
Test accounts — and how to use them
The suite acts as one canonical automation account — test-automation-agent@betacalco.com (use it for
every logged-in spec):
- It exists on both
devandlive(same login), so the suite can target either. - It sits in the Agent Joomla group + the "US Agent" ConfigBox customer group, on the bogus test rep branch → the bogus test Pipedrive organisation, DEV org 6034. So its quotes resolve to org 6034 and its deals land in the bogus org on every environment — which is why it's safe on live (the team is aware). The everyday suite still defaults to capture mode, so it asserts payloads without sending.
- Password lives only in git-ignored
tests/.env(E2E_AGENT_USER/E2E_AGENT_PASS);.env.examplecarries the username.setup/agent.setup.tslogs in once and saves.auth/agent.json; theagentproject replays it — individual agent specs never log in, they just get a logged-inpage. - The test-support endpoint also acts as this account, so its powerful mutations stay on the bogus-org data.
The backend account — test-automation-admin@betacalco.com (forced 2FA). For specs and browser
automation that need the Joomla backend (on dev, staging and live; Administrator +
Infor to Google Sheets Exporters groups, plus Specifiers on dev):
- The
Administratorgroup is incom_users'forceMFAUserGroups, so backend login always demands a second factor. The account is enrolled with TOTP (the only enabled MFA plugin) under the method title "Automation TOTP (tests/.env)" — automation generates the current code from the base32 secret in git-ignoredtests/.env(E2E_ADMIN_TOTP_SECRET). - One secret, all three environments. Dev, staging and live are deliberately enrolled with the same
secret, so a single
E2E_ADMIN_TOTP_SECRETdrives any target and a live→dev/staging DB sync can't desynchronise it (the synced row carries the same key). Per-target overrides (E2E_ADMIN_TOTP_SECRET_DEV/_STAGING/_LIVE) exist and win when set — use them only if one environment's secret is ever rotated on its own. - Specs: call
loginToBackend(page)fromsupport/backendLogin.ts— password step, then the captive TOTP challenge answered with a generated code (submitted with Enter; the captive page's submit button is display-managed by Joomla'scaptive.jsand often not clickable headlessly). - Guided Tours are blocked by
loginToBackend, and that is not cosmetic. When a tour is flagged autostart,plg_system_guidedtoursstarts it on every backend page and, if you are not on the tour's first step, runswindow.location.href = <root> + steps[0].url— navigating the admin to another screen a few seconds after load. A spec still working the page at that moment dies on a timeout that reads like a bug in the page under test ("element not found", "waiting for navigation to finish"). The autostart branch runs ahead of the plugin's ownskipTouropt-out, so the helper routes**/media/plg_system_guidedtours/**toabort(). Symptom to recognise: backend specs that pass individually but fail whenever they linger on a page, with a stray navigation to/administrator/index.phpin the trace. - Worktrees: backend login needs the worktree's
$secretto match the database it was seeded from, or every backend spec fails with "no TOTP method enrolled" — see worktrees.md. - Ad-hoc automation (MCP/interactive sessions):
node scripts/admin-totp.jsprints the current 6-digit code to type into the prompt. - Re-enrolling / rotating. The secret cannot be chosen through the UI: the TOTP plugin validates the
confirmation code against the key it put in the session, not the one POSTed
(
plugins/multifactorauth/totp→onUserMultifactorSaveSetup). Setting a specific secret means writing the record through Joomla'sMfaTableon the target host, sooptionsis encrypted with that site's own$secret. NoteMfaTable::delete()runs an ACL check a CLI run can't satisfy — a scoped directDELETEis needed to clear a record, andstore()needs aUserFactoryinjected or backup-code generation throws. - Don't enroll the agent account in MFA. A user with MFA methods gets the captive challenge on
frontend login too, which would break the programmatic
loginoverlaylogin the whole suite bootstraps with. The agent's groups aren't MFA-forced, so it stays password-only by design — keep it that way.
Anonymous specs don't log in. The no-login Quote Follow-Up page is authorised the way an emailed link is
— mint a signed grant token for the quote's agent with support.mintFollowupToken(serial), then visit
/quote-follow-up/<serial>?token=<token> (it starts a grant cookie and strips the token). It is not a
login. Pass { expired: true } for the expired-token cases.
Settings & env
tests/.env (git-ignored; copy from .env.example):
| Var | What it does |
|---|---|
TARGET = dev|staging|live or BASE_URL | Which environment the suite drives. |
TEST_SUPPORT_SECRET | Must equal the target's test_support_secret setting — unlocks the test-support API. Blank on the server = endpoint disabled. |
E2E_AGENT_USER / E2E_AGENT_PASS | The automation account's login. |
E2E_ADMIN_USER / E2E_ADMIN_PASS | The backend automation admin's login. |
E2E_ADMIN_TOTP_SECRET | Base32 TOTP secret for the admin's forced backend 2FA — the same one on dev, staging and live. Per-target …_DEV/_STAGING/_LIVE overrides win when set. |
PIPEDRIVE_TEST_SERIAL | Optional: skip seeding and run against a specific existing quote. |
Run-time toggles (env vars on the command, not .env):
| Var | Effect |
|---|---|
PIPEDRIVE_TEST=capture | Server-side: BcPipedriveApi records outbound Pipedrive writes and sends nothing. The everyday default. |
PIPEDRIVE_SEND=1 | Harness: an opted-in spec verifies the real destinations (cron-driven) instead of captures. |
E2E_LIVE_SMOKE=1 | Runs the opt-in live smoke specs (real Pipedrive deal + real Google Sheet, confined to org 6034). |
SLOWMO=<ms> | Pause between actions to watch a --headed run. |
Common specs & patterns
- Arrange → Act → Assert, asserting two places — the DB (via the support API) and the effect (captured payload / recorded sync row / due-set / page state). See concepts.md.
- Seed through the app —
support/seedQuote.ts(seedDecorativeQuote(page)) configures a real product and adds it to a new quote, so pricing/BOM/branch→org are correct by construction. Reuse the shared seeded quote viareadSeededQuote()where a fresh one isn't needed. - The support client —
support/cli.ts(support.*) reads/writes state over the test-support endpoint;support/captures.ts(capturesForSerial) reads the intercepted Pipedrive payloads. Reuse these; don't hand-roll DB access. See support-api.md. - Follow-up form helpers —
support/followupForm.ts(waitForConfigbox,chooseOpenAndStage,chooseLost,submitAndConfirm) drive the race-prone landing-page form in one place. - Live smokes are opt-in and confined to DEV org 6034 (
quote-stage-e2e-live,quote-nudge-sheet-live,quote-follow-up-statusin send mode). The everyday suite is capture-only and touches nothing external.
Adding a test for a new feature
- Pick the project:
anon(public/no-login flows) oragent(needs a logged-in account). - Arrange — reuse the seeded quote (
readSeededQuote()), or seed a fresh one; for anon follow-up flows mint a grant token. - Act — drive the real UI action (reuse/extend the
support/*.tshelpers). - Assert — the persisted state (
support.latestQuote/stageSyncRows/collectNudgeDueRow/ …) and the effect (capturesForSerial, recorded rows, page state). - Clean up —
support.deleteQuote(serial),support.clearStageSync(serial), restore any setting you changed (each setter returns the previous value). - Need state the support API can't reach yet? Add a command — see support-api.md.
- Catalog it — add a row to catalog.md.
Browser choice & interactive (MCP) testing
The scripted suite runs Chromium (playwright install chromium). An AI agent can also drive the site
interactively through the Playwright MCP server — the same stack quirks apply (ignore HTTPS errors;
accept the Cookiebot banner once, which the persistent MCP profile keeps). Branded Chrome shows a yellow
"unsupported command-line flag" infobar under MCP's --disable-blink-features=AutomationControlled; use
--browser chromium (no warning) or add --test-type to Chrome. That's per-developer MCP config
(~/.claude.json), not in the repo.
Gotchas / checklist
- HTTPS errors ignored (self-signed cert) — otherwise every navigation fails.
- Consent replayed via
storageState, not bypassed by disabling Cookiebot — else you're testing non-production behaviour (../features/tracking.md). - The backend has no Cookiebot banner; front-end pages do.
- No credentials committed — they live in git-ignored
tests/.env/.auth/state files. - Long local runs can be interrupted by the machine sleeping — wrap with
caffeinate -ion macOS. - The everyday suite must stay capture-only; anything hitting real Pipedrive/Sheets is opt-in
(
E2E_LIVE_SMOKE=1/PIPEDRIVE_SEND=1) and confined to org 6034. - The
anonproject is not anonymous at the ConfigBox layer.setup-agentstarts from.auth/consent.json, and ConfigBox does not regenerate its own session cookie (cb_…) on login — so the agent login promotes the very session the consent state carries. Ananon-project context replaying that state is anonymous to Joomla but logged in as the automation agent to ConfigBox (ConfigboxUserHelper::getUserId()returns the agent). Assertions about Joomla-level auth are unaffected; for anything gated on the ConfigBox user (quote ownership!), use an empty storage state —test.use({ storageState: { cookies: [], origins: [] } })— asquote-endpoints-require-auth.spec.tsdoes (API-only specs don't need the consent cookies anyway).