Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

playwright-mirror

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.

Why

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.

Design rule: mirror causes, not effects

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.

Install

npm install playwright-mirror playwright
npx playwright install chromium

Requires Node ≥ 22 (uses --experimental-strip-types for TypeScript ESM without a build step).

CLI

# 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-mirror

Then 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 followers
  • Alt+Shift+→ — go forward on leader + all followers
  • Alt+Shift+R — reload leader + all followers

Close the leader window (or Ctrl-C) to shut down every follower.

Library

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 shutdown

Full 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;
}

What's mirrored

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 keydownkeyboard.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, change on checkbox/radio, focus/blur, and any !isTrusted event — derived effects.
  • Native OS-picker interactions (<select> popup, <input type=file|date|...>) when defaultPrevented=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.

Locator strategy

Ordered by preference in observer.js:

  1. getByRole(role, { name }) — describes user intent; unique-in-doc check ensures it resolves cleanly on the follower.
  2. Unique attributeid > any data-* > 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-anything all get picked up as long as they uniquely identify the element.
  3. Role + name even if non-unique — dispatcher's .first() picks one; useful for list-page contexts.
  4. getByPlaceholder / getByText — visible-text fallbacks.
  5. Structural CSS path — last resort.

Multi-tab

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.

Self-test

npm test

Four 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).

Known limitations

  • Native <select> popup on real users: the observer captures the trusted change event 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 change on <input type=file> needs file-bytes plumbing to page.setInputFiles. Straightforward to add.

License

MIT — see LICENSE.

About

Mirror a human-driven leader Chromium browser to N follower browsers using stock Playwright. Semantic-locator observer, per-session URL isolation, multi-tab support.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages