🌪️ 2026-11-04 | Chaos in Real Browsers 🎭
🧑💻 Author’s Note
👋 Hello! This deep dive is about e2e/, a small harness that plays Domination in two real Chrome profiles while it hides, freezes and disconnects them at random. Then it checks they end up on the same game. The guide to the series is here.
🧪 Models are only as good as their assumptions. The property test and the TLA+ spec both assume how browsers and sockets behave. This harness is where those assumptions meet real browsers.
📦 It’s about 460 lines of JavaScript, in two files, with two dependencies.
🧱 The Pieces
🖥️ Three Local Servers: servers.js
A room needs three servers, and the harness runs all of them locally so it can break them on command:
- 📄 A static file server for the repository root. It’s guarded against
..paths and sendsCache-Control: no-store. - 📡 A relay that behaves like
server/index.js: broadcast to everyone else. It has two switches per device:halfOpen(device): the socket stays open at both ends, but nothing gets through in either direction. Half-open sockets are hard to cause on purpose with real networking, so faking one at the relay is the reliable way to get one.cut(device): terminate the socket, which the browser notices.
- 📬 A mailbox that imports the Worker’s own
decide.js,cors.jsandroom.js. The rules under test are the production rules, not a re-implementation. AsetUp(false)switch makes it destroy connections, as an outage would.
Each device’s relay URL carries ?device=A or ?device=B, so the relay knows whose socket to break.
🎭 Two Players: chaos.js
- 🌐 Playwright (
playwright-core, using the system’s Chrome) opens two browser contexts. Each context has its ownlocalStorage, so A and B are as separate as two phones. - 🌱 An init script seeds each context: relay and mailbox URLs, a username, a seat and a player count.
- 🎲 Everything random comes from mulberry32, a tiny seeded PRNG. The same
--seedmakes the same choices, so a failure can be replayed.
📱 Faking a Phone
Doing what phones do to pages, from a test, took some care:
| Phone behaviour | How the harness does it |
|---|---|
| Screen off, or another app in front | Override document.visibilityState and hidden with a test switch (__setHidden) that dispatches visibilitychange. Headless pages are never really hidden |
| Chrome freezing a hidden page | The Chrome DevTools Protocol’s Page.setWebLifecycleState, set to frozen, then back to active |
| Losing the network | context.setOffline(true), and at the same moment the relay either cuts the socket or makes it half-open |
| A socket that silently died | relay.halfOpen(device) |
| Leaving the app | A real PageTransitionEvent('pagehide', { persisted: false }) on a page that stays alive, which is the bug from this post |
| Closing the tab | page.close(), and later a fresh page in the same context, with the same storage |
| Mailbox outage | mailbox.setUp(false) |
🃏 Playing a Real Game, Randomly
randomClick runs inside the page and clicks only what the game offers:
- 💬 If a dialogue is open (a choice to make), it clicks one of its cards or buttons.
- 🎯 Otherwise, if it’s this player’s turn, it clicks a playable card, a buyable card or the next-phase button.
- 🙅 Otherwise, it does nothing.
So the games are real Dominion-style games, with attacks, reactions and choices, driven by real UI events. No internal API is called.
🎰 The Chaos Schedule
Each step picks a weighted random action, from those that make sense for each device right now:
| Weight | Action | Weight | Action |
|---|---|---|---|
| 30 | play | 2 | half-open |
| 4 | hide | 1 | cut |
| 2 | pagehide | 1 | close |
| 5 | show | 3 | open |
| 3 | freeze | 1 | mailbox down |
| 4 | resume | 2 | mailbox up |
| 2 | offline | 6 | wait (up to 8 s) |
| 3 | online |
- 🌤️ Then calm. The mailbox comes back, and every device goes online, reopens, resumes and becomes visible. Nobody plays.
- ⏳ Then judgement. Every 2 seconds the harness reads each device’s saved game (and, in mailbox rooms, its sync state). It passes when they agree, and fails if the window runs out:
- 70 seconds in mailbox rooms (two 30-second polls, plus slack);
- 130 seconds in the global room (the longest silence of 2 minutes, plus slack).
- 🔗 These are the same windows the PureScript model derives from
Domination.Data.Resync.
🏃 The “Away” Scenario
--scenario away replays the report about leaving the app instead of acting randomly:
- 🃏 A and B play random moves for a while.
- 🎯 A plays until it’s not A’s turn.
- 🚪 A leaves in one of six ways (
--leave hide|freeze|close|offline|half-open|pagehide, or chosen at random). - ⏳ A random pause, then B makes 40 more plays, then another random pause.
- 🔙 A comes back, and both must agree within 45 seconds.
🧾 Evidence When It Fails
Every run writes to e2e/runs/<scenario>-<room>-<seed>/, which is gitignored:
- 📓
journal.txtis a timestamped log of every chaos step, every play, every relay connect and disconnect, every mailbox request with itsIf-Match,If-None-Matchand status, and the console lines from both pages that aren’t noise. - 📱
A-debug-log.jsonandB-debug-log.jsonare each page’s own debug log. It’s the same ring buffer a player’s “Copy bug report” would send. - 🧪 The last line is
PASSorFAIL, with the final state of both devices and the mailbox’s revision.
🧪 What It Found and Confirmed
- 🕳️ Before the harness existed, a throwaway version of the same idea ran against a relay that could make sockets half-open. v0.40.0 failed in both kinds of room. In the mailbox room, the wife never saw the phone’s move; in the global room, the phone never caught up. v0.41.0 passed in both, after a 5-second nap and after a 35-second nap. That experiment was reproduced as model tests, then made permanent as this harness.
- 🚪 The
pagehidereport is replayed by theawayscenario with--leave pagehide. It passes with v0.42.0, for examplenpm run chaos -- --scenario away --leave pagehide --room global --seed 7 --steps 20. - 🌪️ Random chaos runs in both kinds of room converge.
🏃 Running It
npm run bundle # build dist/app.js
cd e2e && npm ci
npm run chaos -- --room mailbox --seed 11 --runs 3
npm run chaos -- --room global --steps 60
CHROME=/path/to/chrome npm run chaos -- --scenario away - 🐢 A run takes one to a few minutes, mostly in the calm window, so it isn’t part of
npm test. - 🔁
--runs nruns consecutive seeds. The exit code is non-zero if any run failed to converge.
🎓 Takeaways
- 🔌 Break things at the server you control. A relay with a “half-open” switch is far more reliable than trying to make the OS produce one.
- 📐 Reuse production rules in fakes. The local mailbox is the Worker’s
decide. - 🎲 Seed everything. Chaos you can’t replay is just noise.
- 🧾 Make failures self-explaining. A journal next to each device’s own log turns a red run into a story.
- 🔺 Use all three levels. TLA+ for every interleaving of an abstract model, property tests for random runs of the real decisions, and real browsers for the assumptions underneath both. Each caught something, or confirmed something, that the others couldn’t.
✍️ Signed
🤖 Built with care by GitHub Copilot Coding Agent
📅 November 4, 2026
🏠 For bagrounds.org