browserctl 0.8.3 → 0.9.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: cd4d66743b0fba81220c113a7c99a09a94b216acee4d734a6e69ff76d27378b7
4
- data.tar.gz: 60a8eb9469960e376c0d71a51bf801863e60d7a52e935578920ce227d8397e2b
3
+ metadata.gz: e27f4db200c9e26aaad474df3f55c1a7c91d602724d900d462a969ce4506ba9f
4
+ data.tar.gz: fba2acf3be3adf3a3a5ca9b42026abfcbfbeea7a8f8d48e51cd455b710fb403c
5
5
  SHA512:
6
- metadata.gz: ceed28ff7dd8603bbaaee3ddaa5f6e65669a623aca423633a3ce08f64584064d37a400e461f8828260f245c34bba509452431c4af8ce2c672764778337a51075
7
- data.tar.gz: 932c20041989b25fc3fc7d20aa23909edd1651a00500071fbe1ceb4b38edb7c0d64c033cd267f5a4ad240fb3d3f60020320f94f5c45ad9912f79a1960549d7e0
6
+ metadata.gz: 8ac208ae09f276efbf624ed297ef725d5144ec9c13624127f7f949491b0156f7a712ee22dc45cbe218addfe88227e7aa9676feaa105ef1813743b30ac3d5cd29
7
+ data.tar.gz: e5de1ed64d9d88e69c59a6de15bcfefaeb17b28cb578a8f6cddc2f5c97a8ee6d752c92efb6eff5d533d5923bfdcf6f29464a9f8bf06400953f6f874681c75689
data/CHANGELOG.md CHANGED
@@ -10,6 +10,24 @@ All notable changes to this project will be documented in this file.
10
10
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
11
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
+ ## [0.9.0](https://github.com/patrick204nqh/browserctl/compare/v0.8.4...v0.9.0) (2026-05-09)
14
+
15
+
16
+ ### Features
17
+
18
+ * split skills into automate + feedback, refresh for v0.9 driver ([#80](https://github.com/patrick204nqh/browserctl/issues/80)) ([65c1a8e](https://github.com/patrick204nqh/browserctl/commit/65c1a8ee25a81aa5337fb8c447781efd8f6ade2e))
19
+ * v0.9 browser-agnostic driver layer + Brave support ([#77](https://github.com/patrick204nqh/browserctl/issues/77)) ([0156c2d](https://github.com/patrick204nqh/browserctl/commit/0156c2defc105d49be038d108fa3da3ed1c4b402))
20
+
21
+ ## [0.8.4](https://github.com/patrick204nqh/browserctl/compare/v0.8.3...v0.8.4) (2026-05-01)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * onboarding UX/DX improvements before v0.8.4 ([#73](https://github.com/patrick204nqh/browserctl/issues/73)) ([876dc58](https://github.com/patrick204nqh/browserctl/commit/876dc58c8408ffa5b4c861529db9f9d656204770))
27
+ * OSS review improvements — security, community, and docs ([#76](https://github.com/patrick204nqh/browserctl/issues/76)) ([8e532f9](https://github.com/patrick204nqh/browserctl/commit/8e532f97d257b254fff601b7d2f5a38682f7795a))
28
+ * **test:** clear localStorage before stale session save to prevent test pollution ([#72](https://github.com/patrick204nqh/browserctl/issues/72)) ([170b7be](https://github.com/patrick204nqh/browserctl/commit/170b7be68d4c4e366ad59c042500c3030886d70c))
29
+ * UX/DX and docs improvements (M1–M4) ([#70](https://github.com/patrick204nqh/browserctl/issues/70)) ([747c17e](https://github.com/patrick204nqh/browserctl/commit/747c17e8821a0d1dde798ea8717ce3dd6cc33c63))
30
+
13
31
  ## [0.8.3](https://github.com/patrick204nqh/browserctl/compare/v0.8.2...v0.8.3) (2026-04-29)
14
32
 
15
33
 
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <h1 align="center">browserctl</h1>
6
6
 
7
7
  <p align="center">
8
- A browser daemon that keeps sessions alive between commands for AI agents and iterative dev workflows.
8
+ The browser you delegate to your agents with a pause button for the parts that still need you.
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -29,29 +29,6 @@ browserctl daemon stop
29
29
 
30
30
  ---
31
31
 
32
- ## See it in action
33
-
34
- <table align="center"><tr>
35
- <td align="center" width="50%">
36
-
37
- **Terminal**<br/>
38
- <sub>CLI commands, live output, session persistence proof</sub>
39
-
40
- <img src="docs/assets/terminal.gif" alt="browserctl terminal demo"/>
41
-
42
- </td>
43
- <td align="center" width="50%">
44
-
45
- **Browser**<br/>
46
- <sub>What the browser sees as those commands run</sub>
47
-
48
- <img src="docs/assets/browser_demo.gif" alt="browserctl browser demo"/>
49
-
50
- </td>
51
- </tr></table>
52
-
53
- ---
54
-
55
32
  ## Quick Start
56
33
 
57
34
  ```bash
@@ -77,6 +54,11 @@ browserctl click main --ref e3
77
54
  browserctl url main
78
55
  browserctl snapshot main --diff # only what changed
79
56
 
57
+ # Session persistence: save now, pick up later
58
+ browserctl session save my-session
59
+ # On a fresh daemon tomorrow: `browserctl session load my-session`
60
+ # → tabs restored, cookies intact, no re-login needed
61
+
80
62
  # 7. Done
81
63
  browserctl daemon stop
82
64
  ```
@@ -85,6 +67,29 @@ browserctl daemon stop
85
67
 
86
68
  ---
87
69
 
70
+ ## See it in action
71
+
72
+ <table align="center"><tr>
73
+ <td align="center" width="50%">
74
+
75
+ **Terminal**<br/>
76
+ <sub>CLI commands, live output, session persistence proof</sub>
77
+
78
+ <img src="docs/assets/terminal.gif" alt="browserctl terminal demo"/>
79
+
80
+ </td>
81
+ <td align="center" width="50%">
82
+
83
+ **Browser**<br/>
84
+ <sub>What the browser sees as those commands run</sub>
85
+
86
+ <img src="docs/assets/browser_demo.gif" alt="browserctl browser demo"/>
87
+
88
+ </td>
89
+ </tr></table>
90
+
91
+ ---
92
+
88
93
  ## Use cases
89
94
 
90
95
  **AI coding agent authenticating into a staging environment** — the agent logs in once, the session persists, subsequent commands run inside the authenticated context without re-authenticating between steps.
@@ -119,11 +124,19 @@ Most automation tools are stateless — every script spins up a fresh browser an
119
124
 
120
125
  **Requirements:** Ruby >= 3.3 · Chrome or Chromium installed
121
126
 
127
+ **macOS (Homebrew — recommended)**
128
+
129
+ ```bash
130
+ brew install patrick204nqh/tap/browserctl
131
+ ```
132
+
133
+ **RubyGems**
134
+
122
135
  ```bash
123
136
  gem install browserctl
124
137
  ```
125
138
 
126
- Or in your `Gemfile`:
139
+ Or in your `Gemfile` (for projects using the client API directly):
127
140
 
128
141
  ```ruby
129
142
  gem "browserctl"
@@ -182,10 +195,13 @@ The daemon shuts itself down after 30 minutes of inactivity.
182
195
  | | |
183
196
  |---|---|
184
197
  | [Getting Started](docs/getting-started.md) | Install, first session, first snapshot |
198
+ | [Agent Integration](docs/guides/agent-integration.md) | Call browserctl from Python, shell, or Anthropic tool-use agents |
185
199
  | [Concepts](docs/concepts/) | Sessions, snapshots, human-in-the-loop |
186
200
  | [Guides](docs/guides/) | Writing workflows, handling challenges, smoke testing |
201
+ | [Examples](examples/) | Runnable scripts: session reuse, Cloudflare HITL, and more |
187
202
  | [Command Reference](docs/reference/commands.md) | Every command and flag |
188
203
  | [API Stability](docs/reference/api-stability.md) | Wire protocol contract and stability zones |
204
+ | [CHANGELOG](CHANGELOG.md) | Release history |
189
205
  | [Product](docs/product.md) | What browserctl is and who it's for |
190
206
  | [Vision & Roadmap](docs/vision.md) | Philosophy and release roadmap |
191
207
  | [vs. agent-browser](docs/vs-agent-browser.md) | How browserctl differs from Vercel's agent-browser |
@@ -219,3 +235,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md)
219
235
  ## License
220
236
 
221
237
  [MIT](LICENSE)
238
+
239
+ ---
240
+
241
+ Built by [Patrick](https://github.com/patrick204nqh) — I built this because I was building AI agents that needed authenticated web sessions, and every automation tool I tried restarted the browser between runs.
data/bin/browserd CHANGED
@@ -9,17 +9,24 @@ require "browserctl/logger"
9
9
  require "browserctl/server"
10
10
  require "browserctl/version"
11
11
 
12
+ SUPPORTED_BROWSERS = %w[chrome chromium brave].freeze
13
+
12
14
  opts = Optimist.options do
13
15
  version "browserd #{Browserctl::VERSION}"
14
16
  opt :headed, "Run with a visible browser window", default: false, short: "-H"
15
17
  opt :log_level, "Log verbosity: debug, info, warn, error", default: "info", short: "-l", type: :string
16
18
  opt :name, "Daemon instance name for multi-agent use", default: nil, short: "-n", type: :string
19
+ opt :browser, "Browser to use: chrome, chromium, brave", default: "chrome", short: "-b", type: :string
17
20
  end
18
21
 
19
22
  if opts[:name] && opts[:name] !~ /\A[a-zA-Z0-9_-]{1,64}\z/
20
23
  abort "Invalid daemon name #{opts[:name].inspect} — use only letters, digits, _ or -"
21
24
  end
22
25
 
26
+ unless SUPPORTED_BROWSERS.include?(opts[:browser])
27
+ abort "Unsupported browser #{opts[:browser].inspect} — choose one of: #{SUPPORTED_BROWSERS.join(', ')}"
28
+ end
29
+
23
30
  assigned_name = opts[:name] || Browserctl.next_daemon_name
24
31
  if assigned_name && !opts[:name]
25
32
  warn "browserd: default slot taken — starting as '#{assigned_name}'"
@@ -29,8 +36,13 @@ end
29
36
  log_path = Browserctl.log_path(assigned_name)
30
37
  warn "browserd starting — log: #{log_path}"
31
38
  Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path)
32
- Browserctl::Server.new(
33
- headless: !opts[:headed],
34
- socket_path: Browserctl.socket_path(assigned_name),
35
- pid_path: Browserctl.pid_path(assigned_name)
36
- ).run
39
+ begin
40
+ Browserctl::Server.new(
41
+ headless: !opts[:headed],
42
+ browser: opts[:browser],
43
+ socket_path: Browserctl.socket_path(assigned_name),
44
+ pid_path: Browserctl.pid_path(assigned_name)
45
+ ).run
46
+ rescue Browserctl::BrowserNotFound => e
47
+ abort e.message
48
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Demonstrates the authenticate-once, reuse-forever pattern.
4
+ #
5
+ # The first run (no saved session) invokes `login_once` to authenticate,
6
+ # which saves the session. Every subsequent run loads the saved session
7
+ # directly — no re-authentication needed.
8
+ #
9
+ # `expired_if:` detects when the saved session exists but server-side auth
10
+ # has lapsed (rotated cookie, token TTL), and automatically re-authenticates.
11
+ #
12
+ # Run:
13
+ # browserctl workflow run examples/session_reuse.rb \
14
+ # --app_url https://the-internet.herokuapp.com \
15
+ # --username tomsmith \
16
+ # --password "SuperSecretPassword!"
17
+ #
18
+ # On the first run: authenticates and saves the session.
19
+ # On subsequent runs: loads the session and skips the login page entirely.
20
+
21
+ # --- Step 1: define the login workflow (run once, triggered automatically on missing/expired session) ---
22
+
23
+ Browserctl.workflow "session_reuse/login_once" do
24
+ desc "Authenticate and save session — called automatically by session_reuse when needed"
25
+
26
+ param :app_url, required: true
27
+ param :username, required: true
28
+ param :password, required: true, secret: true
29
+
30
+ step "open login page" do
31
+ open_page(:main, url: "#{app_url}/login")
32
+ end
33
+
34
+ step "fill and submit credentials" do
35
+ page(:main).fill("input#username", username)
36
+ page(:main).fill("input#password", password)
37
+ page(:main).click("button[type=submit]")
38
+ end
39
+
40
+ step "verify login succeeded" do
41
+ assert page(:main).url.include?("/secure"), "login failed — still on login page"
42
+ end
43
+
44
+ step "save authenticated session" do
45
+ save_session("session_reuse_demo")
46
+ puts " ✓ Session saved — future runs will skip this step"
47
+ end
48
+ end
49
+
50
+ # --- Step 2: the main workflow that reuses the saved session ---
51
+
52
+ Browserctl.workflow "session_reuse" do
53
+ desc "Authenticate once, reuse forever — demonstrates load_session with fallback and expired_if"
54
+
55
+ param :app_url, default: "https://the-internet.herokuapp.com"
56
+ param :username, default: "tomsmith"
57
+ param :password, default: "SuperSecretPassword!", secret: true
58
+
59
+ step "restore session or log in" do
60
+ load_session("session_reuse_demo",
61
+ fallback: "session_reuse/login_once",
62
+ expired_if: lambda {
63
+ page(:main).navigate("#{app_url}/secure")
64
+ !page(:main).url.include?("/secure")
65
+ })
66
+ puts " ✓ Session ready — authenticated as #{username}"
67
+ end
68
+
69
+ step "do authenticated work" do
70
+ page(:main).navigate("#{app_url}/secure")
71
+ heading = page(:main).evaluate("document.querySelector('h2')?.textContent?.trim()")
72
+ assert heading&.include?("Secure Area"), "expected to be in secure area, got: #{heading.inspect}"
73
+ puts " ✓ Landed in secure area without re-authenticating"
74
+ end
75
+ end
@@ -20,14 +20,14 @@ Browserctl.workflow "the_internet/login" do
20
20
 
21
21
  step "verify secure area" do
22
22
  assert page(:main).url.include?("/secure"), "expected redirect to /secure"
23
- flash = client.evaluate("main", "document.querySelector('.flash.success')?.innerText?.trim()")[:result]
23
+ flash = page(:main).evaluate("document.querySelector('.flash.success')?.innerText?.trim()")
24
24
  assert flash&.include?("You logged into a secure area!"), "expected success flash, got: #{flash.inspect}"
25
25
  page(:main).screenshot(path: screenshot_path)
26
26
  end
27
27
 
28
28
  step "logout and verify" do
29
29
  page(:main).click("a[href='/logout']")
30
- flash = client.evaluate("main", "document.querySelector('.flash.success')?.innerText?.trim()")[:result]
30
+ flash = page(:main).evaluate("document.querySelector('.flash.success')?.innerText?.trim()")
31
31
  assert flash&.include?("You logged out"), "expected logout flash, got: #{flash.inspect}"
32
32
  end
33
33
  end
@@ -18,7 +18,7 @@ module Browserctl
18
18
  Recording.append(cmd, **params) if result[:ok]
19
19
  result
20
20
  rescue Errno::ENOENT, Errno::ECONNREFUSED
21
- raise "browserd is not running — start it with: browserd"
21
+ raise DaemonUnavailableError, "browserd is not running — start it with: browserd"
22
22
  end
23
23
 
24
24
  # Opens or focuses a named browser page.
@@ -234,27 +234,41 @@ module Browserctl
234
234
  # @param name [String] logical page name
235
235
  # @param key [String] key name e.g. "Enter", "Tab", "Escape", "ArrowDown"
236
236
  # @return [Hash] `{ ok: true }` or `{ error: }`
237
- def press(name, key) = call("press", name: name, key: key)
237
+ def press(name, key) = call("press", name: name, key: key)
238
238
 
239
239
  # Moves the mouse to the centre of the element matched by selector.
240
240
  # @param name [String] logical page name
241
241
  # @param selector [String] CSS selector
242
242
  # @return [Hash] `{ ok: true }` or `{ error: }`
243
- def hover(name, selector) = call("hover", name: name, selector: selector)
243
+ def hover(name, selector = nil, ref: nil)
244
+ raise ArgumentError, "hover: provide selector or ref:" unless selector || ref
245
+
246
+ call("hover", name: name, selector: selector, ref: ref)
247
+ end
244
248
 
245
249
  # Sets a file-input element to the given file path.
246
250
  # @param name [String] logical page name
247
- # @param selector [String] CSS selector for the file input
248
- # @param path [String] absolute or relative file path
251
+ # @param selector [String, nil] CSS selector for the file input
252
+ # @param path [String, nil] absolute or relative file path
253
+ # @param ref [String, nil] element ref from a prior snapshot
249
254
  # @return [Hash] `{ ok: true }` or `{ error: }`
250
- def upload(name, selector, path) = call("upload", name: name, selector: selector, path: path)
255
+ def upload(name, selector = nil, path = nil, ref: nil)
256
+ raise ArgumentError, "upload: provide selector or ref:" unless selector || ref
257
+
258
+ call("upload", name: name, selector: selector, ref: ref, path: path)
259
+ end
251
260
 
252
261
  # Sets a <select> element's value and fires a change event.
253
262
  # @param name [String] logical page name
254
- # @param selector [String] CSS selector for the select element
255
- # @param value [String] option value to select
263
+ # @param selector [String, nil] CSS selector for the select element
264
+ # @param value [String, nil] option value to select
265
+ # @param ref [String, nil] element ref from a prior snapshot
256
266
  # @return [Hash] `{ ok: true }` or `{ error: }`
257
- def select(name, selector, value) = call("select", name: name, selector: selector, value: value)
267
+ def select(name, selector = nil, value = nil, ref: nil)
268
+ raise ArgumentError, "select: provide selector or ref:" unless selector || ref
269
+
270
+ call("select", name: name, selector: selector, ref: ref, value: value)
271
+ end
258
272
 
259
273
  # Pre-registers a one-shot handler to accept the next JS dialog on a page.
260
274
  # @param name [String] logical page name
@@ -14,7 +14,13 @@ module Browserctl
14
14
  def self.run(client, args)
15
15
  sub = args.shift or abort USAGE
16
16
  case sub
17
- when "ping" then print_result(client.ping)
17
+ when "ping"
18
+ begin
19
+ print_result(client.ping)
20
+ rescue Browserctl::DaemonUnavailableError => e
21
+ puts JSON.generate({ ok: false, daemon: "offline", error: e.message })
22
+ exit 1
23
+ end
18
24
  when "status" then run_status(client)
19
25
  when "start" then run_start(args)
20
26
  when "stop" then print_result(client.shutdown)
@@ -36,9 +42,7 @@ module Browserctl
36
42
  protocol_version: ping[:protocol_version],
37
43
  pages: page_info
38
44
  )
39
- rescue RuntimeError => e
40
- raise unless e.message.include?("browserd is not running")
41
-
45
+ rescue Browserctl::DaemonUnavailableError => e
42
46
  puts JSON.pretty_generate(daemon: "offline", error: e.message)
43
47
  exit 1
44
48
  end
@@ -67,7 +71,7 @@ module Browserctl
67
71
  next unless status&.dig(:ok)
68
72
 
69
73
  { name: display_name, pid: status[:pid], pages: (client.page_list[:pages] || []).length }
70
- rescue RuntimeError
74
+ rescue Browserctl::DaemonUnavailableError, RuntimeError
71
75
  nil
72
76
  end.compact
73
77
  puts({ daemons: rows }.to_json)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "json"
4
5
  require "optimist"
5
6
  require "browserctl/recording"
6
7
 
@@ -29,8 +30,7 @@ module Browserctl
29
30
  abort "Invalid recording name #{name.inspect} — use only letters, digits, _ or -" \
30
31
  unless name =~ /\A[a-zA-Z0-9_-]{1,64}\z/
31
32
  Recording.start(name)
32
- puts "Recording started: #{name}"
33
- puts "Run browser commands, then: browserctl record stop"
33
+ puts JSON.generate({ ok: true, name: name })
34
34
  end
35
35
 
36
36
  def run_stop(args)
@@ -42,13 +42,12 @@ module Browserctl
42
42
  out = opts[:out] || File.join(".browserctl/workflows", "#{name}.rb")
43
43
  FileUtils.mkdir_p(File.dirname(out))
44
44
  Recording.generate_workflow(name, output_path: out)
45
- puts "Workflow saved: #{out}"
46
- puts "Run with: browserctl workflow run #{name}"
45
+ puts JSON.generate({ ok: true, name: name, path: out })
47
46
  end
48
47
 
49
48
  def run_status
50
49
  active = Recording.active
51
- puts active ? "Active recording: #{active}" : "No active recording."
50
+ puts JSON.generate({ active: active })
52
51
  end
53
52
  end
54
53
  end
@@ -61,6 +61,7 @@ module Browserctl
61
61
  name = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
62
62
  dest = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
63
63
 
64
+ Browserctl::Session.validate_name!(name)
64
65
  session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
65
66
  abort "session '#{name}' not found" unless Dir.exist?(session_dir)
66
67
 
@@ -85,6 +86,8 @@ module Browserctl
85
86
  sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
86
87
  FileUtils.mkdir_p(sessions_dir)
87
88
 
89
+ before = Dir[File.join(sessions_dir, "*/")].map { |p| File.basename(p) }
90
+
88
91
  if encrypted_zip?(zip_path)
89
92
  passphrase = prompt_passphrase
90
93
  decrypt_import(zip_path, sessions_dir, passphrase)
@@ -93,7 +96,10 @@ module Browserctl
93
96
  Process.wait(pid)
94
97
  end
95
98
 
96
- puts({ ok: true }.to_json)
99
+ after = Dir[File.join(sessions_dir, "*/")].map { |p| File.basename(p) }
100
+ name = (after - before).first
101
+
102
+ puts({ ok: true, name: name }.to_json)
97
103
  end
98
104
 
99
105
  # --- private helpers ---
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "cli_output"
4
5
 
5
6
  module Browserctl
@@ -52,7 +53,7 @@ module Browserctl
52
53
 
53
54
  def self.run_list(runner)
54
55
  list = runner.list_workflows
55
- list.each { |w| puts "#{w[:name].ljust(24)} #{w[:desc]}" }
56
+ puts JSON.generate({ workflows: list.map { |w| { name: w[:name], desc: w[:desc] } } })
56
57
  end
57
58
 
58
59
  def self.run_describe(runner, args)
@@ -11,7 +11,7 @@ module Browserctl
11
11
 
12
12
  # Returns true if the page appears to be showing a Cloudflare challenge.
13
13
  # Checks both the current URL and the page body for known Cloudflare signals.
14
- # @param page [Ferrum::Page] the browser page to inspect
14
+ # @param page [#current_url, #body] the browser page to inspect
15
15
  # @return [Boolean]
16
16
  def self.cloudflare?(page)
17
17
  url = page.current_url.to_s
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Driver
5
+ class Base
6
+ def create_page = raise NotImplementedError, "#{self.class.name}#create_page not implemented"
7
+ def quit = raise NotImplementedError, "#{self.class.name}#quit not implemented"
8
+ def headed? = raise NotImplementedError, "#{self.class.name}#headed? not implemented"
9
+ def supports?(_) = false
10
+ def devtools_info(_page) = raise NotImplementedError, "#{self.class.name}#devtools_info not implemented"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ferrum"
4
+ require_relative "base"
5
+ require_relative "cdp_page"
6
+ require_relative "../errors"
7
+
8
+ module Browserctl
9
+ module Driver
10
+ class CDP < Base
11
+ BRAVE_PATHS = {
12
+ darwin: [
13
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
14
+ File.expand_path("~/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")
15
+ ],
16
+ linux: [
17
+ "/usr/bin/brave-browser",
18
+ "/usr/bin/brave",
19
+ "/snap/bin/brave"
20
+ ],
21
+ windows: [
22
+ "C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe",
23
+ "C:/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe"
24
+ ]
25
+ }.freeze
26
+
27
+ def initialize(headless: true, browser: "chrome") # rubocop:disable Lint/MissingSuper
28
+ @headless = headless
29
+ @browser = browser
30
+ @ferrum = Ferrum::Browser.new(**ferrum_options)
31
+ end
32
+
33
+ def create_page
34
+ CDPPage.new(@ferrum.create_page)
35
+ end
36
+
37
+ def quit
38
+ @ferrum.quit
39
+ end
40
+
41
+ def headed?
42
+ !@headless
43
+ end
44
+
45
+ def supports?(capability)
46
+ capability == :devtools
47
+ end
48
+
49
+ def devtools_info(page)
50
+ port = @ferrum.process.port
51
+ target_id = page.target_id
52
+ { port: port, target_id: target_id }
53
+ end
54
+
55
+ private
56
+
57
+ def ferrum_options
58
+ opts = {
59
+ timeout: 30,
60
+ process_timeout: 30,
61
+ browser_options: { "disable-dev-shm-usage" => nil, "disable-gpu" => nil }
62
+ }
63
+
64
+ if @browser != "chrome" && (path = resolve_browser_path)
65
+ opts[:browser_path] = path
66
+ end
67
+
68
+ if ENV["CI"] || ENV["BROWSERCTL_NO_SANDBOX"]
69
+ Browserctl.logger.warn "no-sandbox enabled (CI or BROWSERCTL_NO_SANDBOX set)"
70
+ opts[:browser_options]["no-sandbox"] = nil
71
+ end
72
+
73
+ opts[:headless] = @headless
74
+ opts
75
+ end
76
+
77
+ def resolve_browser_path
78
+ case @browser
79
+ when "chromium"
80
+ resolve_chromium_path
81
+ when "brave"
82
+ resolve_brave_path
83
+ else
84
+ raise ArgumentError, "Unknown browser: #{@browser.inspect}"
85
+ end
86
+ end
87
+
88
+ def resolve_chromium_path
89
+ env_override("CHROMIUM_PATH")
90
+ # Returns nil when no override — Ferrum finds Chromium automatically on most platforms.
91
+ end
92
+
93
+ def resolve_brave_path
94
+ override = env_override("BRAVE_PATH")
95
+ return override if override
96
+
97
+ platform = detect_platform
98
+ candidates = BRAVE_PATHS.fetch(platform, [])
99
+ path = candidates.find { |p| File.executable?(p) }
100
+ unless path
101
+ raise BrowserNotFound,
102
+ "Brave browser not found. Install Brave or set BRAVE_PATH to its executable."
103
+ end
104
+
105
+ path
106
+ end
107
+
108
+ def env_override(var)
109
+ value = ENV.fetch(var, nil)
110
+ return nil if value.nil? || value.empty?
111
+ raise BrowserNotFound, "#{var}=#{value} is not an executable file" unless File.executable?(value)
112
+
113
+ value
114
+ end
115
+
116
+ def detect_platform
117
+ case RUBY_PLATFORM
118
+ when /darwin/ then :darwin
119
+ when /mingw|mswin|windows/ then :windows
120
+ else :linux
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Browserctl
6
+ module Driver
7
+ # Wraps a Ferrum::Page so handlers receive a driver-namespaced object instead of
8
+ # a raw Ferrum type. Currently a transparent delegator — the seam exists so future
9
+ # CDP-specific behaviour (capability checks, instrumentation) can live here without
10
+ # touching every handler. Delete this class if no override lands by the next driver.
11
+ class CDPPage < SimpleDelegator
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "driver/base"
4
+ require_relative "driver/cdp_page"
5
+ require_relative "driver/cdp"
@@ -21,8 +21,10 @@ module Browserctl
21
21
  class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
22
22
  class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
23
  class TimeoutError < Error; def self.default_code = "timeout" end
24
- class KeyNotFound < Error; def self.default_code = "key_not_found" end
24
+ class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
+ class DaemonUnavailableError < Error; def self.default_code = "daemon_unavailable" end
26
+ class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
25
27
 
26
- class WorkflowError < StandardError; end
27
- class SecretResolverError < WorkflowError; end
28
+ class WorkflowError < Error; def self.default_code = "workflow_error" end
29
+ class SecretResolverError < WorkflowError; def self.default_code = "secret_resolver_error" end
28
30
  end
@@ -67,7 +67,8 @@ module Browserctl
67
67
  end
68
68
 
69
69
  def fetch_workflow(name)
70
- return Browserctl.lookup_workflow(name.to_s) if Browserctl.lookup_workflow(name.to_s)
70
+ wf = Browserctl.lookup_workflow(name.to_s)
71
+ return wf if wf
71
72
 
72
73
  validate_name!(name)
73
74
  load_workflow_file(name)
@@ -8,7 +8,7 @@ module Browserctl
8
8
  def self.scheme = "keychain"
9
9
 
10
10
  def available?
11
- RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
11
+ RUBY_PLATFORM.include?("darwin") && system("which", "security", out: File::NULL, err: File::NULL)
12
12
  end
13
13
 
14
14
  def resolve(reference)
@@ -8,10 +8,16 @@ module Browserctl
8
8
  def self.scheme = "op"
9
9
 
10
10
  def available?
11
- system("which op > /dev/null 2>&1")
11
+ system("which", "op", out: File::NULL, err: File::NULL)
12
12
  end
13
13
 
14
+ SAFE_REFERENCE = %r{\A[a-zA-Z0-9._\-/]+\z}
15
+
14
16
  def resolve(reference)
17
+ unless SAFE_REFERENCE.match?(reference)
18
+ raise SecretResolverError, "invalid 1Password reference format: #{reference.inspect}"
19
+ end
20
+
15
21
  result, status = Open3.capture2("op", "read", "op://#{reference}")
16
22
  raise SecretResolverError, "1Password item not found: op://#{reference}" unless status.success?
17
23
 
@@ -73,9 +73,9 @@ module Browserctl
73
73
  SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
74
74
  SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
75
75
 
76
- def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
76
+ def initialize(pages, driver, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
77
77
  @pages = pages
78
- @browser = browser
78
+ @driver = driver
79
79
  @snapshot_builder = snapshot_builder
80
80
  @global_mutex = global_mutex
81
81
  @kv_store = {}
@@ -7,11 +7,14 @@ module Browserctl
7
7
  private
8
8
 
9
9
  def cmd_devtools(req)
10
+ return { error: "devtools is not supported by this driver" } unless @driver.supports?(:devtools)
11
+
10
12
  session = @global_mutex.synchronize { @pages[req[:name]] }
11
13
  return { error: "no page named '#{req[:name]}'" } unless session
12
14
 
13
- port = @browser.process.port
14
- target_id = session.page.target_id
15
+ info = @driver.devtools_info(session.page)
16
+ port = info[:port]
17
+ target_id = info[:target_id]
15
18
  devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
16
19
  "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
17
20
  { ok: true, devtools_url: devtools_url }
@@ -16,15 +16,18 @@ module Browserctl
16
16
 
17
17
  def cmd_hover(req)
18
18
  with_page(req[:name]) do |session|
19
+ sel = resolve_selector_from(session, req)
20
+ return sel if sel.is_a?(Hash)
21
+
19
22
  coords = session.page.evaluate(
20
23
  "(function(sel) { " \
21
24
  "var el = document.querySelector(sel); " \
22
25
  "if (!el) return null; " \
23
26
  "var r = el.getBoundingClientRect(); " \
24
27
  "return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
25
- "})(#{req[:selector].to_json})"
28
+ "})(#{sel.to_json})"
26
29
  )
27
- return { error: "selector not found: #{req[:selector]}" } unless coords
30
+ return { error: "selector not found: #{sel}" } unless coords
28
31
 
29
32
  session.page.mouse.move(x: coords["x"], y: coords["y"])
30
33
  { ok: true }
@@ -36,8 +39,11 @@ module Browserctl
36
39
  return { error: "file not found: #{path}" } unless File.exist?(path)
37
40
 
38
41
  with_page(req[:name]) do |session|
39
- el = session.page.at_css(req[:selector])
40
- return { error: "selector not found: #{req[:selector]}" } unless el
42
+ sel = resolve_selector_from(session, req)
43
+ return sel if sel.is_a?(Hash)
44
+
45
+ el = session.page.at_css(sel)
46
+ return { error: "selector not found: #{sel}" } unless el
41
47
 
42
48
  el.select_file(path)
43
49
  { ok: true }
@@ -46,8 +52,11 @@ module Browserctl
46
52
 
47
53
  def cmd_select(req)
48
54
  with_page(req[:name]) do |session|
49
- el = session.page.at_css(req[:selector])
50
- return { error: "selector not found: #{req[:selector]}" } unless el
55
+ sel = resolve_selector_from(session, req)
56
+ return sel if sel.is_a?(Hash)
57
+
58
+ el = session.page.at_css(sel)
59
+ return { error: "selector not found: #{sel}" } unless el
51
60
 
52
61
  el.evaluate(
53
62
  "this.value = #{req[:value].to_json}; " \
@@ -8,7 +8,7 @@ module Browserctl
8
8
 
9
9
  def cmd_page_open(req)
10
10
  session = @global_mutex.synchronize do
11
- @pages[req[:name]] ||= PageSession.new(@browser.create_page)
11
+ @pages[req[:name]] ||= PageSession.new(@driver.create_page)
12
12
  end
13
13
  session.page.go_to(req[:url]) if req[:url]
14
14
  { ok: true, name: req[:name] }
@@ -25,9 +25,7 @@ module Browserctl
25
25
  end
26
26
 
27
27
  def cmd_page_focus(req)
28
- unless @browser.options.headless == false
29
- return { error: "page focus requires headed mode — start browserd with --headed" }
30
- end
28
+ return { error: "page focus requires headed mode — start browserd with --headed" } unless @driver.headed?
31
29
 
32
30
  with_page(req[:name]) do |session|
33
31
  session.page.activate
@@ -47,7 +47,7 @@ module Browserctl
47
47
  if existing
48
48
  existing.page.go_to(page_data[:url])
49
49
  else
50
- new_page = @browser.create_page
50
+ new_page = @driver.create_page
51
51
  new_page.go_to(page_data[:url])
52
52
  @global_mutex.synchronize { @pages[page_name.to_s] = PageSession.new(new_page) }
53
53
  end
@@ -61,7 +61,7 @@ module Browserctl
61
61
  data[:local_storage].each do |origin, keys|
62
62
  next if keys.empty?
63
63
 
64
- tmp_page = @browser.create_page
64
+ tmp_page = @driver.create_page
65
65
  begin
66
66
  tmp_page.go_to(origin)
67
67
  keys.each do |k, v|
@@ -75,7 +75,7 @@ module Browserctl
75
75
 
76
76
  { ok: true, cookies: cookie_count, pages: data[:metadata][:pages].length,
77
77
  local_storage_keys: ls_key_count }
78
- rescue RuntimeError => e
78
+ rescue Browserctl::Error, ArgumentError, JSON::ParserError, RuntimeError => e
79
79
  { error: e.message }
80
80
  end
81
81
 
@@ -1,23 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ferrum"
4
3
  require "socket"
5
4
  require "json"
6
5
  require "fileutils"
7
6
  require "timeout"
8
7
  require_relative "constants"
9
8
  require_relative "logger"
9
+ require_relative "driver"
10
10
  require_relative "server/command_dispatcher"
11
11
  require_relative "server/idle_watcher"
12
12
  require_relative "server/page_session"
13
13
 
14
14
  module Browserctl
15
15
  class Server
16
- def initialize(headless: true, socket_path: SOCKET_PATH, pid_path: PID_PATH)
16
+ def initialize(headless: true, browser: "chrome", socket_path: SOCKET_PATH, pid_path: PID_PATH)
17
17
  @socket_path = socket_path
18
18
  @pid_path = pid_path
19
- prepare_runtime(headless)
20
- @dispatcher = CommandDispatcher.new(@pages, @browser, global_mutex: @mutex)
19
+ prepare_runtime(headless, browser)
20
+ @dispatcher = CommandDispatcher.new(@pages, @driver, global_mutex: @mutex)
21
21
  end
22
22
 
23
23
  def run
@@ -33,21 +33,12 @@ module Browserctl
33
33
 
34
34
  private
35
35
 
36
- def prepare_runtime(headless)
36
+ def prepare_runtime(headless, browser)
37
37
  FileUtils.mkdir_p(File.dirname(@socket_path))
38
- @browser = init_browser(headless)
38
+ @driver = Driver::CDP.new(headless: headless, browser: browser)
39
39
  init_state
40
40
  end
41
41
 
42
- def init_browser(headless)
43
- Ferrum::Browser.new(headless: headless, **ferrum_options)
44
- end
45
-
46
- def ferrum_options
47
- { timeout: 30, process_timeout: 30,
48
- browser_options: { "no-sandbox" => nil, "disable-dev-shm-usage" => nil, "disable-gpu" => nil } }
49
- end
50
-
51
42
  def init_state
52
43
  @pages = {}
53
44
  @last_used = Time.now
@@ -114,7 +105,7 @@ module Browserctl
114
105
  def teardown(idle, server)
115
106
  idle&.kill
116
107
  quietly { server&.close }
117
- quietly { Timeout.timeout(5) { @browser.quit } }
108
+ quietly { Timeout.timeout(5) { @driver.quit } }
118
109
  quietly { File.unlink(@socket_path) }
119
110
  quietly { File.unlink(@pid_path) }
120
111
  end
@@ -125,7 +116,7 @@ module Browserctl
125
116
 
126
117
  def quietly
127
118
  yield
128
- rescue Exception
119
+ rescue StandardError
129
120
  nil
130
121
  end
131
122
  end
@@ -162,7 +162,7 @@ module Browserctl
162
162
  private_class_method :keychain_fetch
163
163
 
164
164
  def self.keychain_available?
165
- RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
165
+ RUBY_PLATFORM.include?("darwin") && system("which", "security", out: File::NULL, err: File::NULL)
166
166
  end
167
167
  private_class_method :keychain_available?
168
168
 
@@ -187,7 +187,7 @@ module Browserctl
187
187
  private_class_method :decrypt_json
188
188
 
189
189
  def self.write_json(path, data)
190
- File.write(path, JSON.generate(data))
190
+ File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
191
191
  end
192
192
  private_class_method :write_json
193
193
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.8.3"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -104,14 +104,28 @@ module Browserctl
104
104
  raise WorkflowError, msg unless condition
105
105
  end
106
106
 
107
+ def compose(*)
108
+ raise WorkflowError,
109
+ "`compose` must be called at the workflow definition level, not inside a step block. " \
110
+ "Did you mean `invoke`?"
111
+ end
112
+
107
113
  private
108
114
 
109
115
  def validate_expired_if!(expired_if)
110
- return unless expired_if && !expired_if.lambda?
116
+ return unless expired_if
117
+
118
+ unless expired_if.lambda?
119
+ raise ArgumentError,
120
+ "expired_if: must be a lambda (-> { }), not a Proc — " \
121
+ "bare return inside a Proc unwinds the caller"
122
+ end
123
+
124
+ return if expired_if.arity.zero?
111
125
 
112
126
  raise ArgumentError,
113
- "expired_if: must be a lambda (-> { }), not a Proc — " \
114
- "bare return inside a Proc unwinds the caller"
127
+ "expired_if: lambda must take zero arguments (got #{expired_if.arity}) — " \
128
+ "use -> { page(:name).url... } to access pages via the workflow context"
115
129
  end
116
130
 
117
131
  def call_expired_if(expired_if, session_name)
@@ -176,9 +190,16 @@ module Browserctl
176
190
  @client = client
177
191
  end
178
192
 
179
- def navigate(url) = unwrap @client.navigate(@name, url)
180
- def fill(sel, val) = unwrap @client.fill(@name, sel, val)
181
- def click(sel) = unwrap @client.click(@name, sel)
193
+ def navigate(url) = unwrap @client.navigate(@name, url)
194
+
195
+ def fill(selector = nil, value = nil, ref: nil)
196
+ unwrap @client.fill(@name, selector, value, ref: ref)
197
+ end
198
+
199
+ def click(selector = nil, ref: nil)
200
+ unwrap @client.click(@name, selector, ref: ref)
201
+ end
202
+
182
203
  def snapshot(**) = unwrap @client.snapshot(@name, **)
183
204
  def screenshot(**) = unwrap @client.screenshot(@name, **)
184
205
  def wait(sel, timeout: 30) = unwrap @client.wait(@name, sel, timeout: timeout)
@@ -195,10 +216,20 @@ module Browserctl
195
216
  unwrap @client.storage_set(@name, key, value, store: store)
196
217
  end
197
218
 
198
- def press(key) = unwrap @client.press(@name, key)
199
- def hover(selector) = unwrap @client.hover(@name, selector)
200
- def upload(selector, path) = unwrap @client.upload(@name, selector, path)
201
- def select(selector, value) = unwrap @client.select(@name, selector, value)
219
+ def press(key) = unwrap @client.press(@name, key)
220
+
221
+ def hover(selector = nil, ref: nil)
222
+ unwrap @client.hover(@name, selector, ref: ref)
223
+ end
224
+
225
+ def upload(selector = nil, path = nil, ref: nil)
226
+ unwrap @client.upload(@name, selector, path, ref: ref)
227
+ end
228
+
229
+ def select(selector = nil, value = nil, ref: nil)
230
+ unwrap @client.select(@name, selector, value, ref: ref)
231
+ end
232
+
202
233
  def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
203
234
  def dialog_dismiss = unwrap @client.dialog_dismiss(@name)
204
235
 
@@ -260,7 +291,7 @@ module Browserctl
260
291
  (defn.retry_count + 1).times do
261
292
  execute_block(ctx, defn)
262
293
  return StepResult.new(name: defn.label, ok: true)
263
- rescue WorkflowError, StandardError => e
294
+ rescue StandardError => e
264
295
  last_error = e
265
296
  end
266
297
  StepResult.new(name: defn.label, ok: false, error: last_error.message)
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.8.3
4
+ version: 0.9.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-29 00:00:00.000000000 Z
11
+ date: 2026-05-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ferrum
@@ -123,7 +123,7 @@ dependencies:
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0.9'
125
125
  description: Named browser sessions, Ruby workflow DSL, and a token-efficient DOM
126
- snapshot format. Built on Ferrum (Chrome DevTools Protocol).
126
+ snapshot format. Built on a browser-agnostic driver layer (Ferrum/CDP backend).
127
127
  email:
128
128
  - patrick204nqh@gmail.com
129
129
  executables:
@@ -139,6 +139,7 @@ files:
139
139
  - bin/browserd
140
140
  - bin/setup
141
141
  - examples/cloudflare_hitl.rb
142
+ - examples/session_reuse.rb
142
143
  - examples/test_automation_practices/advanced/ab_testing.rb
143
144
  - examples/test_automation_practices/advanced/broken_images.rb
144
145
  - examples/test_automation_practices/advanced/file_download.rb
@@ -184,6 +185,10 @@ files:
184
185
  - lib/browserctl/commands/workflow.rb
185
186
  - lib/browserctl/constants.rb
186
187
  - lib/browserctl/detectors.rb
188
+ - lib/browserctl/driver.rb
189
+ - lib/browserctl/driver/base.rb
190
+ - lib/browserctl/driver/cdp.rb
191
+ - lib/browserctl/driver/cdp_page.rb
187
192
  - lib/browserctl/errors.rb
188
193
  - lib/browserctl/logger.rb
189
194
  - lib/browserctl/policy.rb
@@ -221,6 +226,7 @@ metadata:
221
226
  source_code_uri: https://github.com/patrick204nqh/browserctl
222
227
  changelog_uri: https://github.com/patrick204nqh/browserctl/blob/main/CHANGELOG.md
223
228
  bug_tracker_uri: https://github.com/patrick204nqh/browserctl/issues
229
+ documentation_uri: https://github.com/patrick204nqh/browserctl/tree/main/docs
224
230
  rubygems_mfa_required: 'true'
225
231
  post_install_message:
226
232
  rdoc_options: []