Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ba80c70
feat(recipe): saved, shareable `session` scripts (RFC-0009)
sanketsudake Jul 26, 2026
dc773da
fix(policy): classify the recipe verbs and pin per-step enforcement
sanketsudake Jul 26, 2026
5c7018f
feat(encode): pure GIF/frames/video exporter for session recording
sanketsudake Jul 27, 2026
c83784c
feat(record): screencast capture in the daemon, with reported bounds
sanketsudake Jul 27, 2026
bb3a664
feat(cli): record start/stop/status/cancel, and session --record
sanketsudake Jul 27, 2026
a285d36
docs: the Recording section, and ignore recording artifacts
sanketsudake Jul 27, 2026
bd1234a
fix(record): nudge a stalled tab, and reject the duplicate frames it …
sanketsudake Jul 27, 2026
7e9408c
test(record): the frames survive a failed command, and the daemon RPC
sanketsudake Jul 27, 2026
54097d5
refactor(record): refuse a double start before touching the tab
sanketsudake Jul 27, 2026
ebe6920
fix(encode): --loop n plays n times, not n+1
sanketsudake Jul 27, 2026
7bd98ed
fix(encode): letterbox frames that changed shape, don't stretch them
sanketsudake Jul 27, 2026
27f2cd4
fix(encode): --max-size kept the frames and threw away the markers
sanketsudake Jul 27, 2026
48dfc44
fix(recipe): a step's verb is argv[0], and a recipe cannot re-enter i…
sanketsudake Jul 27, 2026
97cde93
fix(encode): report the mp4/webm that was written, not the one requested
sanketsudake Jul 27, 2026
0a5d6d8
fix(policy): verbs_denied is checked first, so it reaches Exempt verb…
sanketsudake Jul 27, 2026
382e8af
fix(recipe): a substituted value can never reach flag position
sanketsudake Jul 27, 2026
9f01eaf
fix(record): stop destroying the recording when the export fails
sanketsudake Jul 27, 2026
2b3c32a
fix(recipe): `target:` is argv, so it is checked and substituted like…
sanketsudake Jul 27, 2026
df0cc93
fix(record): -o - writes the artifact and nothing else
sanketsudake Jul 27, 2026
0b0f9fa
fix(recipe): a recipe run is a batch, so a streaming step is a usage …
sanketsudake Jul 27, 2026
639ffa8
fix(record): report a missed --max-size ceiling even with no reduction
sanketsudake Jul 27, 2026
e8faf31
fix(recipe): a null entry in `steps:` is an error, not a silent renum…
sanketsudake Jul 27, 2026
8c7c967
fix(encode): bound the --max-size reduction by the command's deadline
sanketsudake Jul 27, 2026
d178d66
fix(recipe): a step's raw stdout no longer corrupts the run, and the …
sanketsudake Jul 27, 2026
48495dd
fix(recipe): runPlan borrows the App's defaults instead of keeping them
sanketsudake Jul 27, 2026
530e046
fix(record): four small leaks and losses in the capture path
sanketsudake Jul 27, 2026
3a5deae
docs(recipes): the scenario guide covers flag parsing, not just word …
sanketsudake Jul 27, 2026
731df26
fix(recipe): a fresh command tree per split, so classification is rac…
sanketsudake Jul 27, 2026
7134095
fix(record): size the screencast from a settled viewport
sanketsudake Jul 27, 2026
1f8927f
test(record): wait on both viewport measures before asserting --scale
sanketsudake Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
/cdp
*.png
*.pdf
*.gif
*.mp4
*.webm
/dist/

