@review-kit/playwright
v0.3.13
Published
Playwright capture SDK for review-kit: reporter, capture contract and annotation helper.
Maintainers
Readme
@review-kit/playwright
Playwright capture SDK for review-kit. It
turns passing Playwright tests into before/after review captures: a reporter writes a
scenarios.json sidecar plus the screenshots, and a small contract (one annotation,
one attachment per capture) tells it what each screenshot is.
There are two ways to integrate. Both produce the exact same output ; pick whichever fits your repo.
| Mode | Install | Wiring | Per-test call |
| --- | --- | --- | --- |
| Imported (devDep) | @review-kit/playwright | reporter by package specifier | attachReviewCapture(...) |
| Contract (zero-dep) | nothing | reporter by injected path | raw annotation + attachment |
Mode 1: imported (devDependency)
pnpm add -D @review-kit/playwrightReference the reporter by its package specifier in playwright.config.ts. Your own
html reporter still owns the HTML report ; this one only writes the sidecar + assets.
import { defineConfig } from '@playwright/test'
export default defineConfig({
reporter: [['html', { open: 'never' }], ['@review-kit/playwright/reporter']],
})In a test, attach one capture with the helper. meta is the scenario descriptor ;
persona is null for anonymous scenarios.
import { test } from '@playwright/test'
import { attachReviewCapture } from '@review-kit/playwright'
test('login', async ({ page }, testInfo) => {
await page.goto('/login')
const shot = testInfo.outputPath('login.png')
await page.screenshot({ path: shot })
await attachReviewCapture(testInfo, { id: 'login', app: 'web', title: 'Login', persona: null, viewport: 'mobile' }, shot)
})A fourth argument, statePath, is optional : the path to a file describing the screen's
state at capture time. review-kit copies that file next to the screenshot and references it
from the sidecar ; it never reads it, so its format is yours. Below, page.ariaSnapshot()
is one possible way to produce it, not a required one.
import { writeFile } from 'node:fs/promises'
test('login', async ({ page }, testInfo) => {
await page.goto('/login')
const shot = testInfo.outputPath('login.png')
await page.screenshot({ path: shot })
const state = testInfo.outputPath('login.state.txt')
await writeFile(state, await page.ariaSnapshot())
const meta = { id: 'login', app: 'web', title: 'Login', persona: null, viewport: 'mobile' }
await attachReviewCapture(testInfo, meta, shot, state)
})A fifth argument, page, is optional : pass the Playwright page and the SDK serializes an
anchor map sidecar at screenshot time, the map of annotable elements (bounding boxes,
multi-signal anchors, ARIA roles, stacking order). It also records the buffered console errors
and page errors for that scenario. This mode requires Playwright 1.56 or newer. The review page
uses the anchor map to resolve a click on the capture into a real element. An invalid map fails
the capture, never a silently absent sidecar.
test('login', async ({ page }, testInfo) => {
await page.goto('/login')
const shot = testInfo.outputPath('login.png')
await page.screenshot({ path: shot })
const meta = { id: 'login', app: 'web', title: 'Login', persona: null, viewport: 'mobile' }
await attachReviewCapture(testInfo, meta, shot, undefined, page)
})resolveClick(map, x, y) resolves a point on the capture to the deepest element under it plus
the ordered stack of candidates ; parseAnchorMap validates a sidecar read back from disk. The
anchoring engine itself (generateAnchor, resolveAnchor) is exported too : it is vendored from
SitePing (MIT) and re-anchors an element across
re-renders with a reported confidence.
Author your review-kit.config.ts with defineConfig for inference and inline errors.
Keep it inside your e2e package (the one carrying this devDependency) : the CLI discovers it
by walking your workspace packages, so there is no root artifact to wire.
// packages/e2e/review-kit.config.ts
import { defineConfig } from '@review-kit/playwright'
export default defineConfig({
capture: 'pnpm capture',
seed: 'pnpm seed',
})Two fields only. capture and seed are the names of scripts the CLI runs from this file's
directory ; repoSlug is optional and defaults to the repo folder name.
Each field references one named script, never an inline shell pipeline. A multi-step
pipeline belongs in that package's scripts (or, better, in code owned by your app), so the
config stays a stable pointer :
// packages/e2e/package.json : the pipeline lives here, the config just names it
{ "scripts": { "seed": "pnpm db:reset && pnpm db:seed:e2e", "capture": "playwright test" } }review-kit init scaffolds the config. If you prefer, import type { ReviewKitConfig } and
type the object yourself.
Optional: the environment block
By default review-kit runs seed and capture with the current environment. If a checkout
needs to be provisioned first, or the capture needs runtime variables (where services listen,
which database), add an optional environment block. Two tokens are substituted in either
command : {path}, the checkout's absolute path, and {branch}, its branch name.
import { defineConfig } from '@review-kit/playwright'
export default defineConfig({
capture: 'pnpm capture',
seed: 'pnpm seed',
environment: {
setup: 'my-tool provision {path} --branch {branch}', // idempotent, run once per checkout before seed/capture
env: 'my-tool env {path}', // prints KEY=VALUE lines, merged over the current env
refresh: 'pnpm install && pnpm -r build', // base worktree only : rebuild what the detached checkout left stale
},
}){branch}: the branch of the checkout being readied. The PR side gets the PR's branch ; the base side is a worktree detached on the merge-base, which has no branch to derive, so review-kit names itreview-base. A tool that keys an environment per branch (a database per branch, say) therefore gives each side its own, which is what makes the base/branch comparison meaningful.setup(optional) : an idempotent command review-kit runs once per checkout (base worktree and branch) before seeding, to ready its environment.env(optional) : a command that prints the checkout's environment as dotenv (oneKEY=VALUEper line ; blank lines and#comments allowed). review-kit merges those variables over the current env and hands them toseedandcapture. It reads no key by name and builds none : it is a pipe.refresh(optional) : a command review-kit runs only on the base worktree, right after it is (re)materialized and beforesetup/seed. The base is a detached checkout on the merge-base, so its git-ignored artifacts (node_modules,dist) are stale or absent :refreshrebuilds them (install+build) and clears any reference-only baseline. It never runs on the PR branch checkout, whose artifacts the developer already keeps current. It runs on every base cache miss.
Both are optional. A repo with no provisioning step just omits the block. Any tool that provisions a checkout and prints its environment as dotenv fits, including a script of your own :
export default defineConfig({
capture: 'pnpm capture',
seed: 'pnpm seed',
environment: {
setup: 'pnpm provision {path} --branch {branch}',
env: 'pnpm print-env {path}',
},
})Optional: the live env (serve + personas)
review-kit can bring a PR's captured world back up as an interactive environment
(review-kit env start --pr <n>). It is opt-in and needs infrastructure on the review host
(wildcard DNS, TLS, a reverse proxy, the daemon) : see the CLI's live env guide. Two optional
fields drive it, both degrading cleanly when absent:
export default defineConfig({
capture: 'pnpm capture',
seed: 'pnpm seed',
serve: { command: 'pnpm serve', urlVar: 'APP_URL' },
environment: { env: 'my-env-tool env {path}' },
personas: [
{ label: 'Admin', login: '[email protected]', password: 'review-only' },
{ label: 'Anonymous' },
],
})serve(optional) :{ command, urlVar }.commandstarts your app's services in the foreground (review-kit kills its process group on idle-stop).urlVaris the name of the environment variable (from yourenvironment.envoutput) that carries the local URL your app listens on ; review-kit reads it, reverse-proxies the public URL to it, and allocates nothing. Your app binds the port it already owns (nothing is injected).servereceives the same merged environment asseed/capture(theenvironment.envoutput over the current env), so it reads its own URL from that same variable.personas(optional) : credentials review-kit shows on the review page so a reviewer logs in by hand.labelis required ;login/passwordare optional. Never put production secrets here.
If serve is absent the live-env button is simply not offered. Re-running review-kit run stops the PR's live env (its world is re-seeded).
Mode 2: contract (zero dependency)
No install. The review-kit CLI injects REVIEW_KIT_REPORTER (an absolute path to the
reporter) and REVIEW_KIT_CAPTURE_OUT when it runs your capture command. Load the
reporter from that path:
import { defineConfig } from '@playwright/test'
const reviewKit = process.env.REVIEW_KIT_REPORTER
export default defineConfig({
reporter: [['html', { open: 'never' }], ...(reviewKit ? [[reviewKit]] : [])],
})Emit the same contract by hand: a review-kit:scenario annotation whose description is
the JSON of the scenario meta, plus a review-kit:capture attachment for the screenshot.
The optional review-kit:state attachment carries the state file (here written from
page.ariaSnapshot(), one possible source among others).
test('login', async ({ page }, testInfo) => {
await page.goto('/login')
const shot = testInfo.outputPath('login.png')
await page.screenshot({ path: shot })
const state = testInfo.outputPath('login.state.txt')
await writeFile(state, await page.ariaSnapshot())
testInfo.annotations.push({
type: 'review-kit:scenario',
description: JSON.stringify({ id: 'login', app: 'web', title: 'Login', persona: null, viewport: 'mobile' }),
})
await testInfo.attach('review-kit:capture', { path: shot, contentType: 'image/png' })
await testInfo.attach('review-kit:state', { path: state, contentType: 'text/plain' })
})Capture contract
- Annotation
review-kit:scenario: description isJSON.stringify(ScenarioMeta). - Attachment
review-kit:capture: the screenshot (required for a passing annotated test). - Attachment
video: Playwright's native video, picked up automatically when present. - Attachment
review-kit:state(optional) : a file describing the screen's state at capture time. review-kit transports it and never parses it : any format works, and a capture without it stays valid. - Attachment
review-kit:anchors(optional) : the anchor map sidecar as an in-memory JSON body (never a file path).attachReviewCapture(..., page)emits it ; a capture without it stays valid. - A capture identity is
${id}-${viewport}, so one scenario captured at several viewports yields distinct entries. - Each captured scenario in
scenarios.jsoncarriesimage, plusvideo,stateandanchorswhen those attachments were present :assets/${id}-${viewport}.png,.webm,.state.txtand.anchors.json. ScenarioMeta.route(optional) : the app path the scenario visited (e.g./login). It flows to the manifest so the live env can deep-link a scenario to its screen.
License
MIT
