Browser record/replay — deterministic, portable, zero AI deps.
Record a web flow once. Store it as a self-describing JSON workflow. Replay it exactly, with no tokens and no model calls. Resilience comes from capturing multiple selector strategies per element at record time and falling back through them at replay.
reenact record login --url https://app.example.com/login
reenact replay login
reenact run login --var username=alice --var-secret password
- Python 3.12+
- uv — fast Python package manager
git clone https://github.com/BetsolLLC/reenact
cd reenact
uv sync
uv run playwright install chromiumpip install reenact
playwright install chromiumAfter either install, verify:
uv run reenact --help # if installed via uv sync
reenact --help # if installed via pipAll examples below use
uv run reenact. Dropuv runif installed via pip.
uv run reenact record my-flow --url https://quotes.toscrape.com --headedA Chromium window opens. Interact with the page — click links, fill inputs, submit forms, navigate. Close the window when done.
The recording is saved to ~/.reenact/recordings/my-flow.json.
What gets captured:
| Action | Notes |
|---|---|
| Navigate | Every page load / SPA route change |
| Click | Buttons, links, checkboxes |
| Input | Text fields — captured on blur, not per keystroke |
| Select | <select> dropdowns — value, label, and index all captured |
| Key press | Keyboard shortcuts (e.g. Enter, Escape, Tab) |
| Scroll | Page and element scroll |
| Hover | Mouse-over on elements |
Tips:
- For text inputs: type, then click elsewhere (captured on blur)
- Password fields are never recorded — replaced with
{{password}}placeholder automatically - Accidental clicks on blank structural elements (div, body, nav) are filtered out
uv run reenact replay my-flowHeadless by default. Add --headed to watch it run:
uv run reenact replay my-flow --headedOutput:
Replaying my-flow (5 steps) ...
┏━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━┓
┃ ID ┃ Type ┃ Intent ┃ Strategy ┃ ms ┃ ┃
┡━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━┩
│ s1 │ navigate │ Navigate to https://... │ — │ 1867 │ ✓ │
│ s2 │ click │ Click the 'inspirational' link │ role │ 1288 │ ✓ │
│ s3 │ navigate │ Navigate to https://... │ — │ 257 │ ✓ │
└──────┴───────────┴───────────────────────────────┴────────────┴──────┴────┘
3/3 passed (3412ms total)
The Strategy column shows which selector won: testid → role → text → css → xpath → direct-nav. If the CSS selector breaks after a site redesign, replay falls back to role or text silently — no AI, no healing, just the fallback chain.
Use {{variable_name}} placeholders in input values during recording, then supply them at replay time.
# Pass plain values on the command line
uv run reenact run login --var username=alice
# Prompt for a secret at runtime (never stored on disk)
uv run reenact run login --var username=alice --var-secret password
# Set via environment variables (REENACT_VAR_<name>)
REENACT_VAR_username=alice REENACT_VAR_password=secret uv run reenact run loginPriority order (highest wins): --var-secret > --var > REENACT_VAR_* env vars > recording defaults.
uv run reenact list # table of all saved recordings
uv run reenact show my-flow # pretty-print steps and intents
uv run reenact edit my-flow # open recording JSON in $EDITORreenact record <name> [--url URL] [--headed/--headless]
reenact replay <name> [--headed/--headless]
reenact run <name> [--var key=value]... [--var-secret name]...
reenact list
reenact show <name>
reenact edit <name>
Global option: --recordings-dir PATH (default: ~/.reenact/recordings)
Env var override: REENACT_RECORDINGS_DIR=/path/to/dir reenact list
For every interactive element, Reenact captures up to five selector strategies at record time and tries them in priority order at replay:
| Priority | Strategy | Source |
|---|---|---|
| 1 | testid |
data-testid attribute |
| 2 | role |
ARIA role + accessible name |
| 3 | text |
Visible text content (buttons / links) |
| 4 | css |
#id or [type][name] attribute selector |
| 5 | xpath |
//tag[@id] or //tag[normalize-space()="..."] |
| — | direct-nav |
For <a href> links: navigates directly instead of clicking |
The first strategy yielding exactly one visible match wins. The Strategy column in replay output shows which one was used. If a site redesigns and the CSS selector breaks, role or text silently takes over.
Recordings can reference variables with {{name}} syntax in input values.
{ "id": "s2", "type": "input", "value": "{{username}}", ... }Declare variables in the recording (auto-detected from placeholders):
"variables": [
{ "name": "username", "default": null, "secret": false },
{ "name": "password", "default": null, "secret": true }
]Secrets ("secret": true) are:
- Never written to disk
- Masked in all output and error messages
- Prompted at runtime via
--var-secretor read fromREENACT_VAR_<name>
Recordings are plain JSON files, readable and editable by humans:
{
"version": "1.0",
"name": "login",
"start_url": "https://app.example.com/login",
"variables": [
{ "name": "username", "default": null, "secret": false },
{ "name": "password", "default": null, "secret": true }
],
"steps": [
{
"id": "s1", "type": "navigate",
"url": "https://app.example.com/login",
"intent": "Navigate to the login page"
},
{
"id": "s2", "type": "input",
"selectors": {
"testid": "login-username",
"role": { "role": "textbox", "name": "Username" },
"css": "#username",
"xpath": "//input[@id='username']"
},
"value": "{{username}}",
"intent": "Type the username"
},
{
"id": "s3", "type": "click",
"selectors": {
"role": { "role": "button", "name": "Sign in" },
"text": "Sign in",
"xpath": "//button[normalize-space()='Sign in']"
},
"intent": "Submit the login form",
"wait": { "strategy": "navigation", "timeout_ms": 10000 }
}
]
}The intent field on every step is plain English — human-readable and useful for debugging.
| Type | Description |
|---|---|
navigate |
Navigate to a URL |
click |
Click an element |
input |
Fill a text field |
select |
Choose a <select> option (by value, label, or index) |
key |
Press a keyboard key (e.g. Enter, Tab, Escape) |
wait |
Explicit wait (actionable, navigation, networkidle, or fixed ms) |
assert |
Assert element presence or text content |
scroll |
Scroll page or element |
hover |
Hover over an element |
Both recorder and replayer run with a realistic browser fingerprint to avoid bot-detection blocking:
navigator.webdriverflag is patched toundefined- Realistic Chrome user-agent and
sec-ch-uaheaders - Plugins, languages,
window.chrome, permissions,outerWidth/Height,deviceMemory, andhardwareConcurrencyall match a real desktop Chrome session
This is transparent — no configuration required.
git clone https://github.com/BetsolLLC/reenact
cd reenact
uv sync # install all deps including dev
uv run playwright install chromium # install browser binaries
uv run ruff check src tests # lint
uv run mypy --strict src # type check (CI gate)
uv run pytest tests/ -v # run testsCI runs lint → typecheck → tests on every push.
src/reenact/
schema.py # Pydantic v2 models — source of truth for all types
migrations.py # schema version migrations (from_ver, to_ver) → fn
storage.py # load/save recordings as JSON, auto-migrates on load
config.py # Config dataclass, default paths
interpolation.py # {{variable}} substitution and secret masking
stealth.py # browser fingerprint patching (recorder + replayer)
cli.py # Typer app — thin wrappers, asyncio.run at boundaries
recorder/
recorder.py # Playwright session + EventQueue → Recording
injected.js # in-page JS event listeners, posts events to Python
selectorgen.py # builds SelectorBundle + intent strings per element
replayer/
engine.py # async step executor: resolve → act → wait
resolver.py # multi-strategy resolution; iframe + shadow DOM aware
waits.py # WaitStrategy implementations
result.py # StepResult, ReplayReport
Current version: "1.0". Migrations are keyed by (from_version, to_version) in migrations.py and run automatically when loading older recordings.
The JSON Schema is exported to schema/reenact.schema.json and can be used for editor validation.
Recording captures no steps
- Make sure you're interacting with the page — clicks and inputs must happen inside the browser window
- Some pages may block Playwright even with stealth mode; try
--headedto verify the page loads
Replay fails on a step
- Run with
--headedto watch which step fails - Check the
Strategycolumn — if it shows—, no selector matched - Open the recording with
reenact edit <name>and verify the selectors are correct - The site may have changed structure; update the
cssorxpathselector in the JSON
FileNotFoundError: Recording not found
- Run
reenact listto see available recordings - Check
--recordings-dirorREENACT_RECORDINGS_DIRif using a custom path
Secret value appears in output
- Ensure the variable is declared with
"secret": truein the recording JSON - Use
--var-secretinstead of--varfor sensitive values
BSD 3-Clause