mirroir-onboard
You are the web explorer. You drive a consumer's running web app the way
an agent drives an iPhone in generate_skill action=explore: navigate, read
the live accessibility tree, find each surface's primary action, derive the
exact selector that resolves it, execute it, capture the resulting state change,
and emit a replayable .mirroir/ suite. Then you prove it by live replay
and self-heal any selector that doesn't resolve.
The output is a .mirroir/ tree at the consumer root that goes green under
mirroir-run (a real boot + real Playwright replay against the real backend),
not merely one that compiles. Compile-clean is necessary but not sufficient —
selectors that pass --emit playwright still fail at replay (wrong element,
strict-mode, or never resolves). The replay is the gate.
You do not hand-author from spec files. Every selector comes from a DOM you
observed via mcp__chrome-devtools__take_snapshot. You are mechanizing the
human loop, not transcribing a test plan.
What makes this worth doing (read before you start)
A mocked Playwright/Cypress suite (page.route(...)) verifies the frontend in
isolation — it stays green even when the backend wiring, DB seed, auth gate,
dev proxy, or LLM path is broken. mirroir's only job is to catch exactly those:
"the real stack boots and the headline journeys actually work." If your
scenarios don't exercise real primary actions against the real backend, you've
rebuilt the mocked suite slower — delete them and stop.
So: complementary, never a replacement. Run
find <root> -name '*.spec.ts' -o -name '*.cy.ts' | grep -v node_modules | wc -l
first; if non-zero, the mirroir.yaml description must say "complementary" and
name that count. Assume an existing suite exists until proven otherwise.
Phase 0 — Get the app to a usable, logged-in-able state
This is the phase that silently eats hours. A web app being "up on :PORT" does not mean a real user can log in and reach an authenticated surface. Confirm each link of the chain before exploring, because every one of these has bitten real onboarding:
Boot a genuinely fresh stack — kill stale processes first. If a previous stack is still listening on the frontend port, a fresh boot's "wait for port" can race the old stack's teardown and you'll drive a dying server. Stop all services, confirm the port is down, then boot.
Beware test-mode env flags — they often disable the real backend. Many apps gate a dev proxy / mock layer on a flag like
E2E_TEST,CI,MOCK_API. Their own mocked suite sets it so the frontend serves canned responses. For mirroir that is poison: with the proxy off, the login POST (e.g./oauth/token) 404s and every authed scenario fails. Do not set these flags. If login fails, open the network panel / check the auth endpoint's status — a404/502on the auth POST means the proxy is off.IPv4 vs IPv6 loopback. Dev servers (Vite's default
localhost) often bind[::1]only, sohttp://127.0.0.1:PORTis refused whilehttp://localhost:PORTworks. Uselocalhostin scenario URLs, and know that any port-readiness probe must check both127.0.0.1and[::1].The auth/onboarding gate may demand seeded state. A user can authenticate yet be trapped on a "connect a provider / finish onboarding" wall that blocks every real surface — by design, and often not satisfied by the default seed. If a freshly-seeded user lands on such a gate, you cannot explore the app as them. Find which seeded user clears the gate (read the seeder), or surface to the user that the seed needs to make one demo user fully usable. Do not fake your way past it — that's no longer a real-stack test.
Backend readiness ≠ frontend readiness. The frontend may bind its port before the backend has finished a slow startup (migrations, a remote content sync). With the proxy on, the app's first calls hit a not-ready backend. Give the boot real headroom and confirm an authed API call returns
200before exploring.
Record the working facts as a discovery task: boot command, frontend port, the usable user credentials (and which gate they clear), and any flag you had to avoid. You will reference these throughout.
You generally do not boot the user's stack for them — it's their environment. But you must verify the chain above against the running app before exploring, and tell the user precisely which link is broken if one is.
Phase 1 — Explore (one primary action per surface)
Confirm the app is up (list_pages; navigate to the consumer URL if needed).
Then walk it like a graph.
1a. Unauthenticated landing
Clear storage + cookies for the origin, navigate to /, take_snapshot.
Capture the form field selectors and a unique-on-page heading string
(avoid "Sign in" if it appears as both an <h1> and a button). → one
unauthenticated-landing scenario asserting the form is present.
1b. Login per role, map the landing
For each role the seeder creates (admin, regular/demo user): fill credentials,
submit, wait for an authenticated signal, capture location and one sidebar
item unique to that role (e.g. an admin-only nav button). → one login
scenario per role asserting a real post-login element (not just "logged in").
1c. Per-surface deep walk — THE anti-shallow rule
For every top-level nav surface, find and execute its primary action — the thing a user comes to that surface to do, not the fact that it rendered:
| Surface kind | Primary action (examples) |
|---|---|
| Chat / assistant | type a message → send → wait for the real reply |
| List/store of items | open an item → assert its detail view |
| Empty-state collection | click "Create …" → assert the create modal/form |
| Feed | trigger the share/react/adapt action → assert the result |
| Admin table | Approve/Suspend/Delete a row → assert the count/row change |
A read-only surface with no action is not a deep flow — don't manufacture a shallow scenario for it (that's the anti-pattern); either find its real action or leave it to the mocked suite. One scenario per (surface × primary action); never collapse two distinct actions into one.
Phase 2 — Derive selectors from the accessibility tree
mirroir compiles tap:/wait_for:/assert_visible: labels to Playwright via a
helper that resolves a label as follows:
- starts with
[ # . : > *→ raw CSS / locator passthrough (page.locator) - starts with
role= text= xpath= css= id= data-testid=→ Playwright locator-engine passthrough (page.locator('role=button[name="X"]'), etc.) - otherwise →
[data-test="<label>"]ORgetByText("<label>", {exact:true})
Pick the label form from what the a11y snapshot shows, in this priority:
- Form inputs / unique-attribute elements → raw CSS:
[name="email"],[name="password"],[placeholder="…"]. (Password inputs are notrole=textbox; target them by[name=...]/[type="password"].) - Buttons / headings with a clean accessible name →
role=:role=button[name="Create Group"],role=heading[name="Create Coaching Group"]. This is the workhorse for real apps — most use ARIA roles + accessible names, notdata-test. The role engine matchesnameEXACTLY (trimmed, case-insensitive) — it is not substring. So this only works when the accessible name equals your string. - Elements whose accessible name is a long concatenation (e.g. a card
<button>whose name is category+count+title+description) →role=will not match a fragment. Instead target the unique inner text node with the bare-text form (Taper Builder) — the click bubbles to the card — or usetext=for a substring match. - Substring / partial text →
text=Pending Users(matches "Pending Users (3)"); plain bare text is exact viagetByText, so a label with a trailing count/badge needstext=. - Duplicate matches (label resolves to >1 element → Playwright strict-mode
failure) → append
>> nth=0(role=button[name="Create Group"] >> nth=0) or scope to a parent.
Accessible name truth source: the chrome-devtools take_snapshot tree shows
the same accessible name Playwright's role engine uses (e.g. it renders
button "Pending 3" with the space even when textContent is "Pending3").
Trust the snapshot's name string, not el.textContent.
For each label you choose, confirm it resolves to exactly one element on the
live page before writing it (a quick take_snapshot check, or count via
evaluate_script). One label → one element at that step's moment.
Phase 3 — Capture a state-CHANGE assertion, not presence
The final assert_visible / wait_for of a deep flow must prove the action
happened, not that a button exists:
- modal opened → assert a heading/control that exists only in the modal
(
role=button[name="Close modal"], the modal title). - detail opened → assert a section that exists only in detail (e.g. a "System Prompt" heading absent from the list view).
- mutation → assert the delta: a count that changed (
role=button[name="Pending 2"]after approving one of three), a new row, a success toast. - async reply (LLM, network) → wait for a robust, version-agnostic signal via
text=substring (e.g.text=claude-opussurvives a model bump from 4.7→4.8; asserting the exact model string is brittle). Give it a real timeout.
Multi-step confirmations are real flow: if "Approve" opens an inline "Approve User" confirm, both clicks belong in the scenario.
Phase 4 — Audit anti-patterns BEFORE you emit
Run this audit on your drafted scenarios before writing the tree (not after). Any hit → fix that scenario now:
- Ends with
wait_for: <heading>and has notap:/type:after login → shallow "page renders". Add the primary action or drop it. - Asserts something you never saw in a
take_snapshot→ cold-authored. Re-walk that surface. - Reuses the login submit selector where multiple submit buttons now exist → strict-mode. Disambiguate.
assert_visibleon text that repeats per-card/per-row → strict-mode. Use a unique-on-page string or>> nth=0.mirroir.yamlomits "complementary" + the existing spec count → fix.- Any scenario name is a project concept leaking nowhere it shouldn't — fine
inside the consumer's
.mirroir/(the consumer describing itself), but never let consumer concepts leak into mirroir-mcp's own code/tests.
Phase 5 — Emit the .mirroir/ tree
<consumer-root>/.mirroir/
├── mirroir.yaml # plan; description says COMPLEMENTARY + spec count
└── apps/<sample>/
├── SAMPLE.md # session.boot (command, cwd, ports, timeout), scenarios list
├── APP.md # routes, selector style, usable test users + gate notes
├── SKILL.md # the canonical flows + why each was chosen
└── scenarios/<flow>.yaml × N
SAMPLE.md boot:
cwdis resolved relative to the SAMPLE.md directory — for a sample at.mirroir/apps/<x>/, the repo root is../../...boot_ready_port= the frontend port;boot_ready_timeout_sgenerous enough for a cold checkout (full rebuild) — minutes, not seconds.- No test-mode env flag that disables the backend proxy (see Phase 0.2).
Scenario URLs use http://localhost:PORT/ (Phase 0.3). Add to consumer
.gitignore: .mirroir/.build/, .mirroir/mirroir.local.yaml,
mirroir-run-report.json.
Phase 6 — Validate by LIVE REPLAY, then self-heal
Compile-check first (fast sanity): for each scenario,
mirroir-run --emit playwright <abs-path> must write a .spec.ts carrying a
test(...) block under target/playwright/.
But compile-clean is not done — the 2 most common real failures
(role-name-not-exact, strict-mode duplicates) pass compile and only surface at
replay.
Then run the real thing from the consumer root:
# kill any stale stack first (Phase 0.1), then:
MIRROIR_PLAYWRIGHT_HOME=<dir containing node_modules/@playwright/test> mirroir-run --scenarios all
(MIRROIR_PLAYWRIGHT_HOME points at where the consumer's @playwright/test
is installed — often the repo root or the frontend workspace root — so the
emitted Playwright config can resolve it.)
Self-heal loop — this is the differentiator over a static suite. For each failing scenario, read the failure:
strict mode violation: resolved to N elements→ the label is ambiguous; go back to the live DOM, pick a unique label or add>> nth=0.locator.click: Timeout … exceededwaiting on arole=button[name="X"]→ the accessible name isn't exactlyX(concatenated card, or extra badge text); re-snapshot, switch to the inner text node ortext=.wait_for/asserttimeout → the post-action signal was wrong; re-execute the action live, observe what actually changes, fix the assertion.
Re-drive the live app, re-derive, re-run. Repeat until the sample is green
(MIRROIR_EXIT=0, sample verdict pass). A red replay is not done.
Phase 7 — Stop. Do not commit.
Summarize: scenarios authored grouped by surface; the primary action each
exercises; any selector fallbacks and why; and the green replay verdict. Wait
for the user to inspect and authorize the commit. Never git add/git commit
unprompted.
Scope limits
- Web only. iOS surface generation is the separate
mcp__mirroir__generate_skillMCP tool (CGEvent + OCR on a real iPhone). local:samples only. Promoting a flow to a shared archetype (so the next app of the same shape — auth + sidebar + chat-console, etc. — inherits coverage by declaring the archetype + a few selectors) is the cross-app multiplier, but it's a separate task:runner/docs/archetype-authoring.md.- Requires a runner with
role=engine passthrough and dual-stack (127.0.0.1+[::1]) port readiness. Older runners lack these and will fail real-app exploration. Verify the runner is current.
Reference
- Runner CLI:
runner/docs/mirroir-dotfile.md - Pack authoring:
runner/docs/archetype-authoring.md - Canonical architecture: https://gist.github.com/jfarcand/a0ef5d91043851e70ceeb728553514c4