datalayer-analytics-playwright

v1.0.0
localskills install madhurs-team/datalayer-analytics-playwright
1 downloads
Created Mar 12, 2026
Madhur Batra
SKILL.md

DataLayer Analytics — Playwright

Automated window.dataLayer event validation using Playwright. Creates, runs, and maintains analytics tracking tests so you never ship broken tracking again.

When to Use

  • Validate GTM / GA4 events fire correctly with the right payloads
  • Create analytics regression tests from a tracking spec (Confluence, Notion, or manual)
  • Discover what events a page actually fires (no spec needed)
  • Debug mismatches between expected and actual dataLayer payloads
  • Maintain test data as tracking implementations evolve

Two Test Modes

Auto-detected based on your project setup:

ModeFiles per ComponentDetection
Standard Playwright3 (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.

Supported Events

EventFires When
element_visibilityComponent scrolls into view
cta_clickUser clicks a CTA / button / link
form_interactionUser interacts with form fields
form_submitForm is submitted
generate_leadLead generation conversion (GA4)
select_contentUser selects content (GA4)
add_to_cartItem added to cart (GA4 ecommerce)
purchaseTransaction completed (GA4 ecommerce)
Any custom eventYour tracking fires it

What You Can Say to Your Agent

"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"

Workflow Modes

ModeWhenWhat 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 Strategy

AssertionBDD StepClears?Context
First-match + clearcontain expected "{key}" eventYesStorybook (isolated components)
Component-aware matchcontain a matching "{key}" eventNoApp tests (multi-component pages)
Component-aware + clearcontain a matching "{key}" event and clearYesApp 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.

Critical Rules

  1. Capture ALL properties from actual events. Only exclude gtm.uniqueEventId.
  2. Never remove properties to fix flaky tests. Make dynamic values deterministic via URL args.
  3. Never modify 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.
  4. Use toMatchObject() for assertions. toEqual() is banned.
  5. Never auto-update JSON — always present comparison and wait for user approval.
  6. Only fetch tracking specs from URLs explicitly provided by the user. Always display extracted event names for user approval before creating files.

References

Full implementation details are inlined below.


Detailed Workflow — Agent Instructions

This is the full step-by-step implementation guide. Read this when creating, executing, or debugging analytics tests.


Setup (first-time only)

If the project doesn't already have the analytics testing utilities, create them from the assets/ folder.

Both modes (shared utilities):

  1. Copy assets/datalayer-util.ts{test-root}/utils/datalayer-util.ts
  2. Copy assets/analytics-logger.ts{test-root}/utils/analytics-logger.ts
  3. Install: npm install -D allure-js-commons allure-playwright

Standard 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.


Step 1: Get the Tracking Spec

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:

  • Confluence + MCP: 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)
  • Confluence + REST: curl -s "https://{domain}/wiki/rest/api/content/{pageId}?expand=body.storage" -H "Authorization: Bearer {token}" — pipe through jq '.body.storage.value'
  • Other documentation URLs: Fetch the user-provided page and extract event names, parameter tables, code blocks

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/link → likely cta_click or select_content
  • Visible on load → likely element_visibility or custom viewport event
  • Interactive controls → likely form_interaction or select_item
  • Form submission → likely form_submit or generate_lead

Create files with skeleton JSON payloads, run → extract → compare → fill.


Step 2: Get Locators

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):

  1. CSS class + attribute: button.ui-tabs__link#tab-tab2
  2. Unique CSS class: a.ui-text-nav-link--with-underline
  3. data-testid + class: .ui-checkbox-input[data-testid="checkbox-test"]
  4. Stable ID + class: .ui-radio-input#radio-1
  5. Text (LAST RESORT): a: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.


Step 3: Create the Component Files

All files go in: {test-root}/analytics/{component}/

VALIDATION RULES (enforced by shared infrastructure):

Before creating files, ensure all names follow these patterns:

  • Component names (folder and file prefix): kebab-case alphanumeric only: [a-z0-9-]+
    • Valid: button, hero-section, checkout-flow
    • Invalid: Button, hero_section, ../../../evil, button$(whoami)
  • Event names (JSON keys, feature file references): snake_case alphanumeric: [a-z_][a-z0-9_]*
    • Valid: element_visibility, cta_click, form_submit
    • Invalid: ElementVisibility, cta-click, cta_click();, __proto__
  • Variant/URL keys (JSON keys for multi-variant): kebab-case alphanumeric (same as component names)
    • Valid: default, secondary, mobile-view
    • Invalid: Default, mobile_view, ../other

