Playwright CLI ↔ Vela setup (this container)
@playwright/cli keeps a persistent browser as a background session (-s=<name>)
and you drive it one command at a time — open, goto, snapshot, click e15,
press ArrowRight, eval "…" — reading state between steps instead of running a
blind script. window globals survive across invocations, so a helper installed
once (e.g. a timing harness) stays live. Output (snapshots, console logs) is written
to .playwright-cli/ (gitignored); read only what you need.
It is a different package from playwright and from Playwright MCP. Prefer it
here for interactive/exploratory driving; it's cheaper on context than MCP (nothing
streams into the conversation unless you read a file).
Setup — do this once per fresh container
1. Restore the locked deps, then install the CLI isolated (see Supply-chain safety below for why it's not committed):
npm ci --ignore-scripts # locked tree first
npm install --no-save --no-audit --no-fund --ignore-scripts @playwright/cli@0.1.15
2. Config is already committed at .playwright/cli.config.json (the default
config path, read from the repo root). It solves the two things that otherwise fail:
{
"allowUnrestrictedFileAccess": true,
"browser": {
"browserName": "chromium",
"launchOptions": {
"executablePath": "/opt/pw-browsers/chromium-1194/chrome-linux/chrome",
"headless": true,
"args": ["--no-sandbox"]
}
}
}
Recreate it if missing. Update executablePath if the image's Chromium moves
(ls /opt/pw-browsers/).
Supply-chain safety (why @playwright/cli is NOT committed)
This repo pins its supply chain via a committed package-lock.json (exact
versions + sha512), npm ci --ignore-scripts in CI, npm audit gating, and a
7-day release-cooldown (.npmrc: ignore-scripts=true, see docs/SECURITY.md).
@playwright/cli@0.1.15 transitively depends on a days-old alpha of
playwright (nested in its own node_modules, so it does not override the
repo's locked top-level playwright). Committing it would pull that fresh alpha
into the audited, shipped dependency tree — against the cooldown policy. So it is
deliberately kept out of package.json / package-lock.json and installed as
an isolated, dev/test-only tool. The install is hardened accordingly:
@playwright/cli@0.1.15— exact version pin, no range (no surprise upgrades).--ignore-scripts— blocks pre/post-install hooks (belt-and-suspenders with.npmrc: ignore-scripts=true); the #1 npm supply-chain vector.--no-save— never writes topackage.json/package-lock.json; the audited, shipped tree stays exactly as locked.npmstill verifies the registry sha512 on download.- Ephemeral & unshipped —
node_modules/is gitignored; the CLI never enters the Vela artifact, a release, or CI's locked install. - Always run
npm ci --ignore-scriptsfirst so the top-levelplaywright/etc. match the lockfile (an ad-hocnpm installearlier can drift them).
Before bumping the pin, check the new version's transitive playwright dep
(npm view @playwright/cli@<v> dependencies) and prefer a version that resolves a
stable playwright once one ships.
Drive a Vela deck
# 1. Build an OFFLINE render (transpiled, deck injected via STARTUP_PATCH).
# Do NOT serve.py / esm.sh here — the React/lucide CDNs are blocked.
node tools/vela-dev/scripts/render-offline.js examples/vela-demo.vela /tmp/vout
# 2. Open a persistent session on the render (file:// works via the config).
npx playwright-cli -s=vela open "file:///tmp/vout/render.html"
# 3. Wait for hydration (~2–4s; offline app.js transpiles then mounts).
npx playwright-cli -s=vela eval "({b:!!window.__velaBooted, blocks:document.querySelectorAll('[data-block-type]').length})" --raw
# 4. Drive it. Examples:
npx playwright-cli -s=vela press f # enter presentation (auto-selects 1st module)
npx playwright-cli -s=vela press ArrowRight # next slide
npx playwright-cli -s=vela screenshot --filename=/tmp/slide.png
npx playwright-cli -s=vela eval "(document.querySelector('[data-block-type=heading]')||{}).textContent" --raw
# 5. Housekeeping.
npx playwright-cli list
npx playwright-cli -s=vela close
npx playwright-cli kill-all # nuke stale/zombie browsers
Tip: node_modules/.bin/playwright-cli skips npx resolution overhead in loops.
Parallel agents — isolating concurrent browser tests
Multiple agents can drive Vela at once, but they share one machine-wide session
registry (~/.cache/...) and one per-cwd output dir. Verified isolation ladder,
weakest → strongest:
| axis | how | what it isolates | gotcha |
|---|---|---|---|
| session name | -s=agentA vs -s=agentB |
the browser + page state (independent — one advancing slides doesn't move the other) | same registry & same .playwright-cli/ output dir |
| registry | PWTEST_DAEMON_SESSION_DIR=/tmp/agentA-daemon (per agent) |
the whole session daemon — an agent's sessions are invisible to another's list/close-all/kill-all |
must set it on every command for that agent |
| workspace/cwd | run each agent from its own dir / git worktree | session namespace and the .playwright-cli/ output dir (both keyed off findWorkspaceDir(cwd)) |
needs a separate checkout |
Recommended patterns
- Best: give each parallel agent its own git worktree (the Agent tool's
isolation: "worktree"). Different cwd ⇒ different workspace ⇒ separate session namespace and separate.playwright-cli/output, for free. Add a unique-s=<name>per agent too. - Same cwd: give each agent a unique
-s=<name>and a uniquePWTEST_DAEMON_SESSION_DIR— otherwise one agent'sclose-all/kill-allkills every agent's browsers (the single biggest footgun). Output files still land in the shared.playwright-cli/(timestamped names rarely collide, but they intermix).
Two things that make this easy here: the offline file:// render needs no
port, so parallel sessions never collide on a port (unlike serving over HTTP); and
each deck can render to its own /tmp/<agent>-render dir. Cost: each session is a
full Chromium (~200-300 MB RAM) — cap concurrency to the box, and never use the global
close-all/kill-all from a parallel agent unless its registry is isolated.
Vela-specific hooks & selectors (for eval / interaction)
| what | how |
|---|---|
| boot done | window.__velaBooted === true and #root innerText non-empty |
| headless UI battery | await window.__velaRunUITests() (see part-uitest.jsx) |
| current slide blocks | [data-block-type] elements (text signature changes on nav) |
| current heading | [data-block-type=heading] |
| in presentation? | <header> is removed while presenting |
| gallery open? | [data-testid=gallery-close] present |
| gallery scroll rail | [data-scroll-container] |
| enter/exit present | key f (or F5); Escape exits |
| next / prev slide | ArrowRight / ArrowLeft |
| open gallery | key g |
Note: window.__velaGetCurrentSlide is gated behind local mode (off in offline
renders) — use the DOM signals above instead.
Gotchas already solved (don't rediscover)
file://is blocked by default → "Access to file: protocol is blocked". Fixed byallowUnrestrictedFileAccess: truein the config (or envPLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS=1). Config is read atopentime — if you change it,closeand re-openthe session.- Bundled playwright-core wants a newer Chromium than the image ships
(expects ~1229, image has 1194) → the CDN download is blocked, so the config's
executablePathpoints at the pinned build. Don't runplaywright install. - CDNs (esm.sh / React / lucide / Babel) are blocked → never use
serve.py's default importmap HTML; alwaysrender-offline.js(vendored UMD + Node transpile). - One harmless console error on every load (
ERR_INVALID_URL/ font fetch) — ignore it; the app renders fully. - Don't
pkillbackground jobs from a Bash tool here — the sandbox may SIGKILL the shell (exit 144). Useplaywright-cli close/kill-allfor browsers.
Ready-made benchmark
bench/vela-interaction-bench.sh uses exactly this setup to measure slide-nav /
interaction latency and thumbnail-scroll FPS. See bench/README.md.