# local wayfinder planning tracker
Expand Down
5 changes: 5 additions & 0 deletions cmd/chrome-cdp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ func main() {
ConsoleMaxEntry: env.ConsoleMaxEntry,
NetBuffer: env.NetBuffer,
NetMaxBody: env.NetMaxBody,
RecordBuffer: env.RecordBuffer,
RecordMaxBytes: env.RecordMaxBytes,
}
if err := daemon.RunDaemon(os.Args[2], opts, 30*time.Minute); err != nil {
fmt.Fprintln(os.Stderr, "chrome-cdp daemon:", err)
Expand Down Expand Up @@ -109,6 +111,8 @@ func main() {
"CHROME_CDP_CONSOLE_MAX_ENTRY="+strconv.Itoa(defs.ConsoleMaxEntry),
"CHROME_CDP_NET_BUFFER="+strconv.Itoa(defs.NetBuffer),
"CHROME_CDP_NET_MAX_BODY="+strconv.Itoa(defs.NetMaxBody),
"CHROME_CDP_RECORD_BUFFER="+strconv.Itoa(defs.RecordBuffer),
"CHROME_CDP_RECORD_MAX_BYTES="+strconv.Itoa(defs.RecordMaxBytes),
)
return env
}
Expand All @@ -119,6 +123,7 @@ func main() {
PortFile: portFile, NoLaunch: o.NoLaunch, ProfileDir: o.ProfileDir, Port: o.Port,
ConsoleBuffer: defs.ConsoleBuffer, ConsoleMaxEntry: defs.ConsoleMaxEntry,
NetBuffer: defs.NetBuffer, NetMaxBody: defs.NetMaxBody,
RecordBuffer: defs.RecordBuffer, RecordMaxBytes: defs.RecordMaxBytes,
})
}
client, err := daemon.Ensure(socketFor(o), exe, daemonEnv(o))
Expand Down
2 changes: 2 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@
# console_max_entry = 8192 # per-message text cap, in bytes
# net_buffer = 500 # network records retained per tab (`net` reports evictions as `dropped`)
# net_max_body = 65536 # per-body cap in bytes, for `net --body`
# record_buffer = 600 # frames retained per tab by `record` (evictions are reported as dropped_frames)
# record_max_bytes = 100663296 # retained frame bytes per tab; a frame count alone does not bound memory on a large viewport
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ From there:
- **[Automating a logged-in web app](scenarios/automating-a-logged-in-web-app.md)** — the `list → use → snap → act → verify` loop against an app you're already signed into.
- **[Forms and grids](scenarios/forms-and-grids.md)** — fill fields by visible label, grid cells by column header, and a whole grid over one batched connection.
- **[Driving widgets with `select`](scenarios/driving-widgets-with-select.md)** — portal menus, cascade prompts, and native `<select>`s that a plain click can't open.
- **[Saving an automation as a recipe](scenarios/saving-an-automation-as-a-recipe.md)** — turn a working `session` batch into a named, parameterised file you can re-run, review, and commit for your team.

## For tool authors

Expand Down
237 changes: 234 additions & 3 deletions docs/cli-reference.md

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions docs/scenarios/recipes/submit-timesheet.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# submit-timesheet — a worked chrome-cdp recipe.
#
# Copy this to .chrome-cdp/recipes/submit-timesheet.yaml in your repo, change
# the URL and the field names to match your app, and run:
#
# chrome-cdp recipe run submit-timesheet --set week=2026-07-20 --dry-run
# chrome-cdp recipe run submit-timesheet --set week=2026-07-20
#
# Every `run:` below is exactly the argv you would type after `chrome-cdp`.
# There is no shell here: an input substitutes into ONE argv element, so a value
# containing quotes, semicolons, or newlines is passed through untouched.
#
# There is no credential in this file, and there must never be one: chrome-cdp
# drives the Chrome you are already signed into.
name: submit-timesheet
description: Fill a week of hours in the timesheet grid and save it.

inputs:
week:
required: true
description: "Monday of the week being submitted, as the grid labels it (e.g. Mon, 7/20)"
hours:
default: "8"
description: "Hours to enter in each weekday cell"

# Every step runs against this tab unless it names its own --target.
target: url:workday

steps:
- label: open the timesheet
run: ["nav", "https://workday.internal/time"]

# An SPA is not loaded when nav returns; wait for the network to settle rather
# than for a fixed duration.
- label: let the grid load
run: ["wait", "--idle"]