The shared test infrastructure (analytics-common-step.ts and validation utilities) validates these patterns at runtime and rejects any inputs that don't conform.

3a. JSON — {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.

3b. Page Object — {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;

Standard mode: 3c. Test File — {component}-analytics.spec.ts
import { 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.


BDD mode: 3c. Feature — {component}-analytics.feature
Feature: {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
BDD mode: 3d. Step Definitions — {component}-analytics-step.ts

The 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.


Step 4: Execute, Extract & Compare

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:

  • A terminal output file (e.g. Cursor terminals folder, Claude Code session log)
  • Piped stdout from the test run (redirect with 2>&1 | tee test-output.txt)
  • Any text file containing the Playwright console output
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));

Step 5: DOM Inspection (if locators fail)

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);

Parameter Mapping (from tracking spec tables)

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 Naming Conventions

FileStandard ModeBDD 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}

Nested Components (variants)

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

Quick Reference

Spec extraction (Confluence / wiki)

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'
Run commands

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"
Debugging

Console shows Expected vs Actual side by side. Common fixes:

  1. Casing/field mismatch → update JSON (after user confirms)
  2. Event not found → increase wait, check if tracking deployed, verify locator
  3. Actual differs from spec → may be tracking bug (don't update — user investigates)

Locator Rules

Priority Order

Pick the first available strategy from this list:

1. CSS class + attribute (best)
page.locator("button.ui-tabs__link#tab-tab2")
page.locator("input.ui-input-field[type='email']")
2. Unique CSS class
page.locator("a.ui-text-nav-link--with-underline")
page.locator("button.btn-standard-secondary")
3. data-testid + class
page.locator(".ui-checkbox-input[data-testid='checkbox-test']")
4. Stable ID + class
page.locator(".ui-radio-input#radio-1")
5. Text (LAST RESORT)
page.locator("a:has-text('Submit')")

Banned Selectors

These create brittle, unreliable tests:

BannedWhy
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 attributesAlways verify against actual DOM

DOM Inspection

When locators are unknown, use a targeted page.evaluate() to extract interactive elements:

General inspection (all 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);
Scoped inspection (specific component)
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.

CTA Navigation Prevention

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();
  }
}

Reusing Existing Locators

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");

Assertion Strategy

Three Assertion Types

Each assertion type is available in both Standard and BDD mode:

1. First-Match with Clear (default)

BDD:

Then the dataLayer should contain expected "cta_click" event

Standard:

await expectEvent(testData, "cta_click");
  • Finds first event in window.dataLayer matching the event property name
  • Asserts with toMatchObject() (expected is a subset of actual)
  • Clears window.dataLayer after assertion — critical for sequential tests
  • Use for: most cases (one event per name in the dataLayer)
2. Deep Partial Match

BDD:

Then the dataLayer should contain a matching "form_interaction_focus" event

Standard:

await expectMatchingEvent(testData, "form_interaction_focus");
  • Iterates all events, does recursive deep-match of every key in expected against actual
  • Does NOT clear dataLayer
  • Use for: same event name fires multiple times with different nested properties
3. Deep Partial Match with Clear

BDD:

Then the dataLayer should contain a matching "cta_click" event and clear

Standard:

await expectMatchingEventAndClear(testData, "cta_click");
  • Same as deep partial match, but clears dataLayer after assertion
  • Use for: interaction events on live pages with multiple components

When to Use Which

Default to first-match with clear unless you have a specific reason not to.

Example: Different event names → use first-match

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.

Example: Same event name, different props → use deep match

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.

Sequential Events (Setup + Target)

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.

App/Live Page Testing

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.

AssertionBDD StepLookupClears?
Component-aware matchcontain a matching "{key}" eventfindMatchingDataLayerEvent (event + component name)No
Component-aware + clearcontain a matching "{key}" event and clearSameYes

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.

How Deep Matching Works

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.


DataLayer Event Patterns

Standard Event Types

element_visibility

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:

