Profiles and page pools
A profile is a kind of page your tests ask for by name: "a signed-in employee on the dashboard", "a guest on the pricing page". The browser boots pages of a profile before any test asks, so pyro.page({ as }) returns one that has already loaded the app, run its scripts and read its tokens. This page lists every option of pyro.profile() and pyro.page() and explains pools, connections, refills and memory. Fork mode in tests covers the fork-only options in depth; Profiles and pre-booted pages explains the concept.
await pyro.profile('employee', {
url: `${APP}/platform`,
localStorage: { accessToken: tokens.access, refreshToken: tokens.refresh },
viewport: { width: 1440, height: 900 },
pool: 4,
});
await using page = await pyro.page({ as: 'employee', route: `/positions/${id}` });pyro.profile(name, profile)
Declares the profile and starts booting its pages. It resolves once the server has registered it, not when the pages are booted (use warm for that).
| Option | Type | Default | Meaning |
|---|---|---|---|
url | string | required | Where its pages boot, and stay until a test takes one. Any page of the app works; a test moves on with route |
localStorage | Record<string, string> | Written to localStorage before the app's scripts run, in every document the page loads | |
initScripts | string[] | Scripts run in every document before its own scripts, after the localStorage writes | |
viewport | { width, height } | 1920 × 1080 | The viewport in CSS px (device scale factor 1) |
pool | number (1 to 8) | 2 | Pages kept booted per connection. Ignored in fork mode |
prepare | boolean | true | false declares the profile without booting ahead: each pyro.page({ as }) then boots a fresh page |
setup | StepLike[] | Steps run once on the booted page before tests get it. Fork mode only | |
base | string | Start from another profile's snapshot. Fork mode only |
Declaring setup or base against a server without --fork fails with Pyro.registerPreparedPage: setup and base need serve --fork. A pool outside 1 to 8 fails with Pyro.registerPreparedPage: pool must be between 1 and 8.
Signing in with localStorage
Most single-page apps keep their session in localStorage. Get tokens from your auth API once, in beforeAll, and give them to the profile:
beforeAll(async () => {
const tokens = await getAuthTokens(); // your API, not the UI
pyro = await Pyro.launch();
await pyro.profile('employee', {
url: `${APP}/platform`,
localStorage: {
'auth.tokens': JSON.stringify(tokens),
'user.store': JSON.stringify(await getEmployeeAccount()),
},
});
});The entries are written by an init script, so they are written again in every document the page loads, including after a reload. Values are strings: serialize objects yourself.
There is no cookie option. For cookie sessions, either sign in once in a setup (fork mode, see below and Signing in once) or have an init script call your app's own sign-in logic. A server-wide seed for every context exists too: --local-storage.
initScripts
Code that must run before the app in every document: feature flags, a fixed Math.random, test hooks.
await pyro.profile('flags', {
url: `${APP}/`,
initScripts: ['window.__FEATURES__ = { newCheckout: true };'],
});pyro.page(options)
| Option | Meaning |
|---|---|
as | The profile to take a page from. Without it you get a blank page (about:blank) in a new browser context |
route | Same-document navigation run as the first step of the first run: pushState plus popstate, as a client-side router sees a link. The app does not boot again |
url | A full navigation (goto) run as the first step of the first run |
initScripts | Extra scripts for this page, run after the profile's. A pooled page already ran its document, so with this option the page boots fresh instead of coming from the pool |
route and url are not sent until your first page.run, so they cost no extra round trip. A failure there is reported as opening the page (...) instead of a step index.
Use route for an app whose routes fetch their data when they mount. Use url when the test needs a full load, for example because the app reads the data it shows only once at boot.
A page asked for while no booted page is waiting is booted for the test: a new context, the profile's scripts and viewport, then a navigation to the profile's url. A page taken while it is still booting gets a loaded() step in front of your steps, so your first step sees the finished document.
pyro.stats counts what happened: prepared (pages taken from a pool), preparedBooting (of those, still booting when taken) and fresh (booted on demand).
Pools and refills
Each connection keeps pool booted pages per profile. When a test takes one, the connection boots its replacement in the background:
- as soon as the connection has no open page, or
- after 15 ms without commands on that connection.
Close every page when its test ends (await using). An open page keeps its connection busy, and the pool does not refill behind it. Back to back on one connection, the replacement boot shares the browser thread with the next test.
Connections
const pyro = await Pyro.launch({ connections: 4 });
// or
const pyro = await Pyro.connect(wsUrl, { connections: 4 });Each connection is its own browser thread with its own pools. pyro.page() takes the page from the connection with the fewest open pages (round robin among equals), and a connection refills while tests run on the others. With 4 connections the TOS close flow took 2.7 ms per test back to back, against 7.4 ms on one connection (bench/results/pyro-tos-e501cef).
Every connection boots pool pages of every profile, so memory grows with connections × pool × profiles. pyro.profile() registers the profile on every connection.
In fork mode connections and pool make no difference: every page gets a connection of its own.
Memory
Every booted page is a full copy of the app. A pool of N holds N booted pages plus the one in use, per connection and per profile. Two ways to cap it:
--prepare-refill after-close: the replacement boots only once the taken page's context closes, so a connection never holds more thanpoolcopies. Pass it to the server:Pyro.launch({ args: ['--prepare-refill', 'after-close'] }). It applies to every profile; the SDK has no per-profile option for it.- Fork mode: the profile boots once in a template process, and each page is a copy-on-write fork that exits when the page closes. See Fork mode in tests.
Setup steps and base profiles
In fork mode, a profile can run steps once on its booted page, and every page of it starts from their result:
const pyro = await Pyro.launch({ args: ['--fork'] });
await pyro.profile('signed-out', { url: `${APP}/` });
await pyro.profile('app', {
base: 'signed-out',
url: `${APP}/`,
setup: [label('Email').fill('ada@example.com'), button('Sign in').click(), check(text('Signed in')).visible()],
});base forks the new profile's page from the base profile's snapshot, and only the new profile's own setup runs on it. Chains work: boot the login page once, sign in once, then one snapshot per test file on top. A base that neither this client nor any other client of the server registered fails with no profile <name> on this server: declare it with pyro.profile(); against a server without --fork, with no prepared profile <name> to start from. Details and the cost model are on Fork mode in tests.
warm()
await pyro.warm('employee'); // default timeout 5,000 ms
await pyro.warm('employee', 30_000);Waits until every connection's pool of the profile is fully booted (in fork mode: until the profile's template page is ready). It throws no booted page for <name> after <ms> ms on timeout. Use it before timing tests, or in a launcher before workers start. Tests do not need it: pyro.page() takes a page that is still booting and waits for it in the browser.
releaseProfile() and redeclaring
await pyro.releaseProfile(name) drops the profile: the server unregisters it and retires its waiting pages. Pages already taken stay independent until they close. In fork mode with several clients, it drops only this client's hold (see One server, many workers).
Declaring the same name again with a different definition replaces it.
Recipes
One profile per kind of user. Project layout signs in an admin and a member through the API in the setup module and registers both profiles once per run.
await pyro.profile('admin', { url: `${APP}/admin`, localStorage: adminTokens });
await pyro.profile('employee', { url: `${APP}/platform`, localStorage: employeeTokens });A page per test, seeded through the API.
test.concurrent('shows the order', async () => {
const order = await api.createOrder();
await using page = await pyro.page({ as: 'employee', route: `/orders/${order.id}` });
await page.run([check(heading(`Order ${order.number}`)).visible()]);
});A test that needs extra init code.
await using page = await pyro.page({ as: 'employee', initScripts: ['window.__TEST_CLOCK__ = 0'] });This page boots fresh, so it costs a full app boot. Prefer a second profile when many tests need the same script.
Next
- Fork mode in tests:
setupandbase, and what tests still share. - Suite runner: pyro e2e: register profiles once for a whole suite.