# Confirm the grid is really on screen before typing into it. A missing header
# here fails the run with a clear target error instead of filling the wrong page.
- label: confirm the week is the one we mean
run: ["wait", "--text", "{{week}}"]

# --by cell addresses a grid input by its column header, so the recipe survives
# the app renaming an internal id overnight.
- label: fill the weekday hours
run: ["fill", "--by", "cell", "{{week}}", "{{hours}}"]

# Read the row back before committing: this is the step that turns "the click
# did not error" into "the value is actually in the field".
- label: read the entered hours back
run: ["value", "--all", "input[data-automation-id=numericInput]"]

# --wait-text folds "save, then confirm the save landed" into one call, so a
# save that silently failed validation is a failure here and not a surprise
# next week.
- label: save and confirm
run: ["click", "--by", "name", "Save and Close", "--role", "button", "--wait-text", "saved"]
on_error: abort

# Optional tidy-up: a screenshot for the record. on_error: continue means a
# failure here does not make an otherwise successful submission look failed.
- label: keep a screenshot for the record
run: ["screenshot", "-o", "timesheet-{{week}}.png"]
on_error: continue
140 changes: 140 additions & 0 deletions docs/scenarios/saving-an-automation-as-a-recipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Saving an automation as a recipe

You worked out the eleven commands that submit your timesheet.
Right now that knowledge is in your shell history, and next week it will be gone.

A **recipe** is where it goes instead: a YAML file whose steps are the same argv arrays `session` already reads, with declared inputs so one file covers every week.
Name it, re-run it, commit it, hand it to a colleague.

The worked example this guide builds is [`recipes/submit-timesheet.yaml`](recipes/submit-timesheet.yaml).

## Start from something that works

Get the sequence right on the command line first, the way [Automating a logged-in web app](automating-a-logged-in-web-app.md) describes: `list → use → snap → act → verify`.
A recipe is a record of a working sequence, not a way to discover one.
Nothing in the format helps you find a selector.

Once the batch runs cleanly under `session`, you already have the recipe's `steps` — they are the same lines.

## Scaffold the file

```sh
chrome-cdp recipe new submit-timesheet
# → .chrome-cdp/recipes/submit-timesheet.yaml
```

The template is a working recipe, not a sketch: it loads, validates, and dry-runs as written, so you can edit it one step at a time and check yourself as you go.

## The format, and why it is this small

```yaml
name: submit-timesheet
description: Fill a week of hours in the timesheet grid and save it.
inputs:
week: { required: true, description: "Monday of the week, as the grid labels it" }
hours: { default: "8", description: "Hours per weekday" }
target: url:workday
steps:
- label: open the timesheet
run: ["nav", "https://workday.internal/time"]
- run: ["wait", "--idle"]
- label: save and confirm
run: ["click", "--by", "name", "Save and Close", "--role", "button", "--wait-text", "saved"]
on_error: abort
```

`run` is an argv array — identical to a `session` stdin line.
Anything valid in `session` is valid here and vice versa, which is the whole design: there is one execution path, and a recipe is a file that feeds it.
Flags a verb already takes go in the array, so `--timeout 60s` on the one slow step is just another element.

`{{name}}` substitutes an input **into one argv element**.
There is no shell in this design and there is no `shell:` step type, so `--set note="; rm -rf /"` reaches the browser as one literal string and nothing in it is interpreted.
That is not a hardening measure bolted on afterwards; it is why the format has argv arrays instead of command lines.

The same holds for flag parsing, which is the less obvious half.
Which elements of a step are flags is decided by the recipe as written, never by a value you pass in, so `--set note=--target=@2` arrives as the text `--target=@2` rather than as a second `--target` pointing the step at another tab.
You can see it in `--dry-run`: a step's data is emitted after a `--` terminator.

`on_error` is `abort` (the default) or `continue`.
There are no retries, no conditionals, and no loops.
If your automation needs them, write a program that calls `session` — that is the supported answer, and a bigger recipe format is not.

## Run it

```sh
chrome-cdp recipe run submit-timesheet --set week="Mon, 7/20"
```

You get one envelope per step, each carrying `step` and `label`, then a summary:

```json
{"ok":true,"command":"nav","result":{"url":"https://workday.internal/time"},"step":1,"label":"open the timesheet"}
...
{"ok":true,"command":"recipe","result":{"recipe":"submit-timesheet","steps":7,"completed":7,"failed":null}}
```

If a step fails, the run stops there, the summary says which step and why —
`failed: {"index":6,"label":"save and confirm","code":"target_timeout"}` — and **the process exits with that step's code**.
So a shell caller branches exactly as it would on a single command:

```sh
chrome-cdp recipe run submit-timesheet --set week="Mon, 7/20" || echo "failed with $?"
```

Under `--quiet` you get the summary alone, which is what you want from cron.

## Read one before you run it

A recipe drives the browser you are already signed into.
Treat one you were sent the way you would treat a shell script someone sent you:

```sh
chrome-cdp recipe show their-recipe # the source, comments and all
chrome-cdp recipe run their-recipe --dry-run # the resolved argv, one array per line
```

The dry run prints the exact bytes `session` consumes, so you can also just run them:

```sh
chrome-cdp recipe run their-recipe --dry-run | chrome-cdp session
```

That pipeline is worth knowing for debugging, and it is the honest demonstration that recipes add no hidden magic — the two paths drive the browser identically, and a test in the suite asserts it.

## Share it with your team

Recipes resolve project-local first, then from your own config directory:

1. `./.chrome-cdp/recipes/`
2. `$XDG_CONFIG_HOME/chrome-cdp/recipes/`
3. `--dir <path>`

Commit `.chrome-cdp/recipes/` and everyone who clones the repo gets your internal-app automations, in a form a reviewer can read in a pull request.
Project-local winning is what makes that work: the repo's copy beats whatever a teammate happens to have personally.
`chrome-cdp recipe list` marks each entry's source, so it is always visible which copy is about to run.

**Never put a credential in a recipe.**
The premise of this tool is reusing an already-authenticated browser, so a recipe never needs one — and a recipe is a file you are trying to make easy to share.

## When it fails halfway

`--from-step <n>` starts at step *n*, 1-based:

```sh
chrome-cdp recipe run submit-timesheet --set week="Mon, 7/20" --from-step 5
```

This exists because real automations fail halfway and re-running from the top is often wrong — a form that is already partly submitted does not want to be submitted again.

It is a sharp tool.
It assumes every earlier step's effect is already in place, and nothing checks that assumption for you.
Look at the page (or `chrome-cdp snap`) before you use it, and prefer fixing the recipe when the failure was the recipe's fault rather than the app's.

## What is deliberately missing

No conditionals, loops, retries, or branching.
No reading a value out of one step's envelope and using it in the next — genuinely useful, and the thing that would turn this format into a programming language, so it waits for evidence from real recipes.
Recipes cannot invoke recipes, and one recipe is capped at 200 steps.

The moment you want any of that, the answer is a program that calls `session`.
You lose nothing by dropping down: the steps are the same argv lines you already had.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/chromedp/chromedp v0.15.1
github.com/mailru/easyjson v0.9.2
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,7 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
94 changes: 94 additions & 0 deletions internal/chrome/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,76 @@ type PDFOpts struct {
Footer string // HTML template for the print footer
}

// RecordOpts controls `record start` — the screencast capture behind RFC-0011.
//
// Every value is validated and reduced to a number by the CLI before it arrives
// here; the driver applies its own defaults only for a zero field, which is what
// a direct (non-CLI) caller and the deliberately lenient daemon arg decoders
// leave behind.
type RecordOpts struct {
// FPS is the CAPTURE cadence ceiling, not a fixed interval: a screencast
// pushes frames only when the page changes, so this throttles a busy page
// rather than polling a static one.
FPS float64

// Scale shrinks the captured frames relative to the viewport. It is applied
// by Chrome at capture time (maxWidth/maxHeight), so a scaled recording
// costs less memory in the daemon, not just less disk at export.
Scale float64

// Quality is the JPEG quality of each frame, 0-100.
Quality int

// MaxFrames is the ring-buffer bound. Eviction is REPORTED (dropped_frames /
// truncated), never silent.
MaxFrames int

// MaxDuration stops the capture — but not the recording — after this long.
// The frames captured so far stay exportable, with truncated set.
MaxDuration time.Duration

// Annotate is the DEFAULT for the export, not a capture-time behaviour.
// Action marks are recorded either way (they are a few floats each), so one
// capture can be exported both annotated and clean; `record stop` may
// override this.
Annotate bool
}

