A demo is a flow that records a browser step. Lest films the take with a visible cursor and paced typing, then cuts it into a video you can send: waits removed, popups placed where they happened, and chapters from the story you wrote down.
Because a demo is an ordinary flow, it is also a test. When the product changes, the demo fails at the step that no longer works, instead of a walkthrough silently going stale.
apiVersion: lest/v1
id: connect-weather
name: Connect the weather service
tags: [demo]
resources: [demo-account] # two takes never share the account at once
demo:
title: Water by the forecast
summary: Connect SkyCast in two clicks and Sprout starts watering by the weather.
beats: # the story, in order, keyed by the marker the take emits
- { marker: plants, label: The dashboard shows every plant }
- { marker: consent, label: SkyCast asks for permission }
- { marker: connected, label: The forecast appears }
cut:
maxGap: 4s # stretches with nothing happening longer than this...
keep: 1.5s # ...are shortened to this
steps:
- id: sign_in
flow: sign-in # signs in off camera and saves the session
- id: record
browser:
url: "${{ vars.base_url }}/app"
session: sprout # the take opens signed in
record: true
viewport: [1440, 900]
script: connect-weather.mjs// connect-weather.mjs
export default async function ({ ui, beat, phase, popup }) {
await phase("dashboard", async () => {
await ui.waitFor("[data-testid=plant]"); // real data, not placeholders
beat("plants");
await ui.dwell(1200); // hold the shot
});
await phase("consent", async () => {
const consent = await popup(() => ui.click("#connect"));
beat("consent");
await ui.click("#allow"); // acts in the popup
await consent.waitForEvent("close");
});
await phase("connected", async () => {
await ui.expectText("#weather-text", "Rain expected");
beat("connected");
await ui.dwell(1500);
});
}Declarative steps work too: beat: is an action, and the validator checks
that every labeled beat is marked by some recorded step.
After a recorded step passes, Lest builds the deliverable with ffmpeg and
attaches everything to the step and to the run report's demo section:
| File | What it is |
|---|---|
demo/demo.mp4 |
The cut: H.264, 30 fps, playable anywhere. |
demo/chapters.vtt |
A WebVTT chapter track with one chapter per labeled beat. |
demo/beat-NN-<marker>.jpg |
A still of each labeled beat, taken just after it. |
demo/beat-sheet.jpg |
The stills side by side, for review at a glance. |
video/page@*.webm |
The raw recordings (Recording, and Recording (popup) per popup). |
demo/cut-command.txt |
The exact ffmpeg command, shell-quoted, to reproduce or adjust the cut (kept next to the cut with the popup backgrounds it reads; not attached as an artifact). |
How the cut is made:
-
Popups in place. A popup's recording replaces the main video for the time it was open, cropped to the popup's window and centered over a dimmed still of the page that opened it.
-
Waits removed. Every user action through
ui(click, fill, select, hover, press, goto), every beat and every deliberate hold (ui.dwell, thewaitaction) is a moment to keep. Any stretch longer thanmaxGapwithout one keeps onlykeepafter the last moment and resumes just before the next, so loading and syncing disappear while every action stays. A take with no actions or beats at all is kept whole. Actions taken directly onpageare not seen by the cut; useuifor anything that should stay on camera. -
Context and payoff. The cut opens 1.5 seconds before the first moment and ends 2.5 seconds after the last.
-
Popups in front. When a popup opens another popup (a sign-in, then a verification step), the newest one is shown, and the first returns when it closes.
Without ffmpeg, the raw recording is the deliverable and the run reports a
warning. lest doctor checks for it. Every recorded step that passes gets
its own cut attached to it; the run report's demo section describes the
last one.
- Sign in off camera. Call a flow that signs in with
saveSession, and record withsession. The login, and any credentials, never appear. - Hold shots on purpose.
ui.dwell(ms)(or thewaitaction) after a beat gives the viewer time to read; the cut keeps the whole hold. Without a hold, the cut keepskeep(1.5 seconds by default) after an action or beat. - Wait for the real content. A screen that renders placeholders first satisfies a selector before the data arrives; wait for the data.
- Secrets are filled, not typed. A value that holds a secret is filled at once, so its keystrokes never appear.
- Mark beats where the story moves. Beats are the chapters; markers without a label are cut points only.
- One take per account.
resources:makes a second take wait instead of fighting over a shared demo account.
Every label lives next to what it labels: beat labels in the flow, keyed by the marker the take emits, so renaming a marker without its label is a validation error rather than a chapter that silently disappears.