datalayer-analytics-playwright

v1.0.0
localskills install CHZ6wGdpFL
1 downloads
(1 this week)
Created Mar 12, 2026
Madhur Batra
Skill Content
# 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:

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

## Supported Events

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

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

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

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

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

```json
{
  "{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)

```typescript
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`

```typescript
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`

```gherkin
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.

```typescript
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:

```typescript
// 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:

```gherkin
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:
```bash
npx playwright test --config analytics.playwright.config.ts --grep "@{componentTag}"
```

BDD mode:
```bash
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

```bash
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:
```typescript
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:
```typescript
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

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

---

### 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:
```gherkin
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:
```bash
npx playwright test --config analytics.playwright.config.ts --grep "@tag"
```

BDD mode:
```bash
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)
```typescript
page.locator("button.ui-tabs__link#tab-tab2")
page.locator("input.ui-input-field[type='email']")
```

#### 2. Unique CSS class
```typescript
page.locator("a.ui-text-nav-link--with-underline")
page.locator("button.btn-standard-secondary")
```

#### 3. data-testid + class
```typescript
page.locator(".ui-checkbox-input[data-testid='checkbox-test']")
```

#### 4. Stable ID + class
```typescript
page.locator(".ui-radio-input#radio-1")
```

#### 5. Text (LAST RESORT)
```typescript
page.locator("a:has-text('Submit')")
```

### Banned Selectors

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 |

### DOM Inspection

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

#### General inspection (all interactive elements)
```typescript
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)
```typescript
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:

```typescript
await dataLayerUtils.preventCtaNavigation(componentPage.ctaSelector);
await componentPage.primaryCTA.click();
```

This requires exposing the raw CSS selector as an instance property on the page object:

```typescript
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:
```typescript
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:
```gherkin
Then the dataLayer should contain expected "cta_click" event
```

Standard:
```typescript
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:
```gherkin
Then the dataLayer should contain a matching "form_interaction_focus" event
```

Standard:
```typescript
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:
```gherkin
Then the dataLayer should contain a matching "cta_click" event and clear
```

Standard:
```typescript
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:
```gherkin
When I click submit
Then the dataLayer should contain expected "cta_click" event
Then the dataLayer should contain expected "form_submit" event
```

Standard:
```typescript
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"):

```json
{
  "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:
```gherkin
Then the dataLayer should contain a matching "form_interaction_focus" event
And the dataLayer should contain a matching "form_interaction_value_changed" event
```

Standard:
```typescript
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:
```gherkin
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:
```typescript
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.

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

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

```json
{
  "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" |

#### cta_click
Fires when user clicks a CTA button or link.

```json
{
  "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 |

#### form_interaction
Fires on form field interactions (focus, input, selection).

```json
{
  "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 |

#### form_submit
Fires after a form is submitted.

```json
{
  "event": "form_submit",
  "form_name": "Contact Us",
  "form_type": "Lead Generation",
  "submission_status": "success"
}
```

#### generate_lead (GA4 recommended)
Fires on lead generation conversions.

```json
{
  "event": "generate_lead",
  "currency": "USD",
  "value": 50.00
}
```

#### select_content (GA4 recommended)
Fires when user selects content (tabs, accordions, carousels).

```json
{
  "event": "select_content",
  "content_type": "tab",
  "item_id": "pricing-tab"
}
```

#### add_to_cart (GA4 ecommerce)
Fires when an item is added to cart.

```json
{
  "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.

```json
{
  "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:

```json
{
  "{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:

```json
{
  "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

```bash
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:**
```json
{
  "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 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 |

### Token Cost Comparison

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

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

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
/**
 * 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`

```typescript
/**
 * 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`

```typescript
/**
 * 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`**

```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`**

```gherkin
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`**

```typescript
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`**

```typescript
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`**

```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`**

```typescript
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`**

```typescript
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;
```