How do you give each parallel Playwright worker its own admin account without signing in per test?
answer
- One sign-in per worker, not per test
- Key the account off the parallel slot
- Bounded by the configured worker count
- Existing file short-circuits a restart
- Verify the session before caching it
basics
~20 sSign in once inside a worker-scoped fixture and key both the account and its saved-state file off test.info().parallelIndex. That index never exceeds the configured worker count, so a fixed pool of accounts covers a run of any size.
solid answer
~40 sMove the sign-in into a worker-scoped fixture and derive the identity from `test.info().parallelIndex`: worker 0 signs in as `[email protected]` and writes `playwright/.auth/admin-0.json`, worker 1 uses index 1, and so on. The test-scoped `storageState` option then forwards that path, so every test the worker runs starts already authenticated and no test drives the login form. `parallelIndex` is the right key because it is bounded by the `workers` setting and is reused by the process that replaces a crashed worker — the fixture finds the file already on disk and skips the sign-in. Keying off `workerIndex` instead would demand an unbounded pool of accounts, since it climbs by one for every worker process the run ever starts.
code
typescript · 28 linesimport { test as base, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
export const test = base.extend<{}, { workerStorageState: string }>({
workerStorageState: [async ({ browser }, use) => {
const id = test.info().parallelIndex;
const file = path.resolve(test.info().project.outputDir, `.auth/admin-${id}.json`);
if (fs.existsSync(file)) {
await use(file);
return;
}
const page = await browser.newPage({ storageState: undefined });
await page.goto('/login');
await page.getByLabel('Email').fill(`admin+${id}@example.com`);
await page.getByLabel('Password').fill(process.env.STAFF_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('link', { name: 'Audit log' })).toBeVisible();
await page.context().storageState({ path: file });
await page.close();
await use(file);
}, { scope: 'worker' }],
storageState: ({ workerStorageState }, use) => use(workerStorageState),
});go deeper
Know that parallel workers can each hold their own signed-in account, and that the account is chosen by the worker's index rather than written into the test.
Explain where the sign-in lives, how the index picks both the account and the file name, and why the option still has to be forwarded per test.
Diagnose the restart case and the stale-file case, and justify parallelIndex over workerIndex by the size of the account pool each one implies.
Own the sizing: the account pool is bounded by the worker count, so raising parallelism has a credential cost, and that ceiling belongs in the capacity conversation.
## The unit of reuse is the worker An admin console suite that needs a distinct staff account per parallel stream has three natural places to put the sign-in: once per test, once per run, or once per worker. Per test is the slowest; once per run gives every stream the same identity. Per worker is the middle path Playwright is shaped for, because a worker process is exactly the thing that runs tests one at a time — so a session held for the worker's lifetime is never used by two tests simultaneously. The mechanics are a worker-scoped fixture that performs one sign-in and yields a file path, plus the test-scoped `storageState` option forwarding that path to every test the worker runs. ## Which index to key on `test.info()` exposes two numbers that look interchangeable and are not: | | `parallelIndex` | `workerIndex` | |---|---|---| | Range | 0 to the worker count minus one | climbs for the whole run | | After a worker is replaced | the replacement reuses it | the replacement gets a new, higher number | | Distinct across live workers | yes | yes | | Usable as an account key | yes, bounded pool | no, unbounded pool | Because `parallelIndex` identifies the *slot* rather than the *process*, five accounts cover a run with `workers: 5` no matter how many crashes and restarts happen. That property is why the per-worker credential pattern is written against it. ## The fixture, step by step 1. Compute `const id = test.info().parallelIndex` at the top of the worker fixture. 2. Build a stable path from it, either a literal under `playwright/.auth` or something inside `test.info().project.outputDir` so the file is cleaned with the rest of the run's output. 3. If the file already exists, hand it to `use` and return — that is the restart short-circuit. 4. Otherwise open a page from the worker-scoped `browser` fixture with `storageState: undefined`, so the sign-in starts from a context carrying no session. 5. Sign in as that slot's account, then assert a landmark that only appears once authenticated. 6. Write the state with `page.context().storageState({ path })`, close the page, and `use(path)`. ## Reuse after a restart When a worker dies — an unhandled crash, or a retry policy that restarts the process — Playwright starts a replacement for the same parallel slot. The replacement re-enters the worker fixture, finds the file its predecessor wrote, and skips the sign-in entirely. That is not just an optimisation: signing in again could invalidate the previous session on a backend that permits one session per account, and the file check sidesteps it. ## What still needs care - **The accounts must actually exist and be distinct.** The fixture picks a slot; it does not create identities. `[email protected]` through `[email protected]` have to be real, with the same role and the same permissions, or a test will fail depending on which slot it landed in. - **Stale files.** A cached file from a previous run can outlive the session it represents. Writing under the run's output directory rather than a hand-managed folder makes the cache lifetime obvious. - **Two runs at once.** Two simultaneous runs on one machine share the slot numbering, so a fixed path keyed only by index can be written by both. Include the run's output directory in the path. - **Do not assert on the index.** A test that expects `[email protected]` in the page header is pinned to a slot, and slots are an execution detail, not a fixture of the scenario. - **Verify before you cache.** Writing the state file before asserting the sign-in worked caches a failure for every remaining test in that worker. ## The shape it leaves behind The end state reads plainly: one fixture file holding the worker-scoped sign-in and the `storageState` override, specs that name no credentials at all, and a run whose sign-in count equals the worker count rather than the test count.
- How many staff accounts does this pattern actually need?As many as the highest worker count you configure, since `parallelIndex` runs from zero to that count minus one. Sharding does not change it: each shard is its own run with its own slots, so the pool is sized by workers per run, not by total tests or shards.
- Why check whether the state file exists before signing in?A replacement worker takes over the same parallel slot after a crash and re-enters the fixture. Finding the file lets it skip the sign-in, which saves the round trip and avoids invalidating the earlier session on a backend that allows only one live session per account.
- What breaks if the sign-in page is opened without unsetting storage state?The page inherits whatever session the project's `storageState` already supplies, the app redirects past the login form, and the fixture writes the inherited cookies under this slot's file name. Every worker then ends up sharing one identity while appearing to have its own.
saying these in an interview costs you the question
- Keying the account file off workerIndex instead
- Assuming a restarted worker must sign in again
- Asserting on a specific slot's account in a test
- Writing the state file before verifying the sign-in
- Sharing one saved file across every worker slot