npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qavajs/tx

v0.0.14

Published

@qavajs/tx — testing framework via Hammerhead proxy

Readme

@qavajs/tx

@qavajs/tx is a browser test runner that routes websites through the Hammerhead proxy and executes tests directly inside the browser — no WebDriver, no browser-specific binary, no separate driver process. Open any browser, point it at the control panel, and your tests run there.

The API is modelled after Playwright (page, expect, browser, request, fixtures via destructuring), so the authoring experience is familiar and existing page objects work without changes.

Features

  • No browser driver — runs in any browser (including Safari) via a proxy iframe; no WebDriver or CDP required
  • Playwright-compatible APIpage, locator, expect, browser, request, hooks, test.extend() fixtures
  • Multi-window / popup support — open and control native browser popup windows via browser.newWindow() or intercept window.open() / target="_blank" links via the popup event
  • Route interception — mock, modify, or abort requests with page.route(); use route.fetch() to proxy the real response and mutate it before returning
  • Soft assertionsexpect.soft() collects non-fatal failures and reports all of them together at the end of the test
  • Accessibility tree snapshotspage.ariaSnapshot() and locator.ariaSnapshot() return the ARIA tree as YAML for asserting accessible structure
  • Interactive control panel — live browser view, network inspector, console panel, and CSS selector playground in one UI
  • Node.js bridge — call file-system, database, or any Node.js task from browser-side test code via node.task()
  • TypeScript first — spec files written in TypeScript, compiled on the fly with esbuild
  • Snapshot mode — captures computed-style DOM snapshots after each command for visual debugging
  • Pluggable reporters — console and HTML reporters included; custom reporters are a single class
  • CI-ready — headless mode, --test exit-on-finish flag, --workers N for single-machine parallelism, and --shard for multi-machine distribution

Installation

npm install @qavajs/tx

Or run from source:

git clone <repo>
cd @qavajs/tx
npm install
npm run build

Quick Start

# Run in interactive mode (opens the control panel in your browser)
npx tx --config tx.config.js

# Run all tests and exit (CI mode)
npx tx --config tx.config.js --test

Write a spec file:

// specs/login.spec.ts
import { test, expect } from '@qavajs/tx';

test.describe('Login', () => {
  test('redirects to inventory on valid credentials', async ({ page }) => {
    await page.goto('https://www.saucedemo.com');
    await page.getByTestId('username').fill('standard_user');
    await page.getByTestId('password').fill('secret_sauce');
    await page.getByTestId('login-button').click();
    await page.waitForURL(/inventory/);
    await expect(page.getByTestId('title')).toHaveText('Products');
  });
});

Point tx.config.js at it:

module.exports = {
  testFiles: ['./specs/**/*.spec.ts'],
  browser: 'chrome',
};

Then run:

npx tx --config tx.config.js

The control panel opens at http://localhost:11339. Click the spec to run it, or pass --test for CI mode.

Configuration

Create a tx.config.js in your project root:

module.exports = {
  // Proxy ports (Hammerhead)
  port1: 11337,
  port2: 11338,

  // Control panel port
  controlPanelPort: 11339,

  // Test files — glob patterns relative to this config file
  testFiles: ['./specs/**/*.spec.ts'],

  // Filter tests by name substring or /regex/flags (also matches tags)
  // grep: 'login',

  // Viewport applied to the iframe
  viewport: { width: 1600, height: 900 },

  // Timeouts (ms)
  actionTimeout: 10000,
  expectTimeout: 8000,
  testTimeout: 30000,

  // Browser to open ('chrome', 'firefox', 'safari', 'edge', or an absolute path)
  browser: 'chrome',

  // Reporters
  reporters: [
    ['./ConsoleReporter.ts', {}],
    ['./HtmlReporter.ts', { outputPath: 'report/report.html' }],
  ],

  // Node.js task handlers callable from tests via node.task()
  tasks: {
    readFile: ({ path }) => require('fs').readFileSync(path, 'utf-8'),
    dirname:  () => __dirname,
  },

  // Transform each spec file's TypeScript source before it is bundled/parsed.
  // preprocessor(source, filePath) { return source; },

  // Named config profiles — select one at runtime with --profile <name>.
  // Profile values are merged on top of the base config, before CLI args.
  profiles: {
    ci: {
      headless: true,
      browser: 'chromium',
      testMode: true,
    },
    debug: {
      headless: false,
      actionTimeout: 30000,
      testTimeout: 120000,
    },
  },
};

All fields are optional. CLI flags override the config file.

Config fields

| Field | Type | Default | Description | |--------------------|-----------------------------------------|---------------|-------------| | proxyHost | string | "localhost" | Hostname for the Hammerhead proxy | | port1 | number | 11337 | Proxy port 1 | | port2 | number | 11338 | Proxy port 2 | | controlPanelPort | number | 11339 | HTTP server port for the control panel | | headless | boolean | false | Run the browser in headless mode | | browser | string | — | Browser to launch: chrome, firefox, edge, safari, chromium, or an absolute path. Falls back to the first browser found when omitted. | | testFiles | string[] | — | Explicit list of test file paths (relative to config) | | testMatch | string \| string[] | — | Glob pattern(s) for test file discovery | | grep | string | — | Filter tests by name or tag (substring or /regex/flags) | | viewport | { width, height } | — | Fixed iframe viewport size; scales to fit panel | | reporters | [path, config][] | — | Reporter modules — see Reporters | | tasks | Record<string, TaskHandler> | — | Named Node.js task handlers — see node.task | | preprocessor | (source, filePath) => string | — | Transform each spec file's raw TypeScript source before bundling/parsing — see Preprocessor | | profiles | Record<string, Omit<TxConfig, 'profiles'>> | — | Named config profiles selected at runtime with --profile <name>; merged on top of base config, before CLI args | | retries | number | 0 | Number of times to retry a failing test before marking it failed. Each retry re-runs the full test including beforeEach/afterEach hooks. | | testMode | boolean | false | Run all tests automatically on startup, then exit — exit code 0 if all passed, 1 if any failed | | workers | number | 1 | Number of parallel browser workers when testMode is true. Each worker gets its own browser, proxy, and server and runs a round-robin subset of spec files. Has no effect in interactive mode. | | snapshot | boolean | false | Capture a DOM snapshot after each command and show it in the Snapshots panel | | actionTimeout | number | 5000 | Default timeout in ms for locator actions (click, fill, waitFor, etc.) | | expectTimeout | number | 5000 | Default timeout in ms for expect() assertion retry loops | | testTimeout | number | 30000 | Maximum time in ms a single test function may run before it is cancelled |

headless can also be enabled via the environment variable HEADLESS=true without changing the config file.

CLI flags

All config-file fields can be overridden at the command line. CLI values take precedence over the config file and over profile overrides.

