Writing the Playwright tour
The Playwright tour is the single source of UI truth.
@ingcreators/annot-product-docs ships a productDocs fixture
you extend @playwright/test’s test with; each
productDocs.sync(...) call re-syncs the annot:snapshot +
annot:attributes blocks in the matching MDX file in place.
Minimal tour
Section titled “Minimal tour”import { test } from "@ingcreators/annot-product-docs";
test("login flow", async ({ page, productDocs }) => { await page.goto("/login"); await productDocs.sync({ id: "login", mdxPath: "docs/books/example/SC-001-login.mdx", });});Run it:
pnpm exec playwright test tests/docs/auth.spec.tsThe fixture:
- Calls Playwright’s AI-mode
ariaSnapshot({ boxes: true }). - Captures
page.screenshot()to the MDX’s<Screen src>relative path. - Re-finds each
<Overlay match>against the snapshot and writes[box=x,y,w,h]markers next to the matching nodes. - Extracts the per-
refHTML attribute set. - Rewrites the
annot:snapshot+annot:attributescomment blocks byte-stably (unchanged screens produce zero-diff reruns).
Multi-step tours
Section titled “Multi-step tours”Same shape: one productDocs.sync(...) per screen, in narrative
order. Use page.goto / page.click / expect.toBeVisible
between syncs just like a normal Playwright test.
test("checkout flow", async ({ page, productDocs }) => { await page.goto("/cart"); await productDocs.sync({ id: "cart", mdxPath: "docs/checkout/SC-002-cart.mdx" });
await page.getByRole("button", { name: "Checkout" }).click(); await expect(page.getByRole("heading", { name: "Shipping" })).toBeVisible(); await productDocs.sync({ id: "shipping", mdxPath: "docs/checkout/SC-003-shipping.mdx" });
// ...});Multi-screen in one MDX
Section titled “Multi-screen in one MDX”When two screens share frontmatter (same feature, same Excel sheet), bundle them in one MDX:
# Checkout
<Screen id="cart" src="./shots/cart.png"><Overlay match={{ role: "button", name: "Checkout" }} number={1}>Proceed to shipping.</Overlay></Screen>
<Screen id="shipping" src="./shots/shipping.png"><Overlay match={{ role: "textbox", name: "Address" }} number={1}>Shipping address. Required for physical goods.</Overlay></Screen>Two productDocs.sync calls in the tour, both passing the same
mdxPath with different id. Each call updates only its own
screen’s blocks.
Scaling to dozens of screens
Section titled “Scaling to dozens of screens”Two patterns work well:
- One spec file per feature (
auth.spec.ts,checkout.spec.ts) — pairs naturally with how teams split test ownership. - One spec file per screen if Playwright’s parallelism becomes the bottleneck — each spec is independently parallelisable.
Both run nightly + on PRs touching the relevant feature code via a path filter:
on: schedule: - cron: "0 18 * * *" # nightly pull_request: paths: ["packages/web/**"]Tour failures are advisory by default
Section titled “Tour failures are advisory by default”productDocs.sync doesn’t throw when an <Overlay match> fails
to resolve — it writes a match: unresolved marker and lets
the test continue. The drift detector reports the failure as
an added or removed finding at lint time, where it’s a CI
warning, not a hard test failure. This keeps the tour stable
during UI churn — a single removed button doesn’t take down
the whole tour run.
For strict mode, pass productDocs.sync({ strict: true }) per
call (throws on unresolved match).