Human-paced Playwright harness for polished product demos.
Use this when normal product screens exist, but demo experience still feels rough.
This kit turns Playwright from test runner into repeatable demo runner:
- opens app in controlled mobile viewport
- moves through flow at human pace
- shows clean on-screen cursor
- adds short captions for story beats
- records browser video automatically
- renders higher-quality mp4 for sharing
Good fit for:
- founder demos
- investor walkthroughs
- sales enablement clips
- product teasers
- internal launch videos
- repeatable QA demos across multiple apps
Raw screen recordings usually break down in same ways:
- mouse moves too fast
- important moments pass without context
- clicks feel chaotic
- narration becomes required to explain basics
- every retake is manual and inconsistent
This kit fixes that by making demo itself scripted, repeatable, and product-aware.
Main motivation:
- tell one clear story per flow
- keep pacing deliberate
- remove presenter shakiness
- regenerate same demo after UI changes
- reuse same framework across many apps
- mobile-first demo config
- slow motion capture
- fake on-screen cursor
- cinematic caption overlays
- HQ
.webm→.mp4render script - starter demo spec template
npm install
npx playwright install chromium- copy
playwright.demo.config.ts - copy
tests/demo/helpers.ts - copy
scripts/render-demo-video.mjs - copy
scripts/with-recording-lock.pyand add package scripts from this repo - set
PLAYWRIGHT_BASE_URL - optionally set
PLAYWRIGHT_DEMO_WEB_SERVER_COMMAND - replace
tests/demo/example.demo.spec.tswith app flow - run
DEMO_SLUG=check npm run demo:test -- --list
Set one or both env vars:
export PLAYWRIGHT_BASE_URL="http://127.0.0.1:3000"
export PLAYWRIGHT_DEMO_WEB_SERVER_COMMAND="npm run dev -- --hostname 127.0.0.1 --port 3000"If PLAYWRIGHT_DEMO_WEB_SERVER_COMMAND set, Playwright starts app for demo run.
Set DEMO_SLUG to the work being recorded (for example, eng-2637):
DEMO_SLUG=eng-2637 npm run demo:flowFor separate ad-hoc full-suite test and render steps, run DEMO_SLUG=eng-2637 npm run demo:test followed by npm run demo:render. These two steps are not protected by one lock; use the single-feature command below for a shareable feature recording.
The recording lock covers the entire demo:flow test and render. Another recording fails fast with the current slug and start time. The OS releases the lock after the recorder exits, even if its wrapper crashes, so there is no stale timeout. Keep the wrapper alive until capture completes; never delete the lock file (~/Library/Caches/playwright-demo-kit/recording.lock). This coordinates runs on the same machine, not recordings on other machines or commands that bypass the wrapper.
For a single feature when multiple demo specs exist, select its spec and keep capture and rendering under one lock:
DEMO_SLUG=eng-2637 python3 scripts/with-recording-lock.py -- sh -c 'npx playwright test -c playwright.demo.config.ts tests/demo/<feature>.demo.spec.ts && npm run demo:render'Replace <feature> with the actual spec name. npm run demo:flow runs every demo spec and renders the newest video. Do not use it for a single-feature recording unless that is the only selected spec. Confirm that both the frontend URL and its backend are safe for demo actions; do not record credentials or secrets.
playwright.demo.config.tstests/demo/helpers.tsscripts/render-demo-video.mjsscripts/with-recording-lock.py
Then replace tests/demo/example.demo.spec.ts with app-specific walkthrough.
npm run typecheck
DEMO_SLUG=check npm run demo:test -- --listGitHub Actions runs same checks on push and pull request.
Default capture is a maximized desktop window (null viewport, 1440x900 video), suited to dashboards and wide apps. For a tall phone frame instead, set:
export DEMO_DEVICE=mobile # 430x932 frame, Pixel 7 UA, touch
export DEMO_SLOWMO=120 # optional, ms of slow motionCreate React App / webpack-dev-server / Vite show a full-screen error overlay (e.g. "Uncaught runtime errors") that will dominate a demo. Kill it before the app loads:
import { dismissDevOverlays } from "./helpers";
await dismissDevOverlays(page); // call before page.gotoIt uses !important CSS (to beat the overlay's inline display:block) plus a
short interval (because webpack-dev-server re-shows the overlay via a style flip
that a childList MutationObserver misses). If you control the dev server, also
consider disabling it at the source (devServer.client.overlay = false).
Seed localStorage before load so the demo starts authenticated. Read secrets
from env, never hardcode them:
import { injectLocalStorage } from "./helpers";
await injectLocalStorage(page, {
TOKEN: process.env.DEMO_TOKEN ?? "",
CURRENT_LOCATION: process.env.DEMO_LOCATION ?? "",
});Stop a demo from firing against the wrong environment:
import { isSafeDemoTarget } from "./helpers";
test.beforeEach(() => test.skip(!isSafeDemoTarget(), "set DEMO_CONFIRM=1 or use a localhost base URL"));Safe when DEMO_CONFIRM=1 or PLAYWRIGHT_BASE_URL is localhost. It only checks
the frontend URL, not which database the backend points at, so still confirm the
backend target yourself.
Captions render real text into the page, so page.getByText("…") can match a
caption as well as the element you meant. Prefer role/exact locators
(getByRole("button", { name: "Save" }), getByText("Save", { exact: true }))
in steps that run while a caption is visible.
- open page
- wait for load + breathing room
- show caption with one idea
- move demo cursor to next target
- click or type slowly
- pause long enough for viewer to understand
- repeat until story complete
- Default capture is a maximized desktop window; set
DEMO_DEVICE=mobilefor tall phone demos. ffmpegrequired for HQ mp4 render.- Template spec intentionally skipped until customized.
- Repo intended to be used as GitHub template or copy source for other apps.