browserctl 0.8.3 → 0.8.4

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: dbfd167f7b8ad961fa91c544eb2636fa1bdc200b40f4738bbf27d9fa5a835cae
4
+ data.tar.gz: d3fdf8af59edd0c73ea1c415926baefbbe22405343898b46c60b1a18f2e623ac
5
5
  SHA512:
6
- metadata.gz: ceed28ff7dd8603bbaaee3ddaa5f6e65669a623aca423633a3ce08f64584064d37a400e461f8828260f245c34bba509452431c4af8ce2c672764778337a51075
7
- data.tar.gz: 932c20041989b25fc3fc7d20aa23909edd1651a00500071fbe1ceb4b38edb7c0d64c033cd267f5a4ad240fb3d3f60020320f94f5c45ad9912f79a1960549d7e0
6
+ metadata.gz: c46b3611cc0a29633558cf0547cd532cefcf4dbecddbbc62e348af27b9c4e5eec498f00bb90892e84a4665281cad57b20e1a73b14b50cff2e579f222301cf549
7
+ data.tar.gz: f5b8451f0e909212acf63e1eecc49ca607a7d095ea44e4fd4ae42e9b2509b199b7ed35a7158a83764b825dfb6747970e20fe137be465063f50a42c39508bfb68
data/CHANGELOG.md CHANGED
@@ -10,6 +10,16 @@ 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.8.4](https://github.com/patrick204nqh/browserctl/compare/v0.8.3...v0.8.4) (2026-05-01)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * onboarding UX/DX improvements before v0.8.4 ([#73](https://github.com/patrick204nqh/browserctl/issues/73)) ([876dc58](https://github.com/patrick204nqh/browserctl/commit/876dc58c8408ffa5b4c861529db9f9d656204770))
19
+ * OSS review improvements — security, community, and docs ([#76](https://github.com/patrick204nqh/browserctl/issues/76)) ([8e532f9](https://github.com/patrick204nqh/browserctl/commit/8e532f97d257b254fff601b7d2f5a38682f7795a))
20
+ * **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))
21
+ * UX/DX and docs improvements (M1–M4) ([#70](https://github.com/patrick204nqh/browserctl/issues/70)) ([747c17e](https://github.com/patrick204nqh/browserctl/commit/747c17e8821a0d1dde798ea8717ce3dd6cc33c63))
22
+
13
23
  ## [0.8.3](https://github.com/patrick204nqh/browserctl/compare/v0.8.2...v0.8.3) (2026-04-29)
14
24
 
15
25
 
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.
@@ -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)
@@ -21,8 +21,9 @@ 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
25
26
 
26
- class WorkflowError < StandardError; end
27
- class SecretResolverError < WorkflowError; end
27
+ class WorkflowError < Error; def self.default_code = "workflow_error" end
28
+ class SecretResolverError < WorkflowError; def self.default_code = "secret_resolver_error" end
28
29
  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
 
@@ -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}; " \
@@ -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
 
@@ -44,8 +44,13 @@ module Browserctl
44
44
  end
45
45
 
46
46
  def ferrum_options
47
- { timeout: 30, process_timeout: 30,
48
- browser_options: { "no-sandbox" => nil, "disable-dev-shm-usage" => nil, "disable-gpu" => nil } }
47
+ opts = { timeout: 30, process_timeout: 30,
48
+ browser_options: { "disable-dev-shm-usage" => nil, "disable-gpu" => nil } }
49
+ if ENV["CI"] || ENV["BROWSERCTL_NO_SANDBOX"]
50
+ Browserctl.logger.warn "no-sandbox enabled (CI or BROWSERCTL_NO_SANDBOX set)"
51
+ opts[:browser_options]["no-sandbox"] = nil
52
+ end
53
+ opts
49
54
  end
50
55
 
51
56
  def init_state
@@ -125,7 +130,7 @@ module Browserctl
125
130
 
126
131
  def quietly
127
132
  yield
128
- rescue Exception
133
+ rescue StandardError
129
134
  nil
130
135
  end
131
136
  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.8.4"
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.8.4
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-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ferrum
@@ -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
@@ -221,6 +222,7 @@ metadata:
221
222
  source_code_uri: https://github.com/patrick204nqh/browserctl
222
223
  changelog_uri: https://github.com/patrick204nqh/browserctl/blob/main/CHANGELOG.md
223
224
  bug_tracker_uri: https://github.com/patrick204nqh/browserctl/issues
225
+ documentation_uri: https://github.com/patrick204nqh/browserctl/tree/main/docs
224
226
  rubygems_mfa_required: 'true'
225
227
  post_install_message:
226
228
  rdoc_options: []