@qavajs/tx
v0.0.14
Published
@qavajs/tx — testing framework via Hammerhead proxy
Maintainers
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 API —
page,locator,expect,browser,request, hooks,test.extend()fixtures - Multi-window / popup support — open and control native browser popup windows via
browser.newWindow()or interceptwindow.open()/target="_blank"links via thepopupevent - Route interception — mock, modify, or abort requests with
page.route(); useroute.fetch()to proxy the real response and mutate it before returning - Soft assertions —
expect.soft()collects non-fatal failures and reports all of them together at the end of the test - Accessibility tree snapshots —
page.ariaSnapshot()andlocator.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,
--testexit-on-finish flag,--workers Nfor single-machine parallelism, and--shardfor multi-machine distribution
Installation
npm install @qavajs/txOr run from source:
git clone <repo>
cd @qavajs/tx
npm install
npm run buildQuick 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 --testWrite 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.jsThe 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) => stringThe 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>): voidTags 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(): stringReturn 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
trueto 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()); // 200Locator factories
page.locator(selector: string): LocatorMatch 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 }): LocatorMatch 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 }): LocatorMatch elements by ARIA role. Optional name filters by accessible name.
page.getByLabel(text: string | RegExp, opts?: { exact?: boolean }): LocatorMatch form controls associated with a <label> whose text matches, or elements with a matching aria-label.
page.getByPlaceholder(text: string | RegExp): LocatorMatch inputs by their placeholder attribute.
page.getByTestId(id: string): LocatorMatch elements with [data-testid="id"] or [data-test="id"].
page.getByAltText(text: string | RegExp): LocatorMatch elements with a matching alt attribute.
page.getByTitle(text: string | RegExp): LocatorMatch elements with a matching title attribute.
Viewport
page.setViewportSize(size: { width: number; height: number }): voidApply 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): pageSee page Events for available events.
Locator handlers
page.addLocatorHandler(
locator: Locator,
handler: (locator: Locator) => Promise<void>,
options?: { noWaitAfter?: boolean; times?: number }
): voidRegister 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.pngSnapshot
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-labelledby → aria-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(): objectReturns the original request object. Supports .url(), .method(), .headers(), .postData(), .resourceType(), .isNavigationRequest().
page.frameLocator
page.frameLocator(selector: string): FrameLocatorReturn 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 furtherLocator
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 XPathlocator.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(): voidtoPass (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;.notcall fails withmessage.pass: false— the assertion does not hold. Positive call fails withmessage;.notcall 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): voidSwitch 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()andpage.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-blockingon 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): TThe 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(): stringReturns the href of the link that triggered the download.
dl.suggestedFilename(): stringReturns 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(): HTMLInputElementReturns the underlying file <input> element.
fc.isMultiple(): booleanReturns true when the input carries the multiple attribute.
fc.accept(): stringReturns the value of the accept attribute, or an empty string when the attribute is absent.
fc.setFiles(files: File[]): voidProgrammatically 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