browserctl 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2976bab368591b4cb06cd3a34d64c1078bd8588590d70c67ba3d78ecd721e572
4
- data.tar.gz: 0fa9c86ec532df2c6763780bfb6c2ac47380ff1df400ae2639fe914fc66e0d04
3
+ metadata.gz: c12ad0cc3d34e7163bab1d678d65b320e946e0fa16fe7bd3e9f5881855d4801f
4
+ data.tar.gz: 84d80f56270dfc7d252d7e63279f7c7c54123067837af457f5e92e82b2f0384e
5
5
  SHA512:
6
- metadata.gz: ef5ab4df359bde2cc6b635f6ac77c1147df57e85c57ff61222179f223e4d41ef4d58b622aa928cde19c41b1927d6ebafd273847ada8c55bf74cfdc09b6ddb5c7
7
- data.tar.gz: 0af88adb9b7076a4408c65ac25e7d13fd0f70aa2cdf79448fe9be7953695bd12de01d9c98626628163ce8edb738b4d2c83a925a77e2504924b1ee6bfe85c39d3
6
+ metadata.gz: c95dbbbe2a573635421fe90cd65d2ccc41baf968c32bb65edcea52030e1b0080fd2af0f50ea9d85bc827ff4f537e2c5b4f3a7631c8242380a007df432e820906
7
+ data.tar.gz: 1da50ab443d365e4a4b9ce145671e266fdfb0d83432b7b2313d87233b58b425252d2d9a365b0f8b3c31040961b90f684fe9299867c4cccfcd9600ea88cb93cc1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1](https://github.com/patrick204nqh/browserctl/compare/v0.2.0...v0.2.1) (2026-04-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * align CLI with standard conventions ([215c2af](https://github.com/patrick204nqh/browserctl/commit/215c2af3a4c4d27397476932147f3fda39e4039c))
14
+ * remove flag_extractor spec after deleting the module ([cd80045](https://github.com/patrick204nqh/browserctl/commit/cd8004515fcbae68248d7133e10d5c0223be10e1))
15
+ * use GH_PAT for release-please to trigger release workflow ([da83358](https://github.com/patrick204nqh/browserctl/commit/da83358e8ff19e8a30c23b9f5d744cb11ffbc7c6))
16
+ * use PAT for release-please to trigger release workflow ([4a9ea99](https://github.com/patrick204nqh/browserctl/commit/4a9ea99f418cbf719a16e2a971672175e0292221))
17
+
18
+ ## [0.2.0](https://github.com/patrick204nqh/browserctl/compare/v0.1.1...v0.2.0) (2026-04-20)
19
+
20
+
21
+ ### Features
22
+
23
+ * add architecture documentation and decision records with diagrams ([9b67e40](https://github.com/patrick204nqh/browserctl/commit/9b67e40892cb87a075818fd7b166584a48c8edff))
24
+ * add logging functionality and environment variable setup documentation ([feb529c](https://github.com/patrick204nqh/browserctl/commit/feb529c4040c6f84104fa6dc55afc8d0a5d1b1a7))
25
+ * add record command — capture session as replayable workflow ([bb8988b](https://github.com/patrick204nqh/browserctl/commit/bb8988bcf17c0a7689f5a6e53d755d0e6a69b359))
26
+ * add ref registry and diff cache to CommandDispatcher ([217e158](https://github.com/patrick204nqh/browserctl/commit/217e15831b2cf1f0bf693584f47ed5d315594803))
27
+ * add ref-based click and fill using snapshot registry ([f0251c0](https://github.com/patrick204nqh/browserctl/commit/f0251c0732fd535bde8746b2817d3674ec8bc353))
28
+ * add retry_count and timeout options to workflow steps ([7b5e694](https://github.com/patrick204nqh/browserctl/commit/7b5e694cfb246396502a07b02928de79233a715f))
29
+ * add snap --diff returning only changed elements ([0a09558](https://github.com/patrick204nqh/browserctl/commit/0a095583ea23b1cff0df1ecc6cb91a3e35039e2c))
30
+ * add watch command — poll selector and emit when found ([b9a3abf](https://github.com/patrick204nqh/browserctl/commit/b9a3abf15b7e021906dda940f29f029ca3c221c2))
31
+ * named daemon instances via browserd --name and multi-socket support ([8cbc62d](https://github.com/patrick204nqh/browserctl/commit/8cbc62d6c8931b23cbf31e7285ee52c62e280f5c))
32
+ * v0.2 AI-first enhancements ([d3246d4](https://github.com/patrick204nqh/browserctl/commit/d3246d4c861430fda1e7dfa1cb67ce22db33dd68))
33
+
34
+
35
+ ### Bug Fixes
36
+
37
+ * add edited event and workflow_dispatch to release trigger ([b851306](https://github.com/patrick204nqh/browserctl/commit/b85130603986ea5a667f2a89e9af7139c38491f2))
38
+ * add paths-ignore for markdown and docs in CI workflow ([3b7801d](https://github.com/patrick204nqh/browserctl/commit/3b7801dfa536ea0b0cd9bab189f237a2709e1515))
39
+ * describe_workflow after StepDef refactor; skip nil-selector recording for ref commands ([9e437fa](https://github.com/patrick204nqh/browserctl/commit/9e437fa9c18b6c94259a652af238377ce120d12e))
40
+ * fill --ref uses --value flag; add record generate subcommand ([ef3ed43](https://github.com/patrick204nqh/browserctl/commit/ef3ed433e09c0ae8ac6e241949ee9c2c0ccaa03c))
41
+ * remove 'edited' type from release trigger events ([98f63f0](https://github.com/patrick204nqh/browserctl/commit/98f63f0c40cbdcd40e14704379f28c19c436a6c2))
42
+ * rubocop offenses — formatting, guard clauses, predicate rename extract_flag? ([43341be](https://github.com/patrick204nqh/browserctl/commit/43341be50f12ca28699b009efb85b1ff404e4dab))
43
+ * trigger release workflow on GitHub release publication ([55c8faa](https://github.com/patrick204nqh/browserctl/commit/55c8faae8d753f35663ebd3c02996d1a602de091))
44
+ * update brand icon concept and roadmap versioning ([7a97b4b](https://github.com/patrick204nqh/browserctl/commit/7a97b4b0c0eaffbd8743ac30b2fbf13be468c80e))
45
+
8
46
  ## [0.1.1](https://github.com/patrick204nqh/browserctl/compare/v0.1.0...v0.1.1) (2026-04-19)
9
47
 
10
48
 
data/README.md CHANGED
@@ -15,9 +15,9 @@ Unlike tools that restart the browser on every script run, **browserctl keeps a
15
15
  ```bash
16
16
  browserd & # start the daemon (headless)
17
17
  browserctl open login --url https://example.com/login
18
- browserctl fill login "input[name=email]" me@example.com
19
- browserctl click login "button[type=submit]"
20
- browserctl snap login # AI-friendly JSON snapshot
18
+ browserctl snap login # AI-friendly JSON snapshot with ref IDs
19
+ browserctl fill login --ref e1 --value me@example.com # interact by ref
20
+ browserctl click login --ref e2
21
21
  browserctl shutdown
22
22
  ```
23
23
 
@@ -81,24 +81,34 @@ browserd --headed # visible browser window
81
81
  browserctl open login --url https://app.example.com/login
82
82
  ```
83
83
 
84
- **3. Interact with the page**
84
+ **3. Snapshot the page to discover refs**
85
+
86
+ ```bash
87
+ browserctl snap login # AI-friendly JSON with ref IDs (default)
88
+ browserctl snap login --format html
89
+ ```
90
+
91
+ **4. Interact using refs or selectors**
85
92
 
86
93
  ```bash
94
+ browserctl fill login --ref e1 --value user@example.com
95
+ browserctl fill login --ref e2 --value s3cr3t
96
+ browserctl click login --ref e3
97
+
98
+ # or using explicit CSS selectors
87
99
  browserctl fill login "input[name=email]" user@example.com
88
- browserctl fill login "input[name=password]" s3cr3t
89
100
  browserctl click login "button[type=submit]"
90
101
  ```
91
102
 
92
- **4. Observe the result**
103
+ **5. Observe the result**
93
104
 
94
105
  ```bash
95
- browserctl snap login # AI-friendly JSON (default)
96
- browserctl snap login --format html
106
+ browserctl snap login --diff # only changed elements since last snap
97
107
  browserctl shot login --out /tmp/after-login.png --full
98
108
  browserctl url login
99
109
  ```
100
110
 
101
- **5. Manage pages and daemon**
111
+ **6. Manage pages and daemon**
102
112
 
103
113
  ```bash
104
114
  browserctl pages
@@ -119,12 +129,18 @@ browserctl shutdown
119
129
  | `close <page>` | Close a named page |
120
130
  | `pages` | List open pages |
121
131
  | `goto <page> <url>` | Navigate a page to a URL |
122
- | `fill <page> <selector> <value>` | Fill an input field |
123
- | `click <page> <selector>` | Click an element |
124
- | `snap <page> [--format ai\|html]` | Snapshot DOM (default: ai) |
132
+ | `fill <page> <selector> <value>` | Fill an input field by CSS selector |
133
+ | `fill <page> --ref <id> --value <v>` | Fill an input field by snapshot ref |
134
+ | `click <page> <selector>` | Click an element by CSS selector |
135
+ | `click <page> --ref <id>` | Click an element by snapshot ref |
136
+ | `snap <page> [--format ai\|html] [--diff]` | Snapshot DOM; `--diff` returns only changed elements |
137
+ | `watch <page> <selector> [--timeout N]` | Poll until selector appears (default timeout: 30s) |
125
138
  | `shot <page> [--out PATH] [--full]` | Take a screenshot |
126
139
  | `url <page>` | Print current URL |
127
140
  | `eval <page> <expression>` | Evaluate a JS expression |
141
+ | `record start <name>` | Begin recording commands as a replayable workflow |
142
+ | `record stop [--out path]` | End recording; saves to `.browserctl/workflows/` or custom path |
143
+ | `record status` | Show whether a recording is active |
128
144
 
129
145
  ### Daemon commands
130
146
 
@@ -172,7 +188,24 @@ browserctl shutdown
172
188
  ]
173
189
  ```
174
190
 
175
- Use `selector` values directly with `fill` and `click`.
191
+ Use `ref` values directly with `--ref` for zero-fragility interactions, or use `selector` values with `fill` and `click`.
192
+
193
+ ### Ref-based interaction
194
+
195
+ After a `snap`, use ref IDs instead of CSS selectors — no selector knowledge required:
196
+
197
+ ```bash
198
+ browserctl fill login --ref e1 --value user@example.com
199
+ browserctl click login --ref e2
200
+ ```
201
+
202
+ ### Diff snapshots
203
+
204
+ Track only what changed since the last snapshot — useful for AI agents monitoring async updates:
205
+
206
+ ```bash
207
+ browserctl snap login --diff
208
+ ```
176
209
 
177
210
  ---
178
211
 
@@ -222,6 +255,7 @@ browserctl run smoke_login --email me@example.com --password s3cr3t
222
255
  | `desc "text"` | Human-readable description |
223
256
  | `param :name, required:, secret:, default:` | Declare a parameter |
224
257
  | `step "label" { }` | Add a step (runs in order, halts on failure) |
258
+ | `step "label", retry_count: N, timeout: S { }` | Step with retry and/or timeout |
225
259
  | `page(:name)` | Returns a `PageProxy` for the named page |
226
260
  | `invoke "other_workflow", **overrides` | Call another workflow |
227
261
  | `assert condition, "message"` | Raise `WorkflowError` if condition is false |
@@ -242,7 +276,15 @@ For a full guide on building your own workflows, see [docs/writing-workflows.md]
242
276
 
243
277
  ## How it works
244
278
 
245
- `browserd` runs as a background process, listening on a Unix socket at `~/.browserctl/browserd.sock`. It manages a Ferrum (Chrome DevTools Protocol) browser instance with named page handles.
279
+ `browserd` runs as a background process, listening on a Unix socket at `~/.browserctl/browserd.sock`. Start multiple named instances for agent isolation:
280
+
281
+ ```bash
282
+ browserd --name agent-a &
283
+ browserd --name agent-b &
284
+ browserctl --daemon agent-a open main --url https://app.example.com
285
+ ```
286
+
287
+ It manages a Ferrum (Chrome DevTools Protocol) browser instance with named page handles.
246
288
 
247
289
  `browserctl` sends JSON-RPC commands over the socket and prints the result. Workflows run in-process through the same client.
248
290
 
data/bin/browserctl CHANGED
@@ -3,41 +3,80 @@
3
3
 
4
4
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
 
6
+ require "browserctl/version"
7
+
8
+ if ARGV.intersect?(%w[--version -v])
9
+ puts "browserctl #{Browserctl::VERSION}"
10
+ exit 0
11
+ end
12
+
6
13
  require "json"
14
+ require "optimist"
7
15
  require "browserctl"
16
+ require "browserctl/commands/cli_output"
8
17
  require "browserctl/commands/open_page"
9
18
  require "browserctl/commands/fill"
10
19
  require "browserctl/commands/click"
11
20
  require "browserctl/commands/snapshot"
12
21
  require "browserctl/commands/screenshot"
22
+ require "browserctl/commands/watch"
23
+ require "browserctl/commands/record"
13
24
 
25
+ def print_result(res)
26
+ if res.is_a?(Hash) && res[:error]
27
+ warn "Error: #{res[:error]}"
28
+ exit 1
29
+ end
30
+ puts res.to_json
31
+ end
32
+
33
+ # rubocop:disable Metrics/MethodLength
14
34
  def usage
15
35
  puts <<~USAGE
16
36
  Usage: browserctl <command> [args]
17
37
 
18
38
  Browser commands (require browserd running):
19
- open <page> [--url URL] Open or focus a named page
20
- close <page> Close a named page
21
- pages List open pages
22
- goto <page> <url> Navigate a page
23
- fill <page> <selector> <value> Fill an input
24
- click <page> <selector> Click an element
25
- shot <page> [--out PATH] [--full] Take a screenshot
26
- snap <page> [--format ai|html] Snapshot DOM (default: ai)
27
- url <page> Print current URL
28
- eval <page> <expression> Evaluate JS expression
39
+ open <page> [--url URL] Open or focus a named page
40
+ close <page> Close a named page
41
+ pages List open pages
42
+ goto <page> <url> Navigate a page
43
+ fill <page> <selector> <value> Fill an input
44
+ <page> --ref <ref> --value <value> Fill via snapshot ref
45
+ click <page> <selector> Click an element
46
+ <page> --ref <ref> Click via snapshot ref
47
+ shot <page> [--out PATH] [--full] Take a screenshot
48
+ snap <page> [--format ai|html] [--diff] Snapshot DOM (default: ai)
49
+ url <page> Print current URL
50
+ eval <page> <expression> Evaluate JS expression
51
+ watch <page> <selector> [--timeout N] Wait for a selector to appear
52
+
53
+ Recording commands:
54
+ record start <name> Start recording browser commands
55
+ record stop [--out PATH] Stop recording and save workflow
56
+ record status Show active recording name
29
57
 
30
58
  Workflow commands:
31
- run <name|file> [--key value] Run a workflow
32
- workflows List available workflows
33
- describe <name> Describe a workflow
59
+ run <name|file> [--key value ...] Run a workflow
60
+ workflows List available workflows
61
+ describe <name> Describe a workflow
34
62
 
35
63
  Daemon commands:
36
- ping Check if browserd is alive
37
- shutdown Stop browserd
64
+ ping Check if browserd is alive
65
+ shutdown Stop browserd
66
+
67
+ Options:
68
+ --daemon <name> Connect to a named daemon instance
69
+ --version, -v Print version and exit
38
70
  USAGE
39
71
  exit 0
40
72
  end
73
+ # rubocop:enable Metrics/MethodLength
74
+
75
+ daemon_idx = ARGV.index("--daemon")
76
+ daemon_name = if daemon_idx
77
+ ARGV.delete_at(daemon_idx)
78
+ ARGV.delete_at(daemon_idx)
79
+ end
41
80
 
42
81
  cmd = ARGV.shift
43
82
  args = ARGV.dup
@@ -70,22 +109,25 @@ when "describe"
70
109
  name = args.shift or abort "usage: browserctl describe <workflow_name>"
71
110
  puts JSON.pretty_generate(runner.describe_workflow(name))
72
111
 
112
+ when "record" then Browserctl::Commands::Record.run(args)
113
+
73
114
  else
74
- client = Browserctl::Client.new
115
+ client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
75
116
 
76
117
  case cmd
77
- when "open" then Browserctl::Commands::OpenPage.run(client, args)
78
- when "close" then puts client.close_page(args[0]).to_json
79
- when "pages" then puts client.list_pages.to_json
80
- when "goto" then puts client.goto(args[0], args[1]).to_json
81
- when "fill" then Browserctl::Commands::Fill.run(client, args)
82
- when "click" then Browserctl::Commands::Click.run(client, args)
83
- when "shot" then Browserctl::Commands::Screenshot.run(client, args)
84
- when "snap" then Browserctl::Commands::Snapshot.run(client, args)
85
- when "url" then puts client.url(args[0]).to_json
86
- when "eval" then puts client.evaluate(args[0], args[1]).to_json
87
- when "ping" then puts client.ping.to_json
88
- when "shutdown" then puts client.shutdown.to_json
118
+ when "open" then Browserctl::Commands::OpenPage.run(client, args)
119
+ when "close" then print_result(client.close_page(args[0]))
120
+ when "pages" then print_result(client.list_pages)
121
+ when "goto" then print_result(client.goto(args[0], args[1]))
122
+ when "fill" then Browserctl::Commands::Fill.run(client, args)
123
+ when "click" then Browserctl::Commands::Click.run(client, args)
124
+ when "shot" then Browserctl::Commands::Screenshot.run(client, args)
125
+ when "snap" then Browserctl::Commands::Snapshot.run(client, args)
126
+ when "url" then print_result(client.url(args[0]))
127
+ when "eval" then print_result(client.evaluate(args[0], args[1]))
128
+ when "watch" then Browserctl::Commands::Watch.run(client, args)
129
+ when "ping" then print_result(client.ping)
130
+ when "shutdown" then print_result(client.shutdown)
89
131
  else
90
132
  abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
91
133
  end
data/bin/browserd CHANGED
@@ -3,8 +3,22 @@
3
3
 
4
4
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
 
6
+ require "optimist"
6
7
  require "nokogiri"
8
+ require "browserctl/logger"
7
9
  require "browserctl/server"
10
+ require "browserctl/version"
8
11
 
9
- headless = !ARGV.include?("--headed")
10
- Browserctl::Server.new(headless: headless).run
12
+ opts = Optimist.options do
13
+ version "browserd #{Browserctl::VERSION}"
14
+ opt :headed, "Run with a visible browser window", default: false, short: "-H"
15
+ opt :log_level, "Log verbosity: debug, info, warn, error", default: "info", short: "-l", type: :string
16
+ opt :name, "Daemon instance name for multi-agent use", default: nil, short: "-n", type: :string
17
+ end
18
+
19
+ Browserctl.logger = Browserctl.build_logger(opts[:log_level])
20
+ Browserctl::Server.new(
21
+ headless: !opts[:headed],
22
+ socket_path: Browserctl.socket_path(opts[:name]),
23
+ pid_path: Browserctl.pid_path(opts[:name])
24
+ ).run
@@ -3,15 +3,18 @@
3
3
  require "socket"
4
4
  require "json"
5
5
  require_relative "constants"
6
+ require_relative "recording"
6
7
 
7
8
  module Browserctl
8
9
  class Client
9
- def initialize(socket_path = SOCKET_PATH)
10
+ def initialize(socket_path = Browserctl.socket_path)
10
11
  @socket_path = socket_path
11
12
  end
12
13
 
13
14
  def call(cmd, **params)
14
- communicate(JSON.generate({ cmd: cmd }.merge(params)))
15
+ result = communicate(JSON.generate({ cmd: cmd }.merge(params)))
16
+ Recording.append(cmd, **params) if result[:ok]
17
+ result
15
18
  rescue Errno::ENOENT, Errno::ECONNREFUSED
16
19
  raise "browserd is not running — start it with: browserd"
17
20
  end
@@ -21,12 +24,32 @@ module Browserctl
21
24
  def open_page(name, url: nil) = call("open_page", name: name, url: url)
22
25
  def close_page(name) = call("close_page", name: name)
23
26
  def list_pages = call("list_pages")
24
- def goto(name, url) = call("goto", name: name, url: url)
25
- def fill(name, selector, val) = call("fill", name: name, selector: selector, value: val)
26
- def click(name, selector) = call("click", name: name, selector: selector)
27
+ def goto(name, url) = call("goto", name: name, url: url)
28
+
29
+ def click(name, selector = nil, ref: nil)
30
+ raise ArgumentError, "click: provide selector or ref:" unless selector || ref
31
+
32
+ call("click", name: name, selector: selector, ref: ref)
33
+ end
34
+
35
+ def fill(name, selector = nil, value = nil, ref: nil)
36
+ raise ArgumentError, "fill: provide selector or ref:" unless selector || ref
37
+
38
+ call("fill", name: name, selector: selector, ref: ref, value: value)
39
+ end
40
+
27
41
  def screenshot(name, path: nil, full: false) = call("screenshot", name: name, path: path, full: full)
28
- def snapshot(name, format: "ai") = call("snapshot", name: name, format: format)
29
- def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
42
+
43
+ def snapshot(name, format: "ai", diff: false)
44
+ call("snapshot", name: name, format: format, diff: diff)
45
+ end
46
+
47
+ def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
48
+
49
+ def watch(name, selector, timeout: 30)
50
+ call("watch", name: name, selector: selector, timeout: timeout)
51
+ end
52
+
30
53
  def url(name) = call("url", name: name)
31
54
  def evaluate(name, expression) = call("evaluate", name: name, expression: expression)
32
55
  def ping = call("ping")
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Commands
5
+ module CliOutput
6
+ def print_result(res)
7
+ if res.is_a?(Hash) && res[:error]
8
+ warn "Error: #{res[:error]}"
9
+ exit 1
10
+ end
11
+ puts res.to_json
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,12 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "optimist"
4
+ require_relative "cli_output"
5
+
3
6
  module Browserctl
4
7
  module Commands
5
8
  class Click
9
+ extend CliOutput
10
+
6
11
  def self.run(client, args)
7
- name, selector = args
8
- abort "usage: browserctl click <page> <selector>" unless name && selector
9
- puts client.click(name, selector).to_json
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl click <page> <selector>\n " \
14
+ "browserctl click <page> --ref <ref>"
15
+ opt :ref, "Snapshot ref to click", type: :string, short: "-r"
16
+ end
17
+ name = args.shift
18
+ if opts[:ref]
19
+ abort "usage: browserctl click <page> --ref <ref>" unless name
20
+ print_result(client.click(name, ref: opts[:ref]))
21
+ else
22
+ selector = args.shift
23
+ abort "usage: browserctl click <page> <selector>" unless name && selector
24
+ print_result(client.click(name, selector))
25
+ end
10
26
  end
11
27
  end
12
28
  end
@@ -1,12 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "optimist"
4
+ require_relative "cli_output"
5
+
3
6
  module Browserctl
4
7
  module Commands
5
8
  class Fill
9
+ extend CliOutput
10
+
6
11
  def self.run(client, args)
7
- name, selector, value = args
8
- abort "usage: browserctl fill <page> <selector> <value>" unless name && selector && value
9
- puts client.fill(name, selector, value).to_json
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl fill <page> <selector> <value>\n " \
14
+ "browserctl fill <page> --ref <ref> --value <value>"
15
+ opt :ref, "Snapshot ref to fill", type: :string, short: "-r"
16
+ opt :value, "Value to fill", type: :string, short: "-V"
17
+ end
18
+ name = args.shift
19
+ opts[:ref] ? fill_by_ref(client, name, opts) : fill_by_selector(client, name, args, opts[:value])
20
+ end
21
+
22
+ class << self
23
+ private
24
+
25
+ def fill_by_ref(client, name, opts)
26
+ abort "usage: browserctl fill <page> --ref <ref> --value <value>" unless name && opts[:value]
27
+ print_result(client.fill(name, nil, opts[:value], ref: opts[:ref]))
28
+ end
29
+
30
+ def fill_by_selector(client, name, args, value_opt)
31
+ selector = args.shift
32
+ value = value_opt || args.shift
33
+ abort "usage: browserctl fill <page> <selector> <value>" unless name && selector && value
34
+ print_result(client.fill(name, selector, value))
35
+ end
10
36
  end
11
37
  end
12
38
  end
@@ -1,15 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "flag_extractor"
3
+ require "optimist"
4
+ require_relative "cli_output"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
7
8
  class OpenPage
9
+ extend CliOutput
10
+
8
11
  def self.run(client, args)
9
- name = args.shift or abort "usage: browserctl open <name> [--url URL]"
10
- url = FlagExtractor.extract_opt(args, "--url")
11
- res = client.open_page(name, url: url)
12
- puts res.to_json
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl open <page> [--url URL]"
14
+ opt :url, "URL to navigate to", type: :string, short: "-u"
15
+ end
16
+ name = args.shift or abort "usage: browserctl open <page> [--url URL]"
17
+ print_result(client.open_page(name, url: opts[:url]))
13
18
  end
14
19
  end
15
20
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optimist"
5
+ require "browserctl/recording"
6
+
7
+ module Browserctl
8
+ module Commands
9
+ class Record
10
+ USAGE = "Usage: browserctl record start <name> | stop [--out PATH] | status"
11
+
12
+ def self.run(args)
13
+ subcmd = args.shift
14
+ case subcmd
15
+ when "start" then run_start(args)
16
+ when "stop" then run_stop(args)
17
+ when "status" then run_status
18
+ else
19
+ abort "#{USAGE}\nRun 'browserctl record <subcommand> --help' for details."
20
+ end
21
+ end
22
+
23
+ class << self
24
+ private
25
+
26
+ def run_start(args)
27
+ Optimist.options(args) { banner "Usage: browserctl record start <name>" }
28
+ name = args.shift or abort "usage: browserctl record start <name>"
29
+ Recording.start(name)
30
+ puts "Recording started: #{name}"
31
+ puts "Run browser commands, then: browserctl record stop"
32
+ end
33
+
34
+ def run_stop(args)
35
+ opts = Optimist.options(args) do
36
+ banner "Usage: browserctl record stop [--out PATH]"
37
+ opt :out, "Output path for workflow file", type: :string, short: "-o"
38
+ end
39
+ name = Recording.stop
40
+ out = opts[:out] || File.join(".browserctl/workflows", "#{name}.rb")
41
+ FileUtils.mkdir_p(File.dirname(out))
42
+ Recording.generate_workflow(name, output_path: out)
43
+ puts "Workflow saved: #{out}"
44
+ puts "Run with: browserctl run #{name}"
45
+ end
46
+
47
+ def run_status
48
+ active = Recording.active
49
+ puts active ? "Active recording: #{active}" : "No active recording."
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "flag_extractor"
3
+ require "optimist"
4
+ require_relative "cli_output"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
7
8
  class Screenshot
9
+ extend CliOutput
10
+
8
11
  def self.run(client, args)
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl shot <page> [--out PATH] [--full]"
14
+ opt :out, "Output file path", type: :string, short: "-o"
15
+ opt :full, "Capture full page", default: false, short: "-f"
16
+ end
9
17
  name = args.shift or abort "usage: browserctl shot <page> [--out PATH] [--full]"
10
- path = FlagExtractor.extract_opt(args, "--out")
11
- full = args.delete("--full") ? true : false
12
- puts client.screenshot(name, path: path, full: full).to_json
18
+ print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
13
19
  end
14
20
  end
15
21
  end
@@ -1,24 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "flag_extractor"
4
+ require "optimist"
5
5
 
6
6
  module Browserctl
7
7
  module Commands
8
8
  class Snapshot
9
+ VALID_FORMATS = %w[ai html].freeze
10
+
9
11
  def self.run(client, args)
10
- name = args.shift or abort "usage: browserctl snapshot <page> [--format ai|html]"
11
- format = FlagExtractor.extract_opt(args, "--format") || "ai"
12
- res = client.snapshot(name, format: format)
13
- output_snapshot(res, format)
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl snap <page> [--format ai|html] [--diff]"
14
+ opt :format, "Output format: ai or html", default: "ai", short: "-f"
15
+ opt :diff, "Return only changed elements", default: false, short: "-d"
16
+ end
17
+ name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
18
+ unless VALID_FORMATS.include?(opts[:format])
19
+ warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
20
+ exit 1
21
+ end
22
+ res = client.snapshot(name, format: opts[:format], diff: opts[:diff])
23
+ output_snapshot(res, opts[:format])
14
24
  end
15
25
 
16
26
  class << self
17
27
  private
18
28
 
19
29
  def output_snapshot(res, format)
20
- return abort res[:error] if res[:error]
21
-
30
+ if res[:error]
31
+ warn "Error: #{res[:error]}"
32
+ exit 1
33
+ end
22
34
  puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
23
35
  end
24
36
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optimist"
4
+ require_relative "cli_output"
5
+
6
+ module Browserctl
7
+ module Commands
8
+ class Watch
9
+ extend CliOutput
10
+
11
+ def self.run(client, args)
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl watch <page> <selector> [--timeout N]"
14
+ opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
15
+ end
16
+ name = args.shift
17
+ selector = args.shift
18
+ abort "usage: browserctl watch <page> <selector> [--timeout N]" unless name && selector
19
+ unless opts[:timeout].positive?
20
+ warn "Error: --timeout must be a positive number"
21
+ exit 1
22
+ end
23
+ print_result(client.watch(name, selector, timeout: opts[:timeout]))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,7 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- SOCKET_PATH = File.expand_path("~/.browserctl/browserd.sock")
5
- PID_PATH = File.expand_path("~/.browserctl/browserd.pid")
6
- IDLE_TTL = 30 * 60
4
+ BROWSERCTL_DIR = File.expand_path("~/.browserctl")
5
+ IDLE_TTL = 30 * 60
6
+
7
+ def self.socket_path(name = nil)
8
+ File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
9
+ end
10
+
11
+ def self.pid_path(name = nil)
12
+ File.join(BROWSERCTL_DIR, name ? "#{name}.pid" : "browserd.pid")
13
+ end
14
+
15
+ # Backward-compatible constants
16
+ SOCKET_PATH = socket_path
17
+ PID_PATH = pid_path
7
18
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Browserctl
6
+ LEVEL_MAP = {
7
+ "debug" => ::Logger::DEBUG,
8
+ "info" => ::Logger::INFO,
9
+ "warn" => ::Logger::WARN,
10
+ "error" => ::Logger::ERROR
11
+ }.freeze
12
+
13
+ def self.logger
14
+ @logger ||= build_logger("info")
15
+ end
16
+
17
+ def self.logger=(instance)
18
+ @logger = instance
19
+ end
20
+
21
+ def self.build_logger(level_name)
22
+ log = ::Logger.new($stderr)
23
+ log.level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
24
+ log.progname = "browserd"
25
+ log.formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
26
+ log
27
+ end
28
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "date"
5
+ require "fileutils"
6
+ require "tmpdir"
7
+
8
+ module Browserctl
9
+ class Recording
10
+ RECORDINGS_DIR = File.join(Dir.tmpdir, "browserctl-recordings")
11
+ STATE_FILE = File.expand_path("~/.browserctl/active_recording")
12
+
13
+ RECORDABLE = %w[open_page goto fill click screenshot evaluate].freeze
14
+
15
+ def self.start(name)
16
+ FileUtils.mkdir_p(RECORDINGS_DIR)
17
+ FileUtils.mkdir_p(File.dirname(STATE_FILE))
18
+ File.write(STATE_FILE, name)
19
+ FileUtils.rm_f(log_path(name))
20
+ name
21
+ end
22
+
23
+ def self.stop
24
+ name = active
25
+ raise "no active recording — run: browserctl record start <name>" unless name
26
+
27
+ File.unlink(STATE_FILE)
28
+ name
29
+ end
30
+
31
+ def self.active
32
+ File.exist?(STATE_FILE) ? File.read(STATE_FILE).strip : nil
33
+ end
34
+
35
+ def self.append(cmd, **attrs)
36
+ name = active
37
+ return unless name
38
+ return unless RECORDABLE.include?(cmd.to_s)
39
+ # ref-based interactions have no replayable selector — skip them
40
+ return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
41
+
42
+ File.open(log_path(name), "a") do |f|
43
+ f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
44
+ end
45
+ end
46
+
47
+ def self.generate_workflow(name, output_path: nil)
48
+ log = log_path(name)
49
+ raise "no recording found for '#{name}'" unless File.exist?(log)
50
+
51
+ lines = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
52
+ ruby = build_workflow_ruby(name, lines)
53
+ File.write(output_path, ruby) if output_path
54
+ ruby
55
+ ensure
56
+ FileUtils.rm_f(log) if log
57
+ end
58
+
59
+ class << self
60
+ private
61
+
62
+ def log_path(name)
63
+ File.join(RECORDINGS_DIR, "#{name}.jsonl")
64
+ end
65
+
66
+ def build_workflow_ruby(name, commands)
67
+ steps = commands.map { |c| build_step(c) }.join("\n\n")
68
+ <<~RUBY
69
+ # frozen_string_literal: true
70
+
71
+ Browserctl.workflow #{name.inspect} do
72
+ desc "Recorded on #{Date.today}"
73
+
74
+ #{steps.gsub(/^/, ' ')}
75
+ end
76
+ RUBY
77
+ end
78
+
79
+ def build_step(cmd)
80
+ label, body = step_parts(cmd)
81
+ "step #{label.inspect} do\n #{body}\nend"
82
+ end
83
+
84
+ def step_parts(cmd)
85
+ page = cmd[:name]
86
+ case cmd[:cmd]
87
+ when "open_page"
88
+ ["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
89
+ when "goto"
90
+ ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
91
+ when "fill"
92
+ ["fill #{cmd[:selector]} on #{page}",
93
+ "page(:#{page}).fill(#{cmd[:selector].inspect}, #{cmd[:value].inspect})"]
94
+ when "click"
95
+ ["click #{cmd[:selector]} on #{page}",
96
+ "page(:#{page}).click(#{cmd[:selector].inspect})"]
97
+ when "screenshot"
98
+ ["screenshot #{page}", "page(:#{page}).screenshot"]
99
+ when "evaluate"
100
+ ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
101
+ else
102
+ ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -24,7 +24,7 @@ module Browserctl
24
24
 
25
25
  def describe_workflow(name)
26
26
  defn = fetch_workflow(name)
27
- { name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:first) }
27
+ { name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:label) }
28
28
  end
29
29
 
30
30
  private
@@ -15,32 +15,33 @@ module Browserctl
15
15
  "click" => :cmd_click,
16
16
  "screenshot" => :cmd_screenshot,
17
17
  "wait_for" => :cmd_wait_for,
18
+ "watch" => :cmd_watch,
18
19
  "url" => :cmd_url,
19
20
  "ping" => :cmd_ping,
20
21
  "shutdown" => :cmd_shutdown
21
22
  }.freeze
22
23
 
23
24
  def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, mutex: Mutex.new)
24
- @pages = pages
25
- @browser = browser
26
- @snapshot = snapshot_builder
27
- @mutex = mutex
25
+ @pages = pages
26
+ @browser = browser
27
+ @snapshot = snapshot_builder
28
+ @mutex = mutex
29
+ @ref_registries = {}
30
+ @prev_snapshots = {}
28
31
  end
29
32
 
30
33
  def dispatch(req)
31
34
  handler = COMMAND_MAP[req[:cmd]]
32
35
  return { error: "unknown command: #{req[:cmd]}" } unless handler
33
36
 
37
+ Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
34
38
  send(handler, req)
35
39
  end
36
40
 
37
41
  private
38
42
 
39
43
  def cmd_open_page(req)
40
- page = @mutex.synchronize { @pages[req[:name]] } || begin
41
- new_page = @browser.create_page
42
- @mutex.synchronize { @pages[req[:name]] ||= new_page }
43
- end
44
+ page = @mutex.synchronize { @pages[req[:name]] ||= @browser.create_page }
44
45
  page.go_to(req[:url]) if req[:url]
45
46
  { ok: true, name: req[:name] }
46
47
  end
@@ -62,11 +63,31 @@ module Browserctl
62
63
  end
63
64
 
64
65
  def cmd_snapshot(req)
65
- with_page(req[:name]) { |p| build_snapshot(p, req[:format]) }
66
+ with_page(req[:name]) { |p| take_snapshot(req[:name], p, req[:format], req[:diff]) }
66
67
  end
67
68
 
68
- def build_snapshot(page, format)
69
- format == "ai" ? { ok: true, snapshot: @snapshot.call(page) } : { ok: true, html: page.body }
69
+ def take_snapshot(name, page, format, diff)
70
+ return { ok: true, html: page.body } unless format == "ai"
71
+
72
+ snapshot = @snapshot.call(page)
73
+ registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
74
+
75
+ result = @mutex.synchronize do
76
+ prev = @prev_snapshots[name]
77
+ @ref_registries[name] = registry
78
+ @prev_snapshots[name] = snapshot
79
+ diff && prev ? compute_diff(prev, snapshot) : snapshot
80
+ end
81
+
82
+ { ok: true, snapshot: result }
83
+ end
84
+
85
+ def compute_diff(prev, current)
86
+ prev_by_sel = prev.to_h { |el| [el[:selector], el] }
87
+ current.reject do |el|
88
+ old = prev_by_sel[el[:selector]]
89
+ old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
90
+ end
70
91
  end
71
92
 
72
93
  def cmd_evaluate(req)
@@ -74,7 +95,10 @@ module Browserctl
74
95
  end
75
96
 
76
97
  def cmd_fill(req)
77
- with_page(req[:name]) { |p| type_into(p, req[:selector], req[:value]) }
98
+ sel = resolve_selector(req[:name], req)
99
+ return sel if sel.is_a?(Hash)
100
+
101
+ with_page(req[:name]) { |p| type_into(p, sel, req[:value]) }
78
102
  end
79
103
 
80
104
  def type_into(page, selector, value)
@@ -87,7 +111,10 @@ module Browserctl
87
111
  end
88
112
 
89
113
  def cmd_click(req)
90
- with_page(req[:name]) { |p| click_element(p, req[:selector]) }
114
+ sel = resolve_selector(req[:name], req)
115
+ return sel if sel.is_a?(Hash)
116
+
117
+ with_page(req[:name]) { |p| click_element(p, sel) }
91
118
  end
92
119
 
93
120
  def click_element(page, selector)
@@ -110,6 +137,13 @@ module Browserctl
110
137
  with_page(req[:name]) { |p| wait_for_selector(p, req[:selector], req.fetch(:timeout, 10).to_f) }
111
138
  end
112
139
 
140
+ def cmd_watch(req)
141
+ with_page(req[:name]) do |p|
142
+ result = wait_for_selector(p, req[:selector], req.fetch(:timeout, 30).to_f)
143
+ result[:error] ? result : { ok: true, selector: req[:selector] }
144
+ end
145
+ end
146
+
113
147
  def wait_for_selector(page, selector, timeout)
114
148
  deadline = Time.now + timeout
115
149
  sleep 0.2 until (found = page.at_css(selector)) || Time.now > deadline
@@ -135,5 +169,13 @@ module Browserctl
135
169
 
136
170
  yield page
137
171
  end
172
+
173
+ def resolve_selector(name, req)
174
+ return req[:selector] if req[:selector]
175
+ return { error: "selector or ref required" } unless req[:ref]
176
+
177
+ sel = @mutex.synchronize { @ref_registries.dig(name, req[:ref]) }
178
+ sel || { error: "ref '#{req[:ref]}' not found — run snap first" }
179
+ end
138
180
  end
139
181
  end
@@ -25,7 +25,7 @@ module Browserctl
25
25
  end
26
26
 
27
27
  def shutdown(server)
28
- $stdout.puts "browserd idle timeout, shutting down"
28
+ Browserctl.logger.info "idle timeout (#{IDLE_TTL / 60}min), shutting down"
29
29
  quietly { server.close }
30
30
  Process.kill("INT", Process.pid)
31
31
  end
@@ -6,12 +6,15 @@ require "json"
6
6
  require "fileutils"
7
7
  require "timeout"
8
8
  require_relative "constants"
9
+ require_relative "logger"
9
10
  require_relative "server/command_dispatcher"
10
11
  require_relative "server/idle_watcher"
11
12
 
12
13
  module Browserctl
13
14
  class Server
14
- def initialize(headless: true)
15
+ def initialize(headless: true, socket_path: SOCKET_PATH, pid_path: PID_PATH)
16
+ @socket_path = socket_path
17
+ @pid_path = pid_path
15
18
  prepare_runtime(headless)
16
19
  @dispatcher = CommandDispatcher.new(@pages, @browser, mutex: @mutex)
17
20
  end
@@ -29,7 +32,7 @@ module Browserctl
29
32
  private
30
33
 
31
34
  def prepare_runtime(headless)
32
- FileUtils.mkdir_p(File.dirname(SOCKET_PATH))
35
+ FileUtils.mkdir_p(File.dirname(@socket_path))
33
36
  @browser = init_browser(headless)
34
37
  init_state
35
38
  end
@@ -56,18 +59,13 @@ module Browserctl
56
59
  end
57
60
 
58
61
  def setup_socket
59
- FileUtils.rm_f(SOCKET_PATH)
60
- server = UNIXServer.new(SOCKET_PATH)
61
- File.chmod(0o600, SOCKET_PATH)
62
- announce_socket
62
+ FileUtils.rm_f(@socket_path)
63
+ server = UNIXServer.new(@socket_path)
64
+ File.chmod(0o600, @socket_path)
65
+ Browserctl.logger.info "listening on #{@socket_path}"
63
66
  server
64
67
  end
65
68
 
66
- def announce_socket
67
- $stdout.puts "browserd listening on #{SOCKET_PATH}"
68
- $stdout.flush
69
- end
70
-
71
69
  def serve(server)
72
70
  loop do
73
71
  client = server.accept
@@ -78,6 +76,7 @@ module Browserctl
78
76
  def handle(socket)
79
77
  dispatch(socket, socket.gets)
80
78
  rescue StandardError => e
79
+ Browserctl.logger.error "#{e.class}: #{e.message}"
81
80
  quietly { socket.puts JSON.generate({ error: e.message }) }
82
81
  ensure
83
82
  quietly { socket.close }
@@ -100,12 +99,12 @@ module Browserctl
100
99
  idle&.kill
101
100
  quietly { server&.close }
102
101
  quietly { Timeout.timeout(5) { @browser.quit } }
103
- quietly { File.unlink(SOCKET_PATH) }
104
- quietly { File.unlink(PID_PATH) }
102
+ quietly { File.unlink(@socket_path) }
103
+ quietly { File.unlink(@pid_path) }
105
104
  end
106
105
 
107
106
  def write_pid
108
- File.write(PID_PATH, Process.pid.to_s)
107
+ File.write(@pid_path, Process.pid.to_s)
109
108
  end
110
109
 
111
110
  def quietly
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
3
4
  require_relative "client"
4
5
 
5
6
  module Browserctl
@@ -7,6 +8,7 @@ module Browserctl
7
8
 
8
9
  ParamDef = Struct.new(:name, :required, :secret, :default, keyword_init: true)
9
10
  StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
11
+ StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
10
12
 
11
13
  class WorkflowContext
12
14
  attr_reader :client
@@ -106,8 +108,8 @@ module Browserctl
106
108
  @param_defs[name] = ParamDef.new(name: name, required: required, secret: secret, default: default)
107
109
  end
108
110
 
109
- def step(label, &block)
110
- @steps << [label, block]
111
+ def step(label, retry_count: 0, timeout: nil, &block)
112
+ @steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
111
113
  end
112
114
 
113
115
  def call(params, client)
@@ -118,16 +120,30 @@ module Browserctl
118
120
  private
119
121
 
120
122
  def execute_steps(ctx)
121
- @steps.map { |label, block| run_step(ctx, label, block) }.each do |r|
123
+ @steps.map { |defn| run_step(ctx, defn) }.each do |r|
122
124
  raise WorkflowError, "step '#{r.name}' failed: #{r.error}" unless r.ok
123
125
  end
124
126
  end
125
127
 
126
- def run_step(ctx, label, block)
127
- ctx.instance_exec(&block)
128
- StepResult.new(name: label, ok: true)
129
- rescue WorkflowError, StandardError => e
130
- StepResult.new(name: label, ok: false, error: e.message)
128
+ def run_step(ctx, defn)
129
+ last_error = nil
130
+ (defn.retry_count + 1).times do
131
+ execute_block(ctx, defn)
132
+ return StepResult.new(name: defn.label, ok: true)
133
+ rescue WorkflowError, StandardError => e
134
+ last_error = e
135
+ end
136
+ StepResult.new(name: defn.label, ok: false, error: last_error.message)
137
+ end
138
+
139
+ def execute_block(ctx, defn)
140
+ if defn.timeout
141
+ ::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
142
+ else
143
+ ctx.instance_exec(&defn.block)
144
+ end
145
+ rescue ::Timeout::Error
146
+ raise WorkflowError, "step '#{defn.label}' timed out after #{defn.timeout}s"
131
147
  end
132
148
 
133
149
  def resolve_params(provided)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-19 00:00:00.000000000 Z
11
+ date: 2026-04-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ferrum
@@ -103,13 +103,17 @@ files:
103
103
  - examples/the_internet/login.rb
104
104
  - lib/browserctl.rb
105
105
  - lib/browserctl/client.rb
106
+ - lib/browserctl/commands/cli_output.rb
106
107
  - lib/browserctl/commands/click.rb
107
108
  - lib/browserctl/commands/fill.rb
108
- - lib/browserctl/commands/flag_extractor.rb
109
109
  - lib/browserctl/commands/open_page.rb
110
+ - lib/browserctl/commands/record.rb
110
111
  - lib/browserctl/commands/screenshot.rb
111
112
  - lib/browserctl/commands/snapshot.rb
113
+ - lib/browserctl/commands/watch.rb
112
114
  - lib/browserctl/constants.rb
115
+ - lib/browserctl/logger.rb
116
+ - lib/browserctl/recording.rb
113
117
  - lib/browserctl/runner.rb
114
118
  - lib/browserctl/server.rb
115
119
  - lib/browserctl/server/command_dispatcher.rb
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Browserctl
4
- module Commands
5
- module FlagExtractor
6
- def self.extract_opt(args, flag)
7
- i = args.index(flag)
8
- return unless i
9
-
10
- args.delete_at(i)
11
- args.delete_at(i)
12
- end
13
- end
14
- end
15
- end