Sign in once per worker: TesterArmy e2e tests on smol machines
smol machines is integrated with TesterArmy’s open-source testing framework, e2e, as
a browser provider. @e2e-dev/smol allows every test attempt to run in its own branch of a warm, signed-in Chromium machine.
npm install --save-dev @e2e-dev/smol smolmachines e2e.config.ts import type { E2EConfig } from 'e2e';
import { web } from '@e2e-dev/web';
import { smol } from '@e2e-dev/smol';
export default {
targets: [
{
name: 'web',
engine: web({ browser: smol({ hostPorts: [3000] }) }),
app: {
url: 'http://localhost:3000',
command: { executable: 'npm', args: ['run', 'dev'] },
},
},
],
workers: 4,
} satisfies E2EConfig; Shared state impacts end-to-end test results
Each attempt starts in a fresh browser context, but the app under test still remembers what earlier tests wrote to its database and server, and that carries over to the next test.
This property causes two problems. A test can pass or fail depending on what ran before it. And when a test fails and the runner retries it, the retry starts from whatever the failed attempt left behind. So a test can also fail for a perfectly working subject.
The usual fix is to start the new test in a fresh browser with a fresh login, which is correct but repeats the same steps, and the app’s server state stays shared. Signing in through a real login page is a series of steps, and they repeat for every attempt as the test suite grows. A session saves the cookies and storage of one sign-in for other tests to restore, but not the open pages, the browser’s memory or the app’s server state.
With prepare, the sign-in steps run once per worker instead of once per attempt, retries included.
Branch of a signed-in browser
smol machines’ virtual machines resolve this inconvenience through branching. A branch becomes a copy-on-write clone of the machine at that instant, with its memory, its running processes and its disk intact, which removes the need to log in repeatedly.
The e2e testing framework integrates with smol machines and builds on top of this branching feature. Each worker slot
boots one Chromium machine and keeps it warm. A prepare step drives that browser once. Then every test attempt runs in
a branch of the signed-in browser, and the branch is deleted after the attempt ends. Nothing an
attempt does inside the machine reaches another, and a retry starts exactly from the beginning.
| Fresh browser and login | Branch of a signed-in browser | |
|---|---|---|
| Each attempt starts in | a new browser | a copy-on-write branch of the warm machine |
| The sign-in steps run | every attempt | once per worker, in prepare |
| A retry starts from | an empty browser that signs in again | the same signed-in state as the first try |
| What an attempt leaves behind | the app’s server state and database | nothing inside the machine, and with app, the app’s data is the attempt’s own too |
Add the prepare configuration so that every attempt starts with the signed-in state. It only needs to receive the
browser’s DevTools endpoint, and it runs once per warm machine:
import { chromium } from 'playwright-core';
smol({
hostPorts: [3000],
prepare: async (cdpEndpoint) => {
const browser = await chromium.connectOverCDP(cdpEndpoint);
const page = browser.contexts()[0]!.pages()[0]!;
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill(process.env.QA_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await browser.close();
},
}) The tests stay the same across attempts. If a test uses sessions, prepare replaces them:
import { test } from '@e2e-dev/web';
import { expect } from 'e2e';
test('a signed-in member sees the billing page', async ({ app, screen }) => {
await app.open('/settings/billing');
await expect(screen.getByRole('heading', 'Billing')).toBeVisible();
}); Give every attempt its own app
For apps that run on your computer, you can move the app into the machine to prevent sharing a local database between attempts:
e2e.config.tssmol({
app: {
source: '.',
setup: 'npm ci && npm run db:seed',
start: 'npm start',
port: 3000,
env: { DATABASE_URL: 'file:/app/dev.db' },
},
}) The provider copies the project, runs setup and start inside the machine, and waits for the port to answer. Each attempt’s branch now
holds its own copy of the running app and its local database, and two attempts that write the same row will never see
each other’s writes.
With app, set the target’s app.url to http://localhost:<port>, leave out app.command, and keep that port out of hostPorts.
The collaboration
e2e owns the test: the runner, the API, agentic steps like agent.act and the cache that replays them, and the
reporters.
smol machines owns the machine: the microVM, the warm Chromium image, the branch, and cleaning up.
Notes before testing
- The machines run on macOS on Apple Silicon or on Linux with KVM. The first boot pulls the Playwright Ubuntu image, so it is slower than every boot after it.
- The machines run on your own computer, with no account or API key.
hostPortsis not an allowlist. A machine can reach any loopback port on your computer throughhost.smolvm.internal, and it has open outbound network access. Stop sensitive local services before you test an untrusted app.- The browser’s back/forward cache is off, so going back reloads the page.
prepareneeds the defaultattemptscope. Withscope: 'worker', each worker keeps one machine for the whole run and each attempt gets a new browser context instead, as in a local run.
The full option list is on e2e's smol machines page, and the setup is on ours. The provider's code is in tester-army/e2e.