Automated window.dataLayer event validation using Playwright. Creates, runs, and maintains analytics tracking tests so you never ship broken tracking again.
Auto-detected based on your project setup:
| Mode | Files per Component | Detection |
|---|---|---|
| Standard Playwright | 3 (JSON + spec + page object) | Default |
| BDD (playwright-bdd) | 4 (JSON + feature + steps + page object) | playwright-bdd in package.json or .feature files present |
Both modes share the same JSON format, page objects, and DataLayer utilities.
| Event | Fires When |
|---|---|
element_visibility | Component scrolls into view |
cta_click | User clicks a CTA / button / link |
form_interaction | User interacts with form fields |
form_submit | Form is submitted |
generate_lead | Lead generation conversion (GA4) |
select_content | User selects content (GA4) |
add_to_cart | Item added to cart (GA4 ecommerce) |
purchase | Transaction completed (GA4 ecommerce) |
| Any custom event | Your tracking fires it |
"Create dataLayer tests for the hero component using this Confluence spec: https://wiki.example.com/pages/12345"
"Create analytics tests for the newsletter signup — no spec, discover the events from the live page"
"Create dataLayer tests for the checkout button. It fires cta_click with click_text='Buy Now', click_url='/checkout', component_name='Checkout CTA'"
"Run the analytics tests for the button component and show me what's different"
"The cta_click changes look correct — update the JSON with those actuals"
"Don't update — the actual looks wrong, this might be a tracking bug"
"Add an element_visibility event to the button component tests"
"The button test is failing because the locator changed — inspect the live DOM and update the page object"
"Show me everything in the dataLayer on the checkout page"
| Mode | When | What Happens |
|---|---|---|
| Full | "Create tests for X using this spec" | Create files → run → extract → compare → wait for approval → update → verify |
| Discovery | "Create tests for X — no spec" | Infer events → skeleton JSON → run → extract → compare → wait → fill |
| Deferred | "Create test files for X — don't run" | Create files from spec only. Execute later. |
| Debug | "Run the tests for X and show me diffs" | Run → extract → compare. No update unless asked. |
| Assertion | BDD Step | Clears? | Context |
|---|---|---|---|
| First-match + clear | contain expected "{key}" event | Yes | Storybook (isolated components) |
| Component-aware match | contain a matching "{key}" event | No | App tests (multi-component pages) |
| Component-aware + clear | contain a matching "{key}" event and clear | Yes | App interaction events |
Storybook: Default to first-match + clear. App tests: Always use findMatchingDataLayerEvent — extracts event + parameters.content.name from expected JSON to locate the correct component's event, then toMatchObject provides field diffs. See references/assertion-strategy.md for details.
gtm.uniqueEventId.analytics-common-step.ts, analytics-logger.ts, or analytics-helpers.ts. New utility methods may be added to datalayer-util.ts but existing methods must not be altered.toMatchObject() for assertions. toEqual() is banned.Full implementation details are inlined below.
This is the full step-by-step implementation guide. Read this when creating, executing, or debugging analytics tests.
If the project doesn't already have the analytics testing utilities, create them from the assets/ folder.
Both modes (shared utilities):
assets/datalayer-util.ts → {test-root}/utils/datalayer-util.tsassets/analytics-logger.ts → {test-root}/utils/analytics-logger.tsnpm install -D allure-js-commons allure-playwrightStandard mode — additionally:
4. Copy assets/analytics-helpers.ts → {test-root}/utils/analytics-helpers.ts
5. Copy assets/analytics.standard.config.ts → {project-root}/analytics.playwright.config.ts
BDD mode — additionally:
4. Copy assets/analytics-common-step.ts → {test-root}/analytics/analytics-common-step.ts
5. Copy assets/analytics.playwright.config.ts → {project-root}/analytics.playwright.config.ts
6. Install: npm install -D playwright-bdd
Adjust paths in the config and imports to match your project structure. The shared utilities are infrastructure — NEVER modify them per component.
Option A — User-provided tracking spec URL (preferred, most token-efficient):
Only fetch URLs explicitly provided by the user. If the spec lives in a wiki or documentation platform, fetch it via the most efficient method available:
conf_get path="/wiki/rest/api/content/{pageId}" queryParams={"expand": "body.storage"} jq="body.storage.value" — use body.storage format (~55% smaller than rendered HTML)curl -s "https://{domain}/wiki/rest/api/content/{pageId}?expand=body.storage" -H "Authorization: Bearer {token}" — pipe through jq '.body.storage.value'Extract page ID from URL (numeric segment after /pages/). Extract ONLY: event names, parameter tables, code blocks, notes about excluded events. After extraction, display the parsed event names and parameters to the user for approval before creating any files.
Option B — Manual: User provides event names + parameters in chat.
Option C — Discovery (no spec): Infer events from the component's interactive elements:
cta_click or select_contentelement_visibility or custom viewport eventform_interaction or select_itemform_submit or generate_leadCreate files with skeleton JSON payloads, run → extract → compare → fill.
Optional: If the project already has functional component tests or page objects (e.g., from Cypress, Playwright, Selenium, or any other framework), you can inspect those to reuse their CSS selectors.
Otherwise, inspect the live page DOM by navigating to the test URL and using page.evaluate() to extract interactive elements and their selectors.
Locator priority (pick first available):
button.ui-tabs__link#tab-tab2a.ui-text-nav-link--with-underline.ui-checkbox-input[data-testid="checkbox-test"].ui-radio-input#radio-1a:has-text("Submit")BANNED: getByRole(), getByText(), getByLabel(), .nth(), .first(), .last(), bare tag selectors, guessed attributes without DOM inspection.
For DOM inspection when locators are unknown, see locator-rules.md.
All files go in: {test-root}/analytics/{component}/
VALIDATION RULES (enforced by shared infrastructure):
Before creating files, ensure all names follow these patterns:
[a-z0-9-]+
button, hero-section, checkout-flowButton, hero_section, ../../../evil, button$(whoami)[a-z_][a-z0-9_]*
element_visibility, cta_click, form_submitElementVisibility, cta-click, cta_click();, __proto__default, secondary, mobile-viewDefault, mobile_view, ../otherThe shared test infrastructure (analytics-common-step.ts and validation utilities) validates these patterns at runtime and rejects any inputs that don't conform.
{component}.json (both modes — identical){
"{urlKey}": "/path/to/component/page",
"element_visibility": {
"event": "element_visibility",
"component_name": "{Component Name}",
"component_type": "{Type}",
"page_location": "/path/to/component/page"
},
"cta_click": {
"event": "cta_click",
"click_text": "{CTA text}",
"click_url": "{href}",
"component_name": "{Component Name}",
"link_type": "{internal|external|download}"
}
}
Include ALL parameters from the spec — never omit fields. Only exclude gtm.uniqueEventId.
{component}-pageobject.ts (both modes — identical)import { Locator, Page } from "@playwright/test";
class {ComponentName}Analytics {
page: Page;
primaryCTA: Locator;
constructor(page: Page) {
this.page = page;
this.primaryCTA = page.locator("{css-selector}");
}
}
export default {ComponentName}Analytics;
{component}-analytics.spec.tsimport { test } from "@playwright/test";
import { initDataLayer, expectEvent } from "{path-to}/utils/analytics-helpers";
import {ComponentName}Analytics from "./{component}-pageobject";
import testData from "./{component}.json";
const BASE_URL = process.env.BASE_URL || "";
test.describe("{Component Name} - DataLayer Analytics", { tag: ["@analytics", "@{componentTag}"] }, () => {
let componentPage: {ComponentName}Analytics;
test.beforeEach(async ({ page }) => {
initDataLayer(page);
componentPage = new {ComponentName}Analytics(page);
await page.goto(BASE_URL + testData.{urlKey}, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
});
test("element_visibility event", { tag: "@visibility" }, async () => {
await expectEvent(testData, "element_visibility");
});
test("cta_click event on CTA click", { tag: "@ctaClick" }, async () => {
await componentPage.primaryCTA.click();
await componentPage.page.waitForTimeout(500);
await expectEvent(testData, "cta_click");
});
});
Standard mode = 3 files: JSON + page object + spec. See examples/button-standard/ for a complete working example.
{component}-analytics.featureFeature: {Component Name} - DataLayer Analytics
@analytics @{componentTag} @visibility
Scenario: Verify element_visibility event
Given I am on the "{component}" analytics "{urlKey}" page
Then the dataLayer should contain expected "element_visibility" event
@analytics @{componentTag} @ctaClick
Scenario: Verify cta_click event
Given I am on the "{component}" analytics "{urlKey}" page
When I click the {component} CTA
Then the dataLayer should contain expected "cta_click" event
{component}-analytics-step.tsThe step file uses the shared analytics-common-step.ts Given/Then steps. It only defines component-specific When steps.
import { When, Before } from "{path-to}/config/fixture";
import {ComponentName}Analytics from "./{component}-pageobject.js";
let componentPage: {ComponentName}Analytics;
Before(async function () {
componentPage = new {ComponentName}Analytics(this.page);
});
When("I click the {component} CTA", async function () {
await componentPage.primaryCTA.click();
await this.page.waitForTimeout(500);
});
The shared analytics-common-step.ts (copied to {test-root}/analytics/analytics-common-step.ts during setup) provides the Given/Then steps. When creating the feature file, the Given step template in that file uses hardcoded placeholders:
// From analytics-common-step.ts (shared infrastructure):
Given(
"I am on the {string} analytics {string} page",
async function (component: string, variant: string) {
currentTestData = require(`./{component}/{component}.json`);
// ...
},
);
When creating your feature file, use descriptive names in the Given step quotes, and the step's hardcoded path will resolve them correctly:
Given I am on the "button" analytics "urlKey" page
## → The step's hardcoded path becomes: require(`./button/button.json`)
BDD mode = 4 files: JSON + page object + feature + step defs. See examples/button/ for a complete working example.
For deferred workflow, skip Step 4 — user executes later.
4a. Run the tests:
Standard mode:
npx playwright test --config analytics.playwright.config.ts --grep "@{componentTag}"
BDD mode:
npx bddgen --config analytics.playwright.config.ts
npx playwright test --config analytics.playwright.config.ts --grep "@{componentTag}"
4b. Extract actuals from terminal output (do NOT read raw console):
After the test completes, capture the terminal output and run this extraction command. The test output can come from:
2>&1 | tee test-output.txt)node -e "
const t = require('fs').readFileSync('OUTPUT_FILE', 'utf-8');
const r = {};
t.split('='.repeat(60)).forEach(b => {
const ev = b.match(/DATALAYER EVENT:\s*(.+)/);
const st = b.match(/Status:\s*(.*)/);
const act = b.match(/Actual:\n([\s\S]*?)$/);
if (ev && act) {
const key = ev[1].trim();
try {
const j = JSON.parse(act[1].trim());
delete j['gtm.uniqueEventId'];
r[key] = { status: (st ? st[1].trim() : 'unknown'), actual: j };
} catch(e) { r[key] = { status: (st ? st[1].trim() : 'unknown'), actual: null, parseError: true }; }
}
});
console.log(JSON.stringify(r, null, 2));
"
Replace OUTPUT_FILE with the path to whatever file contains the test's console output. To capture output during the test run, pipe it: npx playwright test ... 2>&1 | tee test-output.txt
4c. Present comparison to user — ALWAYS stop here:
Read the source JSON and extracted actuals. Present per-event comparison:
element_visibility:
Match — Expected and Actual align
cta_click:
Mismatch — 2 differences:
click_text: Expected "Learn More" → Actual "Read More"
click_url: Expected "/products" → Actual "/products/overview"
form_submit:
Not Found — No matching event in dataLayer
STOP after presenting the comparison. Do NOT update JSON automatically. Wait for user input.
Why: Spec values may differ from implementation (spec error), actuals may reveal tracking bugs (don't mask them), or user may want selective updates only.
4d. Update JSON (ONLY when user explicitly requests):
Merge approved actuals into source JSON, removing only gtm.uniqueEventId. Write as a single file update. Then re-run to verify pass.
4e. Full dataLayer dump (when event not found):
Temporarily add to a test or step, run, read output, then remove:
const dl = await page.evaluate(() => (window as any).dataLayer || []);
console.log(JSON.stringify(dl, null, 2));
Temporarily add to a test or step, run, read output, then remove:
const _els = await this.page.evaluate(() => {
const s = 'a, button, input, [role="button"], [data-testid]';
return Array.from(document.querySelectorAll(s)).map((el) => {
const e = el as HTMLElement;
return {
tag: e.tagName.toLowerCase(),
class: e.className?.substring(0, 80) || "",
id: e.id || "",
href: e.getAttribute("href") || "",
text: e.textContent?.trim()?.substring(0, 40) || "",
};
});
});
console.table(_els);
Tracking specs use dot notation or flat tables. Map to the JSON structure used in your dataLayer:
component.name → component_name (or ecommerce.component_name)
click.text → click_text
click.url → click_url
form.type → form_type
item.id → ecommerce.items[0].item_id (GA4 ecommerce)
The exact nesting depends on your tracking implementation — match whatever window.dataLayer.push() actually sends.
| File | Standard Mode | BDD Mode |
|---|---|---|
| Folder | {component}/ (kebab-case) | {component}/ (kebab-case) |
| JSON | {component}.json | {component}.json |
| Page object | {component}-pageobject.ts | {component}-pageobject.ts |
| Test file | {component}-analytics.spec.ts | — |
| Feature | — | {component}-analytics.feature |
| Step defs | — | {component}-analytics-step.ts |
| Class name | {ComponentName}Analytics (PascalCase) | {ComponentName}Analytics (PascalCase) |
| Tags | @analytics @{componentTag} @{eventType} | @analytics @{componentTag} @{eventType} |
For components with multiple variants, use subfolders:
Standard mode:
{component}/
{component}-pageobject.ts ← shared locators
{variant}/
{variant}.json ← variant-specific URL + events
{variant}-analytics.spec.ts ← variant-specific test
BDD mode:
{component}/
{component}-pageobject.ts ← shared locators
{component}-analytics-step.ts ← shared When steps
{variant}/
{variant}.json ← variant-specific URL + events
{variant}-analytics.feature ← variant-specific scenarios
In BDD mode, use the 3-string Given step:
Given I am on the "{component}" analytics "{variant}" variant "{urlKey}" page
Fetch via whichever method is available (MCP, REST API, or URL fetch):
conf_get path="/wiki/rest/api/content/{pageId}" queryParams={"expand": "body.storage"} jq="body.storage.value"
curl -s "https://{domain}/wiki/rest/api/content/{pageId}?expand=body.storage" | jq '.body.storage.value'
Standard mode:
npx playwright test --config analytics.playwright.config.ts --grep "@tag"
BDD mode:
npx bddgen --config analytics.playwright.config.ts
npx playwright test --config analytics.playwright.config.ts --grep "@tag"
Console shows Expected vs Actual side by side. Common fixes:
Pick the first available strategy from this list:
page.locator("button.ui-tabs__link#tab-tab2")
page.locator("input.ui-input-field[type='email']")
page.locator("a.ui-text-nav-link--with-underline")
page.locator("button.btn-standard-secondary")
page.locator(".ui-checkbox-input[data-testid='checkbox-test']")
page.locator(".ui-radio-input#radio-1")
page.locator("a:has-text('Submit')")
These create brittle, unreliable tests:
| Banned | Why |
|---|---|
getByRole() | Fragile — role attributes change |
getByText() | Breaks on text changes, i18n |
getByLabel() | Not all elements have labels |
.nth() | Index-dependent, breaks on reorder |
.first() / .last() | Position-dependent |
Bare tag selectors (page.locator('a')) | Matches too many elements |
| Guessed attributes | Always verify against actual DOM |
When locators are unknown, use a targeted page.evaluate() to extract interactive elements:
const _els = await this.page.evaluate(() => {
const s = 'a, button, input, [role="button"], [data-testid]';
return Array.from(document.querySelectorAll(s)).map((el) => {
const e = el as HTMLElement;
return {
tag: e.tagName.toLowerCase(),
class: e.className?.substring(0, 80) || "",
id: e.id || "",
href: e.getAttribute("href") || "",
text: e.textContent?.trim()?.substring(0, 40) || "",
};
});
});
console.table(_els);
const r = await page.evaluate(() => {
const component = document.querySelector('.ui-hero, .hero-block, [class*=hero]');
const all = component ? component.querySelectorAll('button, a, [role=button]') : [];
return Array.from(all).slice(0, 20).map(el => ({
tag: el.tagName,
class: el.className.slice(0, 120),
ariaLabel: el.getAttribute('aria-label') || '',
text: el.textContent.trim().slice(0, 60),
href: el.getAttribute('href') || ''
}));
});
Add these temporarily to a When step, run the test, read console output, then remove.
On live pages, clicking a CTA navigates away — losing the dataLayer event. Use preventCtaNavigation() before clicking:
await dataLayerUtils.preventCtaNavigation(componentPage.ctaSelector);
await componentPage.primaryCTA.click();
This requires exposing the raw CSS selector as an instance property on the page object:
class ComponentAnalytics {
ctaSelector: string;
primaryCTA: Locator;
constructor(page: Page) {
this.ctaSelector = "a.component__cta";
this.primaryCTA = page.locator(this.ctaSelector).first();
}
}
If the project already has existing component tests or QA automation, read those page objects first:
test/components/{component}/{component}-pageobject.ts → CSS selectors
test/components/{component}/{component}.json → URLs, test data
Copy selectors from existing tests rather than guessing. If an element isn't covered, mark it:
this.submitButton = page.locator("TODO_VERIFY_SELECTOR");
Each assertion type is available in both Standard and BDD mode:
BDD:
Then the dataLayer should contain expected "cta_click" event
Standard:
await expectEvent(testData, "cta_click");
window.dataLayer matching the event property nametoMatchObject() (expected is a subset of actual)window.dataLayer after assertion — critical for sequential testsBDD:
Then the dataLayer should contain a matching "form_interaction_focus" event
Standard:
await expectMatchingEvent(testData, "form_interaction_focus");
BDD:
Then the dataLayer should contain a matching "cta_click" event and clear
Standard:
await expectMatchingEventAndClear(testData, "cta_click");
Default to first-match with clear unless you have a specific reason not to.
BDD:
When I click submit
Then the dataLayer should contain expected "cta_click" event
Then the dataLayer should contain expected "form_submit" event
Standard:
await buttonPage.submitButton.click();
await page.waitForTimeout(500);
await expectEvent(testData, "cta_click");
await expectEvent(testData, "form_submit");
Each event has a unique name. First-match works. DataLayer clears between assertions.
When an input field fires form_interaction twice (once for "Focus", once for "Value Changed"):
{
"form_interaction_focus": {
"event": "form_interaction",
"field_name": "email",
"interaction_type": "focus"
},
"form_interaction_value_changed": {
"event": "form_interaction",
"field_name": "email",
"interaction_type": "value_changed",
"field_value": "user@example.com"
}
}
BDD:
Then the dataLayer should contain a matching "form_interaction_focus" event
And the dataLayer should contain a matching "form_interaction_value_changed" event
Standard:
await expectMatchingEvent(testData, "form_interaction_focus");
await expectMatchingEvent(testData, "form_interaction_value_changed");
The JSON key (form_interaction_focus) is the lookup key. The event property inside can be the same across multiple entries. Deep matching differentiates them by nested properties.
For scenarios where you need to perform a setup action before the target action (e.g., click next before clicking previous), assert-and-clear the setup event first:
BDD:
When I click the next button
Then the dataLayer should contain expected "carousel_next" event
When I click the previous button
Then the dataLayer should contain expected "carousel_previous" event
Standard:
await carouselPage.nextButton.click();
await page.waitForTimeout(500);
await expectEvent(testData, "carousel_next");
await carouselPage.prevButton.click();
await page.waitForTimeout(500);
await expectEvent(testData, "carousel_previous");
The first assertion clears the dataLayer, so the second assertion only sees the "previous" event.
On live pages, multiple components fire events with the same name (e.g. Breadcrumb + Hero both fire content_in_viewport). Use findMatchingDataLayerEvent(expected) which filters by event name + parameters.content.name from the expected JSON, then toMatchObject provides clear field-by-field diffs.
| Assertion | BDD Step | Lookup | Clears? |
|---|---|---|---|
| Component-aware match | contain a matching "{key}" event | findMatchingDataLayerEvent (event + component name) | No |
| Component-aware + clear | contain a matching "{key}" event and clear | Same | Yes |
Never use findDataLayerEvent (first-match-by-name — returns wrong component's event) or findDataLayerEventMatching directly for app tests. findMatchingDataLayerEvent handles multi-candidate cases (e.g. 4 Videos on one page) internally via deepMatch with fallback to first candidate for toMatchObject diagnostics.
The deepMatch() function recursively checks that every key in the expected object exists in the actual object with the same value:
deepMatch({ a: { b: 1, c: 2, d: 3 } }, { a: { b: 1 } }) → true
deepMatch({ a: { b: 1 } }, { a: { b: 2 } }) → false
deepMatch({ a: 1 }, { a: 1, b: 2 }) → false (extra key in expected)
This is why toMatchObject() is used — it allows the actual event to have additional properties not in the expected payload.
Fires when a component scrolls into view. Common for tracking impressions.
{
"event": "element_visibility",
"component_name": "Product Card",
"component_type": "Card",
"component_variant": "Featured",
"page_location": "/products/overview"
}
Common properties:
| Property | Description | Example |
|---|---|---|
component_name | Component display name | "Product Card", "Newsletter Banner", "Search Bar" |
component_type | Component category | "Card", "Banner", "Form", "Navigation" |
component_variant | Visual/functional variant | "Featured", "Compact", "Sticky" |
page_location | Page URL path | "/products/overview" |
Fires when user clicks a CTA button or link.
{
"event": "cta_click",
"click_text": "Shop Now",
"click_url": "/products/sale",
"component_name": "Product Card",
"link_type": "internal"
}
Common properties:
| Property | Values | Notes |
|---|---|---|
click_text | Button/link visible text | "Shop Now", "Learn More", "Download PDF" |
click_url | Target URL or href | "/products/sale", "https://external.com" |
component_name | Parent component name | Where the CTA lives |
link_type | internal, external, download | Destination classification |
Fires on form field interactions (focus, input, selection).
{
"event": "form_interaction",
"form_name": "Contact Us",
"field_name": "email",
"field_type": "input",
"interaction_type": "focus"
}
interaction_type values:
| Type | Use case |
|---|---|
| focus | User clicks/tabs into a field |
| value_changed | User changes the field value |
| select | User picks from dropdown/radio/checkbox |
| toggle | User toggles a switch/checkbox |
Fires after a form is submitted.
{
"event": "form_submit",
"form_name": "Contact Us",
"form_type": "Lead Generation",
"submission_status": "success"
}
Fires on lead generation conversions.
{
"event": "generate_lead",
"currency": "USD",
"value": 50.00
}
Fires when user selects content (tabs, accordions, carousels).
{
"event": "select_content",
"content_type": "tab",
"item_id": "pricing-tab"
}
Fires when an item is added to cart.
{
"event": "add_to_cart",
"ecommerce": {
"currency": "USD",
"value": 29.99,
"items": [
{
"item_id": "SKU-12345",
"item_name": "Running Shoes",
"price": 29.99,
"quantity": 1
}
]
}
}
Fires when a transaction completes.
{
"event": "purchase",
"ecommerce": {
"transaction_id": "T-98765",
"currency": "USD",
"value": 59.98,
"items": [
{
"item_id": "SKU-12345",
"item_name": "Running Shoes",
"price": 29.99,
"quantity": 2
}
]
}
}
Every component JSON follows this pattern:
{
"{urlKey}": "/path/to/test/page",
"{anotherUrlKey}": "/path/to/variant/page",
"validEmail": "test@example.com",
"{eventKey}": {
"event": "{eventName}",
...properties
}
}
The event key in JSON can differ from the event property inside the payload. This allows multiple entries for the same event name with different nested properties (e.g. form_interaction_focus and form_interaction_value_changed both having "event": "form_interaction").
Tracking specs (Confluence, Google Sheets, etc.) typically use dot notation or flat tables. Map to the JSON structure your window.dataLayer.push() actually uses:
component.name → component_name
click.text → click_text
click.url → click_url
form.name → form_name
field.type → field_type
item.id → ecommerce.items[0].item_id (GA4 ecommerce)
The exact nesting depends on your GTM implementation. Always validate against the actual window.dataLayer output rather than guessing the structure.
The framework supports any custom event name. Just follow the same JSON structure:
{
"my_custom_event": {
"event": "my_custom_event",
"custom_property_1": "value1",
"custom_property_2": "value2",
"nested": {
"deep_property": "value3"
}
}
}
The assertion steps work with any event name — they're not hardcoded to standard types.
When the agent runs a dataLayer test, the console output contains hundreds of lines (Playwright startup, browser logs, assertion results). Parsing this raw output is expensive in tokens and error-prone.
Instead, the agent runs a shell extraction command that parses the terminal output into a small structured JSON blob. This reduces token consumption by ~75-85% compared to reading raw console output.
The update step is always user-gated because:
After the test completes, capture the console output and run this extraction command. The output can come from:
npx playwright test ... 2>&1 | tee test-output.txtnode -e "
const t = require('fs').readFileSync('OUTPUT_FILE', 'utf-8');
const r = {};
t.split('='.repeat(60)).forEach(b => {
const ev = b.match(/DATALAYER EVENT:\s*(.+)/);
const st = b.match(/Status:\s*(.*)/);
const act = b.match(/Actual:\n([\s\S]*?)$/);
if (ev && act) {
const key = ev[1].trim();
try {
const j = JSON.parse(act[1].trim());
delete j['gtm.uniqueEventId'];
r[key] = { status: (st ? st[1].trim() : 'unknown'), actual: j };
} catch(e) { r[key] = { status: (st ? st[1].trim() : 'unknown'), actual: null, parseError: true }; }
}
});
console.log(JSON.stringify(r, null, 2));
"
Replace OUTPUT_FILE with the path to whatever file contains the test's console output. To capture output during the test run, pipe it: npx playwright test ... 2>&1 | tee test-output.txt
Output format:
{
"element_visibility": {
"status": "✅ Found",
"actual": {
"event": "element_visibility",
"component_name": "Product Card",
"component_type": "Card"
}
},
"cta_click": {
"status": "❌ Not Found",
"actual": null
}
}
Read the source JSON and the extracted actuals. For each event key, present a per-event comparison:
element_visibility:
✅ Match — Expected and Actual align
cta_click:
⚠️ Mismatch — 2 differences:
click_text: Expected "Learn More" → Actual "Read More"
click_url: Expected "/products" → Actual "/products/overview"
form_submit:
❌ Not Found — No matching event in dataLayer
ALWAYS stop here. Present the comparison and wait for user input.
When the user reviews and approves:
gtm.uniqueEventIdWrite the updated JSON as a single file write (not multiple edits).
| User says | Flow |
|---|---|
| "Create analytics tests for X" | Create files → run → extract → compare → wait |
| "Run the analytics test for X" | Run → extract → compare → wait |
| "Debug the analytics test for X" | Run → extract → compare → wait (likely no update) |
| "Update the JSON with actuals" | Update approved events in JSON |
For a component with 3 events:
| Step | Without extraction | With extraction |
|---|---|---|
| Read test output | ~2000-4000 tokens | ~0 (skipped) |
| Run extraction | 0 | ~100 tokens |
| Read extracted actuals | 0 | ~200-400 tokens |
| Parse diffs | ~500-1000 tokens | ~0 (structured) |
| Total | ~2500-5000 | ~300-500 |
If extraction shows ❌ Not Found for an event:
waitForTimeout)const dl = await this.page.evaluate(() => (window as any).dataLayer || []);
console.log(JSON.stringify(dl, null, 2));
Search the output for events with known names. Present findings to the user.
Copy these into your project during first-time setup (see workflow above).
datalayer-util.tsimport { Page } from "@playwright/test";
/**
* Utility class for window.dataLayer operations in analytics testing.
* Provides methods to get, find, match, and clear dataLayer events.
*
* NEVER MODIFY THIS FILE — fix mismatches by updating component JSON only.
*/
export class DataLayerUtils {
page: Page;
constructor(page: Page) {
this.page = page;
}
async getDataLayer(): Promise<any[]> {
return await this.page.evaluate(() => (window as any).dataLayer || []);
}
async findDataLayerEvent(eventName: string): Promise<any | undefined> {
const dataLayer = await this.getDataLayer();
return dataLayer.find((e: any) => e.event === eventName);
}
async findAllDataLayerEvents(eventName: string): Promise<any[]> {
const dataLayer = await this.getDataLayer();
return dataLayer.filter((e: any) => e.event === eventName);
}
/**
* Deep partial match — finds first event where every key in `partial`
* exists in the actual event with the same value (recursive).
* Use when multiple events share the same name but differ by nested props.
*/
async findDataLayerEventMatching(
partial: Record<string, any>,
): Promise<any | undefined> {
const dataLayer = await this.getDataLayer();
return dataLayer.find((e: any) => this.deepMatch(e, partial));
}
private deepMatch(actual: any, expected: any): boolean {
if (expected === null || expected === undefined) return actual === expected;
if (typeof expected !== "object") return actual === expected;
if (typeof actual !== "object" || actual === null) return false;
return Object.keys(expected).every((key) =>
this.deepMatch(actual[key], expected[key]),
);
}
/**
* Prevents default navigation on a CTA link so the dataLayer event
* fires but the page stays on the current URL.
* Call BEFORE clicking the CTA.
*/
async preventCtaNavigation(selector: string): Promise<void> {
await this.page.evaluate((sel) => {
const cta = document.querySelector(sel);
if (cta) {
cta.addEventListener("click", (e) => e.preventDefault(), {
capture: true,
once: true,
});
}
}, selector);
}
async clearDataLayer(): Promise<void> {
await this.page.evaluate(() => ((window as any).dataLayer = []));
}
}
analytics-logger.tsimport * as allure from "allure-js-commons";
/**
* Logs dataLayer events to console (human-readable) and Allure (JSON attachment).
* Console format is parsed by the extraction workflow — do not change delimiters.
*
* NEVER MODIFY THIS FILE.
*/
export class AnalyticsLogger {
static async logDataLayerEvent(
eventType: string,
actual: any,
expected: any,
): Promise<void> {
const status = actual ? "✅ Found" : "❌ Not Found";
console.log(`\n${"=".repeat(60)}`);
console.log(` DATALAYER EVENT: ${eventType}`);
console.log(`${"=".repeat(60)}`);
console.log(` Status: ${status}`);
console.log(` Event Name: ${expected?.event || "N/A"}`);
console.log(`\n Expected:`);
console.log(JSON.stringify(expected, null, 2));
console.log(`\n Actual:`);
console.log(JSON.stringify(actual, null, 2));
console.log(`${"=".repeat(60)}\n`);
const logContent = {
eventType,
status: actual ? "Found" : "Not Found",
expected,
actual,
timestamp: new Date().toISOString(),
};
await allure.attachment(
`DataLayer: ${eventType}`,
JSON.stringify(logContent, null, 2),
"application/json",
);
}
}
analytics-helpers.tsimport { expect, Page } from "@playwright/test";
import { DataLayerUtils } from "./datalayer-util"; // ← adjust path
import { AnalyticsLogger } from "./analytics-logger"; // ← adjust path
/**
* Helper functions for standard Playwright tests (non-BDD).
* Wraps DataLayerUtils + AnalyticsLogger into simple assertion calls.
*
* NEVER MODIFY THIS FILE — fix mismatches by updating component JSON only.
*/
let dlUtils: DataLayerUtils;
export function initDataLayer(page: Page) {
dlUtils = new DataLayerUtils(page);
}
/**
* Assert first-match event and clear dataLayer.
* Use for most cases (one event per name).
*/
export async function expectEvent(
testData: Record<string, any>,
eventKey: string,
) {
const expected = testData[eventKey];
const actual = await dlUtils.findDataLayerEvent(expected.event);
await AnalyticsLogger.logDataLayerEvent(eventKey, actual, expected);
expect(actual, `Event "${expected.event}" not found in dataLayer`).toBeDefined();
expect(actual).toMatchObject(expected);
await dlUtils.clearDataLayer();
}
/**
* Assert deep partial match without clearing.
* Use when same event name fires multiple times with different nested props.
*/
export async function expectMatchingEvent(
testData: Record<string, any>,
eventKey: string,
) {
const expected = testData[eventKey];
const actual = await dlUtils.findDataLayerEventMatching(expected);
await AnalyticsLogger.logDataLayerEvent(eventKey, actual, expected);
expect(
actual,
`No "${expected.event}" event matching expected payload found in dataLayer`,
).toBeDefined();
expect(actual).toMatchObject(expected);
}
/**
* Assert deep partial match AND clear dataLayer.
* Use for interaction events on live pages with multiple components.
*/
export async function expectMatchingEventAndClear(
testData: Record<string, any>,
eventKey: string,
) {
const expected = testData[eventKey];
const actual = await dlUtils.findDataLayerEventMatching(expected);
await AnalyticsLogger.logDataLayerEvent(eventKey, actual, expected);
expect(
actual,
`No "${expected.event}" event matching expected payload found in dataLayer`,
).toBeDefined();
expect(actual).toMatchObject(expected);
await dlUtils.clearDataLayer();
}
export async function preventCtaNavigation(selector: string) {
await dlUtils.preventCtaNavigation(selector);
}
export async function clearDataLayer() {
await dlUtils.clearDataLayer();
}
analytics-common-step.ts/**
* Shared Given/Then steps for all analytics component tests.
* Provides generic navigation and dataLayer assertion logic.
*
* NEVER MODIFY THIS FILE — fix mismatches by updating component JSON only.
*
* Adjust the import paths below to match your project structure.
*/
import { Given, Then, Before } from "../config/fixture"; // ← adjust path
import { DataLayerUtils } from "../utils/datalayer-util"; // ← adjust path
import { AnalyticsLogger } from "../utils/analytics-logger"; // ← adjust path
let dataLayerUtils: DataLayerUtils;
let currentTestData: any;
Before(async function () {
dataLayerUtils = new DataLayerUtils(this.page);
});
/**
* Navigate to a component's analytics test page.
* Loads JSON test data and caches it for Then steps.
*/
Given(
"I am on the {string} analytics {string} page",
async function (component: string, variant: string) {
currentTestData = require(`./{component}/{component}.json`);
const baseUrl = process.env.BASE_URL || "";
const url = baseUrl + currentTestData[variant];
console.log(`Navigating to: ${url}`);
await this.page.goto(url, { waitUntil: "networkidle" });
await this.page.waitForTimeout(1500);
},
);
/**
* Navigate to a nested variant page.
* Resolves JSON from: ./{component}/{subfolder}/{subfolder}.json
*/
Given(
"I am on the {string} analytics {string} variant {string} page",
async function (component: string, subfolder: string, variant: string) {
currentTestData = require(`./{component}/{subfolder}/{subfolder}.json`);
const baseUrl = process.env.BASE_URL || "";
const url = baseUrl + currentTestData[variant];
console.log(`Navigating to: ${url}`);
await this.page.goto(url, { waitUntil: "networkidle" });
await this.page.waitForTimeout(1500);
},
);
/**
* Assert first-match event and clear dataLayer.
* Use for most cases (one event per name).
*/
Then(
"the dataLayer should contain expected {string} event",
async function (eventType: string) {
const expected = currentTestData[eventType];
const actual = await dataLayerUtils.findDataLayerEvent(expected.event);
await AnalyticsLogger.logDataLayerEvent(eventType, actual, expected);
this.expect(actual, `Event "${expected.event}" not found`).toBeDefined();
this.expect(actual).toMatchObject(expected);
await dataLayerUtils.clearDataLayer();
},
);
/**
* Assert deep partial match without clearing.
* Use when same event name fires multiple times with different nested props.
*/
Then(
"the dataLayer should contain a matching {string} event",
async function (eventType: string) {
const expected = currentTestData[eventType];
const actual = await dataLayerUtils.findDataLayerEventMatching(expected);
await AnalyticsLogger.logDataLayerEvent(eventType, actual, expected);
this.expect(
actual,
`No "${expected.event}" event matching expected payload found in dataLayer`,
).toBeDefined();
this.expect(actual).toMatchObject(expected);
},
);
/**
* Assert deep partial match AND clear dataLayer.
* Use for interaction events on live pages with multiple components.
*/
Then(
"the dataLayer should contain a matching {string} event and clear",
async function (eventType: string) {
const expected = currentTestData[eventType];
const actual = await dataLayerUtils.findDataLayerEventMatching(expected);
await AnalyticsLogger.logDataLayerEvent(eventType, actual, expected);
this.expect(
actual,
`No "${expected.event}" event matching expected payload found in dataLayer`,
).toBeDefined();
this.expect(actual).toMatchObject(expected);
await dataLayerUtils.clearDataLayer();
},
);
analytics.standard.config.ts/**
* Playwright config template for standard (non-BDD) analytics dataLayer testing.
* Adjust paths and settings to match your project structure.
*/
import { defineConfig } from "@playwright/test";
import * as os from "node:os";
export default defineConfig({
testDir: "./test/analytics", // ← adjust path
testMatch: "**/*.spec.ts",
fullyParallel: true,
workers: process.env.WORKERS ? parseInt(process.env.WORKERS) : 1,
reporter: [
["list"],
["html", { open: "never" }],
[
"allure-playwright",
{
detail: true,
suiteTitle: true,
environmentInfo: {
os_platform: os.platform(),
os_release: os.release(),
node_version: process.version,
test_type: "Analytics_DataLayer",
},
categories: [
{ name: "DataLayer Mismatch", messageRegex: ".*DataLayer.*" },
{ name: "Page NotFound", messageRegex: "Error:.*" },
{ name: "Timeout errors", messageRegex: ".*timeout.*" },
],
},
],
],
timeout: 60 * 1000,
expect: { timeout: 30 * 1000 },
use: {
headless: process.env.CI ? true : false,
screenshot: "only-on-failure",
video: "off",
trace: "off",
actionTimeout: 15 * 1000,
},
projects: [
{
name: "Analytics_DataLayer",
use: {
browserName: "chromium",
viewport: { width: 1920, height: 1080 },
},
},
],
});
analytics.playwright.config.ts/**
* Playwright config template for analytics dataLayer testing.
* Adjust paths and settings to match your project structure.
*/
import { defineConfig } from "@playwright/test";
import { defineBddConfig } from "playwright-bdd";
import * as os from "node:os";
const testDir = defineBddConfig({
features: ["./test/analytics/**/*.feature"], // ← adjust path
steps: ["./test/analytics/**/*.ts", "./config/fixture.ts"], // ← adjust path
featuresRoot: "./test/analytics/",
});
export default defineConfig({
testDir,
fullyParallel: true,
workers: process.env.WORKERS ? parseInt(process.env.WORKERS) : 1,
reporter: [
["list"],
["html", { open: "never" }],
[
"allure-playwright",
{
detail: true,
suiteTitle: true,
environmentInfo: {
os_platform: os.platform(),
os_release: os.release(),
node_version: process.version,
test_type: "Analytics_DataLayer",
},
categories: [
{ name: "DataLayer Mismatch", messageRegex: ".*DataLayer.*" },
{ name: "Page NotFound", messageRegex: "Error:.*" },
{ name: "Timeout errors", messageRegex: ".*timeout.*" },
],
},
],
],
timeout: 60 * 1000,
expect: { timeout: 30 * 1000 },
use: {
headless: process.env.CI ? true : false,
screenshot: "only-on-failure",
video: "off",
trace: "off",
actionTimeout: 15 * 1000,
},
projects: [
{
name: "Analytics_DataLayer",
use: {
browserName: "chromium",
viewport: { width: 1920, height: 1080 },
},
},
],
});
button.json
{
"primaryButton": "/components/button/primary",
"cta_click": {
"event": "cta_click",
"click_text": "Get Started",
"click_url": "/signup",
"component_name": "Primary Button",
"link_type": "internal"
},
"select_content": {
"event": "select_content",
"content_type": "button",
"item_id": "primary-get-started"
}
}
button-analytics.feature
Feature: Button - DataLayer Analytics
@analytics @button @ctaClick @critical
Scenario: Verify cta_click event on primary button click
Given I am on the "button" analytics "primaryButton" page
When I click the primary button
Then the dataLayer should contain expected "cta_click" event
@analytics @button @selectContent
Scenario: Verify select_content event on button click
Given I am on the "button" analytics "primaryButton" page
When I click the primary button
Then the dataLayer should contain expected "select_content" event
button-analytics-step.ts
import { When, Before } from "../../../../config/fixture"; // ← adjust path
import ButtonAnalytics from "./button-pageobject.js";
let buttonPage: ButtonAnalytics;
Before(async function () {
buttonPage = new ButtonAnalytics(this.page);
});
When("I click the primary button", async function () {
await buttonPage.primaryButton.click();
await this.page.waitForTimeout(500);
});
button-pageobject.ts
import { Locator, Page } from "@playwright/test";
class ButtonAnalytics {
page: Page;
primaryButton: Locator;
constructor(page: Page) {
this.page = page;
this.primaryButton = page.locator("button.btn-primary");
}
}
export default ButtonAnalytics;
button.json
{
"primaryButton": "/components/button/primary",
"cta_click": {
"event": "cta_click",
"click_text": "Get Started",
"click_url": "/signup",
"component_name": "Primary Button",
"link_type": "internal"
},
"select_content": {
"event": "select_content",
"content_type": "button",
"item_id": "primary-get-started"
}
}
button-analytics.spec.ts
import { test } from "@playwright/test";
import {
initDataLayer,
expectEvent,
} from "../../../../utils/analytics-helpers"; // ← adjust path
import ButtonAnalytics from "./button-pageobject";
import testData from "./button.json";
const BASE_URL = process.env.BASE_URL || "";
test.describe("Button - DataLayer Analytics", { tag: ["@analytics", "@button"] }, () => {
let buttonPage: ButtonAnalytics;
test.beforeEach(async ({ page }) => {
initDataLayer(page);
buttonPage = new ButtonAnalytics(page);
await page.goto(BASE_URL + testData.primaryButton, {
waitUntil: "networkidle",
});
await page.waitForTimeout(1500);
});
test("cta_click event on primary button click", { tag: "@ctaClick" }, async () => {
await buttonPage.primaryButton.click();
await buttonPage.page.waitForTimeout(500);
await expectEvent(testData, "cta_click");
});
test("select_content event on primary button click", { tag: "@selectContent" }, async () => {
await buttonPage.primaryButton.click();
await buttonPage.page.waitForTimeout(500);
await expectEvent(testData, "select_content");
});
});
button-pageobject.ts
import { Locator, Page } from "@playwright/test";
class ButtonAnalytics {
page: Page;
primaryButton: Locator;
constructor(page: Page) {
this.page = page;
this.primaryButton = page.locator("button.btn-primary");
}
}
export default ButtonAnalytics;