| Flag | Description | |------|-------------| | --config <path> | Path to config file (auto-detected if omitted) | | --profile <name> | Apply a named profile from profiles in the config file | | --test | Run all tests then exit; non-zero exit on failures | | --grep <pattern> | Filter tests by name or tag (substring or /regex/flags) | | --browser <name> | Browser to open (chrome, firefox, edge, safari, or an absolute path) | | --port <n> | Control panel port | | --headless | Run the browser in headless mode | | --workers <n> | Number of parallel browser workers (testMode only) | | --shard <n>/<total> | Run only the nth shard of total (e.g. --shard 2/4) | | --retries <n> | Number of retry attempts for failing tests | | --port1 <n> | Proxy port 1 | | --port2 <n> | Proxy port 2 | | --proxyHost <host> | Proxy hostname |

Preprocessor

A preprocessor function in tx.config.js receives the raw TypeScript source of each spec file and its absolute path, and must return the transformed source string. It runs before esbuild compiles the file — for both bundling (browser execution) and parsing (test discovery).

(source: string, filePath: string) => string

The preprocessor is called in two places for each spec file:

| Phase | Trigger | What happens next | |---|---|---| | Discovery | File loaded by the watcher or requested by the server | Preprocessed source → esbuild transformSync (TS→CJS) → vm sandbox to extract test names | | Execution | Test run requested from the control panel | Preprocessed source → esbuild build (bundle + IIFE) → sent to browser |

Both phases use the same preprocessor, so the test tree visible in the UI always matches what actually runs.

Inject a shared import into every spec file:

preprocessor(source) {
  return `import { myHelper } from '../support/helpers';\n` + source;
},

Rewrite a path alias:

