browserctl 0.1.1 → 0.2.0

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: 7b5b21accfeb82afa82dc2c12d51ef7da31ed8ab9db073d2b576df0385e5adb6
4
+ data.tar.gz: d9cd601ce2d63b5c3f2c8d539d14dc684dcf3753a7f95002b9c165791ff2f584
5
5
  SHA512:
6
- metadata.gz: ef5ab4df359bde2cc6b635f6ac77c1147df57e85c57ff61222179f223e4d41ef4d58b622aa928cde19c41b1927d6ebafd273847ada8c55bf74cfdc09b6ddb5c7
7
- data.tar.gz: 0af88adb9b7076a4408c65ac25e7d13fd0f70aa2cdf79448fe9be7953695bd12de01d9c98626628163ce8edb738b4d2c83a925a77e2504924b1ee6bfe85c39d3
6
+ metadata.gz: 5bc9c337336ac4fb23da1966a9b51e3dffcef8b23ea00a2990310a0aae5978ccf423416118ebe61b83c92ca84f38846ca16ca909a376e2e3e8f13eb97e82b045
7
+ data.tar.gz: 13b8b5fcbc17e328210488ce8e50ead2ea8576419dc5d493c06285d8e191a4be7599a04a58b651f501febb351704609be6dc06c0c8c00c827a7c796667072943
data/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ 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.0](https://github.com/patrick204nqh/browserctl/compare/v0.1.1...v0.2.0) (2026-04-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * add architecture documentation and decision records with diagrams ([9b67e40](https://github.com/patrick204nqh/browserctl/commit/9b67e40892cb87a075818fd7b166584a48c8edff))
14
+ * add logging functionality and environment variable setup documentation ([feb529c](https://github.com/patrick204nqh/browserctl/commit/feb529c4040c6f84104fa6dc55afc8d0a5d1b1a7))
15
+ * add record command — capture session as replayable workflow ([bb8988b](https://github.com/patrick204nqh/browserctl/commit/bb8988bcf17c0a7689f5a6e53d755d0e6a69b359))
16
+ * add ref registry and diff cache to CommandDispatcher ([217e158](https://github.com/patrick204nqh/browserctl/commit/217e15831b2cf1f0bf693584f47ed5d315594803))
17
+ * add ref-based click and fill using snapshot registry ([f0251c0](https://github.com/patrick204nqh/browserctl/commit/f0251c0732fd535bde8746b2817d3674ec8bc353))
18
+ * add retry_count and timeout options to workflow steps ([7b5e694](https://github.com/patrick204nqh/browserctl/commit/7b5e694cfb246396502a07b02928de79233a715f))
19
+ * add snap --diff returning only changed elements ([0a09558](https://github.com/patrick204nqh/browserctl/commit/0a095583ea23b1cff0df1ecc6cb91a3e35039e2c))
20
+ * add watch command — poll selector and emit when found ([b9a3abf](https://github.com/patrick204nqh/browserctl/commit/b9a3abf15b7e021906dda940f29f029ca3c221c2))
21
+ * named daemon instances via browserd --name and multi-socket support ([8cbc62d](https://github.com/patrick204nqh/browserctl/commit/8cbc62d6c8931b23cbf31e7285ee52c62e280f5c))
22
+ * v0.2 AI-first enhancements ([d3246d4](https://github.com/patrick204nqh/browserctl/commit/d3246d4c861430fda1e7dfa1cb67ce22db33dd68))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * add edited event and workflow_dispatch to release trigger ([b851306](https://github.com/patrick204nqh/browserctl/commit/b85130603986ea5a667f2a89e9af7139c38491f2))
28
+ * add paths-ignore for markdown and docs in CI workflow ([3b7801d](https://github.com/patrick204nqh/browserctl/commit/3b7801dfa536ea0b0cd9bab189f237a2709e1515))
29
+ * describe_workflow after StepDef refactor; skip nil-selector recording for ref commands ([9e437fa](https://github.com/patrick204nqh/browserctl/commit/9e437fa9c18b6c94259a652af238377ce120d12e))
30
+ * fill --ref uses --value flag; add record generate subcommand ([ef3ed43](https://github.com/patrick204nqh/browserctl/commit/ef3ed433e09c0ae8ac6e241949ee9c2c0ccaa03c))
31
+ * remove 'edited' type from release trigger events ([98f63f0](https://github.com/patrick204nqh/browserctl/commit/98f63f0c40cbdcd40e14704379f28c19c436a6c2))
32
+ * rubocop offenses — formatting, guard clauses, predicate rename extract_flag? ([43341be](https://github.com/patrick204nqh/browserctl/commit/43341be50f12ca28699b009efb85b1ff404e4dab))
33
+ * trigger release workflow on GitHub release publication ([55c8faa](https://github.com/patrick204nqh/browserctl/commit/55c8faae8d753f35663ebd3c02996d1a602de091))
34
+ * update brand icon concept and roadmap versioning ([7a97b4b](https://github.com/patrick204nqh/browserctl/commit/7a97b4b0c0eaffbd8743ac30b2fbf13be468c80e))
35
+
8
36
  ## [0.1.1](https://github.com/patrick204nqh/browserctl/compare/v0.1.0...v0.1.1) (2026-04-19)
9
37
 
10
38
 
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
@@ -10,6 +10,8 @@ require "browserctl/commands/fill"
10
10
  require "browserctl/commands/click"
11
11
  require "browserctl/commands/snapshot"
12
12
  require "browserctl/commands/screenshot"
13
+ require "browserctl/commands/watch"
14
+ require "browserctl/commands/record"
13
15
 
14
16
  def usage
15
17
  puts <<~USAGE
@@ -26,6 +28,12 @@ def usage
26
28
  snap <page> [--format ai|html] Snapshot DOM (default: ai)
27
29
  url <page> Print current URL
28
30
  eval <page> <expression> Evaluate JS expression
31
+ watch <page> <selector> [--timeout N] Wait for a selector to appear
32
+
33
+ Recording commands:
34
+ record start <name> Start recording browser commands
35
+ record stop [--out path] Stop recording and save workflow
36
+ record status Show active recording name
29
37
 
30
38
  Workflow commands:
31
39
  run <name|file> [--key value] Run a workflow
@@ -35,10 +43,19 @@ def usage
35
43
  Daemon commands:
36
44
  ping Check if browserd is alive
37
45
  shutdown Stop browserd
46
+
47
+ Options:
48
+ --daemon <name> Connect to a named daemon instance
38
49
  USAGE
39
50
  exit 0
40
51
  end
41
52
 
53
+ daemon_idx = ARGV.index("--daemon")
54
+ daemon_name = if daemon_idx
55
+ ARGV.delete_at(daemon_idx)
56
+ ARGV.delete_at(daemon_idx)
57
+ end
58
+
42
59
  cmd = ARGV.shift
43
60
  args = ARGV.dup
44
61
 
@@ -70,8 +87,10 @@ when "describe"
70
87
  name = args.shift or abort "usage: browserctl describe <workflow_name>"
71
88
  puts JSON.pretty_generate(runner.describe_workflow(name))
72
89
 
90
+ when "record" then Browserctl::Commands::Record.run(args)
91
+
73
92
  else
74
- client = Browserctl::Client.new
93
+ client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
75
94
 
76
95
  case cmd
77
96
  when "open" then Browserctl::Commands::OpenPage.run(client, args)
@@ -84,6 +103,7 @@ else
84
103
  when "snap" then Browserctl::Commands::Snapshot.run(client, args)
85
104
  when "url" then puts client.url(args[0]).to_json
86
105
  when "eval" then puts client.evaluate(args[0], args[1]).to_json
106
+ when "watch" then Browserctl::Commands::Watch.run(client, args)
87
107
  when "ping" then puts client.ping.to_json
88
108
  when "shutdown" then puts client.shutdown.to_json
89
109
  else
data/bin/browserd CHANGED
@@ -3,8 +3,20 @@
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"
8
10
 
9
- headless = !ARGV.include?("--headed")
10
- Browserctl::Server.new(headless: headless).run
11
+ opts = Optimist.options do
12
+ opt :headed, "Run with a visible browser window", default: false
13
+ opt :log_level, "Log verbosity: debug, info, warn, error", default: "info", type: :string
14
+ opt :name, "Daemon instance name for multi-agent use", default: nil, type: :string
15
+ end
16
+
17
+ Browserctl.logger = Browserctl.build_logger(opts[:log_level])
18
+ Browserctl::Server.new(
19
+ headless: !opts[:headed],
20
+ socket_path: Browserctl.socket_path(opts[:name]),
21
+ pid_path: Browserctl.pid_path(opts[:name])
22
+ ).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")
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "flag_extractor"
4
+
3
5
  module Browserctl
4
6
  module Commands
5
7
  class Click
6
8
  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
9
+ name = args.shift
10
+ ref = FlagExtractor.extract_opt(args, "--ref")
11
+ selector = args.shift unless ref
12
+
13
+ if ref
14
+ abort "usage: browserctl click <page> --ref <ref>" unless name
15
+ puts client.click(name, ref: ref).to_json
16
+ else
17
+ abort "usage: browserctl click <page> <selector>" unless name && selector
18
+ puts client.click(name, selector).to_json
19
+ end
10
20
  end
11
21
  end
12
22
  end
@@ -1,12 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "flag_extractor"
4
+
3
5
  module Browserctl
4
6
  module Commands
5
7
  class Fill
6
8
  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
9
+ name = args.shift
10
+ ref = FlagExtractor.extract_opt(args, "--ref")
11
+
12
+ if ref
13
+ value = FlagExtractor.extract_opt(args, "--value")
14
+ abort "usage: browserctl fill <page> --ref <ref> --value <value>" unless name && value
15
+ puts client.fill(name, nil, value, ref: ref).to_json
16
+ else
17
+ selector = args.shift
18
+ value = args.shift
19
+ abort "usage: browserctl fill <page> <selector> <value>" unless name && selector && value
20
+ puts client.fill(name, selector, value).to_json
21
+ end
10
22
  end
11
23
  end
12
24
  end
@@ -7,8 +7,16 @@ module Browserctl
7
7
  i = args.index(flag)
8
8
  return unless i
9
9
 
10
+ sliced = args.slice!(i, 2)
11
+ sliced.length == 2 ? sliced.last : nil
12
+ end
13
+
14
+ def self.extract_flag?(args, flag)
15
+ i = args.index(flag)
16
+ return false unless i
17
+
10
18
  args.delete_at(i)
11
- args.delete_at(i)
19
+ true
12
20
  end
13
21
  end
14
22
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "browserctl/recording"
5
+
6
+ module Browserctl
7
+ module Commands
8
+ class Record
9
+ USAGE = "usage: browserctl record start <name> | stop [--out path] | status"
10
+
11
+ def self.run(args)
12
+ subcmd = args.shift
13
+ case subcmd
14
+ when "start" then run_start(args)
15
+ when "stop" then run_stop(args)
16
+ when "status" then run_status
17
+ else abort USAGE
18
+ end
19
+ end
20
+
21
+ class << self
22
+ private
23
+
24
+ def run_start(args)
25
+ name = args.shift or abort "usage: browserctl record start <name>"
26
+ Recording.start(name)
27
+ puts "Recording started: #{name}"
28
+ puts "Run browser commands, then: browserctl record stop"
29
+ end
30
+
31
+ def run_stop(args)
32
+ idx = args.index("--out")
33
+ out = if idx
34
+ args.delete_at(idx)
35
+ args.delete_at(idx)
36
+ end
37
+ name = Recording.stop
38
+ if out
39
+ FileUtils.mkdir_p(File.dirname(out))
40
+ Recording.generate_workflow(name, output_path: out)
41
+ puts "Workflow saved: #{out}"
42
+ else
43
+ dest_dir = ".browserctl/workflows"
44
+ dest_file = File.join(dest_dir, "#{name}.rb")
45
+ FileUtils.mkdir_p(dest_dir)
46
+ Recording.generate_workflow(name, output_path: dest_file)
47
+ puts "Workflow saved: #{dest_file}"
48
+ end
49
+ puts "Run with: browserctl run #{name}"
50
+ end
51
+
52
+ def run_status
53
+ active = Recording.active
54
+ puts active ? "Active recording: #{active}" : "No active recording."
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -7,9 +7,10 @@ module Browserctl
7
7
  module Commands
8
8
  class Snapshot
9
9
  def self.run(client, args)
10
- name = args.shift or abort "usage: browserctl snapshot <page> [--format ai|html]"
10
+ name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
11
11
  format = FlagExtractor.extract_opt(args, "--format") || "ai"
12
- res = client.snapshot(name, format: format)
12
+ diff = FlagExtractor.extract_flag?(args, "--diff")
13
+ res = client.snapshot(name, format: format, diff: diff)
13
14
  output_snapshot(res, format)
14
15
  end
15
16
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flag_extractor"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ class Watch
8
+ def self.run(client, args)
9
+ name = args.shift
10
+ selector = args.shift
11
+ timeout = (FlagExtractor.extract_opt(args, "--timeout") || 30).to_f
12
+ abort "usage: browserctl watch <page> <selector> [--timeout N]" unless name && selector
13
+ puts client.watch(name, selector, timeout: timeout).to_json
14
+ end
15
+ end
16
+ end
17
+ 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.0"
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.0
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
@@ -107,9 +107,13 @@ files:
107
107
  - lib/browserctl/commands/fill.rb
108
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