Skip to content

Route action screenshots ​

Screenshots of the app, organised so that "what does this route look like now" and "what happened when I did this" both have a stable, guessable path — and so that re-capturing never leaves a pile of stale images.

Why ​

When an agent changes a route or an interaction, the change is invisible in a diff. Reviewers re-run the app by hand, and the agent's own evidence is a throwaway file whose name (screenshot.png, test-1.png) says nothing about what it shows. Two problems follow: nobody can find the previous image to compare against, and every run adds another indistinguishable copy.

This puts every screenshot at a path derived from route + action:

packages/doc/public/img/screenshots/
  app-posts-new/
    page.png                 # the route's landing state (from the full sweep)
    post-created.png         # an action: what the route looks like afterwards
  tools-image-editor/
    page.png
    export-png.png
  manifest.json              # per-route result of the last full sweep

The path is the contract. app-posts-new is /app/posts/new; post-created is the "post was created" state. Anyone — human or agent — can find, compare, and re-capture an image without asking where it lives.

The three skills ​

SkillDoesCommand
route-action-screenshotsCaptures the routes/actions a task actually changedpnpm --filter @local-monorepo/site exec playwright test <spec> --reporter=list
capture-feature-interactionsCaptures a feature's interactions step by step, one image per stepSCREENSHOT_FEATURE=<key> pnpm screenshots:interactions
capture-all-routes-screenshotsSweeps every static route into page.pngpnpm screenshots:routes

page.png per route comes from the sweep. Action shots are added by the first skill as work happens. A full numbered walkthrough of a feature's interactions comes from the second. All three share one folder per route, so a route's entry point, its one-off actions and its step-by-step flows sit together.

Step-by-step journeys ​

A journey is one feature's interactions, in user order, captured as NN-<step>.png:

app-business-id-content/
  01-board-open.png
  02-board-empty-column.png
  03-board-new-card.png
  ...
  09-board-card-deleted.png

Re-running overwrites each step in place, and numbering makes the folder read as the flow a user performs.

Two decisions make this work:

Step IDs come from the feature map. .cursor/skills/verify-magicsync/features/ already documents every sub-feature (board-new-card, board-move-left, …) with exact selectors and the observable result. Those IDs are used verbatim as step IDs, so the images line up with the maintained inventory of what the feature does — one answer to "what does this feature do?", not two.

Journeys are authored, not auto-discovered. No tool can infer that clicking New card should produce Planned (1). A journey is written code; the registry (packages/site/tests/screenshots/journeys/) exists so coverage is enumerable, filterable (SCREENSHOT_FEATURE=content-board) and reportable. Coverage grows by authoring, and interactions.json records what each journey covered, what it required, and what it deliberately did not cover.

The first journey is content-board — the manual card lifecycle: open, empty column, new card, persists across reload, row controls, card menu, edit, delete dialog, deleted. It captures nine steps and passes.

Preconditions are where the real work is ​

The interesting finding from authoring that journey: POST /api/v1/content/update returns 500 BRAND_CONTEXT_REQUIRED — "Publish a Brand Playbook to enable branded AI" for a business with no published playbook. Editing a card is therefore not reachable from createActiveBusiness() alone, no matter how correct the drive is.

publishBrandPlaybook() (packages/site/tests/e2e/helpers/content-fixtures.ts) fixes it: BrandPlaybookSchema is fully defaulted, so an empty draft validates and publishing needs no AI provider. Journeys declare this in preconditions, so a red run says what was missing rather than sending you hunting for a bug that is not there.

How replacement works ​

The capture helper (packages/site/tests/e2e/helpers/route-screenshots.ts) calls removeActionScreenshots(route, action) before writing the new file. Matching ignores a leading NN- step number:

  • 02-post-created.png and post-created.png are the same action; the older is deleted.
  • The folder keeps the latest state of each action, not a history. Git is the history.

That is the whole trick: a deterministic filename plus a delete-before-write step. No timestamps, no suffixes, no cleanup job.

Route discovery ​

The full sweep does not glob page files. It reads packages/site/.nuxt/link-checker/routes.json, written by nuxt-link-checker (a dependency of packages/site). That manifest lists 136 static and 23 dynamic routes and — crucially — it already knows every route contributed by every layer (site, auth, content, connect, ui). A glob would have to re-derive layer aliasing and i18n variants and would get them wrong.

Dynamic routes (/app/business/:id(), /app/posts/feeds/:id()) are excluded: they cannot be visited without a concrete id, so they need a hand-written spec with a real id.

Why a separate Playwright config ​

The sweep is minutes long and visits every route. It therefore lives outside the default testDir:

  • packages/site/playwright.config.ts → testDir: ./tests/e2e (the 413-test suite)
  • packages/site/playwright.screenshots.config.ts → testDir: ./tests/screenshots

pnpm test:e2e never picks up the sweep; pnpm screenshots:routes never runs the suite. It is the same harness and the same auth fixtures — only the entry point differs. No second browser tool was added.

Auth is real ​

The sweep signs up a genuine throwaway user through Better Auth (e2e-<ts>@test.magicsync.dev), verifies it in the libSQL database, signs in, and creates a real business. That is what makes /app/* render its authenticated state instead of redirecting to /login. Nothing is mocked.

Consequence: the sweep leaves fixture rows behind. Clean them with the verify-magicsync skill's purge script (dry-run unless --yes):

bash
node .cursor/skills/verify-magicsync/scripts/purge-fixtures.mjs "$PWD"

Gotchas worth remembering ​

  • Run the sweep twice. The first pass cold-compiles every route; the first hit on a never-visited route can exceed three minutes. The second pass captures clean images.
  • /app/admin/* renders for a non-admin test user as a redirect or 403. The manifest records the real finalUrl and status, so this is visible rather than disguised as a good page.
  • Query strings are dropped from the slug. /app/calendar?view=month and ?view=week share one folder — give those real states distinct action names.
  • Never write to packages/site/test-results/ — Playwright wipes it at the start of every run.

Files ​

FileRole
packages/site/tests/e2e/helpers/route-screenshots.tsSlug rules, replace-before-write capture, journey() ordered steps, dev-overlay suppression
packages/site/tests/e2e/helpers/route-list.tsReads nuxt-link-checker's route manifest
packages/site/tests/e2e/helpers/content-fixtures.tspublishBrandPlaybook() — unblocks branded-AI capabilities
packages/site/tests/screenshots/capture-all-routes.spec.tsThe full-route sweep
packages/site/tests/screenshots/capture-feature-interactions.spec.tsRuns every registered journey
packages/site/tests/screenshots/journeys/index.tsJourney registry
packages/site/tests/screenshots/journeys/content-board.tsThe first authored journey
packages/site/playwright.screenshots.config.tsScreenshot-only Playwright config
packages/doc/skills/route-action-screenshots/SKILL.mdPer-route/action skill
packages/doc/skills/capture-feature-interactions/SKILL.mdStep-by-step journey skill
packages/doc/skills/capture-all-routes-screenshots/SKILL.mdFull-sweep skill

Released under the MIT License.