browser-verify
Give yourself eyes on a running web app so you can verify your own work instead of
guessing. Backed by playwright-cli (Microsoft's @playwright/cli): one-shot commands
that share a persistent browser session between invocations, so each call is cheap.
Core idea: start the dev server → open the page → screenshot / read console / eval → compare against intent → fix → repeat. Never claim a UI change works without looking.
0. Bootstrap (only if the command is missing)
playwright-cli --version # if this fails, install globally:
npm install -g @playwright/cli@latest
# the browser is bundled; if a run complains a browser is missing:
playwright-cli install-browser
The CLI ships its own complete command reference as a skill. For the full verb list
beyond the cheat-sheet below, run playwright-cli --help (it prints the path to the
bundled SKILL.md) or playwright-cli --help <command>.
1. Find and start the dev server (project-agnostic)
Do not assume a framework or port. Detect it:
Read
package.jsonscriptsfor the dev entry: usuallydev, elsestart/serve.Pick the package manager from the lockfile:
pnpm-lock.yaml→pnpm,yarn.lock→yarn,bun.lockb→bun,package-lock.json→npm.Reuse before you start — this is the #1 leak source. Check whether a dev server is already listening before spawning one. If the framework's default port (Vite 5173, Next 3000, etc.) already answers, reuse that URL and skip starting anything.
# Bash: is something already serving on the likely port? curl -sf -o /dev/null http://localhost:5173/ && echo "reuse 5173"# PowerShell: list dev servers already up for THIS project (and their ports) Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -match 'vite|next|run dev' -and $_.CommandLine -match 'YOUR-REPO-NAME' } | Select-Object ProcessId, CommandLineWhy this matters: when you start
npm run devwhile a previous one still holds the port, Vite/Next silently auto-increment to the next free port (5174, 5175, …) instead of failing — so "reuse" never happens and a new server leaks every loop. One real session left 50 orphaned dev servers (~6 GB) this way.Only if nothing is serving: start it in the background (Bash tool
run_in_background: true, or PowerShellStart-Process), read the actual URL from its stdout (do not hardcode the port), and record the PID you started so you can kill its whole process tree in teardown — see §5. Keep one long-lived server for the whole verify session; never start a fresh one per loop.
Example (npm + Vite): run npm run dev in the background, then scrape a line like
Local: http://localhost:5176/ from its output and use that exact URL.
2. The verify loop (command cheat-sheet)
playwright-cli open http://localhost:5176/ # opens browser + navigates (persistent session)
playwright-cli goto http://localhost:5176/about # navigate an already-open session
# LOOK — two complementary ways to "see":
playwright-cli snapshot # structured accessibility tree (DOM, refs, text)
playwright-cli screenshot --filename=/tmp/v.png # pixels — REQUIRED for canvas/animation/visual diff
# then Read the PNG to compare it against the intended design.
# READ runtime health:
playwright-cli console # all console messages (Errors/Warnings counted)
playwright-cli console error # filter by min level
playwright-cli requests # network requests; `request <n>` for details
# INTERACT (use refs from `snapshot`, or css/role/testid selectors):
playwright-cli click e15
playwright-cli fill e5 "user@example.com" --submit
playwright-cli press Enter
playwright-cli mousewheel 0 800 # scroll down (dx dy)
# DRIVE PAGE STATE / call the project's own debug hooks:
playwright-cli --raw eval "document.title"
playwright-cli eval "window.scrollTo(0, document.body.scrollHeight)"
playwright-cli eval "el => el.getAttribute('data-state')" e5 # eval against an element ref
playwright-cli close # close when done
playwright-cli list # show open sessions
Tips:
--rawstrips the status/snapshot wrapper and prints only the result — use it to pipeevaloutput or keep responses small.snapshotis cheapest for "what's on the page / what can I click".screenshotis the right tool when the change is visual (layout, color, canvas, animation) — the accessibility tree is blind to<canvas>content.- Use a named session (
playwright-cli -s=<name> ...) to keep parallel work isolated.
3. Pattern: reuse the project's own debug hooks
Many apps expose runtime hooks on window. Prefer driving those over reverse-engineering
state from pixels. Discover them: playwright-cli --raw eval "Object.keys(window).filter(k => /debug|__/i.test(k))".
Worked example — voidprojects-site drift animation (illustrates the pattern):
the homepage exposes window.driftDebug() and the glyph bodies only exist while scrolling.
playwright-cli open http://localhost:5173/
playwright-cli eval "driftDebug(true)" # overlay the Matter.js wireframe
playwright-cli mousewheel 0 1200 # scroll to populate drift bodies
playwright-cli screenshot --filename=/tmp/drift.png # then Read it: bodies + wireframe present?
playwright-cli console # any Matter.js runtime errors?
playwright-cli close
Assert on source-of-truth state, not incidental rendered output
When verifying behavior or timing (a state machine advanced, an effect fired after a delay, a flag flipped), assert on the app's real state, not a side effect of how it happens to render. Inferring state from rendered output is fragile in both directions: it breaks when the rendering changes, and it passes on a coincidental match.
Fragile proxies seen in the wild, and why they fail:
- Counting nodes —
document.querySelectorAll('svg text').length > 0to mean "the graph formed." Breaks if the graph's rendering changes; false-positives if anything else ever draws SVG text. - Finding an element by class or text —
[...document.querySelectorAll('div.inline-block')].find(d => /Warp to Github/i.test(d.textContent)). Breaks on any copy or class tweak. - Parsing a computed transform to recover logical state.
The robust move: have the app expose a read-only state snapshot on window (the same
console-hook pattern as a debug overlay), returning the actual flags — e.g.
window.driftState() // { cards:[{title,state}], graph:{ formed }, dock:{ active } } — and poll
that. It is a genuine dev affordance, not test-only cruft, and it is stable to assert against.
Add a field (and a tiny read accessor on the relevant object) when you need to observe something
new. Discover existing hooks with the Object.keys(window).filter(...) probe above.
getBoundingClientRect() on a known ref (an element the app already binds, not one you found
by guessing a selector) is the exception — it is source-of-truth for where something rendered,
so position/layout assertions off a real ref are fine.
Verifying responsive layouts and animations
Two things a single snapshot misses: layout that changes with viewport size, and motion.
- Drive breakpoints with
resize.playwright-cli resize <w> <h>sets the window size; follow it withgoto <url>(a reload) so width-gated logic re-runs from scratch. Check desktop and mobile widths (e.g. 1280x800 and 390x844) for any responsive layout. - Verify motion by sampling state over time, not by screenshotting once. A still frame
cannot tell a settled layout from one mid-bounce. Run an
evalthat polls an element every N ms and returns an array, then read the trend: e.g. an element'sgetBoundingClientRect().leftover a few seconds should converge monotonically if it is meant to settle, or swing if it is oscillating. This is how you confirm a spring is damped, a value reaches a target, or a pulse traverses a path.
// returns e.g. [283,183,153,146,143,142,141] -> glides in, no bounce
() => new Promise((resolve) => {
const xs = []; let n = 0;
const id = setInterval(() => {
xs.push(Math.round(document.querySelector('.thing').getBoundingClientRect().left));
if (++n >= 12) { clearInterval(id); resolve(xs); }
}, 500);
})
- Catch a transient frame. To screenshot something visible only briefly (a pulse, a
flash), poll in
evaluntil the condition holds, resolve, then screenshot immediately:() => new Promise(r => { const id = setInterval(() => { if (document.querySelector('.dot')?.style.opacity === '1') { clearInterval(id); r('shown'); } }, 40); }). - Do not verify a sub-second transient with a screenshot in a separate CLI call. Each
playwright-clicall has startup overhead, so the gap between anevalthat sets up the state and a followingscreenshotcan be hundreds of ms — long enough to outrun a state that only holds for ~1s (e.g. "before the graph forms at 900ms"), and the shot lands after it changed. For timing like this, assert the state inside the eval (poll and return the timestamps/flags); use the screenshot only to confirm the final look once the state is settled, not to catch the moment. - Windows path note. A screenshot written to
/tmp/x.pngfrom Git Bash lives atC:\Users\<user>\AppData\Local\Temp\x.png; resolve it withcygpath -w /tmp/x.pngbefore handing the path to the Read tool.
4. Notes / gotchas
- Windows
&in URLs:cmd/PowerShell treat&as a separator. Escape query strings:cmd→"...?a=1^&b=2"; PowerShell →playwright-cli --% goto "...?a=1&b=2". - Stray
.playwright-cli/dir:open/snapshotwrite timestamped snapshot files into a.playwright-cli/folder in the current directory. Run from a scratch dir, pass--filenameto a temp path, or delete the folder afterward so it never lands in a commit. - Dynamic content / animation: give it a beat to settle (re-
snapshot, oreval "new Promise(r => setTimeout(r, 500))") before screenshotting. evalreads stale DOM right after a mutation: a component framework (Svelte 5, React, Vue) re-renders asynchronously — its effects/derived flush in a microtask, not synchronously after your click. So anevalthat clicks/sets a value and then readsaria-pressed/text/state in the same call returns the pre-render value, making a real change look like it did nothing. Split it: oneeval(orclick) to mutate, asleep/short delay, then a secondevalto read. The input.valueyou set yourself updates synchronously, but anything the framework recomputes does not.- Match visible text by its DOM value, not its rendered case: CSS
text-transform: capitalize/uppercasechanges appearance only;textContentkeeps the source case. Selecting a chip via[...buttons].find(b => b.textContent.trim() === 'Rounded')fails when the source isrounded— match the lowercase (or use.toLowerCase()). - Always close (
playwright-cli close/close-all) when finished;kill-allclears stale/zombie browser processes. Butclose/kill-allonly reach sessions the daemon is still tracking — browsers can leak past that tracking (you'll seelistreport "no browsers" while realchromeprocesses are still alive). For those, sweep at the OS level — see §5. - Headless by default; add
--headedonopenif you want to watch.
5. Teardown — close the leak (run at the END of every verify session)
This skill leaks two kinds of long-lived process if you don't tear down: the persistent
browser (playwright-cli open keeps the session alive by design between calls) and any
dev server you started. Across many verify loops these accumulate into GBs. Always run
teardown when you're done verifying — don't leave them for "next time".
playwright-cli close-all # close every tracked browser session
playwright-cli kill-all # kill the daemon + any tracked stragglers
Then sweep OS-level orphans the daemon never tracked. Scope every sweep to this project (match the repo path / playwright cache) so you never touch the user's real browser or unrelated servers:
# PowerShell (Windows) — kill orphaned dev servers for THIS repo, then orphaned playwright chromium
$repo = 'YOUR-REPO-NAME' # e.g. voidprojects-site
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
Where-Object { ($_.CommandLine -match 'vite.*dev' -or $_.CommandLine -match 'next.*dev' -or $_.CommandLine -match 'run dev') -and $_.CommandLine -match $repo } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
# orphaned playwright browsers live under the @playwright/playwright cache, not your normal profile:
Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
Where-Object { $_.CommandLine -match 'playwright|ms-playwright|--remote-debugging-pipe' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
# macOS / Linux — same idea, scoped to the repo / playwright cache
pkill -f "vite.*dev.*$REPO" ; pkill -f "next.*dev.*$REPO"
pkill -f "ms-playwright.*chrome|playwright.*--remote-debugging-pipe"
Killing a dev server is non-destructive (it recompiles on next npm run dev) — but a
project-scoped match keeps it from being destructive to other work. Periodic health
check: if you ever notice the machine slow or many node/chrome processes, run the count
below; a healthy machine has at most one of each per active project.
(Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -match 'run dev|vite|next' }).Count