# API Reference

The following sections document the full API available for writing tests in Stage 3.

## Table of Contents
- [Setup — Fixtures](#setup--fixtures)
- [Locator Format](#locator-format) (css, xpath, id, text, role+name, regex, iframe)
- [Steps API](#steps-api) (navigation, interaction, extraction, verification, expect matcher tree, visibility, listed elements, waiting, composite, screenshot, visual regression)
- [Fluent API — steps.on()](#fluent-api--stepson) (strategy selectors, ifVisible, terminal actions, chaining)
- [Accessing the Repository Directly](#accessing-the-repository-directly)
- [Raw Interactions API](#raw-interactions-api)
- [Email API](#email-api) (setup, sending, receiving, marking, cleaning)
- [Session-aware HTTP Requests](#session-aware-http-requests) (page.request — browser-session cookies)
- [HTTP API Steps](#http-api-steps) (fixture setup, default & named providers, methods, verifications)

---

## Setup — Fixtures

Read `tests/fixtures/base.ts` first if it exists — do not overwrite without checking.

```ts
// tests/fixtures/base.ts
import { test as base, expect } from '@playwright/test';
import { baseFixture } from '@civitas-cerebrum/element-interactions';

export const test = baseFixture(base, 'tests/data/page-repository.json', {
  timeout: 60000,              // element timeout (default: 30000)
  // repoTimeout: 15000,       // repo resolution timeout (default: 15000)
  // interceptionRetry: false, // default true: intercepted clicks fall back to a dispatched DOM click event.
                               // false makes genuine overlay bugs (stuck modals, cookie walls) fail the click —
                               // recommended for adversarial/bug-discovery suites
  // blockedOrigins: /regex/,  // auto-abort matching routes
  // screenshotOnFailure: true, // auto-capture on failure (default: true)
});
export { expect };
```

| Fixture | Type | Description |
|---|---|---|
| `steps` | `Steps` | The full Steps API |
| `repo` | `ElementRepository` | Direct repository access for advanced locator queries |
| `interactions` | `ElementInteractions` | Raw interactions API for custom locators |
| `contextStore` | `ContextStore` | Shared in-memory key-value store for passing data between steps within a test |

> **`interceptionRetry` — available as of 0.3.7 (below the `>=0.3.8` peer floor, so always present).** The `interceptionRetry` opt-out shown above and the report-visible `interception-fallback` test annotation both ship in `@civitas-cerebrum/element-interactions@0.3.7` (`BaseFixture` / `Steps` / `ElementInteractions` accept `interceptionRetry`; the fallback logs a warning and pushes the annotation). The default is `true`: an intercepted click silently falls back to a dispatched DOM `'click'` — which can **mask** a stuck modal or cookie wall that should have failed the click. **For bug-discovery and adversarial suites, set `interceptionRetry: false`** so an interception rethrows and fails the test instead of being papered over; treat any `interception-fallback` annotation in a report as a suspected app bug to investigate, not noise.

`baseFixture` attaches a full-page `failure-screenshot` to the HTML report on every failed test automatically.

**Extending with custom fixtures** — `baseFixture` returns a standard Playwright `test` object, so use `.extend<T>()` as usual.

## Locator Format

All selectors live in `tests/data/page-repository.json`.

```json
{
  "pages": [
    {
      "name": "HomePage",
      "elements": [
        {
          "elementName": "submitButton",
          "selector": {
            "css": "button[data-test='submit']",
            "xpath": "//button[@data-test='submit']",
            "id": "submit-btn",
            "text": "Submit"
          }
        }
      ]
    }
  ]
}
```

Supports `css`, `xpath`, `id`, or `text` strategies. Names: PascalCase pages (`CheckoutPage`), camelCase elements (`submitButton`).

### Role + Accessible Name

Use `role` with `name` to locate elements by their ARIA role and accessible name, resolving via `page.getByRole()`:

```json
{
  "elementName": "loginButton",
  "selector": { "role": "button", "name": "Log in" }
}
```

Regex name patterns for multi-language or variant matching:

```json
{
  "elementName": "authButton",
  "selector": { "role": "button", "name": { "regex": "Log in|Iniciar sesión|Anmelden", "flags": "i" } }
}
```

Works with any role: `button`, `textbox`, `switch`, `radio`, `link`, `dialog`, `combobox`, `slider`, `tab`, `img`.
Cross-platform: resolves via UiSelector on Android and predicate strings on iOS.

### Regex Text Selectors

Match elements by regex text content instead of exact strings:

```json
{
  "elementName": "payRestrictionAlert",
  "selector": { "text": { "regex": "Just Eat Pay.*cannot.*be used", "flags": "i" } }
}
```

Usage is identical to any other element — `steps.click()`, `steps.verifyPresence()`, etc.
Cross-platform: resolves via `textMatches()` on Android and `MATCHES` predicate on iOS.

### Iframe-Scoped Pages

Add a `frame` property to scope all elements on a page inside an iframe:

```json
{
  "name": "AdyenCardIframe",
  "frame": { "css": "iframe[title*='card number' i]" },
  "elements": [
    { "elementName": "cardInput", "selector": { "css": "[data-testid='card-input']" } }
  ]
}
```

Usage is unchanged — `await steps.fill('cardInput', 'AdyenCardIframe', '4111...')`.

Frame disambiguation when multiple frames match:

```json
{ "frame": { "css": "iframe[title*='security code' i]" }, "frameIndex": "last" }
```

`frameIndex` accepts `"first"`, `"last"`, or a zero-based number.

Nested frames:

```json
{ "frame": [{ "css": "iframe[title*='PayPal' i]" }, { "css": "iframe.zoid-component" }] }
```

## Steps API

Every method takes `elementName` and `pageName` as its first two arguments, matching keys in your JSON file.

**Imports** — add at the top of your test file as needed:
```ts
import { DropdownSelectType, ListedElementMatch, VerifyListedOptions, GetListedDataOptions, FillFormValue, ScreenshotOptions, VisualMatchOptions, VisualMaskTarget, EmailFilterType, EmailMarkAction, WebElement } from '@civitas-cerebrum/element-interactions';
```

### Navigation

```ts
await steps.navigateTo('/path');
await steps.navigateTo('/search', { query: { q: 'hello', page: '2' } });   // appends ?q=hello&page=2
await steps.navigateTo('/path', { waitUntil: 'domcontentloaded' });        // 0.3.7+ — SPA-safe; default is 'load'
const res = await steps.navigateTo('/missing-route');                      // 0.3.8+ — returns Response | null
expect(res?.status()).toBe(404);                                           // assert 404/redirect contracts (no raw page.goto)
await steps.refresh();
await steps.backOrForward('back'); // or 'forward'
await steps.setViewport(1280, 720);

// Standalone lifecycle wait — after an action that does NOT navigate.   (0.3.8+)
// state: 'load' | 'domcontentloaded' | 'networkidle' (the LoadState type).
await steps.waitForLoadState('domcontentloaded');
await steps.waitForLoadState('networkidle', { timeout: 10000 });

// Current URL (value getters — companions to verifyUrlContains)  (0.3.7+)
const url  = steps.getUrl();           // full href
const path = steps.getCurrentPath();   // pathname only (no origin/query/hash)

// Wait for a URL change — glob string, RegExp, or (url: URL) => boolean   (0.3.7+)
await steps.waitForUrl(/\/account\//);
await steps.waitForUrl((u) => u.pathname.endsWith('/checkout'));
// Race-safe form: pass the triggering action so the wait is armed BEFORE it runs
// (issued concurrently via Promise.all) — for fast client-side route changes.
await steps.waitForUrl(/\/products\//, async () => {
  await steps.on('colourSwatch', 'ProductPage').first().click();
}, { timeout: 15000, waitUntil: 'domcontentloaded' });

// Tab management
const newPage = await steps.switchToNewTab(async () => {
  await steps.click('newTabLink', 'PageName');
});
await steps.closeTab(newPage);
const count = steps.getTabCount();
```

> **`navigateTo` waitUntil (0.3.7+).** The second arg is `{ query?, waitUntil? }`.
> `waitUntil` (`'load'` default, `'domcontentloaded'`, `'networkidle'`, `'commit'`)
> threads straight into `page.goto`. Prefer `'domcontentloaded'` for SPA
> navigations that stall a cold WebKit/Safari on the full `'load'` event — this is
> the in-framework replacement for dropping to raw `page.goto(url, { waitUntil })`.
> On **0.3.6 and earlier** `navigateTo` accepts only `{ query }` and always waits for `'load'`.

### Interaction

```ts
await steps.click('elementName', 'PageName');
await steps.click('elementName', 'PageName', { force: true });              // dispatches a DOM 'click' event directly — NOT Playwright's force: true (no pointer simulation, no actionability checks; rename pending in a future major)
await steps.click('elementName', 'PageName', { withoutScrolling: true });   // alias semantics of force: dispatches a DOM 'click' event without scrolling into view
const clicked = await steps.clickIfPresent('elementName', 'PageName');      // returns boolean, false if absent
await steps.clickRandom('elementName', 'PageName');
await steps.clickNth('elementName', 'PageName', 2);           // zero-based index
await steps.rightClick('elementName', 'PageName');
await steps.doubleClick('elementName', 'PageName');
await steps.check('elementName', 'PageName');
await steps.uncheck('elementName', 'PageName');
await steps.hover('elementName', 'PageName');
await steps.scrollIntoView('elementName', 'PageName');
await steps.fill('elementName', 'PageName', 'text');
await steps.clearInput('elementName', 'PageName');
await steps.typeSequentially('elementName', 'PageName', 'text', 50); // optional delay ms
await steps.uploadFile('elementName', 'PageName', 'path/to/file.pdf');
await steps.setSliderValue('elementName', 'PageName', 75);
await steps.pressKey('Enter');                                 // 'Escape', 'Tab', 'Control+A', etc.
await steps.pressKeys(['Control', 'A']);                       // 0.3.8+ — multi-key chord, joined with '+'; throws on []

// dispatchEvent — synthetic DOM event, no actionability checks (0.3.8+)
// LAST RESORT: reach for this only when a real interaction cannot express the case.
// The element only needs to be attached, not visible or enabled.
await steps.dispatchEvent('elementName', 'PageName', 'click');
await steps.dispatchEvent('elementName', 'PageName', 'input', { bubbles: true });
await steps.dispatchEvent('elementName', 'PageName', 'change');
await steps.dispatchEvent('elementName', 'PageName', 'focus');
await steps.dispatchEvent('elementName', 'PageName', 'keydown', { key: 'Enter', bubbles: true });

// Dropdowns
const val = await steps.selectDropdown('elementName', 'PageName');                                              // random (default)
const val2 = await steps.selectDropdown('elementName', 'PageName', { type: DropdownSelectType.VALUE, value: 'xl' });
const val3 = await steps.selectDropdown('elementName', 'PageName', { type: DropdownSelectType.INDEX, index: 2 });
await steps.selectMultiple('multiSelect', 'PageName', ['opt1', 'opt2']);

// Drag and drop — target accepts a Locator or Element from the repository
await steps.dragAndDrop('elementName', 'PageName', { target: otherLocatorOrElement });
await steps.dragAndDrop('elementName', 'PageName', { xOffset: 100, yOffset: 0 });
await steps.dragAndDropListedElement('elementName', 'PageName', 'Item Label', { target: otherLocatorOrElement });
```

**Note:** `click()` automatically falls back to a dispatched DOM `'click'` event when Playwright reports pointer interception — no `{ force: true }` needed in most cases. As of **0.3.7 (below the `>=0.3.8` peer floor, so always present)** the fallback also logs a warning and pushes a report-visible `interception-fallback` test annotation naming `PageName.elementName`, and `interceptionRetry: false` on the fixture (or the `Steps` / `ElementInteractions` constructor options) rethrows the original interception error instead — **recommended for adversarial/bug-discovery suites** where a stuck modal or cookie wall should fail the click rather than be clicked through. See [Setup — Fixtures](#setup--fixtures).

**`dispatchEvent` — when to use it and when not to (0.3.8+).** `dispatchEvent(elementName, pageName, type, eventInit?)` fires a synthetic DOM event directly on the element, bypassing all Playwright actionability checks (visibility, enabled state, scrolling into view). Use it **only** in situations where a real interaction cannot express the case:
- **Custom events** the app dispatches or listens for (e.g. `'app:cart-updated'`).
- **`input` / `change` on widgets** that intercept pointer events and ignore synthetic typing, preventing `fill()` from triggering their handlers.
- **`focus` / `blur`** to drive validation logic without a real tab sequence.
- **`keydown` / `keyup` / `keypress`** with a specific `key` payload on a focusable element (e.g. `{ key: 'Escape', bubbles: true }` to close a modal via keyboard).

The optional `eventInit` object is passed as the second argument to `new Event(type, eventInit)` / `new KeyboardEvent(type, eventInit)` — any property accepted by the relevant DOM event constructor works. Always set `bubbles: true` when the handler lives on an ancestor element.

**Prefer `click()` / `fill()` / `pressKey()` for real user input.** `dispatchEvent` skips actionability, so a misspelled element name or an off-screen element will not be caught. It also skips Playwright's retry-on-intercept logic — if you're trying to click an element that is behind an overlay, `click()` (which retries and can fall back to a dispatched click automatically) is the right call, not `dispatchEvent`.

### Data Extraction

```ts
const text = await steps.getText('elementName', 'PageName');
const href = await steps.getAttribute('elementName', 'PageName', 'href');
const count = await steps.getCount('elementName', 'PageName');
const inputVal = await steps.getInputValue('elementName', 'PageName');
const color = await steps.getCssProperty('elementName', 'PageName', 'color');
const box = await steps.getBoundingBox('elementName', 'PageName');  // 0.3.8+ — { x, y, width, height } | null (null when not rendered)
const inner = await steps.getHtml('elementName', 'PageName');                 // innerHTML  (0.3.8+)
const outer = await steps.getHtml('elementName', 'PageName', { outer: true }); // outerHTML (incl. the tag)

// Bulk extraction
const allTexts = await steps.getAll('listItems', 'PageName');
const allChildTexts = await steps.getAll('tableRows', 'PageName', { child: { pageName: 'TablePage', elementName: 'nameCell' } });
const allHrefs = await steps.getAll('links', 'PageName', { extractAttribute: 'href' });

// Browser storage — page-level reads, no element scope.
// Returns null when the key is absent (matches native getItem contract).
const theme = await steps.getLocalStorage('theme');           // string | null
const cart  = await steps.getSessionStorage('cart.count');    // string | null

// Enumerate all keys — e.g. to discover a runtime-derived key.   (0.3.8+)
const lsKeys = await steps.getLocalStorageKeys();             // string[]
const ssKeys = await steps.getSessionStorageKeys();           // string[]

// Page-level text (document.body.innerText) — companion to getPageHtml.   (0.3.8+)
// For 404-body / page-copy assertions without page.locator('body').innerText().
const bodyText = await steps.getPageText();                   // string

// Browser storage — page-level writes (mutating companions to the getters).  (0.3.7+)
// Use to seed persisted state, or to drive resilience checks with malformed values.
await steps.setLocalStorage('theme', 'dark');
await steps.setLocalStorage('wishlist', 'not-json-{[bogus');   // corrupt value the app must tolerate
await steps.setSessionStorage('cart.count', '3');

// Browser storage — page-level removes / clears (native removeItem / clear).  (0.3.7+)
await steps.removeLocalStorage('wishlist');       // drop one key (no-op when absent)
await steps.removeSessionStorage('cart.count');
await steps.clearLocalStorage();                  // empty the whole store
await steps.clearSessionStorage();

// Window-level JS state — controlled access by dotted path (no raw page.evaluate).  (0.3.8+)
const fired = await steps.getWindowProperty('__XSS_FIRED');   // read; undefined if absent
await steps.setWindowProperty('__test.flag', true);           // set (creates intermediate objects)
// verifyWindowProperty — retrying; one matcher of equals|contains|matches|present|truthy|
// greaterThan|lessThan, with { negated?, timeout?, errorMessage? }.
await steps.verifyWindowProperty('dataLayer.length', { greaterThan: 0 });
await steps.verifyWindowProperty('__XSS_FIRED', { truthy: false });
// The single labelled escape hatch over page.evaluate — typed + logged; prefer targeted steps.
const imgs = await steps.evaluateScript(() => document.querySelectorAll('img').length);
```

### Verification

```ts
await steps.verifyPresence('elementName', 'PageName');
// parallel presence check — all resolved concurrently via Promise.all
await steps.verifyAllPresent([
  { elementName: 'title',  pageName: 'ProductDetailsPage' },
  { elementName: 'price',  pageName: 'ProductDetailsPage' },
  { elementName: 'addToCart', pageName: 'ProductDetailsPage' },
]);
await steps.verifyAbsence('elementName', 'PageName');
await steps.verifyText('elementName', 'PageName', 'Expected text');
await steps.verifyText('elementName', 'PageName');  // no args = asserts not empty
await steps.verifyTextContains('elementName', 'PageName', 'partial');
await steps.verifyCount('elementName', 'PageName', { exactly: 3 });        // also: greaterThan, lessThan
await steps.verifyState('elementName', 'PageName', 'enabled');              // 'disabled', 'editable', 'checked', 'focused', 'visible', 'hidden', 'attached', 'inViewport'
await steps.verifyAttribute('elementName', 'PageName', 'href', '/path');
await steps.verifyInputValue('elementName', 'PageName', 'expected');
await steps.verifyImages('elementName', 'PageName');                        // visibility + src + naturalWidth (default)
await steps.verifyImages('elementName', 'PageName', true, { verifyDecoded: true }); // also runs Image.decode() per image
await steps.verifyUrlContains('/dashboard');
await steps.verifyTabCount(2);

// Page-level text — document scope, web-first (string | RegExp).   (0.3.8+)
// For 404-body / page-copy / "no <script>" checks without page.locator('body').innerText().
await steps.verifyPageContainsText('Wishlist');
await steps.verifyPageContainsText(/404|niet gevonden/i);
await steps.verifyPageNotContainsText('<script>alert');
await steps.verifyPageTitle(/Wishlist/i);                   // wraps expect(page).toHaveTitle

// Browser storage — one method per store, discriminated matcher.
// Pick exactly one: { equals } | { contains } | { matches } | { present }.
// All forms accept { negated, timeout, errorMessage } modifiers.
await steps.verifyLocalStorage('theme', { equals: 'dark' });
await steps.verifyLocalStorage('flag', { contains: 'enabled' });
await steps.verifyLocalStorage('build', { matches: /^v\d+$/ });
await steps.verifyLocalStorage('seen', { present: true });
await steps.verifyLocalStorage('temp', { present: false });                  // absence
await steps.verifyLocalStorage('theme', { equals: 'light', negated: true }); // not equal
await steps.verifySessionStorage('cart.count', { equals: '3' });             // same shape
await steps.verifyOrder('listItems', 'PageName', ['First', 'Second', 'Third']);
await steps.verifyListOrder('listItems', 'PageName', 'asc');               // or 'desc'
await steps.verifyCssProperty('elementName', 'PageName', 'color', 'rgb(255, 0, 0)');
```

### Expect Matcher Tree

A chain-style assertion API available at both the top level (`steps.expect(el, page)`) and the fluent builder (`steps.on(el, page)`). Each matcher retries against a fresh snapshot until the element timeout expires, then throws with the final snapshot in the error message. `verify*` still works and remains the shortest form for the basic cases it covers — use the matcher tree when you need regex, contains, negation, multi-field conditions, or custom predicates.

**Field matchers — available on both entry points:**

```ts
// Top-level entry — steps.expect(elementName, pageName)
await steps.expect('price', 'ProductPage').text.toBe('$19.99');
await steps.expect('price', 'ProductPage').text.toContain('Premium');
await steps.expect('price', 'ProductPage').text.toMatch(/^\$\d+\.\d{2}$/);
await steps.expect('price', 'ProductPage').text.toStartWith('$');
await steps.expect('price', 'ProductPage').text.toEndWith('USD');

await steps.expect('emailInput', 'LoginPage').value.toBe('user@test.com');
await steps.expect('emailInput', 'LoginPage').value.toMatch(/@/);

await steps.expect('link', 'NavPage').attributes.get('href').toBe('/dashboard');
await steps.expect('link', 'NavPage').attributes.get('class').toContain('active');
await steps.expect('link', 'NavPage').attributes.get('href').toMatch(/\/products\/\d+/);
await steps.expect('btn', 'Page').attributes.toHaveKey('disabled');

await steps.expect('items', 'ListPage').count.toBe(3);
await steps.expect('items', 'ListPage').count.toBeGreaterThan(3);
await steps.expect('items', 'ListPage').count.toBeLessThan(20);
await steps.expect('items', 'ListPage').count.toBeGreaterThanOrEqual(5);
await steps.expect('items', 'ListPage').count.toBeLessThanOrEqual(10);

await steps.expect('banner', 'Page').visible.toBeTrue();
await steps.expect('banner', 'Page').visible.toBe(true);
await steps.expect('spinner', 'Page').visible.toBeFalse();
await steps.expect('submitBtn', 'Page').enabled.toBeTrue();

await steps.expect('banner', 'Page').css('color').toBe('rgb(255, 0, 0)');
await steps.expect('banner', 'Page').css('cursor').toMatch(/pointer|default/);

// Fluent entry — same matchers directly on steps.on()
await steps.on('price', 'ProductPage').text.toBe('$19.99');
await steps.on('row', 'TablePage').nth(2).attributes.get('data-id').toBe('42');
await steps.on('cards', 'ListPage').random().text.toMatch(/\$\d+/);
await steps.on('banner', 'HomePage').ifVisible().text.toContain('Promo');
```

**Chained multi-verification on `steps.on()`:**

Every matcher call enqueues an assertion onto the builder and returns it, so multiple verifications on a single element compose in one expression. `await` executes the queue sequentially; the first failure short-circuits the rest.

```ts
// Chain 8+ verifications on one element in a single expression
await steps.on('primaryButton', 'ButtonsPage')
  .text.toBe('Primary')
  .visible.toBeTrue()
  .enabled.toBeTrue()
  .count.toBe(1)
  .attributes.get('data-testid').toBe('btn-primary')
  .attributes.toHaveKey('data-testid')
  .not.attributes.toHaveKey('disabled')
  .css('cursor').toMatch(/pointer|default|auto/)
  .satisfy(el => el.visible && el.enabled && el.text === 'Primary');

// .not is one-shot — applies to the next matcher only
await steps.on('btn', 'Page')
  .not.text.toBe('Wrong')    // negated
  .count.toBe(1);            // NOT negated

// .throws(msg) attaches to the most recently queued assertion
await steps.on('btn', 'Page')
  .text.toBe('Primary').throws('primary button must have correct label')
  .visible.toBeTrue();

// .timeout(ms) mixes long and short per-matcher in one chain
await steps.on('slowThenFast', 'Page')
  .text.timeout(5000).toBe('Ready')      // this one may take up to 5s
  .visible.timeout(100).toBeTrue()       // this one must be visible within 100ms
  .count.timeout(500).toBe(1);
```

**Per-call timeout override — `.timeout(ms)`:**

Composes anywhere in the chain. Useful for slow widgets or fast-failing assertions without changing the fixture-level default.

```ts
// On ElementAction (fluent)
await steps.on('slowWidget', 'Page').timeout(5000).text.toBe('Ready');

// On ExpectBuilder (top-level)
await steps.expect('slowWidget', 'Page').timeout(5000).text.toBe('Ready');

// On a specific field matcher
await steps.expect('el', 'Page').text.timeout(5000).toBe('x');
await steps.expect('el', 'Page').count.timeout(2000).toBeGreaterThan(3);
await steps.expect('el', 'Page').attributes.get('href').timeout(1000).toBe('/x');

// On the predicate chain — order independent with .throws()
await steps.expect('price', 'Page')
  .satisfy(el => parseFloat(el.text.slice(1)) > 10)
  .timeout(2000)
  .throws('price must be above $10');

// Composes with .not and strategy selectors
await steps.on('item', 'Page').nth(2).timeout(500).text.toBe('x');
await steps.expect('error', 'Page').not.timeout(1000).visible.toBeTrue();
```

**Negation with `.not`:**

```ts
// Flip any matcher via .not — composes on either side of the field accessor
await steps.expect('error', 'Page').not.text.toContain('Crash');
await steps.expect('error', 'Page').text.not.toContain('Crash');
await steps.on('submitBtn', 'Page').enabled.not.toBe(false);
await steps.on('link', 'Page').attributes.not.toHaveKey('disabled');
await steps.on('link', 'Page').attributes.get('href').not.toBe('/wrong');
```

**Predicate escape hatch** — for assertions the matcher tree doesn't cover (multi-field combinations, parsed numeric thresholds, JSON in `data-*`). Use `.satisfy(predicate)` — returns a chainable, awaitable assertion. Add `.throws(message)` for a custom failure message.

```ts
// Top-level
await steps.expect('price', 'ProductPage').satisfy(el => parseFloat(el.text.slice(1)) > 10);
await steps.expect('price', 'ProductPage')
  .satisfy(el => parseFloat(el.text.slice(1)) > 10)
  .throws('price must be above $10');

// Fluent
await steps.on('price', 'ProductPage').satisfy(el => parseFloat(el.text.slice(1)) > 10);
await steps.on('card', 'DashboardPage').satisfy(
  el => el.visible && el.attributes['data-status'] === 'ready' && el.count > 0,
);

// Negated — predicate's expected outcome is flipped
await steps.on('error', 'Page').not.satisfy(el => el.visible);
```

Predicates receive an `ElementSnapshot` — plain data, no async methods:

```ts
interface ElementSnapshot {
    readonly text: string;
    readonly value: string;                         // input value, '' for non-inputs
    readonly attributes: Readonly<Record<string, string>>;
    readonly visible: boolean;
    readonly enabled: boolean;
    readonly count: number;                         // total matches post-strategy
}
```

On predicate timeout, the error message includes the full snapshot pretty-printed so you can see exactly why the assertion failed:

```
expect().satisfy(predicate) failed on ProductPage.price after 30000ms
  snapshot at timeout:
    {
      "text": "12.99 USD",
      "value": "",
      "attributes": { ... },
      "visible": true,
      "enabled": true,
      "count": 1
    }
```

When `.throws(message)` is chained, that message replaces the default header while the snapshot is still appended — so you get both the domain-specific explanation and the raw state.

### Visibility Probe

```ts
// Non-throwing boolean check — never throws, returns true/false
const present = await steps.isPresent('elementName', 'PageName');                   // uses default element timeout

// Non-throwing visibility check with short timeout — returns boolean, never throws
const visible = await steps.isVisible('elementName', 'PageName');                    // default 2000ms timeout
const still = await steps.isVisible('modal', 'PageName', { timeout: 500 });         // custom timeout
const hasOffer = await steps.isVisible('banner', 'PageName', {                      // with text filter
  containsText: '50% off',
});
```

### Listed Elements

```ts
// Click by text or attribute match
await steps.clickListedElement('tableRows', 'PageName', { text: 'John' });
await steps.clickListedElement('tableRows', 'PageName', {
  attribute: { name: 'data-id', value: '5' },
  child: { pageName: 'TablePage', elementName: 'editButton' }
});

// Verify text/attribute of a listed element
await steps.verifyListedElement('entries', 'PageName', {
  text: 'Name',
  child: { pageName: 'TablePage', elementName: 'valueCell' },
  expectedText: 'John Doe'
});

// Extract data from a listed element
const text = await steps.getListedElementData('entries', 'PageName', { text: 'Name' });
const href = await steps.getListedElementData('tableRows', 'PageName', {
  text: 'John',
  child: { pageName: 'TablePage', elementName: 'profileLink' },
  extractAttribute: 'href'
});
```

### Waiting

> **`waitForState` semantics — throwing as of 0.3.7 (below the `>=0.3.8` peer floor, so always present).** `waitForState` throws on timeout and returns `Promise<boolean>` (`true` = state reached). Use `{ optional: true }` to keep the soft-probe behavior — the call then resolves `false` on timeout instead of rejecting; follow a non-optional wait with an explicit `verify*` only if you want a more specific failure message.

```ts
// waitForState THROWS on timeout (0.3.7+) and returns Promise<boolean>.
await steps.waitForState('elementName', 'PageName');                        // default: 'visible'; throws on timeout
await steps.waitForState('elementName', 'PageName', 'hidden');              // also: 'attached', 'detached'
// Soft probe: { optional: true } resolves false on timeout instead of throwing.
const present = await steps.waitForState('elementName', 'PageName', 'visible', { optional: true });
// Per-call timeout override:
await steps.waitForState('elementName', 'PageName', 'visible', { timeout: 5_000 });
await steps.waitAndClick('elementName', 'PageName');                        // waits for visible, then clicks; throws if never visible
await steps.waitForNetworkIdle();
await steps.waitForNetworkIdle({ timeout: 10000, optional: true });        // 0.3.7+ — bounded; optional swallows timeout
await steps.waitForResponse('/api/data', async () => {
  await steps.click('submitButton', 'PageName');
});
```

### Timing / pacing (0.3.8+)

> **Reach for `waitForState` / `waitForUrl` / web-first assertions first.** `pace` is *deliberate* timing — settling a debounce, spacing rapid-fire actions — NOT a substitute for waiting on a condition. A `pace` standing in for a missing wait is a flake waiting to happen.

```ts
await steps.pace(120);                                // deliberate pause (ms); throws on negative/non-finite

// repeat: run an action N times with the zero-based index, collect each result;
// intervalMs paces BETWEEN iterations (never before the first or after the last).
const codes = await steps.repeat(
  (i) => steps.requestGet(`/p/${i}`).then(r => r.status),
  3,
  { intervalMs: 100 },
);                                                    // => [200, 200, 200]
await steps.repeat((i) => steps.clickNth('swatch', 'PDP', i), 3);  // throws on a non-integer / negative count
```

### Composite / Workflow

```ts
// Fill multiple fields in one call
await steps.fillForm('FormsPage', {
  nameInput: 'John Doe',
  emailInput: 'john@example.com',
  countrySelect: { type: DropdownSelectType.VALUE, value: 'us' }
});

// Retry an action until a verification passes
await steps.retryUntil(
  async () => { await steps.click('refreshButton', 'PageName'); },
  async () => { await steps.verifyText('status', 'PageName', 'Ready'); },
  3, 1000  // maxRetries, delayMs
);
```

### Screenshot

```ts
const buf = await steps.screenshot();                                       // page screenshot
const buf2 = await steps.screenshot({ fullPage: true, path: 'out.png' });   // full page with save
const buf3 = await steps.screenshot('elementName', 'PageName');             // element screenshot
```

### Visual regression — `verifyVisualMatch`

Asserts the current page (or a named element) matches its stored baseline screenshot. Dynamic regions can be masked by name so the pixel diff stays stable across runs.

```ts
// Page-level visual match. Dynamic regions are masked by element name.
await steps.verifyVisualMatch('dashboard.png', {
  mask: [
    { elementName: 'currentTime',   pageName: 'DashboardPage' },
    { elementName: 'transactionId', pageName: 'DashboardPage' },
  ],
});

// Element-level visual match. The scope is the named element; masks may
// reference any element on the page (or anywhere in the repository).
await steps.verifyVisualMatch('header.png', {
  elementName: 'header',
  pageName:    'DashboardPage',
  mask: [{ elementName: 'liveCounter', pageName: 'DashboardPage' }],
});

// Raw selector escape hatch when the masked region doesn't warrant a
// repository entry.
await steps.verifyVisualMatch('dashboard.png', {
  mask: [{ selector: '[data-testid="current-time"]' }],
});

// Tuning the diff envelope when fonts antialias slightly differently
// across runs.
await steps.verifyVisualMatch('login.png', {
  maxDiffPixelRatio: 0.01,
  mask: [{ elementName: 'sessionExpiry', pageName: 'LoginPage' }],
});
```

**Why mask?** Visual regression is great until the UI has dynamic data — a clock that ticks every second or a generated transaction id is enough to break a snapshot on the first run. Mask those regions and the surrounding UI stays pixel-perfect for comparison. Playwright disables CSS animations during the snapshot by default, so you only need `mask` for content-level dynamism (live counters, "updated N minutes ago" badges, randomly-generated ids, user avatars, charts that re-render).

**Baselines:** the first run writes the baseline; subsequent runs diff. Use `npx playwright test --update-snapshots` to refresh baselines intentionally. Playwright fingerprints baselines per OS / browser channel, so generate them in the same environment your CI runs.

The full options surface — `maskColor`, `fullPage`, `maxDiffPixelRatio`, `maxDiffPixels`, `timeout` — is defined by `VisualMatchOptions` in the `@civitas-cerebrum/element-interactions` package.

## Fluent API — `steps.on()`

For a chainable alternative to the standard Steps methods, use `steps.on(elementName, pageName)`. It returns an `ElementAction` builder with strategy selectors and terminal actions.

```ts
// Strategy selectors (chainable)
await steps.on('productCards', 'CollectionsPage').first().click();
await steps.on('productCards', 'CollectionsPage').random().click({ withoutScrolling: true });
await steps.on('productCards', 'CollectionsPage').nth(2).click();
await steps.on('categories', 'HomePage').byText('Buttons').click();
await steps.on('items', 'ListPage').byAttribute('data-status', 'active').click();
// .visible() — resolve the VISIBLE match among responsive duplicates, then act.   (0.3.8+)
// Selects the visible one and proceeds (throws if none visible) — unlike ifVisible()
// which SKIPS when hidden. Replaces getByRole(...).filter({ visible: true }).first().
await steps.on('accordionTrigger', 'ProductPage').visible().click();

// Scoped child queries — resolve a child WITHIN the parent element, then act/verify.   (0.3.8+)
// Closes scoped getByRole counts and page.locator(parent).getByText/.locator(child) compositions.
await steps.on('cookieDialog', 'CookieBanner').findByRole('button').count.toBe(2);
await steps.on('cookieDialog', 'CookieBanner').findByRole('button', { name: /preferences|manage/i }).count.toBe(0);
await steps.on('cartPanel', 'CartPage').findByText('Your cart is empty').verifyState('visible');
await steps.on('panel', 'Page').findBySelector("input[name='email']").fill('a@b.com');

// Conditional visibility — silently skips if element is not visible
await steps.on('cookieBanner', 'Page').ifVisible().click();
await steps.on('promoPopup', 'Page').ifVisible(500).click();       // custom timeout (ms)
await steps.on('optionalField', 'Page').ifVisible().fill('text');

// Terminal interactions
await steps.on('submitButton', 'LoginPage').click();
await steps.on('submitButton', 'LoginPage').click({ force: true });        // native DOM click
await steps.on('submitButton', 'LoginPage').click({ withoutScrolling: true });
await steps.on('menuItem', 'Nav').hover();
await steps.on('emailInput', 'LoginPage').fill('user@test.com');
await steps.on('checkbox', 'SettingsPage').check();
await steps.on('slider', 'SettingsPage').setSliderValue(75);
await steps.on('fileInput', 'UploadPage').uploadFile('path/to/file.pdf');

// Terminal verifications
await steps.on('title', 'ProductPage').verifyPresence();
await steps.on('title', 'ProductPage').verifyText('Expected Title');
await steps.on('title', 'ProductPage').verifyText();                  // no args = not empty
await steps.on('title', 'ProductPage').verifyTextContains('partial');
await steps.on('items', 'ListPage').verifyCount({ greaterThan: 3 });
await steps.on('disabledBtn', 'Page').verifyState('disabled');
const present = await steps.on('banner', 'Page').isPresent();
const visible = await steps.on('banner', 'Page').isVisible();                 // 2000ms timeout
const vis2 = await steps.on('banner', 'Page').isVisible({ timeout: 500 });   // custom timeout
const vis3 = await steps.on('banner', 'Page').isVisible({ containsText: 'Special' });

// Terminal extractions
const text = await steps.on('price', 'ProductPage').getText();
const href = await steps.on('link', 'NavPage').getAttribute('href');
const count = await steps.on('items', 'ListPage').getCount();

// Waiting
await steps.on('modal', 'Page').waitForState('visible');
```

### Chaining Examples

Combine strategy selectors with actions for concise, readable test flows:

```ts
// Navigate a hover menu — select random item, bypass actionability checks
await steps.on('mainNavItems', 'HomePage').first().hover();
await steps.on('subcategoryItems', 'HomePage').random().click({ withoutScrolling: true });

// Fill a form field then verify it took
await steps.on('emailInput', 'LoginPage').fill('user@test.com');
await steps.on('emailInput', 'LoginPage').verifyInputValue('user@test.com');

// Find a specific item by text, click it, verify navigation
await steps.on('categories', 'HomePage').byText('Accessories').click();
await steps.verifyUrlContains('/accessories');
await steps.on('productCards', 'CollectionsPage').verifyCount({ greaterThan: 0 });

// Verify multiple properties — use element.action() chain for sequencing
const submitBtn = await repo.get('submitButton', 'CheckoutPage');
await submitBtn.action()
  .verifyPresence()
  .verifyText('Place Order')
  .verifyEnabled();

// Extract data from a specific element in a list
const thirdPrice = await steps.on('price', 'CollectionsPage').nth(2).getText();
const isVisible = await steps.on('saleBadge', 'ProductPage').isPresent();
```

## Accessing the Repository Directly

Use `repo` when you need to filter by visible text, iterate all matches, or pick a random item. Repository methods use `(elementName, pageName)` order (no driver arg — driver is bound at construction). Methods return `Element` wrappers with `click()`, `fill()`, `textContent()`, etc. To access the underlying Playwright `Locator`, cast to `WebElement`:

```ts
import { WebElement } from '@civitas-cerebrum/element-interactions';

test('example', async ({ repo, steps }) => {
  await steps.navigateTo('/');
  const link = await repo.getByText('categories', 'HomePage', 'Forms');
  await link?.click();                              // Element.click() works directly

  // Fluent action chain
  const element = await repo.get('elementName', 'PageName');
  await element.action(5000).waitForState('visible').click();

  // When you need the underlying Locator:
  const locator = (element as WebElement).locator;  // access raw Playwright Locator
});
```

```ts
await repo.get('elementName', 'PageName');                         // single Element (first match)
await repo.get('elementName', 'PageName', { strategy: SelectionStrategy.RANDOM }); // with options
await repo.getAll('elementName', 'PageName');                      // array of Elements
await repo.getRandom('elementName', 'PageName');                   // random from matches
await repo.getByText('elementName', 'PageName', 'Text');           // exact match, then contains
await repo.getByAttribute('elementName', 'PageName', 'data-status', 'active');
await repo.getByIndex('elementName', 'PageName', 2);
await repo.getByRole('elementName', 'PageName', 'button');
await repo.getVisible('elementName', 'PageName');
repo.getSelector('elementName', 'PageName');                       // sync, returns selector string
repo.getSelectorRaw('elementName', 'PageName');                    // sync, { strategy, value }
repo.driver;                                                       // bound Page/Browser
```

## Raw Interactions API

Bypass the repository for dynamically generated locators. **Verified at 0.3.6:** every `interact` / `verify` / `extract` method takes a `WebElement` (the element-repository `Element`), **not** a raw Playwright `Locator`. To bridge a raw locator, wrap it with `new WebElement(locator)` at the call site:

```ts
import { ElementInteractions, WebElement } from '@civitas-cerebrum/element-interactions';

const interactions = new ElementInteractions(page);

// Wrap a raw Playwright Locator before passing it in.
const el = new WebElement(page.locator('button.dynamic-class'));
await interactions.interact.click(el, { withoutScrolling: true });
await interactions.verify.count(el, { greaterThan: 2 });

// Or get an Element straight from the repo — already a WebElement.
const element = await repo.get('submitButton', 'LoginPage');
await interactions.interact.click(element);
```

All `interact`, `verify`, `extract`, and `navigate` methods are available on `ElementInteractions`. `WebElement` is re-exported from `@civitas-cerebrum/element-interactions` (originating in `@civitas-cerebrum/element-repository`).

## Email API

Send and receive emails in tests. Supports plain-text, inline HTML, and HTML file templates.

### Setup

Provide `smtp`, `imap`, or both depending on which features you need:

```ts
export const test = baseFixture(base, 'tests/data/page-repository.json', {
  emailCredentials: {
    smtp: {
      email: process.env.SENDER_EMAIL!,
      password: process.env.SENDER_PASSWORD!,
      host: process.env.SENDER_SMTP_HOST!,
    },
    imap: {
      email: process.env.RECEIVER_EMAIL!,
      password: process.env.RECEIVER_PASSWORD!,
    },
  }
});
```

### Sending

```ts
await steps.sendEmail({ to: 'user@example.com', subject: 'Test', text: 'Hello' });
await steps.sendEmail({ to: 'user@example.com', subject: 'Report', html: '<h1>Results</h1>' });
await steps.sendEmail({ to: 'user@example.com', subject: 'Report', htmlFile: 'emails/report.html' });
```

### Receiving

```ts
import { EmailFilterType } from '@civitas-cerebrum/element-interactions';

const email = await steps.receiveEmail({
  filters: [{ type: EmailFilterType.SUBJECT, value: 'Your OTP' }]
});
await steps.navigateTo('file://' + email.filePath);

const email2 = await steps.receiveEmail({
  filters: [
    { type: EmailFilterType.SUBJECT, value: 'Verification' },
    { type: EmailFilterType.FROM, value: 'noreply@example.com' },
    { type: EmailFilterType.CONTENT, value: 'verification code' },
  ]
});

const allEmails = await steps.receiveAllEmails({
  filters: [{ type: EmailFilterType.FROM, value: 'alerts@example.com' }]
});
```

### Marking Emails

```ts
import { EmailMarkAction } from '@civitas-cerebrum/element-interactions';

await steps.markEmail(EmailMarkAction.READ, {
  filters: [{ type: EmailFilterType.SUBJECT, value: 'OTP' }]
});
await steps.markEmail(EmailMarkAction.FLAGGED, {
  filters: [{ type: EmailFilterType.FROM, value: 'noreply@example.com' }]
});
await steps.markEmail(EmailMarkAction.ARCHIVED, {
  filters: [{ type: EmailFilterType.SUBJECT, value: 'Report' }]
});
await steps.markEmail(EmailMarkAction.UNREAD); // mark all in folder
```

Mark actions: `READ`, `UNREAD`, `FLAGGED`, `UNFLAGGED`, `ARCHIVED`.

### Cleaning the Inbox

```ts
await steps.cleanEmails({
  filters: [{ type: EmailFilterType.FROM, value: 'noreply@example.com' }]
});
await steps.cleanEmails(); // delete all
```

Filter types: `SUBJECT`, `FROM`, `TO`, `CONTENT` (body text/HTML), `SINCE` (Date).

## Session-aware HTTP Requests  (0.3.8+)

Browser-context HTTP, backed by Playwright's `page.request` — **shares the browser
context's cookies/session**. The right tool for authenticated redirect / protected-route
contracts (e.g. "a logged-out user hitting `/account` is 307'd to `/login`"). Distinct
from the `## HTTP API Steps` below, which use the `wasapi` external-service client and do
**not** carry the browser session.

```ts
// verbs: requestGet / requestPost / requestPut / requestPatch / requestDelete / requestHead
// opts: { maxRedirects?, headers?, params?, data?, form?, failOnStatusCode? }  (failOnStatusCode defaults false)
const res = await steps.requestGet('/account', { maxRedirects: 0 });   // uses the logged-in session
// BrowserResponse: { status, ok, url, headers, statusText, json<T>(), text(), body() }
await steps.verifyRequestStatus(res, 307);
await steps.verifyRequestHeader(res, 'location', /\/login/);            // value optional → presence check
await steps.verifyRequestOk(res);                                      // 2xx
const body = await res.json();
```

## HTTP API Steps

Direct HTTP calls against one or more backends, co-located with UI tests. Powered by `@civitas-cerebrum/wasapi` — no Playwright `request` fixture required. Use these for contract testing, cross-service setup/teardown, or hybrid UI+API scenarios.

### Fixture Setup

Both `apiBaseUrl` and `apiProviders` are optional. Configure either, both, or neither. Testing multiple backends in the same test is first-class:

```ts
export const test = baseFixture(base, 'tests/data/page-repository.json', {
  apiBaseUrl: 'https://api.example.com',
  apiProviders: {
    billing: 'https://billing.example.com',
    auth:    'https://auth.example.com',
  },
});
```

- `apiBaseUrl` registers the `default` client. Steps called without a provider name use it.
- `apiProviders` registers additional named clients. The first argument of any `api*` step then becomes the provider name.
- Calling an `api*` step with no configuration throws a clear "API client is not configured" error.

### Methods

Every method returns an `ApiResponse<T>` (except `apiHead`, which returns a flat `Record<string, string>` of headers).

```ts
// Default client
const res = await steps.apiGet<User>('/users/42');
const created = await steps.apiPost<User>('/users', { name: 'Ada' });
const updated = await steps.apiPut<User>('/users/42', { name: 'Ada L.' });
const patched = await steps.apiPatch<User>('/users/42', { active: true });
await steps.apiDelete('/users/42');
const headers = await steps.apiHead('/users/42');

// Named provider (first arg)
const invoices = await steps.apiGet<Invoice[]>('billing', '/invoices', { query: { status: 'open' } });
await steps.apiPost('auth', '/login', { email, password });

// Query params, path params, headers
await steps.apiGet('/search', { query: { q: 'hello', page: '2' }, headers: { 'X-Trace': 'abc' } });
await steps.apiPut('/users/:id', { active: false }, { pathParams: { id: '42' } });
```

### ApiResponse Shape

```ts
interface ApiResponse<T> {
  status: number;
  headers: Record<string, string>;
  body: T;          // parsed JSON when content-type is JSON
  rawBody: string;  // raw text always available
  // ...
}
```

Inspect `status`, `headers`, and `body` directly. Assertions have dedicated step helpers:

### Verifications

```ts
await steps.verifyApiStatus(res, 200);
await steps.verifyApiHeader(res, 'content-type');                        // presence
await steps.verifyApiHeader(res, 'content-type', 'application/json');    // exact value (case-insensitive name)
```

For shape/schema verification, combine with Playwright's `expect` on the parsed `body`:

```ts
const res = await steps.apiGet<User>('/users/42');
await steps.verifyApiStatus(res, 200);
expect(res.body).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
```

### When to Use

- ✅ Contract-style tests (status codes, headers, schema shape on real endpoints) — see the `contract-testing` companion skill
- ✅ Cross-service setup/teardown (seed data via API, drive UI through `steps`, tear down via API)
- ✅ Mixed-protocol flows (create resource via API, verify rendering via UI)
- ❌ Deep business-logic validation of internal services (keep those in the service's own test suite)
- ❌ Load testing (wrong tool)
