Custom Code Step
A Custom Code Step lets you attach a hand-written JavaScript snippet directly to a step. Use it when the built-in AI Chat, Record, and Assert modes are not enough — for example, to call a backend API, query a database, read a value from the page and store it, or seed test data programmatically.
Custom code runs in a secure sandbox with a curated ctx API that gives you access to the live browser page, an HTTP client, the test-data store, secrets, logging, checkpoints, and a Postgres client.
How to add a custom code step
- Hover over the step where you want to add custom code.
- Open the custom actions dropdown — the "Search custom actions..." field that appears on the step.
- Choose the Custom Code option from the list.
- The Custom Code Step modal opens.
To edit an existing custom code step, click the step's actions menu and choose Edit code.
Inside the modal
| Area | What it does |
|---|---|
| Step name | Label shown in the test (e.g. "Create backend order via API"). Defaults to "Custom code step" if left blank. |
| Code editor | Monaco editor with ctx.* autocomplete. Top-level await is supported. |
| ctx Reference panel | Tabs for Browser, HTTP, Test Data, Variables, Logging, and Checkpoints — quick reference without leaving the modal. |
| Use Template | Load a saved project snippet into the editor. Templates are searchable and deletable. |
| Save as template | Save the current snippet for reuse across scenarios. |
Keyboard shortcuts
| Shortcut | Action |
|---|---|
Cmd/Ctrl + F | Find |
Cmd/Ctrl + H | Find and replace |
Ctrl + Space | Trigger autocomplete |
Cmd/Ctrl + Enter | Save and close |
Step parameters
Any <token> placeholder used in the code or step name automatically becomes a step parameter. For example, writing ctx.variables.orderId (where orderId is a <token>) adds an orderId parameter to the step that callers can fill in.
The ctx API
ctx is the only object your code needs. Every method is async — use await.
Logging
js
await ctx.log.info('Starting order creation');
await ctx.log.warn('Retrying after empty response');
await ctx.log.error('Unexpected status', { status: response.status });ctx.log.info, ctx.log.warn, and ctx.log.error each accept any number of arguments and write structured log entries visible in the execution output.
Checkpoints
js
await ctx.checkpoint('order-created', { orderId: '12345' });Records a named checkpoint in the execution timeline. The optional second argument can be any serialisable value and is shown alongside the checkpoint in the report.
Browser
ctx.browser.page gives you a curated Playwright Page. Use it to interact with the live browser session running your test.
Navigating and querying
js
await ctx.browser.page.goto('https://example.com/orders');
const title = await ctx.browser.page.title();
const url = ctx.browser.page.url();Finding elements and interacting
js
const button = ctx.browser.page.getByRole('button', { name: 'Submit' });
await button.click();
const input = ctx.browser.page.getByLabel('Email');
await input.fill('user@example.com');
const items = await ctx.browser.page.locator('.cart-item').all();
await ctx.log.info('Cart items', { count: items.length });Waiting
js
await ctx.browser.page.waitForSelector('.confirmation-banner');
await ctx.browser.page.waitForURL('**/order-confirmed');
await ctx.browser.page.waitForLoadState('networkidle');Supported locator methods: locator, getByRole, getByLabel, getByPlaceholder, getByText, getByTestId, getByAltText, getByTitle
Supported locator actions: click, fill, check, uncheck, clear, hover, press, type, selectOption, count, all, first, last, nth, filter, textContent, innerText, inputValue, getAttribute, isVisible, isChecked, isEnabled, isDisabled, isHidden, waitFor, scrollIntoViewIfNeeded, boundingBox
Not available in custom code
The following Playwright APIs are intentionally blocked and will throw a capability-denied error if called:
page.evaluate/page.evaluateHandle/page.$eval/page.$$evalpage.screenshotpage.setInputFilespage.route/page.unroutepage.addScriptTag/page.addInitScriptpage.exposeFunctionpage.on(event listeners) — exceptwaitForEvent('download'), which is allowed
HTTP client
js
const response = await ctx.http.fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ item: 'widget', qty: 2 }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = JSON.parse(response.body);
await ctx.log.info('Order created', { orderId: data.id });ctx.http.fetch(url, init?) returns { ok, status, statusText, headers, body }. The body property is raw text — parse it yourself with JSON.parse.
Allowed URLs: any https:// host, plus http://localhost and http://127.0.0.1. Private IP ranges are blocked.
Test data
js
// Store a value for later steps
await ctx.testData.set('orderId', '12345', { dataType: 'string' });
// Read it back
const orderId = await ctx.testData.get('orderId');
// Runtime values (survive across steps in this execution)
await ctx.testData.setRuntime('sessionToken', token);
const token = await ctx.testData.getRuntime('sessionToken');
// Scoped lookup (checks execution scope first, then project defaults)
const baseUrl = await ctx.testData.getScoped('BASE_URL');All test-data methods return null when the key does not exist.
Variables (step parameters)
ctx.variables gives you the values of the step's <token> parameters as a plain object:
js
const username = ctx.variables.username; // string
const itemCount = Number(ctx.variables.itemCount);Variables are read-only strings
ctx.variables cannot be mutated. All values are strings — convert to numbers or booleans as needed.
Secrets
js
const apiKey = await ctx.secrets.get('PAYMENT_API_KEY');
if (!apiKey) throw new Error('Secret PAYMENT_API_KEY is not configured');ctx.secrets.get(key) returns the secret value, or null if the key does not exist. Secrets are never written to logs.
Database
ctx.dep.pg exposes the pg (node-postgres) library, pre-loaded and ready to use without an import.
js
const client = new ctx.dep.pg.Client({
host: 'your-db-host.example.com',
port: 5432,
database: 'mydb',
user: 'testuser',
password: await ctx.secrets.get('DB_PASSWORD'),
ssl: { rejectUnauthorized: false }, // required for self-signed certs
});
await client.connect();
const result = await client.query('SELECT id FROM orders WHERE reference = $1', ['ORD-001']);
await ctx.log.info('Order row', { id: result.rows[0]?.id });
await client.end();The database host must be allowlisted, and TLS is required for all connections.
Limits
| Resource | Enforced limit |
|---|---|
| Execution time | 60 seconds |
| Memory | 256 MB |
| Log / output data | 512 KB |
| Data payload across the sandbox | 2 MB |
| Code length | 1 – 50,000 characters |
Available packages
The following npm packages are pre-loaded and available without any import:
| Package | Usage |
|---|---|
@faker-js/faker | Generate realistic fake data |
pg | Postgres client (via ctx.dep.pg) |
zod | Schema validation |
yaml | YAML parsing and serialisation |
require() and dynamic import() are not allowed. The following global identifiers are blocked: process, require, eval, Function, globalThis, window, document, self.
Error reference
| Error | What it means |
|---|---|
| Feature disabled | Custom code is not enabled for this worker or execution configuration |
| Syntax error | The JavaScript in your snippet has a parse error |
| Import not allowed | Your code tried to use require() or import() |
| Capability denied | Your code called a blocked browser API (e.g. page.evaluate) |
| Timeout | Execution exceeded 60 seconds |
| Memory limit exceeded | Memory use exceeded 256 MB |
| Payload too large | Data passed across the sandbox boundary exceeded 2 MB |
| Execution error | An uncaught exception was thrown inside your code |
Gotchas
ctx.variablesis read-only and string-typed. Convert to the type you need (Number(...),JSON.parse(...)) before use.- HTTP response bodies are raw text. Call
JSON.parse(response.body)to work with JSON — there is no automatic deserialization. - Only
'download'works withwaitForEvent. Passing any other event name will throw a capability-denied error. - Secrets and test-data methods throw if no data layer is bound. This can happen in certain local preview executions — check the execution configuration if you see unexpected errors from
ctx.testDataorctx.secrets. - Database calls require TLS and an allowlisted host. Connections to unlisted hosts or without TLS will be refused. For self-signed certificates, set
ssl: { rejectUnauthorized: false }.
Examples
Basic examples
These show plain data manipulation on the test-data store — no browser, network, or database required. Use them as building blocks: read a value, transform it in JavaScript, and write the result back for later steps.
Read an array, sum it, and store the total
Reads a cartPrices array from test data, totals it with reduce, and writes the result back to test data as cartTotal.
js
// Read an array of prices from test data, sum it, and store the total
const prices = await ctx.testData.get('cartPrices'); // e.g. [19.99, 5, 49.5]
const total = prices.reduce((sum, price) => sum + price, 0);
await ctx.testData.set('cartTotal', total, { dataType: 'number' });
await ctx.log.info('Cart total computed', { count: prices.length, total });Filter and transform an array, then store it back
Reads a catalog array from test data, keeps the in-stock entries, maps them to names, and stores the filtered list back in test data.
js
// Keep only in-stock items, reduce to their names, and save the filtered list
const items = await ctx.testData.get('catalog'); // [{ name, inStock }, ...]
const availableNames = items
.filter((item) => item.inStock)
.map((item) => item.name);
await ctx.testData.set('availableItems', availableNames, { dataType: 'json' });
await ctx.log.info('Filtered catalog', { total: items.length, available: availableNames.length });Increment a counter, handling the missing-key case
Reads runCount from test data (defaulting to 0 when the key is missing), adds one, and saves the new count back to test data.
js
// testData.get returns null when the key does not exist — default to 0 on the first run
const previous = (await ctx.testData.get('runCount')) ?? 0;
const next = Number(previous) + 1;
await ctx.testData.set('runCount', next, { dataType: 'number' });
await ctx.log.info('Run count', { runCount: next });Compute a derived value from several keys
Reads subtotal and discountPct from test data, computes the discounted total, and stores it back in test data as finalTotal.
js
// Read two stored numbers, compute a discounted total, and store the result
const subtotal = Number(await ctx.testData.get('subtotal'));
const discountPct = Number(await ctx.testData.get('discountPct')); // e.g. 15
const finalTotal = Number((subtotal * (1 - discountPct / 100)).toFixed(2));
await ctx.testData.set('finalTotal', finalTotal, { dataType: 'number' });
await ctx.log.info('Final total computed', { subtotal, discountPct, finalTotal });Advanced examples
These combine the ctx API with the browser, network, and database to drive real side effects.
Call an external API and save the result to test data
Posts to a backend API to create an order, then stores the returned order ID in test data for later steps.
js
// Create an order via the backend API and store the order ID for later steps
const token = await ctx.secrets.get('API_TOKEN');
const response = await ctx.http.fetch('https://api.example.com/v1/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
product: ctx.variables.productId,
quantity: Number(ctx.variables.quantity),
}),
});
if (!response.ok) {
throw new Error(`Failed to create order: ${response.status} ${response.statusText}`);
}
const order = JSON.parse(response.body);
await ctx.testData.set('orderId', order.id, { dataType: 'string' });
await ctx.checkpoint('order-created', { orderId: order.id });
await ctx.log.info('Order created successfully', { orderId: order.id });Read a value from the page and checkpoint it
Reads the confirmation number rendered in the live browser page, saves it to runtime test data, and records a checkpoint for the report.
js
// Extract the confirmation number shown on screen and record it
const confirmationEl = ctx.browser.page.getByTestId('confirmation-number');
await confirmationEl.waitFor({ state: 'visible' });
const confirmationNumber = await confirmationEl.textContent();
await ctx.testData.setRuntime('confirmationNumber', confirmationNumber?.trim());
await ctx.checkpoint('confirmation-captured', { confirmationNumber });
await ctx.log.info('Captured confirmation number', { confirmationNumber });Verify a record in Postgres
Reads the orderId from test data, queries Postgres for the matching row, and fails the step if the order is missing.
js
// Verify the order landed in the database
const orderId = await ctx.testData.get('orderId');
const client = new ctx.dep.pg.Client({
host: 'orders-db.internal.example.com',
port: 5432,
database: 'orders',
user: 'qa_reader',
password: await ctx.secrets.get('QA_DB_PASSWORD'),
ssl: { rejectUnauthorized: false },
});
await client.connect();
const result = await client.query(
'SELECT status, created_at FROM orders WHERE external_id = $1',
[orderId],
);
if (result.rows.length === 0) {
throw new Error(`Order ${orderId} not found in database`);
}
const { status, created_at } = result.rows[0];
await ctx.checkpoint('db-order-verified', { orderId, status, created_at });
await ctx.log.info('Database record found', { status });
await client.end();What's next
- Running Tests — Executing your scenario and reading the execution output
- Test Data & Parameters — Using
<token>parameters, faker, and the test-data store
