@decocms/qa
v0.9.0
Published
E2E QA test runner for deco.cx ecommerce stores. Runs a deterministic purchase journey against data-qa-marked DOM and emits JUnit XML + JSON report.
Readme
@decocms/qa
E2E QA test runner for deco.cx ecommerce stores. Runs a deterministic 11-step purchase journey (home → PLP → PDP → cart → /checkout → search) 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. When the drawer's items are themselves buttons that open a submenu (signal/JS-driven menus), mark those group headers with[data-qa-menu-group]— the engine drills through them (bounded depth) until a marked link is visible. Checkbox-toggled nested groups (deco's DesktopContainer pattern) are drilled automatically. 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 emptiessearch(optional, auxiliary) — if[data-qa-search-input]is visible, type a term derived from the PDP title, press Enter, and assert ≥1[data-qa-product-card]renders in the results. A broken search fails the strictverdictbut notfunnelVerdict(it's not a purchase blocker).
Optional steps are skipped silently when their slugs aren't on the page.
Out-of-stock variants
When the buy flow needs a size and some sizes can't be bought, mark the OOS options with data-qa-variant-unavailable (alongside data-qa-variant-option) or give them aria-disabled="true" / disabled — the engine skips them when picking. <label for=…> options whose associated <input> is disabled are skipped too.
If nothing machine-readable marks an OOS option (style-only dimming), the engine can still recover: when picking a variant unmounts [data-qa-buy-button] (the common "swap the buy area into notify me" pattern), it dismisses the auto-opened modal, skips that size and tries the next available option — preferring options styled differently from the one that failed (OOS styling is uniform within a store, so a differently-classed option is the likely in-stock one). Bounded at 3 attempts, recorded in the step's actionDescription.
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 11-step purchase journey |
| qa doctor --url <url> | Report which data-qa-* slugs are present across the funnel (home → PLP → PDP; --deep adds add-to-cart + minicart) |
| qa list-slugs | Print the 31 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 31 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.