preprocessor(source, filePath) {
  return source.replace(/from '@app\//g, `from '${path.resolve(__dirname, 'src')}/`);
},

Wrap every file in a describe block based on its path:

preprocessor(source, filePath) {
  const rel = path.relative(__dirname, filePath);
  return `import { test } from '@qavajs/tx';\ntest.describe(${JSON.stringify(rel)}, () => {\n${source}\n});\n`;
},

Writing Tests

Tests look and feel like Playwright. Import test and expect from '@qavajs/tx'. Fixtures (page, browser, node, request, log, attach) are injected via destructuring — not globals.

import { test, expect } from '@qavajs/tx';

test.describe('Login', () => {
  test('navigates to inventory after valid credentials', async ({ page }) => {
    await page.goto('https://www.saucedemo.com');
    await page.getByTestId('username').fill('standard_user');
    await page.getByTestId('password').fill('secret_sauce');
    await page.getByTestId('login-button').click();
    await page.waitForURL(/inventory/, { timeout: 5000 });
    await expect(page.getByTestId('title')).toHaveText('Products');
  });

  // Tags are displayed as chips in the control panel and matched by --grep
  test('smoke check', { tag: ['@smoke'] }, async ({ page }) => {
    await page.goto('https://www.saucedemo.com');
    await expect(page.getByTestId('login-button')).toBeVisible();
  });
});

Imports from '@qavajs/tx'

| Export | Description | |-------------------|-------------| | test | Define a test case | | expect | Assertion function (see expect) | | test.describe | Define a test suite | | test.beforeEach | Hook run before each test in the nearest test.describe | | test.afterEach | Hook run after each test in the nearest test.describe | | test.beforeAll | Hook run once before all tests in the nearest test.describe | | test.afterAll | Hook run once after all tests in the nearest test.describe | | test.extend | Create a custom test function with additional fixtures |

Built-in fixtures

| Fixture | Description | |--------------|-------------| | page | Playwright-style page object (see page) | | browser | Multi-tab browser object (see browser) | | node | Node.js context bridge (see node) | | request | HTTP request context (see request) | | log | (message, opts?) => void — write to the panel console; opts: { type?: 'info'\|'success'\|'error', cmd?: string, duration?: number } | | log.open | (message, cmd) => TxCommandHandle — open a pending entry; resolve with .success() / .fail() | | log.group | (message, cmd?, fn?) => TxGroupHandle \| Promise — group log entries into a collapsible section (see log.group) | | attach | (label, body, contentType?) => void — attach data to the test result | | step | (title, fn) => T \| Promise<T> — run fn inside a named collapsible group in the log panel (see step) | | testInfo | Metadata about the currently running test — title, full title path, retry count, and tags (see testInfo) |

Tags

The optional second argument to test() accepts a tag array:

test(name: string, options: { tag?: string[] }, fn: (fixtures) => void | Promise<void>): void

Tags are freeform strings — conventionally prefixed with @ (e.g. '@smoke', '@regression'). They are shown as chips in the spec list and matched by grep / --grep.

Page object pattern

Page objects receive page from the test and can import expect from '@qavajs/tx' for assertions:

// pages/LoginPage.ts
import { expect } from '@qavajs/tx';

export class LoginPage {
  constructor(private page: Page) {}

  goto()  { return this.page.goto('https://www.saucedemo.com'); }
  login(user: string, pass: string) { /* ... */ }
  async expectLoaded() { await expect(this.page.getByTestId('title')).toBeVisible(); }
}

// specs/login.spec.ts
import { test } from '@qavajs/tx';
import { LoginPage } from '../pages/LoginPage';

test.describe('Login', () => {
  test('logs in', async ({ page }) => {
    const lp = new LoginPage(page);
    await lp.goto();
    await lp.login('standard_user', 'secret_sauce');
    await lp.expectLoaded();
  });
});

Fixtures

test.extend() creates a custom test function with additional fixtures. Built-in fixtures are always available via destructuring.

import { test } from '@qavajs/tx';

const myTest = test.extend({
  // Static fixture — value computed once, passed to every test
  credentials: async ({}, use) => {
    await use({ username: 'admin', password: 's3cret' });
  },

  // Fixture that depends on another fixture
  apiToken: async ({ request }, use) => {
    const resp = await request.fetch('https://auth.example.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ client_id: 'test' }),
    });
    const { token } = await resp.json();
    await use(token);
  },

  // Page fixture — navigate before each test, clean up after
  loggedInPage: async ({ page, credentials }, use) => {
    await page.goto('https://app.example.com/login');
    await page.getByTestId('username').fill(credentials.username);
    await page.getByTestId('password').fill(credentials.password);
    await page.getByTestId('login-button').click();
    await page.waitForURL(/dashboard/);
    await use(page);
    await page.goto('https://app.example.com/logout');
  },

  // Node.js fixture — read from disk via node.task
  serverData: async ({ node }, use) => {
    const raw = await node.task('readFile', { path: './fixtures/data.json' });
    await use(JSON.parse(raw));
  },
});

myTest('dashboard shows username', async ({ loggedInPage, credentials }) => {
  await expect(loggedInPage.getByTestId('welcome')).toHaveText(credentials.username); // expect imported from '@qavajs/tx'
});

Fixture teardown — code after await use(value) runs after the test completes, making fixtures self-cleaning:

const myTest = test.extend({
  dbRecord: async ({}, use) => {
    const id = await db.insert({ name: 'test' });
    await use(id);
    await db.delete(id); // runs after test, pass or fail
  },
});

Hooks

import { test } from '@qavajs/tx';

test.describe('suite', () => {
  test.beforeAll(async () => { /* runs once before all tests in this describe */ });
  test.afterAll(async  () => { /* runs once after  all tests in this describe */ });
  test.beforeEach(async ({ page }) => { await page.goto('https://example.com'); });
  test.afterEach(async  () => { /* runs after  each test */ });

  test('test', async ({ page }) => { /* ... */ });
});

API Reference

page

Operates on the proxied iframe. Available as the page fixture via destructuring.

Navigation

await page.goto(url: string): Promise<void>

Navigate the iframe to url. Waits for the load event (30 s timeout).

await page.reload(): Promise<void>

Reload the current page. Waits for the load event.

page.url(): string

Return the current URL (proxy prefix stripped).

await page.title(): Promise<string>

Return the <title> of the current page.

await page.waitForURL(url: string | RegExp, opts?: { timeout?: number }): Promise<void>

Poll until page.url() matches url. Default timeout: 5000 ms.

await page.waitForSelector(selector: string, opts?: { state?: 'visible'|'attached'; timeout?: number }): Promise<Locator>

Wait until an element matching selector reaches the given state, then return a Locator for it.

await page.waitForTimeout(ms: number): Promise<void>

Wait unconditionally for ms milliseconds.

await page.waitForRequest(
  urlOrPredicate: string | RegExp | ((req: Request) => boolean | Promise<boolean>),
  options?: { timeout?: number }
): Promise<Request>

Wait for a network request matching urlOrPredicate and return it. Exposes .url(), .method(), .headers(), .postData(), .resourceType(), .isNavigationRequest(). Default timeout: 30 000 ms.

  • string — treated as a glob pattern (* matches within a path segment, ** matches across segments).
  • RegExp — tested against the full request URL.
  • function — called with the request object; must return true to match.
// Wait for any POST to /api/submit
const req = await page.waitForRequest('**/api/submit');
console.log(req.method()); // 'POST'
await page.waitForResponse(
  urlOrPredicate: string | RegExp | ((resp: Response) => boolean | Promise<boolean>),
  options?: { timeout?: number }
): Promise<Response>

Wait for a network response matching urlOrPredicate. Exposes .url(), .status(), .statusText(), .ok(), .headers(), .body(), .request(). Default timeout: 30 000 ms.

// Trigger an action and wait for the resulting API response
const [, resp] = await Promise.all([
  page.locator('button[type="submit"]').click(),
  page.waitForResponse('**/api/login'),
]);
console.log(resp.status()); // 200

Locator factories

page.locator(selector: string): Locator

Match elements by CSS selector or XPath expression. Selectors prefixed with // or xpath= are treated as XPath and evaluated via document.evaluate(); all other strings are treated as CSS.

// CSS selector
page.locator('#submit')
page.locator('.card, .panel')   // comma-separated CSS

// XPath — // prefix
page.locator(`//button[@id='submit']`)
page.locator(`//h2[text()='Login']`)
page.locator(`//input[contains(@placeholder,'email')]`)

// XPath — explicit xpath= prefix
page.locator(`xpath=//button[text()='OK']`)
page.getByText(text: string | RegExp, opts?: { exact?: boolean }): Locator

Match elements by their text content. Prefers leaf (childless) elements. exact defaults to false (substring match).

page.getByRole(role: string, opts?: { name?: string | RegExp; exact?: boolean }): Locator

Match elements by ARIA role. Optional name filters by accessible name.

page.getByLabel(text: string | RegExp, opts?: { exact?: boolean }): Locator

Match form controls associated with a <label> whose text matches, or elements with a matching aria-label.

page.getByPlaceholder(text: string | RegExp): Locator

Match inputs by their placeholder attribute.

page.getByTestId(id: string): Locator

Match elements with [data-testid="id"] or [data-test="id"].

page.getByAltText(text: string | RegExp): Locator

Match elements with a matching alt attribute.

page.getByTitle(text: string | RegExp): Locator

Match elements with a matching title attribute.

Viewport

page.setViewportSize(size: { width: number; height: number }): void

Apply a fixed viewport to the iframe (scales to fit the panel container).

Script evaluation

await page.evaluate(
  pageFunction: string | ((...args: any[]) => any),
  arg?: any
): Promise<any>

Evaluate a function or expression in the page's JavaScript context. Functions are serialized and cannot close over variables in test scope. arg is passed as the sole argument when pageFunction is a function (must be JSON-serializable).

// Expression string
const title = await page.evaluate('document.title');

// Function — read from the page
const itemCount = await page.evaluate(() => {
  return document.querySelectorAll('.item').length;
});

// Function with arg — write into the page
await page.evaluate((token) => {
  localStorage.setItem('auth_token', token);
}, 'my-secret-token');

// Async function — awaited automatically
const data = await page.evaluate(async () => {
  const res = await fetch('/api/user');
  return res.json();
});

Script injection

page.addInitScript(
  script: string | ((...args: any[]) => void),
  arg?: any
): { dispose(): void }

Register a script to run inside the page on every navigation, before any test code interacts with it. Returns a dispose() handle to remove the script.

// Set a global before the page's own code runs
page.addInitScript(`window.__ENV__ = 'test'`);

// Mock an API on every navigation
page.addInitScript(() => {
  window.fetch = async () => new Response('{"ok":true}', {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
});

// Function form with arg
page.addInitScript((cfg) => {
  window.__CONFIG__ = cfg;
}, { featureFlags: { darkMode: true } });

// Remove a specific script
const handle = page.addInitScript(`window.DEBUG = true`);
handle.dispose();

Keyboard

await page.keyboard.press(key: string): Promise<void>    // e.g. 'Enter', 'Shift+A'
await page.keyboard.type(text: string, opts?: { delay?: number }): Promise<void>
await page.keyboard.insertText(text: string): Promise<void>  // no key events
await page.keyboard.down(key: string): Promise<void>
await page.keyboard.up(key: string): Promise<void>

Mouse

Low-level mouse control. Coordinates are relative to the iframe viewport.

await page.mouse.move(x: number, y: number, opts?: { steps?: number }): Promise<void>
await page.mouse.down(opts?: { button?: 'left' | 'middle' | 'right' }): Promise<void>
await page.mouse.up(opts?: { button?: 'left' | 'middle' | 'right' }): Promise<void>
await page.mouse.click(x: number, y: number, opts?: {
  button?: 'left' | 'middle' | 'right';
  clickCount?: number;
  delay?: number;
}): Promise<void>
await page.mouse.dblclick(x: number, y: number, opts?: {
  button?: 'left' | 'middle' | 'right';
  delay?: number;
}): Promise<void>
await page.mouse.wheel(deltaX: number, deltaY: number): Promise<void>

Drag and drop:

await page.mouse.move(100, 200);
await page.mouse.down();
await page.mouse.move(300, 200, { steps: 10 });
await page.mouse.up();

Events

page.on(event: string, fn: (...args) => any): page
page.off(event: string, fn: (...args) => any): page
page.once(event: string, fn: (...args) => any): page

See page Events for available events.

Locator handlers

page.addLocatorHandler(
  locator: Locator,
  handler: (locator: Locator) => Promise<void>,
  options?: { noWaitAfter?: boolean; times?: number }
): void

Register a handler called automatically whenever the given locator becomes visible — before any Locator action is attempted. Useful for dismissing overlays, cookie banners, or modals that appear at unpredictable times.

| Option | Type | Default | Description | |---------------|-----------|---------|-------------| | noWaitAfter | boolean | false | Skip waiting for the locator to become hidden after the handler returns | | times | number | 0 | Maximum invocations; 0 means unlimited |

page.removeLocatorHandler(locator: Locator): void
// Dismiss cookie banner throughout a suite
page.addLocatorHandler(
  page.locator('#cookie-banner'),
  async (banner) => {
    await banner.getByRole('button', { name: 'Accept all' }).click();
  }
);

// Dismiss a modal at most once
page.addLocatorHandler(
  page.locator('.promo-modal'),
  async (modal) => { await modal.locator('[aria-label="Close"]').click(); },
  { times: 1 }
);

Lifecycle

await page.bringToFront(): Promise<void>

Switch the tab bar focus to this page (makes it the active/visible tab).

await page.close(): Promise<void>

Close this tab and emit the close event. If other tabs are open the most recent one becomes active.

Screenshot

Captures the current iframe as a PNG and returns a data URL. Pass path to also save the file relative to the working directory.

// Capture and use in-memory
const dataUrl = await page.screenshot();

// Capture and persist to disk
await page.screenshot({ path: 'my-screenshot' }); // saved as my-screenshot.png

Snapshot

Captures the current page as a self-contained HTML file — external stylesheets, images, and web fonts are all inlined as data URLs so the file is fully standalone and opens correctly without a server.

// Capture in-memory (returns the HTML string)
const html = await page.snapshot();

// Capture and save to disk
await page.snapshot({ path: 'snapshots/checkout' }); // saved as snapshots/checkout.html

// Attach to the test result so the HTML reporter can display it
attach('checkout snapshot', await page.snapshot(), 'text/html');

The HTML reporter renders text/html attachments in an embedded <iframe> and adds an button that opens the snapshot in a new browser tab for full-page inspection.

Accessibility tree snapshot

Returns the ARIA accessibility tree of the current page (or a locator's subtree) as a YAML string. Uses a Playwright-compatible format: roles, accessible names, and state attributes on each line; container roles produce an indented child list.

await page.ariaSnapshot(): Promise<string>
await locator.ariaSnapshot(opts?: { timeout?: number }): Promise<string>

Output format:

- heading "Page Title" [level=1]
- navigation "Site Nav":
  - link "Home"
  - link "About"
- main:
  - heading "Form Section" [level=2]
  - form "Login":
    - textbox "Email"
    - textbox "Password"
    - checkbox "Remember me" [unchecked]
    - button "Sign in"
    - button "Cancel" [disabled]
  - list:
    - listitem:
      - link "Dashboard"
    - listitem:
      - link "Settings"

What is included / excluded:

| Element | Behaviour | |---|---| | display:none or visibility:hidden | excluded | | aria-hidden="true" subtrees | excluded | | role="none" / role="presentation" | excluded; children bubble up | | Anonymous containers (<div>, <span>) | excluded; children bubble up | | Semantic landmarks, headings, controls | included |

Accessible name resolution order: aria-labelledbyaria-label → associated <label> (or wrapping label) → placeholder / alt → text content (for buttons, links, headings, etc.) → title.

State attributes: [level=N] for headings; [checked] / [unchecked] for checkboxes and radios; [disabled], [required], [expanded], [collapsed], [selected] where applicable; : "value" for filled text inputs.

// Full-page accessibility tree
const yaml = await page.ariaSnapshot();
console.log(yaml);

// Assert accessible structure of a specific widget
const nav = await page.locator('nav').ariaSnapshot();
expect(nav).toContain('- link "Home"');
expect(nav).toContain('- link "About"');

// Assert a form is accessible after filling
await page.getByLabel('Email').fill('[email protected]');
const form = await page.locator('form').ariaSnapshot();
expect(form).toContain('"[email protected]"');

// Assert no disabled submit button remains after loading
const yaml2 = await page.ariaSnapshot();
expect(yaml2).not.toContain('button "Submit" [disabled]');

page.route / page.unroute

Intercept, modify, mock, or abort network requests made by the page.

await page.route(
  pattern: string | RegExp | ((url: string) => boolean),
  handler: (route: Route, request: any) => void | Promise<void>
): Promise<void>

Register a route handler. Multiple handlers can be registered; the most recently registered matching handler wins. Must call route.fulfill(), route.abort(), or route.continue().

await page.unroute(
  pattern: string | RegExp | ((url: string) => boolean),
  handler?: (route: Route, request: any) => void | Promise<void>
): Promise<void>

Remove a previously registered handler. If handler is omitted, all handlers for that pattern are removed.

// Mock a REST endpoint
await page.route('https://api.example.com/users', async route => {
  await route.fulfill({ json: [{ id: 1, name: 'Alice' }] });
});

// Block all image requests
await page.route(/\.(png|jpe?g|gif|webp|svg)$/i, route => route.abort());

// Add an auth header to every API call
await page.route(/api\.example\.com/, async (route, req) => {
  await route.continue({
    headers: { ...req.headers(), Authorization: 'Bearer test-token' },
  });
});

await page.unroute('**/api/users');

Route

Passed to the handler registered with page.route().

await route.fulfill(options?: {
  status?:      number;                  // HTTP status code (default: 200)
  contentType?: string;                  // Sets Content-Type header
  headers?:     Record<string, string>;  // Additional response headers
  body?:        string | Uint8Array;     // Raw response body
  json?:        any;                     // Body as JSON (sets Content-Type: application/json)
}): Promise<void>
await route.abort(errorCode?: string): Promise<void>

Abort the request. errorCode defaults to 'failed'. Common values: 'aborted', 'blockedbyclient', 'connectionrefused', 'timedout'.

await route.continue(opts?: {
  url?:      string;
  method?:   string;
  headers?:  Record<string, string>;
  postData?: BodyInit;
}): Promise<void>

Pass the request through, optionally modifying it.

await route.fetch(opts?: {
  url?:      string;
  method?:   string;
  headers?:  Record<string, string>;
  postData?: BodyInit;
}): Promise<Response>

Fetch the actual upstream response from within a route handler without triggering route interception again. Useful for intercepting a request, inspecting or modifying the response, and re-fulfilling it. Options override the corresponding properties of the original request; omitted properties fall through from the intercepted request.

// Intercept, modify, and re-fulfill a JSON response
await page.route('**/api/products', async route => {
  const resp = await route.fetch();
  const json = await resp.json();
  json.push({ id: 999, name: 'Mock Product', price: 0 });
  await route.fulfill({ json });
});

// Modify a request header before forwarding
await page.route('**/api/**', async route => {
  const resp = await route.fetch({
    headers: { ...route.request().headers(), 'X-Test': 'true' },
  });
  await route.fulfill({ response: resp });
});
route.request(): object

Returns the original request object. Supports .url(), .method(), .headers(), .postData(), .resourceType(), .isNavigationRequest().


page.frameLocator

page.frameLocator(selector: string): FrameLocator

Return a FrameLocator scoped to the <iframe> matched by selector. Use it to query elements inside a nested iframe.

const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Card number').fill('4242 4242 4242 4242');
await frame.getByRole('button', { name: 'Pay' }).click();

// Chain for doubly-nested iframes
const inner = page.frameLocator('#outer').frameLocator('#inner');
await inner.locator('.result').waitFor();

FrameLocator

All methods return a Locator scoped to the target iframe's document.

frameLocator.locator(selector: string): Locator
frameLocator.getByText(text: string | RegExp, opts?: { exact?: boolean }): Locator
frameLocator.getByRole(role: string, opts?: { name?: string | RegExp; exact?: boolean }): Locator
frameLocator.getByLabel(text: string | RegExp, opts?: { exact?: boolean }): Locator
frameLocator.getByPlaceholder(text: string | RegExp): Locator
frameLocator.getByTestId(id: string): Locator
frameLocator.getByAltText(text: string | RegExp): Locator
frameLocator.getByTitle(text: string | RegExp): Locator
frameLocator.frameLocator(selector: string): FrameLocator  // nest further

Locator

A lazy query that re-evaluates against the live DOM on each access. All action methods auto-wait up to timeout (default 5000 ms) for the element to appear.

Chaining

locator.nth(n: number): Locator
locator.first(): Locator
locator.last(): Locator
locator.filter(opts: { hasText?: string | RegExp; hasNotText?: string | RegExp }): Locator
locator.locator(selector: string): Locator  // scoped child query — CSS or XPath

locator.locator() accepts the same CSS and XPath syntax as page.locator(). XPath expressions are evaluated relative to each matched root element, so descendants-only axes (//) search within that subtree.

// CSS then XPath chain
page.locator('.card').locator(`//button[text()='Add']`)

// XPath then CSS is not directly chainable, but XPath first works too
page.locator(`//section[@class='card']`).locator('button')

Actions

await locator.click(opts?: { force?: boolean; timeout?: number }): Promise<void>
await locator.dblclick(opts?: { timeout?: number }): Promise<void>
await locator.rightClick(opts?: { timeout?: number }): Promise<void>  // dispatches contextmenu
await locator.fill(value: string, opts?: { timeout?: number; delay?: number }): Promise<void>

fill clears the field first, then types with full keyboard events (works with React/Vue controlled inputs).

await locator.clear(opts?: { timeout?: number }): Promise<void>   // alias for fill('')
await locator.type(text: string, opts?: { delay?: number; timeout?: number }): Promise<void>

type appends text without clearing.

await locator.press(key: string, opts?: { timeout?: number }): Promise<void>
await locator.selectOption(value: string | string[], opts?: { timeout?: number }): Promise<void>
await locator.check(opts?: { timeout?: number }): Promise<void>
await locator.uncheck(opts?: { timeout?: number }): Promise<void>
await locator.focus(opts?: { timeout?: number }): Promise<void>
await locator.blur(opts?: { timeout?: number }): Promise<void>
await locator.hover(opts?: { timeout?: number }): Promise<void>
await locator.scrollIntoViewIfNeeded(opts?: { timeout?: number }): Promise<void>
await locator.setInputFiles(files: string | string[] | { name: string; mimeType: string; buffer: Buffer }, opts?: { timeout?: number }): Promise<void>
await locator.evaluate(fn: Function, arg?: any): Promise<any>
await locator.boundingBox(opts?: { timeout?: number }): Promise<{ x: number; y: number; width: number; height: number } | null>

Queries

await locator.textContent(): Promise<string | null>
await locator.innerText(): Promise<string>
await locator.inputValue(): Promise<string>
await locator.getAttribute(name: string): Promise<string | null>
await locator.isVisible(): Promise<boolean>
await locator.isHidden(): Promise<boolean>
await locator.isEnabled(): Promise<boolean>
await locator.isDisabled(): Promise<boolean>
await locator.isChecked(): Promise<boolean>
await locator.isEditable(): Promise<boolean>
await locator.count(): Promise<number>
await locator.ariaSnapshot(opts?: { timeout?: number }): Promise<string>

Waiting

await locator.waitFor(opts?: {
  state?:   'visible' | 'hidden' | 'attached' | 'detached';  // default: 'visible'
  timeout?: number;
}): Promise<void>

expect

expect is the Playwright-style assertion function. Import it directly from '@qavajs/tx' — it is not a fixture.

import { test, expect } from '@qavajs/tx';

Matchers that take a Locator auto-retry until the condition is met or the timeout expires (default 5000 ms). Matchers that take a plain value are synchronous.

Locator matchers (async, auto-retry)

await expect(locator).toBeVisible(opts?: { timeout?: number }): Promise<void>
await expect(locator).toBeHidden(opts?: { timeout?: number }): Promise<void>
await expect(locator).toBeEnabled(opts?: { timeout?: number }): Promise<void>
await expect(locator).toBeDisabled(opts?: { timeout?: number }): Promise<void>
await expect(locator).toBeChecked(opts?: { timeout?: number }): Promise<void>
await expect(locator).toBeEditable(opts?: { timeout?: number }): Promise<void>
await expect(locator).toBeEmpty(opts?: { timeout?: number }): Promise<void>
await expect(locator).toHaveText(text: string | RegExp, opts?: { exact?: boolean; timeout?: number }): Promise<void>
await expect(locator).toContainText(text: string | RegExp, opts?: { timeout?: number }): Promise<void>
await expect(locator).toHaveValue(value: string | RegExp, opts?: { timeout?: number }): Promise<void>
await expect(locator).toHaveAttribute(name: string, value: string | RegExp, opts?: { timeout?: number }): Promise<void>
await expect(locator).toHaveCount(count: number, opts?: { timeout?: number }): Promise<void>
await expect(locator).toHaveClass(cls: string | RegExp, opts?: { timeout?: number }): Promise<void>
await expect(locator).toHaveCSS(property: string, value: string | RegExp, opts?: { timeout?: number }): Promise<void>

Page-level matchers (async, auto-retry)

await expect(page).toHaveURL(url: string | RegExp, opts?: { timeout?: number }): Promise<void>
await expect(page).toHaveTitle(title: string | RegExp, opts?: { timeout?: number }): Promise<void>

Plain-value matchers (sync)

expect(value).toBe(expected: any): void
expect(value).toEqual(expected: any): void      // deep equality via JSON
expect(value).toBeTruthy(): void
expect(value).toBeFalsy(): void
expect(value).toBeNull(): void
expect(value).toBeUndefined(): void
expect(value).toBeGreaterThan(n: number): void
expect(value).toBeLessThan(n: number): void
expect(value).toContain(item: any): void        // array or substring
expect(value).toMatch(r: RegExp | string): void
expect(array).toHaveLength(n: number): void
expect(fn).toThrow(): void

toPass (async polling)

Retries an arbitrary callback until it stops throwing, useful for wrapping multi-step assertions:

await expect(async () => {
  await expect(page.locator('.status')).toHaveText('ready');
}).toPass({ timeout: 10_000 });

Negation

All matchers are available under .not:

await expect(locator).not.toBeVisible();
expect(value).not.toBe(expected);

Soft assertions — expect.soft

expect.soft(target) works like expect(target) but does not throw on failure. Instead, each failure is collected. After the test body finishes, all accumulated soft failures are thrown together as a single aggregated error. This lets a test report multiple issues in one run rather than stopping at the first failed assertion.

test('form validation errors', async ({ page }) => {
  await page.goto('https://example.com/form');
  await page.getByRole('button', { name: 'Submit' }).click();

  // Check all error messages without stopping on the first failure
  await expect.soft(page.getByTestId('name-error')).toHaveText('Name is required');
  await expect.soft(page.getByTestId('email-error')).toHaveText('Email is required');
  await expect.soft(page.getByTestId('phone-error')).toHaveText('Phone is required');

  // If any of the above soft assertions failed, the test fails here with all errors listed
});

Soft assertions support negation, all built-in matchers, and the full auto-retry behaviour. They are cleared automatically at the start of each test attempt.

Custom matchers — expect.extend

expect.extend(matchers) returns a new expect function with the given matchers added. It does not mutate the original — call it once at the top of a spec file (or in a shared module) and use the result throughout.

import { expect as baseExpect } from '@qavajs/tx';

const expect = baseExpect.extend({
  async toHaveItemCount(locator, expected: number) {
    const actual = await locator.count();
    return {
      pass: actual === expected,
      message: `Expected item count ${expected}, got ${actual}`,
    };
  },

  toBeWithinRange(value: number, min: number, max: number) {
    return {
      pass: value >= min && value <= max,
      message: `Expected ${value} to be within [${min}, ${max}]`,
    };
  },
});

// usage (custom matchers are also available under .not)
await expect(page.locator('.item')).toHaveItemCount(3);
await expect(page.locator('.item')).not.toHaveItemCount(0);
expect(score).toBeWithinRange(1, 10);

Sharing across files — export the extended function from a support module:

// support/expect.ts
import { expect as baseExpect } from '@qavajs/tx';

export const expect = baseExpect.extend({
  toBeWithinRange(value: number, min: number, max: number) {
    return {
      pass: value >= min && value <= max,
      message: `Expected ${value} to be within [${min}, ${max}]`,
    };
  },
});
// specs/my.spec.ts
import { test } from '@qavajs/tx';
import { expect } from '../support/expect';

test('score is in range', () => {
  expect(score).toBeWithinRange(1, 10);
});

The matcher function receives the value passed to expect() as its first argument, followed by any additional arguments. Return { pass: boolean, message: string }:

  • pass: true — the assertion currently holds. Positive call passes; .not call fails with message.
  • pass: false — the assertion does not hold. Positive call fails with message; .not call passes.

browser

Multi-tab manager available as the browser fixture.

await browser.newPage(): Promise<void>

Open a new blank tab and make it the active tab. After the call, interact with it via the global page fixture.

await browser.newWindow(url?: string): Promise<void>

Open a new native browser window, navigate it to url if provided, and make it the active page. After the call, interact with it via the global page fixture. Use browser.switchTab() to move between open tabs and windows.

browser.tabs(): TxTabInfo[]

Return a snapshot array of all open tabs and windows. Each entry has id, title, url, and active fields.

browser.switchTab(predicate: (tab: TxTabInfo) => boolean): void

Switch the active tab to the first tab where predicate returns true. Works for both iframe-based tabs and popup windows. Use page to interact with it afterwards.

Storage state

Capture and restore browser state (cookies + localStorage) across tests or test runs. Useful for seeding an authenticated session without repeating the login flow.

await browser.storageState(opts?: { path?: string }): Promise<TxStorageState>

Capture the current cookie jar and localStorage items for the active origin. Pass { path } to also write the state to a JSON file.

await browser.loadStorageState(state: TxStorageState | string): Promise<void>

Restore a previously captured state. Pass either a TxStorageState object or a file path (string) to a JSON file saved by storageState({ path }). Cookies are applied immediately to the proxy session; localStorage items are written for the current page's origin.

// Capture after login
await page.goto('https://app.example.com/login');
await page.getByTestId('username').fill('alice');
await page.getByTestId('password').fill('s3cret');
await page.getByTestId('submit').click();
await page.waitForURL(/dashboard/);
await browser.storageState({ path: 'auth.json' });

// Restore in a later test (skip the login flow entirely)
await browser.loadStorageState('auth.json');
await page.goto('https://app.example.com/dashboard');
await expect(page.locator('h1')).toHaveText('Dashboard');

You can also construct a state object inline to seed specific cookies or localStorage values without navigating:

await browser.loadStorageState({
  cookieJar: {
    version: '[email protected]',
    storeType: 'MemoryCookieStore',
    rejectPublicSuffixes: true,
    enableLooseMode: false,
    allowSpecialUseDomain: true,
    prefixSecurity: 'silent',
    cookies: [
      { key: 'session', value: 'abc123', domain: 'app.example.com', path: '/', hostOnly: true },
    ],
  },
  origins: [
    {
      origin: 'https://app.example.com',
      localStorage: [{ name: 'theme', value: 'dark' }],
    },
  ],
});

TxStorageState

interface TxStorageState {
  cookieJar: object;  // serialized tough-cookie jar — treat as opaque; pass back to loadStorageState as-is
  origins: Array<{
    origin: string;
    localStorage: Array<{ name: string; value: string }>;
  }>;
}

// Multi-tab flow
test('multi-tab', async ({ page, browser }) => {
  await page.goto('https://example.com');

  await browser.newPage();
  await page.goto('https://example.org');   // page now refers to the new tab

  console.log(browser.tabs().length);       // 2

  browser.switchTab(t => t.url.includes('example.com'));
  await expect(page).toHaveURL(/example\.com/);

  await page.close();  // close the active tab
});

// Open a popup window programmatically
test('popup via newWindow', async ({ browser, page }) => {
  await browser.newWindow('https://example.com/popup');

  // page now controls the popup window
  await expect(page.locator('h1')).toHaveText('Popup');
  await page.locator('#submit').click();

  // switch back to the original tab
  browser.switchTab(t => t.url.includes('main'));
  await expect(page).toHaveURL(/main/);
});

// Handle window.open / target="_blank"
test('popup intercepted', async ({ page }) => {
  page.on('popup', async popup => {
    await popup.waitForURL(/popup-page/);
    console.log(await popup.title());
    await popup.close();
  });
  await page.locator('a[target="_blank"]').click();
});

Popup Windows

Popup windows are native browser windows (not iframe tabs) opened either by test code or by the page under test. All standard page APIs work identically in popup windows.

Opening a popup from a test

Use browser.newWindow(url?) to open a window programmatically. The new window immediately becomes the active page:

test('controls a popup window', async ({ browser, page }) => {
  await page.goto('https://example.com');

  await browser.newWindow('https://example.com/admin');

  // page now refers to the popup window
  await expect(page.locator('h1')).toHaveText('Admin');
  await page.locator('#save').click();

  // switch focus back to the original tab
  browser.switchTab(t => t.url.includes('example.com') && !t.url.includes('admin'));
  await expect(page).toHaveURL(/example\.com/);

  // close the popup
  browser.switchTab(t => t.url.includes('admin'));
  await page.close();
});

Intercepting windows opened by the page

When the page calls window.open() or the user clicks a target="_blank" link, a popup event fires on the current page. Use page.on('popup', …) to handle it asynchronously, or page.waitForEvent('popup') to await the next popup synchronously:

// Async handler — fires whenever the page opens a window
test('intercepts window.open', async ({ page }) => {
  page.on('popup', async popup => {
    await popup.waitForURL(/new-window/);
    await expect(popup.locator('h1')).toHaveText('New Window');
    await popup.close();
  });

  await page.locator('#open-window-btn').click();
});

// Await the next popup in-line
test('awaits target=_blank click', async ({ page }) => {
  const [, popup] = await Promise.all([
    page.locator('a[target="_blank"]').click(),
    page.waitForEvent('popup'),
  ]);

  await popup.waitForURL(/target-page/);
  await expect(popup.locator('.content')).toBeVisible();
  await popup.close();
});

Tab management with popup windows

browser.tabs() returns all open tabs and popup windows in the same list. Use browser.switchTab() to move focus between them — it works identically for both:

test('manages multiple windows', async ({ browser, page }) => {
  await page.goto('https://example.com/page-a');
  await browser.newWindow('https://example.com/page-b');

  console.log(browser.tabs().length); // 2

  browser.switchTab(t => t.url.includes('page-a'));
  await expect(page).toHaveURL(/page-a/);

  browser.switchTab(t => t.url.includes('page-b'));
  await expect(page).toHaveURL(/page-b/);
});

Notes

  • Popup windows are real browser windows — they are not sandboxed like iframes and are not subject to iframe CSP restrictions.
  • page.goto() and page.reload() work in popup windows using polling instead of iframe load events.
  • page.close() closes the popup window and returns focus to the most recently used tab.
  • Popup blocking must be disabled in the browser for window.open() interception to work. The framework does this automatically via launch arguments (--disable-popup-blocking on Chrome/Firefox).

node

Node.js context bridge available as the node fixture. Provides access to Node.js APIs (file system, environment variables, databases, etc.) from within browser-side test code.

node.task

await node.task<T = unknown>(name: string, payload?: unknown): Promise<T>

Execute a named task handler registered in tx.config.js under tasks.

Defining tasks in tx.config.js:

module.exports = {
  tasks: {
    getEnv:       (name) => process.env[name] ?? null,
    readFile:     ({ path }) => fs.readFileSync(path, 'utf-8'),
    writeFile:    ({ path, content }) => { fs.writeFileSync(path, content); return null; },
    seedDatabase: async (records) => { await db.insertMany(records); return records.length; },
  },
};

Using node.task in tests:

test('reads a fixture from disk', async ({ node }) => {
  const json = await node.task('readFile', { path: './fixtures/user.json' });
  const user = JSON.parse(json);
  expect(user.name).toBe('Alice');
});

Using node in test.extend:

const myTest = test.extend({
  serverData: async ({ node, log, attach }, use) => {
    const raw = await node.task('readFile', { path: './fixtures/data.json' });
    log('loaded data fixture');
    attach('data fixture', raw, 'application/json');
    await use(JSON.parse(raw));
  },
});

request

An APIRequestContext available as the request fixture. Makes HTTP requests directly from the panel process (not through the proxied iframe), so there are no CORS restrictions. All requests appear in the Network tab.

await request.fetch(url: string, options?: RequestInit): Promise<APIResponse>

options accepts the full standard RequestInit object (method, headers, body, credentials, etc.).

APIResponse

| Method | Returns | Description | |---|---|---| | ok() | boolean | true when status is 200–299 | | status() | number | HTTP status code | | statusText() | string | HTTP status text | | headers() | Record<string, string> | Response headers (lowercased keys) | | url() | string | Final response URL (after redirects) | | json<T>() | Promise<T> | Parse body as JSON | | text() | Promise<string> | Body as a string | | body() | Promise<ArrayBuffer> | Raw body bytes |

test('CRUD operations', async ({ request }) => {
  const resp = await request.fetch('https://api.example.com/users');
  expect(resp.status()).toBe(200);
  const users = await resp.json();

  const resp2 = await request.fetch('https://api.example.com/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: 'Alice' }),
  });
  expect(resp2.ok()).toBe(true);
});

log and attach

log writes a message to the command panel during a test. attach adds named data (text, JSON, images, …) to the test result so reporters can display or store it.

test('checkout', async ({ page, log, attach }) => {
  log('starting checkout flow');

  // Attach plain text
  attach('cart state', JSON.stringify(cart), 'application/json');

  // Attach a screenshot inline
  attach('page state', await page.screenshot(), 'image/png');

  // Attach an HTML snapshot (rendered as an iframe in the HTML reporter)
  attach('page snapshot', await page.snapshot(), 'text/html');
});

The HTML reporter renders image attachments inline and text/JSON attachments in a code block, grouped under the test row they belong to.


log.group

Groups log entries into a collapsible section in the command panel. Groups can be nested. The group header turns red if any child entry fails, green if any pass.

Functional form — all log entries produced inside the callback are nested automatically; the group closes when the callback resolves or throws:

test('checkout', async ({ page, log }) => {
  await log.group('add item to cart', async () => {
    await page.click('#add-to-cart');
    log('item added', { type: 'success' });
  });

  // Custom cmd label (replaces the default "group" label)
  await log.group('place order', 'step', async () => {
    await page.click('#checkout');
    await page.fill('#email', '[email protected]');
  });
});

Imperative form — open the group manually and call .end() when done:

test('setup', async ({ log }) => {
  const g = log.group('prepare fixtures');        // cmd defaults to "group"
  const g = log.group('prepare fixtures', 'step'); // custom cmd label
  log('seed database', { type: 'success' });
  log('clear cache',   { type: 'success' });
  g.end();
});

Signatures:

log.group(message: string, cmd?: string): TxGroupHandle
log.group<T>(message: string, fn: () => T | Promise<T>): Promise<T>
log.group<T>(message: string, cmd: string, fn: () => T | Promise<T>): Promise<T>

step

Groups all commands executed inside the callback into a named collapsible section in the command panel. The group header reflects the pass/fail state of its children. Supports both async and sync callbacks and passes the return value through.

// Async
await step('Log in', async () => {
  await page.goto('https://example.com/login');
  await page.getByTestId('username').fill('alice');
  await page.getByTestId('password').fill('s3cret');
  await page.getByTestId('submit').click();
});

// Sync — returns T directly (no await needed)
const label = step('Read page title', () => page.url());

Signatures:

step<T>(title: string, fn: () => Promise<T>): Promise<T>
step<T>(title: string, fn: () => T): T

The step fixture is a thin wrapper over log.group with the cmd label fixed to 'step'. Use it to add readable structure to long test flows without affecting execution order:

test('checkout flow', async ({ page, step }) => {
  await step('Add item to cart', async () => {
    await page.locator('#add-to-cart').click();
  });

  await step('Fill shipping address', async () => {
    await page.getByLabel('Street').fill('Main St 1');
    await page.getByLabel('City').fill('Springfield');
  });

  await step('Place order', async () => {
    await page.locator('#submit-order').click();
    await expect(page.locator('.confirmation')).toBeVisible();
  });
});

testInfo

Metadata about the currently running test, injected as the testInfo fixture. Available in test bodies, beforeEach, and afterEach hooks.

| Property | Type | Description | |---|---|---| | title | string | The leaf test title — everything after the last > in the full name | | titlePath | string[] | All title segments from outermost suite to test name | | retry | number | Zero-based retry attempt index (0 on the first run, 1 on the first retry, …) | | tags | string[] | Tags applied to this test via { tag: [...] } | | timeout | number | Test timeout in ms (mirrors testTimeout config, default 30000) | | retries | number | Max retry attempts configured (mirrors retries config, default 0) | | actionTimeout | number | Default locator action timeout in ms (mirrors actionTimeout config, default 5000) | | expectTimeout | number | Default expect() assertion timeout in ms (mirrors expectTimeout config, default 5000) |

test.describe('Checkout', () => {
  test('places an order', { tag: ['@smoke'] }, async ({ testInfo }) => {
    console.log(testInfo.title);         // 'places an order'
    console.log(testInfo.titlePath);     // ['Checkout', 'places an order']
    console.log(testInfo.retry);         // 0 (1 on first retry)
    console.log(testInfo.tags);          // ['@smoke']
    console.log(testInfo.timeout);       // 30000 (or whatever testTimeout is set to)
    console.log(testInfo.retries);       // 2 (or whatever retries is set to)
    console.log(testInfo.actionTimeout); // 5000
    console.log(testInfo.expectTimeout); // 5000
  });
});

Use retry to skip expensive setup on retries:

test('syncs data', async ({ page, testInfo }) => {
  if (testInfo.retry === 0) {
    await page.evaluate(() => localStorage.clear());
  }
  // …
});

Attach a screenshot with a retry-aware name:

test('checkout', async ({ page, attach, testInfo }) => {
  // …
  attach(
    `screenshot-attempt-${testInfo.retry}`,
    await page.screenshot(),
    'image/png',
  );
});

Access testInfo from a custom fixture:

const myTest = test.extend({
  dbRecord: async ({ testInfo }, use) => {
    const record = await db.insert({ testName: testInfo.title });
    await use(record);
    await db.delete(record.id);
  },
});

page Events

Subscribe via page.on(event, handler). Events are emitted by bridges installed inside the proxied iframe after each navigation.

| Event | Handler signature | Description | |--------------------|------------------------------------------------|-------------| | close | () => void | page.close() was called | | console | (msg) => void | Console output. msg.type(), msg.text(), msg.args(), msg.location() | | dialog | (dialog) => void | alert/confirm/prompt. dialog.type(), .message(), .accept(text?), .dismiss() | | download | (dl) => void | User clicked a download link. See Download | | filechooser | (fc) => void | File input clicked. See FileChooser | | frameattached | (frame) => void | A sub-frame was added. frame.url(), frame.name(), frame.isMainFrame() | | framedetached | (frame) => void | A sub-frame was removed | | framenavigated | (frame) => void | A sub-frame navigated | | pageerror | (err: Error) => void | Uncaught error or unhandled rejection | | popup | (popup: Page) => void | window.open() or target="_blank" click. Receives a full Page-like object. | | request | (req) => void | fetch/XHR started. req.url(), .method(), .headers(), .postData(), .resourceType() | | requestfailed | (req) => void | Request failed. req.failure().errorText | | requestfinished | (req) => void | Request completed successfully | | response | (res) => void | Response received. res.url(), .status(), .statusText(), .ok(), .request() | | websocket | (ws: WebSocket) => void | A WebSocket was created | | worker | (worker: Worker) => void | A Web Worker was created |

page.on('dialog', async dialog => {
  console.log(dialog.type(), dialog.message());
  await dialog.accept();
});

page.on('console', msg => {
  if (msg.type() === 'error') console.error('[page]', msg.text());
});

Download

Passed to handlers registered with page.on('download', …) and page.waitForEvent('download'). Emitted whenever the user clicks a link that carries a download attribute or whose URL ends with a recognized file extension (.pdf, .zip, .csv, etc.).

dl.url(): string

Returns the href of the link that triggered the download.

dl.suggestedFilename(): string

Returns the download attribute value when set; otherwise falls back to the last path segment of the URL.

await dl.createReadStream(): Promise<ReadableStream<Uint8Array>>

Fetches the download URL and returns its content as a Web ReadableStream<Uint8Array>. Use a ReadableStreamDefaultReader to consume the bytes.

await dl.saveAs(path: string): Promise<void>

Fetches the download URL and writes the content to path on the server's filesystem. Parent directories are created automatically.

// Inspect and stream content
page.on('download', async dl => {
  console.log(dl.url());                 // 'https://example.com/report.csv'
  console.log(dl.suggestedFilename());   // 'report.csv'

  const stream = await dl.createReadStream();
  const reader = stream.getReader();
  const chunks: Uint8Array[] = [];
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    chunks.push(value);
  }
  const text = new TextDecoder().decode(
    chunks.reduce((a, c) => {
      const m = new Uint8Array(a.length + c.length);
      m.set(a); m.set(c, a.length);
      return m;
    }, new Uint8Array(0))
  );
  console.log(text);
});

// Save to disk
page.on('download', async dl => {
  await dl.saveAs(`/tmp/downloads/${dl.suggestedFilename()}`);
});

FileChooser

Passed to handlers registered with page.on('filechooser', …) and page.waitForEvent('filechooser'). Emitted whenever a <input type="file"> element receives a click.

fc.element(): HTMLInputElement

Returns the underlying file <input> element.

fc.isMultiple(): boolean

Returns true when the input carries the multiple attribute.

fc.accept(): string

Returns the value of the accept attribute, or an empty string when the attribute is absent.

fc.setFiles(files: File[]): void

Programmatically sets the input's FileList to the supplied array and dispatches a change event on the element. Use the browser's built-in File constructor to create entries.

// Accept all file-chooser dialogs automatically
page.on('filechooser', fc => {
  fc.setFiles([
    new File(['hello world'], 'hello.txt', { type: 'text/plain' }),
  ]);
});

// Inspect chooser properties before deciding
page.on('filechooser', fc => {
  console.log(fc.isMultiple()); // true / false
  console.log(fc.accept());     // e.g. 'image/png,image/jpeg'
  console.log(fc.element().id); // DOM id of the input
});

Reporters

Reporters receive structured run events (begin, test result, run end). Pass them in tx.config.js:

reporters: [
  ['./ConsoleReporter.ts', {}],
  ['./HtmlReporter.ts', { outputPath: 'report/report.html' }],
],

Architecture

Tests execute inside the browser. Results travel to Node.js reporters via HTTP, then ReporterEmitter fans them out to every registered reporter.

Browser (controller.ts)
  │  POST /api/run-begin   → emitBegin(config, suite)
  │  POST /api/report      → emitTestBegin + emitTes