Quick start
Three short starts, one per way of using Pyro. Each is complete: copy the files, run the command, and it passes. None of them needs a web server, because the browser serves a local directory in place of a real origin with --static.
Install the package first (Installation) in an empty directory:
mkdir pyro-start && cd pyro-start
bun init -y
bun add --dev @povio/pyroNot on npm yet
@povio/pyro has not been published yet. Until it is, install the client from a checkout and set PYRO_BIN as described in Before the first @povio/pyro release; every command on this page then works unchanged.
All three starts use the same small app. Save it as site/index.html:
<!doctype html>
<html>
<head><title>Todo</title></head>
<body>
<h1>Todos</h1>
<p id="user"></p>
<label>New todo <input id="new" /></label>
<button id="add">Add</button>
<ul id="list"></ul>
<script>
const token = localStorage.getItem('token');
document.getElementById('user').textContent = token ? 'Signed in as ' + token : 'Signed out';
document.getElementById('add').addEventListener('click', async () => {
const input = document.getElementById('new');
const li = document.createElement('li');
li.textContent = input.value;
await new Promise((r) => setTimeout(r, 20)); // pretend to save
document.getElementById('list').append(li);
input.value = '';
});
</script>
</body>
</html>1. Run a Playwright test unchanged
A Playwright test runs on Pyro when it imports from @povio/pyro/wright instead of @playwright/test. Nothing else changes, and Playwright is not installed.
playwright.config.ts:
import { defineConfig } from '@povio/pyro/wright';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://app.test',
// Serve ./site in place of http://app.test.
pyro: { args: ['--static', `http://app.test=${import.meta.dirname}/site`] },
},
});e2e/todo.spec.ts:
import { test, expect } from '@povio/pyro/wright'; // was '@playwright/test'
test('adds a todo', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle('Todo');
await page.getByLabel('New todo').fill('Buy milk');
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByRole('listitem')).toHaveText(['Buy milk']);
});Run it as you would run playwright test:
bunx pyro runRunning 1 file using 5 workers
✓ 1 e2e/todo.spec.ts › adds a todo (179ms)
1 passed (0.2s)pyro run takes Playwright's usual flags (-c, --project, -j, -g, --retries, -u, ...) and needs Bun on PATH. Without -j or a workers setting it uses half the CPUs. The browser starts once per worker. Next: Migrating a suite.
2. Write a native page.run test
The native client sends a whole scenario as one command. The browser runs every step on its own thread, waits where it needs to, and replies once. This test also uses a profile: pages of the app are booted ahead, already signed in through localStorage, before the test asks for one.
tests/todo.test.ts:
import { afterAll, beforeAll, expect, test } from 'bun:test';
import { Pyro, button, check, label, role } from '@povio/pyro';
let pyro: Pyro;
beforeAll(async () => {
// Starts the bundled browser on a free port. It exits with this process.
pyro = await Pyro.launch({
args: ['--static', `http://app.test=${import.meta.dirname}/../site`],
});
// Pages of this profile boot ahead with a token in localStorage.
await pyro.profile('user', { url: 'http://app.test/', localStorage: { token: 'ada' } });
});
afterAll(() => pyro.close());
test('adds a todo', async () => {
await using page = await pyro.page({ as: 'user' });
const r = await page.run([
check(role('paragraph')).text('Signed in as ada'),
label('New todo').fill('Buy milk'),
button('Add').click(), // settles: waits for the 20 ms timer
check(role('listitem')).count(1),
role('listitem').read('first'),
]);
expect(r.values.first).toBe('Buy milk');
});bun test --timeout 10000 tests/todo.test.tsGive tests a longer timeout than their steps. Both default to 5,000 ms, so with the defaults a failing step shows only a test timeout, without its reason.
There is no await per step and no polling. button('Add').click() waits until the page is quiet before the next step starts, so the check sees the new item. If a step never holds, page.run throws a RunError naming the step and what it was waiting for. Next: Your first test.
TIP
await using closes the page when the test ends. While a page is open, its connection waits for a 15 ms gap between commands before it boots the next page for the profile, so close pages as soon as a test is done.
3. Save a signed-in profile and scrape
The same client works outside a test runner. A profile is a named recipe for a page: where it boots and what is in its localStorage (or init scripts) before the app runs. Every page you take from it starts signed in.
scrape.ts:
import { Pyro, check, css, evaluate } from '@povio/pyro';
const pyro = await Pyro.launch({
args: ['--static', `http://app.test=${import.meta.dirname}/site`],
});
try {
await pyro.profile('signed-in', { url: 'http://app.test/', localStorage: { token: 'ada' } });
const page = await pyro.page({ as: 'signed-in' });
const r = await page.run([
check(css('#user')).text('Signed in as ada'), // waits until the app has rendered it
evaluate(
`({ title: document.title, user: document.querySelector('#user').textContent })`,
'data',
),
]);
console.log(r.values.data);
page.close();
} finally {
pyro.close();
}bun scrape.tspyro: serving http://app.test from /…/pyro-start/site
pyro: CDP server listening on ws://127.0.0.1:61467/
{
title: "Todo",
user: "Signed in as ada",
}The two pyro: lines are the launched server's own output on stderr.
evaluate(expression, name) stores the JSON value of a page expression under r.values[name]; a returned promise is awaited. To scrape a real site, drop --static and use its URL in the profile. The client also runs under Node 22+ from a .mjs file. Next: Scraping and automation.
Where to go next
- Choosing an API: which of the three to use, and how to mix them.
- Settling: the exact rule that replaces waits and sleeps.
- Profiles and Fork mode: the two features that remove app boot time from tests.