@decocms/qa
E2E QA test runner for deco.cx ecommerce stores. Runs a deterministic 10-step purchase journey (home → PLP → PDP → cart → /checkout) against a single URL and emits JUnit XML + a JSON report + screenshots. The journey asserts cart state — quantity, variant, price, and that the cart isn't empty — so a broken add-to-cart fails the run instead of passing green.
Selectors are resolved from data-qa-* attributes on the store's JSX, with a configurable override fallback in .qarc.json. No LLM discovery, no visual diff — deterministic by design. The marking is consumed by the setup-deco-ecommerce-qa Claude Code skill that scaffolds GitHub Actions workflows in store repos.
Quickstart
bunx @decocms/qa journey \
--url https://mystore.example.com \
--junit junit.xml \
--github \
--viewports desktop,mobileThe journey clicks through:
visit-home— load the URLnavigate-plp— click the first visible[data-qa-category-link]. If no visible match exists and[data-qa-menu-trigger]is present, the engine clicks the trigger (e.g. a hamburger button) to open the menu drawer, then clicks the category link. If a blocking popup/overlay intercepts the click, the engine dismisses it and retries (see Dismissing blocking overlays).enter-pdp— click the first[data-qa-product-card], extract[data-qa-pdp-title]and (optional)[data-qa-pdp-price]shipping-calc-pdp(optional) — fill[data-qa-cep-input]+ click[data-qa-cep-submit]add-to-cart— click[data-qa-buy-button]. If the click opens a size/variant modal (common on mobile), the engine picks the first in-stock[data-qa-variant-option], clicks[data-qa-variant-confirm]if present, then verifies via[data-qa-cart-count](soft — never fails the step).open-minicart— dismiss blocking overlays (see Dismissing blocking overlays), click[data-qa-cart-icon], wait for[data-qa-minicart], then assert the cart line (see "Cart-state assertions" below)cart-persists-reload(optional) — reload the page, reopen the minicart, assert the cart line still has ≥1[data-qa-minicart-item]shipping-calc-cart(optional) — same as step 4 but in the cart contextgo-checkout— click[data-qa-minicart-checkout], assert[data-qa-checkout-page], verify the PDP title persisted in the cart DOM (preferring[data-qa-minicart-item-name]when present)cart-controls(optional) — click[data-qa-quantity-increment]and assert[data-qa-quantity-value]becomes2, then click[data-qa-minicart-item-remove]and assert the cart empties
Optional steps are skipped silently when their slugs aren't on the page.
After every navigation the engine waits for the network to settle and scrolls the page to trigger IntersectionObserver-driven lazy content (product grids), and polls for each required selector for up to 10s — so lazily-hydrated storefronts don't flake.
The variant slugs (data-qa-variant-option, data-qa-variant-confirm) and the badge slug (data-qa-cart-count) are optional: add them only on stores where buying requires picking a size or where you want add-to-cart verified by the cart badge.
Dismissing blocking overlays
Stores often show a blocking modal/popup (newsletter, cookie-consent, age-gate) before the shopper reaches a product. The engine masks automation (real UA, AutomationControlled disabled) so it sees the real shopper experience — which means the store can't detect the run to suppress the popup. So the engine dismisses overlays itself: proactively before navigating, and reactively whenever a click is intercepted — retrying a normal click once the overlay is gone (it never clicks through an overlay). In order, it tries:
data-qa-dismiss(recommended) — clicks every visible[data-qa-dismiss]. Mark the popup's close control (the ✕) with this attribute and dismissal is deterministic and zero-config — no?qa=-style store branching needed.- Escape — clears overlays bound to a keydown listener.
- Heuristic (opt-in via
features.dismissOverlaysHeuristic) — clicks a generic close affordance (a✕/×/fechar/closebutton, or[aria-label*="close"]/[aria-label*="fechar"]). Hardened to never follow a link (href) or submit a form, but best-effort and may misfire — off by default so deterministic runs aren't surprised.
Dismissal is bounded (capped rounds) and a no-op when no overlay is present, so stores without popups are unaffected.
WAF-bypass user-agent
By default the engine appends the token deco-qa-bot/1.0 to the browser user-agent on every viewport (desktop, mobile and tablet). This token matches a Cloudflare WAF skip rule scoped to the preview hosts (*.decocdn.com), so the QA bot is let through without weakening the protection for real shopper traffic.
Set QA_USER_AGENT to override the user-agent completely (the token included) — useful for one-off debugging:
QA_USER_AGENT="my-debug-agent/1.0" bunx @decocms/qa journey --url https://mystore.example.comCart-state assertions
Once the minicart is open (step 6), the engine validates the cart line — but only for the markers a store has actually added. A missing marker means "not verified" (skipped), never a failure, so stores adopt the checks incrementally:
| Marker | Assertion |
|---|---|
data-qa-minicart-items |
The cart-line list wrapper (rendered even when empty). When present and it contains zero data-qa-minicart-item rows → fail (empty cart — add-to-cart didn't persist). Also scopes the line queries below, so a recommendation carousel can't be mistaken for a cart line. |
data-qa-minicart-item |
One element per cart line. |
data-qa-quantity-value |
Must read 1 after a single add. Put the marker on whatever displays the quantity — a text node (<span>1</span>), a form field (<input value="1">), a wrapper around the field (<div data-qa-quantity-value><input value="1"></div>), or carry the value on the attribute itself (data-qa-quantity-value="1"). The engine reads all of these — no mirror <span> needed for input-based quantities. If you declare the value on the attribute, bind it reactively (step 10 increments and expects it to become 2). |
data-qa-minicart-item-variant |
Must contain the size picked in step 5 (exact token match). |
data-qa-minicart-item-price + data-qa-pdp-price |
Line price must be > 0 and ≥ the PDP "from" price. |
data-qa-minicart-item-name |
Used at go-checkout to confirm the right product reached the checkout; a mismatch here (and only here) escalates step 9 to a failure. |
Additional optional markers: data-qa-minicart-subtotal, data-qa-minicart-total, data-qa-quantity-increment, data-qa-quantity-decrement, data-qa-minicart-item-remove (the last three drive the cart-controls step).
Commands
| Command | Purpose |
|---|---|
qa journey --url <url> |
Run the 10-step purchase journey |
qa doctor --url <url> |
Report which data-qa-* slugs are present on a URL |
qa list-slugs |
Print the 28 canonical slugs as JSON |
qa journey flags
| Flag | Type | Purpose |
|---|---|---|
--url <url> |
string | Target URL (required) |
--junit <file> |
string | Emit JUnit XML to this path |
--github |
boolean | Emit GitHub Actions ::group:: / ::error:: annotations |
--viewports <list> |
string | Comma-separated: desktop,mobile,tablet. Default: desktop. |
--cep <cep> |
string | Override the CEP used in shipping-calc steps |
--smoke |
boolean | Run only steps 1,2,3,5 (skips shipping, minicart, checkout) |
--headed |
boolean | Run with a visible browser (local debug) |
--debug |
boolean | Pause for inspection (implies --headed) |
.qarc.json config
Lives at the root of the store repo. All fields except url are optional.
{
"url": "https://farmrio.com.br",
"cep": "01310-100",
"viewports": ["desktop", "mobile"],
"selectors": {
"data-qa-buy-button": "button.custom-add-to-cart"
},
"features": {
"checkoutUrlPattern": "/checkout"
}
}selectors— per-slug CSS override used when[data-qa-<slug>]is missing. Keys are restricted to the 28 canonical slug names (Zod-enforced).features.checkoutUrlPattern— a substring the post-click checkout URL must contain (e.g./checkout), with*/**as wildcards. When set, the engine drops the[data-qa-checkout-page]assertion and validates by URL instead — works for same-origin, cross-origin (external VTEX), and hash-route SPA checkouts (…/checkout#/email). Because it's a contains match, a plain/checkoutcovers all of these; the legacy glob form (**/checkout**) still works. On a local base (localhost/127.0.0.1) where the click never reaches a matching URL, the step is skipped (verdict stays pass) rather than failing. Takes precedence overcheckoutCrossOrigin.features.checkoutCrossOrigin— set totruefor VTEX legacy stores where the checkout opens oncheckout.vtex.com.br. The engine drops the[data-qa-checkout-page]assertion and validates by URL change instead; the PDP-title persistence sweep still runs on the VTEX DOM. PrefercheckoutUrlPatternfor new configs.
CLI flags take precedence over .qarc.json.
Output
qa-output/<runId>/
├── report.json # full structured report (validated against the v0.1 Zod schema)
└── screenshots/
├── 1-visit-home.png
├── 2-navigate-plp.png
└── ...
When --junit <file> is passed, a separate JUnit XML file is written for dorny/test-reporter@v2 or similar CI parsers.
Exit codes
| Code | Meaning |
|---|---|
0 |
Journey passed (all required steps OK; optional steps OK or deliberately skipped) |
1 |
Assertion failure (a required step failed — the standard red-test case) |
2 |
Setup failure (URL invalid, .qarc.json broken, browser couldn't launch) |
3 |
Global timeout (journey exceeded its deadline; pages were force-closed) |
Development
bun install
bunx playwright install chromium
bun run check # tsc --noEmit
bun run lint # biome lint
bun run test # vitest run
bun run build # bundle dist/cli.js for npmTests use a local HTTP server (tests/harness/server.ts) plus HTML fixtures. They run against real Chromium — no mocks for browser interactions.
License
MIT.