// FrameMark is one action that happened during a recording: where the pointer
// landed, in PAGE (CSS) pixels, and which verb put it there.
//
// It is recorded alongside the frames and drawn — if at all — at export, which
// is what keeps capture free of any drawing (RFC-0011 design notes, VS-13). The
// coordinates come from the pointer verbs, which already resolve and report an
// occlusion-verified centre; nothing here re-resolves geometry.
type FrameMark struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Command string `json:"command,omitempty"`
TS time.Time `json:"ts"`
}

// Frame is one captured screencast frame as it left the daemon.
//
// Data is the JPEG exactly as Chrome produced it: the driver never re-encodes,
// so `record stop --format frames` hands back the capture's own pixels.
type Frame struct {
Data []byte `json:"data"`
TS time.Time `json:"ts"`
Width int `json:"width"` // image pixels
Height int `json:"height"` // image pixels

// CSSWidth/CSSHeight are what the frame covers in PAGE pixels. They are the
// mapping a mark's coordinates need when the capture was scaled down, and
// come from the screencast frame's own metadata rather than from the
// requested scale — the two differ whenever the device scale factor is not 1.
CSSWidth float64 `json:"css_width,omitempty"`
CSSHeight float64 `json:"css_height,omitempty"`

// Marks are the actions that landed while this frame was on screen.
Marks []FrameMark `json:"marks,omitempty"`
}

// TextOpts controls the `text` verb. The zero value is the long-standing
// behaviour: the visible text of Query's selector, verbatim.
//
Expand Down Expand Up @@ -331,6 +401,30 @@ type Browser interface {
// decoding the image itself.
Screenshot(ctx context.Context, targetID string, opts ShotOpts) ([]byte, map[string]any, error)
PDF(ctx context.Context, targetID string, opts PDFOpts) ([]byte, map[string]any, error)
// RecordStart begins a screencast recording of a tab (RFC-0011). The frames
// are retained where the CONNECTION lives — the daemon — because a recording
// spans many CLI invocations and no per-command process can hold them. That
// is also what makes a crashed automation's frames survive it (US-7).
RecordStart(ctx context.Context, targetID string, opts RecordOpts) (map[string]any, error)
// RecordStop ends the recording and hands back the retained frames plus the
// accounting an honest export needs (frames, dropped_frames, truncated).
// Encoding happens in the CLI, via internal/encode, so one capture can be
// exported in more than one format and annotation stays an export decision.
RecordStop(ctx context.Context, targetID string) ([]Frame, map[string]any, error)
// RecordRestore hands a drained recording back, so an export that failed
// after RecordStop can be retried instead of costing the user the frames.
//
// It exists because RecordStop is destructive and encoding is not free of
// failure modes the CLI cannot rule out in advance (a full disk, an ffmpeg
// that dies). Everything that CAN be checked first is — the encoder's
// availability and the output path both are — and this covers the rest. The
// restored recording is not capturing: it holds the frames for a retry, and
// `record cancel` discards it like any other.
RecordRestore(ctx context.Context, targetID string, frames []Frame, meta map[string]any) error
// RecordStatus reports whether a recording is active and how much it holds.
RecordStatus(ctx context.Context, targetID string) (map[string]any, error)
// RecordCancel discards a recording without exporting anything.
RecordCancel(ctx context.Context, targetID string) (map[string]any, error)
// Console returns the console messages and uncaught exceptions retained for
// a tab since the connection attached to it, filtered by opts BEFORE the
// result is built (see ConsoleOpts in console.go).
Expand Down
Loading
Loading