PropertyDescriptionExample
component_nameComponent display name"Product Card", "Newsletter Banner", "Search Bar"
component_typeComponent category"Card", "Banner", "Form", "Navigation"
component_variantVisual/functional variant"Featured", "Compact", "Sticky"
page_locationPage URL path"/products/overview"
cta_click

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:

PropertyValuesNotes
click_textButton/link visible text"Shop Now", "Learn More", "Download PDF"
click_urlTarget URL or href"/products/sale", "https://external.com"
component_nameParent component nameWhere the CTA lives
link_typeinternal, external, downloadDestination classification
form_interaction

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:

TypeUse case
focusUser clicks/tabs into a field
value_changedUser changes the field value
selectUser picks from dropdown/radio/checkbox
toggleUser toggles a switch/checkbox
form_submit

Fires after a form is submitted.

{
  "event": "form_submit",
  "form_name": "Contact Us",
  "form_type": "Lead Generation",
  "submission_status": "success"
}
generate_lead (GA4 recommended)

Fires on lead generation conversions.

{
  "event": "generate_lead",
  "currency": "USD",
  "value": 50.00
}
select_content (GA4 recommended)

Fires when user selects content (tabs, accordions, carousels).

{
  "event": "select_content",
  "content_type": "tab",
  "item_id": "pricing-tab"
}
add_to_cart (GA4 ecommerce)

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
      }
    ]
  }
}
purchase (GA4 ecommerce)

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
      }
    ]
  }
}

JSON Structure Pattern

Every component JSON follows this pattern:

{
  "{urlKey}": "/path/to/test/page",
  "{anotherUrlKey}": "/path/to/variant/page",

  "validEmail": "test@example.com",

  "{eventKey}": {
    "event": "{eventName}",
    ...properties
  }
}
  • URL keys: Map scenario names to page paths (relative)
  • Event keys: Map to expected event payloads (used by Given/Then steps)
  • Data keys: Additional test data (emails, inputs, etc.)

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").

Parameter Mapping from Tracking Specs

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.

Custom Events

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.


Extraction Workflow — Extract → Compare → (optional) Update

Why This Pattern Exists

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:

  • Confluence specs may have errors (wrong casing, missing fields)
  • Actuals may reveal tracking bugs (the JSON is correct, the implementation is wrong)
  • The user may want to update only specific events, not all

The Three Steps

1. Extract

After the test completes, capture the console output and run this extraction command. The output can come from:

  • A terminal output file (e.g. Cursor terminals folder, Claude Code session log, VS Code terminal)
  • Piped stdout: npx playwright test ... 2>&1 | tee test-output.txt
  • Any text file containing the Playwright console output
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

Output format:

{
  "element_visibility": {
    "status": "✅ Found",
    "actual": {
      "event": "element_visibility",
      "component_name": "Product Card",
      "component_type": "Card"
    }
  },
  "cta_click": {
    "status": "❌ Not Found",
    "actual": null
  }
}
2. Compare

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.

3. Update (only on user request)

When the user reviews and approves:

  • "Update all": Merge each actual into source JSON, removing gtm.uniqueEventId
  • "Update X only": Update only the specific events the user names
  • "Don't update": The actual may be wrong (tracking bug) — user investigates

Write the updated JSON as a single file write (not multiple edits).

Workflow Triggers

User saysFlow
"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

Token Cost Comparison

For a component with 3 events:

StepWithout extractionWith extraction
Read test output~2000-4000 tokens~0 (skipped)
Run extraction0~100 tokens
Read extracted actuals0~200-400 tokens
Parse diffs~500-1000 tokens~0 (structured)
Total~2500-5000~300-500

When Events Are Not Found

If extraction shows ❌ Not Found for an event:

  1. Increase wait time — the event may not have fired yet (add waitForTimeout)
  2. Check tracking deployment — is analytics tracking live on the target URL?
  3. Verify the event name — compare against the full dataLayer dump
  4. Full dataLayer dump — temporarily add to a When step:
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.

Requirements

  • Node.js 18+
  • Playwright
  • allure-js-commons + allure-playwright
  • playwright-bdd (BDD mode only)

Asset Files

Copy these into your project during first-time setup (see workflow above).

datalayer-util.ts

import { 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.ts

import * 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.ts

import { 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 },
      },
    },
  ],
});

Examples

BDD Example — Button

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;

Standard Playwright Example — Button

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;