Skip to main content

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 in playwright.config.ts; even curl needs -k). See ../configbox/environment.md.
  • Backend (Joomla): …/administrator — does not load the front-end Cookiebot script, so no consent banner on backend pages.

The site loads Cookiebot; consent lives in a cookie named CookieConsent, and the front-end tracking gate reads it (trackingmanager.jshasConsent('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/utc fields. Capturing real consent via storageState is 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:

ProjectDoesDepends on
setupgrants Cookiebot consent → .auth/consent.json
setup-agentlogs in as the automation account → .auth/agent.jsonsetup
setup-seedseeds a realistic Decorative quote (drives the configurator) → .auth/seed-quote.jsonsetup-agent
cleanup-seeddeletes the seeded quote (teardown of setup-seed)
anonthe not-logged-in specs (specs/anon/**), replaying consent onlysetup, setup-seed
agentthe logged-in specs (specs/agent/**), replaying the agent sessionsetup-agent
backendJoomla-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 dev and live (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.example carries the username. setup/agent.setup.ts logs in once and saves .auth/agent.json; the agent project replays it — individual agent specs never log in, they just get a logged-in page.
  • 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 Administrator group is in com_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-ignored tests/.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_SECRET drives 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) from support/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's captive.js and often not clickable headlessly).
  • Guided Tours are blocked by loginToBackend, and that is not cosmetic. When a tour is flagged autostart, plg_system_guidedtours starts it on every backend page and, if you are not on the tour's first step, runs window.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 own skipTour opt-out, so the helper routes **/media/plg_system_guidedtours/** to abort(). Symptom to recognise: backend specs that pass individually but fail whenever they linger on a page, with a stray navigation to /administrator/index.php in the trace.
  • Worktrees: backend login needs the worktree's $secret to 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.js prints 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/totponUserMultifactorSaveSetup). Setting a specific secret means writing the record through Joomla's MfaTable on the target host, so options is encrypted with that site's own $secret. Note MfaTable::delete() runs an ACL check a CLI run can't satisfy — a scoped direct DELETE is needed to clear a record, and store() needs a UserFactory injected 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 loginoverlay login 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):

VarWhat it does
TARGET = dev|staging|live or BASE_URLWhich environment the suite drives.
TEST_SUPPORT_SECRETMust equal the target's test_support_secret setting — unlocks the test-support API. Blank on the server = endpoint disabled.
E2E_AGENT_USER / E2E_AGENT_PASSThe automation account's login.
E2E_ADMIN_USER / E2E_ADMIN_PASSThe backend automation admin's login.
E2E_ADMIN_TOTP_SECRETBase32 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_SERIALOptional: skip seeding and run against a specific existing quote.

Run-time toggles (env vars on the command, not .env):

VarEffect
PIPEDRIVE_TEST=captureServer-side: BcPipedriveApi records outbound Pipedrive writes and sends nothing. The everyday default.
PIPEDRIVE_SEND=1Harness: an opted-in spec verifies the real destinations (cron-driven) instead of captures.
E2E_LIVE_SMOKE=1Runs 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 appsupport/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 via readSeededQuote() where a fresh one isn't needed.
  • The support clientsupport/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 helperssupport/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-status in send mode). The everyday suite is capture-only and touches nothing external.

Adding a test for a new feature

  1. Pick the project: anon (public/no-login flows) or agent (needs a logged-in account).
  2. Arrange — reuse the seeded quote (readSeededQuote()), or seed a fresh one; for anon follow-up flows mint a grant token.
  3. Act — drive the real UI action (reuse/extend the support/*.ts helpers).
  4. Assert — the persisted state (support.latestQuote / stageSyncRows / collectNudgeDueRow / …) and the effect (capturesForSerial, recorded rows, page state).
  5. Clean upsupport.deleteQuote(serial), support.clearStageSync(serial), restore any setting you changed (each setter returns the previous value).
  6. Need state the support API can't reach yet? Add a command — see support-api.md.
  7. 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 -i on 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 anon project is not anonymous at the ConfigBox layer. setup-agent starts 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. An anon-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: [] } }) — as quote-endpoints-require-auth.spec.ts does (API-only specs don't need the consent cookies anyway).