Demo specs and CI
Save a take as demotake.json, commit it next to your code, and run it as an end-to-end test on every pull request or re-record only the scenes that changed on every release.
A demo that lives only in a video goes stale the day your UI changes. A demotake.json spec is the demo as code: the scenes, the steps that perform them and the assertions that prove they worked. The same file runs as a test (no video) or records a fresh take, and on a release it can re-record only the scenes whose screens changed.
- Record a take
- take_to_specdemotake.json
- Commit
- demotake testevery PR
- demotake run --only-changedevery release
What a spec looks like
This is a trimmed version of the example that ships with DemoTake. The full example records TextEdit in three scenes; two are shown here, each ending with an assertion that proves it worked.
{
"version": 1,
"project": "TextEdit Demos",
"target": { "bundle_id": "com.apple.TextEdit", "app_name": "TextEdit" },
"outputs": ["16:9", "9:16", "guide"],
"defaults": { "timeout": 10, "attempts": 2, "rehearse": false, "step_delay": 0.4 },
"edit": { "target_duration": 30, "compress_waits": { "threshold": 1, "to": 0.4 } },
"takes": [
{
"name": "Write and save a note",
"goal": "Create a document in TextEdit, write a note and save it.",
"setup": [{ "action": "key", "keys": "escape", "optional": true, "label": "Dismiss the open panel" }],
"scenes": [
{
"title": "New document",
"checkpoint": "An empty Untitled document is open",
"steps": [
{ "action": "key", "keys": "cmd+n", "label": "Create a new document" },
{ "action": "wait", "element": { "role": "textarea" }, "timeout": 5 },
{ "action": "assert_state", "title": "Blank document open", "window_title": "Untitled" }
]
},
{
"title": "Save",
"checkpoint": "The document is saved",
"steps": [
{ "action": "key", "keys": "cmd+s", "label": "Save" },
{ "action": "wait", "element": { "role": "button", "title": "Save" }, "timeout": 5 },
{ "action": "key", "keys": "cmd+a", "label": "Select the file name" },
{ "action": "type", "text": "DemoTake note" },
{ "action": "click", "element": { "role": "button", "title": "Save" }, "label": "Click Save" },
{ "action": "wait", "gone": { "role": "button", "title": "Save" }, "timeout": 5 },
{ "action": "assert_state", "title": "Document saved", "window_title": "DemoTake note" }
]
}
]
}
]
}The parts you'll use most:
| Field | What it does |
|---|---|
version, takes |
Required. version is 1; takes has at least one take. |
target |
The app: a bundle id, an app name, or {bundle_id, app_name, window_title, url}. |
outputs |
What a recorded run renders, for example 16:9, 9:16, gif, guide. |
defaults |
timeout per step, attempts per scene, rehearse before recording, step_delay between steps. |
style, edit |
Pointer, camera and caption style; edit presets such as target_duration, compress_waits, no_zoom, mask. |
take setup |
Off-camera steps that reach the starting state, before rehearsal and before recording. |
scene title |
Identifies the scene across runs, so diffs and only-changed runs can match it. |
scene checkpoint |
The stable state the scene ends on. |
scene setup |
Off-camera steps run before every attempt, so a retry starts clean. |
Steps and element queries
Each step has an action: click, double_click, right_click, type, key, scroll, wait, assert_text, assert_element, assert_state, hold or note. Any step can also take a label (shown in the timeline and guides), a timeout and optional: true (a failing optional step is reported but doesn't fail the scene).
Steps find UI by element query, not by pixel position, so they survive layout changes. A plain string matches visible text. An object can combine role (button, textfield, checkbox, menuitem, row…), title, text, identifier (an exact accessibility identifier), selector and index. Queries search the target window. Items in the app's menu bar are found only when the query asks for one, with role: "menuitem" or a selector that starts with AXApplication/AXMenuBar, so looking for some text never presses a menu item with the same name. wait takes text, element, gone (wait for an element to disappear) or idle (seconds without UI changes).
Assertions
Assertions are what make a demo a test. assert_text checks that text is visible, assert_element that an element exists, and assert_state checks the observed UI: window_title, contains, not_contains or an element. Give each a title saying what it proves (assert_state requires one). Results appear in the take's proof manifest.
Secrets
Specs never contain secrets. A type step takes exactly one of text (never a secret), secret_env or keychain:
{ "action": "type", "secret_env": "DEMO_PASSWORD", "element": { "role": "securetextfield" } }secret_env names an environment variable. The demotake CLI reads it and forwards the value to the app for that run only: it's never logged, stored in the take or shown in videos. {"keychain": {"service": "com.example.demo", "account": "ci"}} reads a generic password from the runner's login Keychain instead.
From a take to a spec
You don't have to write specs by hand. Record a take (or let your agent record it), then save it as code:
- over MCP,
take_to_specreturns the spec and can write it to apath; - in a terminal,
demotake to-spec <take_id> --out demotake.json.
Steps come from what happened in the take, with element queries for the controls that were used and assert_* steps for its assertions. Secrets typed during the take become secret_env references. Check the file with spec_validate or demotake validate demotake.json, which needs no app at all.
Run it
| Goal | CLI | MCP |
|---|---|---|
| Check the file | demotake validate demotake.json |
spec_validate |
| Record a fresh take | demotake run demotake.json |
spec_run |
| Test without video (DemoTest) | demotake test demotake.json --junit out.xml |
spec_run with record: false |
| Re-record changed scenes only | demotake run demotake.json --only-changed |
spec_run or take_replay with only_changed: true |
| Re-run a recorded take | demotake replay <take_id> |
take_replay |
| See what changed | demotake diff <take_id> |
take_diff |
A run works scene by scene: optional rehearsal, then the take, a checkpoint at the end of each scene, and a retry of only the scene that failed, up to its attempts. demotake test reuses one "(DemoTest)" take per spec take, so pull-request runs don't flood your library. When a step fails, the evidence (a blurred snapshot and a UI excerpt) goes into the JUnit report.
spec_run and take_replay operate the app, so they're available in DemoTake Agent only.
Only the scenes that changed
After a release, most of your demo is still correct. --only-changed (or only_changed: true) compares each scene's checkpoint with the last recording of the same spec take: the accessibility state plus a perceptual diff of the checkpoint snapshot, ignoring redacted regions. Changed scenes are re-recorded; unchanged ones keep their footage and their edits. If nothing changed, no render runs and your existing exports stay current.
$ demotake run demotake.json --only-changed --outputs 16:9,9:16,guide Checkout Take: PASS · 8 scene(s) · 3 of 8 scene(s) changed · 3 regenerated, 5 kept
- 1Sign inKept
- 2Open cartKept
- 3Apply couponchanged · re-recorded
- 4Shipping addressKept
- 5Payment methodchanged · re-recorded
- 6Review orderchanged · re-recorded
- 7Place orderKept
- 8ConfirmationKept
Run it in CI
demotake is a thin client for the DemoTake app on the same Mac. Your runner needs:
- a self-hosted macOS runner with a logged-in GUI session, since DemoTake operates real apps;
- DemoTake Agent installed and running (DemoTake Studio can't operate other apps, so it can't run specs);
- Screen Recording and Accessibility granted to DemoTake once, interactively.
demotake statusshows what's missing.
Put the CLI on the runner's PATH once:
mkdir -p ~/.local/bin
ln -sf /Applications/DemoTake.app/Contents/MacOS/demotake-cli ~/.local/bin/demotake
demotake statusThis adds a link in your home folder only; no administrator password needed. If demotake isn't found afterwards, add export PATH="$HOME/.local/bin:$PATH" to ~/.zshrc and open a new terminal window. You can also run the CLI by its full path, /Applications/DemoTake.app/Contents/MacOS/demotake-cli.
The CLI finds DemoTake's endpoint and token by itself. Keep the runner's display awake and unlocked; minimized windows pause capture.
A shortened GitHub Actions workflow: DemoTest on pull requests, demo regeneration on release tags.
name: DemoTake
on:
pull_request:
push:
tags: ["v*"]
jobs:
demotest:
if: github.event_name == 'pull_request'
runs-on: [self-hosted, macOS, demotake]
steps:
- uses: actions/checkout@v4
- run: ./scripts/build.sh && open -g build/MyApp.app # your app
- run: demotake validate demotake.json
- name: DemoTest (no video)
env:
DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}
run: demotake test demotake.json --junit reports/demotake.xml --commit "$GITHUB_SHA"
regenerate-demos:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: [self-hosted, macOS, demotake]
steps:
- uses: actions/checkout@v4
- run: ./scripts/build.sh --release && open -g build/MyApp.app
- name: Re-record changed scenes
env:
DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}
run: |
demotake run demotake.json --only-changed \
--outputs 16:9,9:16,guide --commit "$GITHUB_SHA" \
--json > reports/demotake-run.jsonThe --json result lists every export's output path, so a later step can collect the files and upload them as build artifacts.
Exit codes
| Code | Meaning |
|---|---|
0 |
Everything passed |
1 |
A scene or assertion failed, or the spec is invalid |
2 |
Usage error (unknown command or option) |
3 |
DemoTake isn't reachable |
4 |
DemoTake reported an error |
Give scenes that may be retried a setup that brings the app back to the scene's starting state. Retries and rehearsals then start clean instead of failing on leftovers from the last attempt.
Where to go next
- CLI reference for every command and option.
- Retakes and checkpoints for how scene retries work.
- Update a demo video without re-recording it.