Mirror a human-driven leader Chromium browser to N follower browsers using stock Playwright — no fork, no cross-process transport, no CDP replay. Designed for two-account (or N-account) differential-testing setups where the researcher drives the leader by hand and every follower must reproduce the same interaction against its own session.
Existing mirroring approaches either:
- Fork Playwright to add a leader/follower transport at the action layer (fragile against Playwright releases, patches touch selector engine + tab lifecycle + tracing).
- Replay CDP input events at viewport coordinates (breaks on any layout drift between two accounts' personalised pages).
- Replay CSS selectors captured on the leader (breaks on the exact DOM differences a differential-testing setup is designed to expose).
playwright-mirror uses semantic-locator observation: a small content-script observer runs on every leader page, captures primary user input events (click, input, select's change, keydown Enter, scroll), computes a role + accessible name locator (Playwright's getByRole), and forwards it to a Node driver that dispatches the equivalent action on every follower via the public Playwright API. One Node process, N + 1 browsers, no fork.
Every mirrored action is a primary user input. Everything the browser derives from it — submit after a click on a type=submit button, change on a checkbox after a click, focus/blur, most re-renders — is a derived effect. Replaying an effect on top of its cause causes double-dispatch. The follower's browser derives the effect naturally when we mirror the cause, with the site's real preventDefault / validation / SPA-router semantics.
Session-isolation corollary: navigation is not mirrored. When the leader clicks an <a href="/orders/123?token=leader-token">, the follower's browser clicks its own link with its own token. Force-navigating the follower to the leader's URL would leak per-account state and defeat the isolation the two-account setup depends on.
npm install playwright-mirror playwright
npx playwright install chromiumRequires Node ≥ 22 (uses --experimental-strip-types for TypeScript ESM without a build step).
# 1 follower
npx playwright-mirror https://example.com/
# 3 followers
npx playwright-mirror https://example.com/ --followers 3
# or via env
START_URL=https://example.com/ N_FOLLOWERS=3 npx playwright-mirrorThen interact with the LEADER window; every follower mirrors it. Each browser gets a small color-coded label overlay (LEADER green, F1 orange, F2 blue, …).
Keyboard shortcuts (in the leader window):
Alt+Shift+←— go back on leader + all followersAlt+Shift+→— go forward on leader + all followersAlt+Shift+R— reload leader + all followers
Close the leader window (or Ctrl-C) to shut down every follower.
import { startMirror } from 'playwright-mirror';
const handle = await startMirror({
startUrl: 'https://example.com/',
nFollowers: 2,
onAction: (leaderPage, action) => {
console.log('mirrored', action.type, 'from', leaderPage.url());
},
});
// handle.leaderPage — the leader Page (initial tab)
// handle.followerPages — Page[] of every follower's initial tab
// handle.followerLabels — ['F1', 'F2', ...]
await handle.closed; // resolves when leader closes
// or: await handle.close('done'); // programmatic shutdownFull option list:
interface MirrorOptions {
startUrl: string; // required
nFollowers?: number; // default 1
headless?: boolean; // default false
viewport?: { width: number; height: number }; // default 900x700
onAction?: (leaderPage: Page, action: Action) => void;
onPair?: (info: { host: string; follower: string }) => void;
onShutdown?: (reason: string) => void;
}| Event | Rule |
|---|---|
| Left click | Trusted only. Deferred one microtask so defaultPrevented reflects site intent. |
| Text input | Trusted input on <input>/<textarea>. |
| Select change | Trusted change on <select>. |
| Enter in text field | Trusted keydown → keyboard.press('Enter') on follower. |
| Scroll | Debounced 250ms of idle; only if position changed since last send. |
| Browser back/forward/reload | Only via Alt+Shift+←/→/R. Applied to leader + all followers. |
Not mirrored, by design:
submit,changeon checkbox/radio,focus/blur, and any!isTrustedevent — derived effects.- Native OS-picker interactions (
<select>popup,<input type=file|date|...>) whendefaultPrevented=false— the OS only shows one popup at a time. - Navigation via URL bar — chrome-only signal and would leak per-account URL data.
- New-tab clicks that don't leave a semantic click event to mirror — but
<a target=_blank>clicks are mirrored via their normal click event, and follower tabs are paired to leader tabs by URL host on first navigation.
Ordered by preference in observer.js:
getByRole(role, { name })— describes user intent; unique-in-doc check ensures it resolves cleanly on the follower.- Unique attribute —
id> anydata-*>name/title/aria-*> else, filtered to stable-looking values (no whitespace, no hash blobs, no pure-numeric ids). No allowlist of specific attributes —data-hook,data-automation-id,data-cy,data-anythingall get picked up as long as they uniquely identify the element. - Role + name even if non-unique — dispatcher's
.first()picks one; useful for list-page contexts. getByPlaceholder/getByText— visible-text fallbacks.- Structural CSS path — last resort.
Follower tabs are paired to leader tabs by URL host on first navigation, not by open order. Rationale: sites often spawn spurious tabs (window.open in a click handler, share-page redirects, popup blockers acting differently on one side) — index-pairing would misroute events after a single divergence. Host-based pairing correctly lines up twitter.com ↔ twitter.com tabs regardless of order, and stray leader tabs whose host never matches a follower stay unpaired (events dropped with a diagnostic).
Each follower keeps its own Map<leaderPage, followerPage>, so a slow-loading tab on F1 doesn't block F2 from pairing its own.
npm testFour cases run headless:
- A — SPA click submit (button with JS handler; no native form navigation).
- B — Native form POST (verifies no double-submit on
<button type=submit>). - C — Enter-in-textbox submit (native submit derived from mirrored keydown).
- D — Interactions in a mirrored new tab (proves tab-pair routing; original follower tab must NOT be navigated).
- Native
<select>popup on real users: the observer captures the trustedchangeevent on option pick, but Playwright's programmatic.selectOption()fires an untrusted change, so the self-test can't cover it. Real humans work. - List pages with many identical elements (e.g., an e-commerce inventory list where every card has an "Add to cart" button): role+name is non-unique → falls through to a session-scoped attribute selector → follower may not find the corresponding element. Needs a positional/parent-scoped locator strategy (not implemented).
- URL-bar typing on the leader is chrome-only and not observable; it would also leak per-account URL data if it were.
- File uploads are not yet mirrored — captured
changeon<input type=file>needs file-bytes plumbing topage.setInputFiles. Straightforward to add.
MIT — see LICENSE.