browserctl 0.8.2 → 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: c5145a9c65fc1dc92bd238aecd15c672286dfa41005333dbaa1e6a613834c9fc
4
- data.tar.gz: 0f1c56d87a58b41fbdea2e3ec1abf211bcf08a3677348d34565301f15bb76e96
3
+ metadata.gz: dbfd167f7b8ad961fa91c544eb2636fa1bdc200b40f4738bbf27d9fa5a835cae
4
+ data.tar.gz: d3fdf8af59edd0c73ea1c415926baefbbe22405343898b46c60b1a18f2e623ac
5
5
  SHA512:
6
- metadata.gz: 162679d64a18bfb241ef1312a7ecfac12cc312407ccd598bc0741c2f985d1754dbdcbe00112ee2cc522a3987b88d8cd34dd4799b48651238efb548348123d58f
7
- data.tar.gz: 710809a3eb707ad0f1f7300c77cb4b6cfdc4f631dc7f1e02b4b426262164b061d7008ba1901e501483e85a09ecb7be63fd60afa08dba9039b79670e711532fa4
6
+ metadata.gz: c46b3611cc0a29633558cf0547cd532cefcf4dbecddbbc62e348af27b9c4e5eec498f00bb90892e84a4665281cad57b20e1a73b14b50cff2e579f222301cf549
7
+ data.tar.gz: f5b8451f0e909212acf63e1eecc49ca607a7d095ea44e4fd4ae42e9b2509b199b7ed35a7158a83764b825dfb6747970e20fe137be465063f50a42c39508bfb68
data/CHANGELOG.md CHANGED
@@ -10,6 +10,28 @@ 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
+
23
+ ## [0.8.3](https://github.com/patrick204nqh/browserctl/compare/v0.8.2...v0.8.3) (2026-04-29)
24
+
25
+
26
+ ### Features
27
+
28
+ * expired_if: on load_session — detect stale sessions and auto-recover ([#67](https://github.com/patrick204nqh/browserctl/issues/67)) ([c6a4674](https://github.com/patrick204nqh/browserctl/commit/c6a467439835373165b1323c4aad6144388533be))
29
+
30
+
31
+ ### Miscellaneous Chores
32
+
33
+ * pin next release to 0.8.3 ([#69](https://github.com/patrick204nqh/browserctl/issues/69)) ([a017e3c](https://github.com/patrick204nqh/browserctl/commit/a017e3c3b464060ced2ecd4d76fa04049f40e46a))
34
+
13
35
  ## [0.8.2](https://github.com/patrick204nqh/browserctl/compare/v0.8.1...v0.8.2) (2026-04-29)
14
36
 
15
37
 
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
@@ -270,8 +284,8 @@ module Browserctl
270
284
  # Saves the current browser state (cookies, localStorage, open pages) to a named session.
271
285
  # @param session_name [String] name for the saved session
272
286
  # @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
273
- def session_save(session_name)
274
- call("session_save", session_name: session_name)
287
+ def session_save(session_name, encrypt: false)
288
+ call("session_save", session_name: session_name, encrypt: encrypt)
275
289
  end
276
290
 
277
291
  # Restores a previously saved session into the running daemon.
@@ -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}; " \
@@ -32,7 +32,8 @@ module Browserctl
32
32
  created_at: now, updated_at: now, pages: pages_meta },
33
33
  cookies: cookies,
34
34
  local_storage: local_storage,
35
- session_storage: {}
35
+ session_storage: {},
36
+ encrypt: req[:encrypt] || false
36
37
  )
37
38
  { ok: true, path: Browserctl::Session.path(req[:session_name]),
38
39
  pages: pages_meta.length, cookies: cookies.length }
@@ -74,7 +75,7 @@ module Browserctl
74
75
 
75
76
  { ok: true, cookies: cookie_count, pages: data[:metadata][:pages].length,
76
77
  local_storage_keys: ls_key_count }
77
- rescue RuntimeError => e
78
+ rescue Browserctl::Error, ArgumentError, JSON::ParserError, RuntimeError => e
78
79
  { error: e.message }
79
80
  end
80
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.2"
4
+ VERSION = "0.8.4"
5
5
  end
@@ -68,23 +68,21 @@ module Browserctl
68
68
  res
69
69
  end
70
70
 
71
- def load_session(session_name, fallback: nil)
71
+ def load_session(session_name, fallback: nil, expired_if: nil)
72
+ validate_expired_if!(expired_if)
73
+ fallback_name = fallback&.to_s
72
74
  res = @client.session_load(session_name)
73
- return res unless res[:error]
74
75
 
75
- raise WorkflowError, res[:error] unless fallback
76
+ if res[:error]
77
+ raise WorkflowError, res[:error] unless fallback_name
76
78
 
77
- invoke(fallback.to_s)
78
- res2 = @client.session_load(session_name)
79
- if res2[:error]
80
- msg = "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
81
- unless Session.exist?(session_name)
82
- msg += "\n Hint: '#{fallback}' did not call save_session(\"#{session_name}\") — add it as the last step."
83
- end
84
- raise WorkflowError, msg
79
+ invoke(fallback_name)
80
+ return load_after_fallback(session_name, fallback_name)
85
81
  end
86
82
 
87
- res2
83
+ return res if expired_if.nil? || !call_expired_if(expired_if, session_name)
84
+
85
+ recover_expired_session(session_name, fallback_name, expired_if)
88
86
  end
89
87
 
90
88
  def list_sessions
@@ -106,8 +104,64 @@ module Browserctl
106
104
  raise WorkflowError, msg unless condition
107
105
  end
108
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
+
109
113
  private
110
114
 
115
+ def validate_expired_if!(expired_if)
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?
125
+
126
+ raise ArgumentError,
127
+ "expired_if: lambda must take zero arguments (got #{expired_if.arity}) — " \
128
+ "use -> { page(:name).url... } to access pages via the workflow context"
129
+ end
130
+
131
+ def call_expired_if(expired_if, session_name)
132
+ expired_if.call
133
+ rescue WorkflowError, StandardError => e
134
+ raise WorkflowError, "expired_if check failed for session '#{session_name}': #{e.message}"
135
+ end
136
+
137
+ def recover_expired_session(session_name, fallback_name, expired_if)
138
+ unless fallback_name
139
+ raise WorkflowError,
140
+ "session '#{session_name}' is expired; provide fallback: to auto-recover"
141
+ end
142
+
143
+ invoke(fallback_name)
144
+ res = load_after_fallback(session_name, fallback_name)
145
+
146
+ if call_expired_if(expired_if, session_name)
147
+ raise WorkflowError,
148
+ "session '#{session_name}' still expired after running fallback '#{fallback_name}'"
149
+ end
150
+
151
+ res
152
+ end
153
+
154
+ def load_after_fallback(session_name, fallback)
155
+ res = @client.session_load(session_name)
156
+ return res unless res[:error]
157
+
158
+ msg = "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
159
+ unless Session.exist?(session_name)
160
+ msg += "\n Hint: '#{fallback}' did not call save_session(\"#{session_name}\") — add it as the last step."
161
+ end
162
+ raise WorkflowError, msg
163
+ end
164
+
111
165
  def invoke_stack
112
166
  @invoke_stack ||= []
113
167
  end
@@ -136,9 +190,16 @@ module Browserctl
136
190
  @client = client
137
191
  end
138
192
 
139
- def navigate(url) = unwrap @client.navigate(@name, url)
140
- def fill(sel, val) = unwrap @client.fill(@name, sel, val)
141
- 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
+
142
203
  def snapshot(**) = unwrap @client.snapshot(@name, **)
143
204
  def screenshot(**) = unwrap @client.screenshot(@name, **)
144
205
  def wait(sel, timeout: 30) = unwrap @client.wait(@name, sel, timeout: timeout)
@@ -155,10 +216,20 @@ module Browserctl
155
216
  unwrap @client.storage_set(@name, key, value, store: store)
156
217
  end
157
218
 
158
- def press(key) = unwrap @client.press(@name, key)
159
- def hover(selector) = unwrap @client.hover(@name, selector)
160
- def upload(selector, path) = unwrap @client.upload(@name, selector, path)
161
- 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
+
162
233
  def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
163
234
  def dialog_dismiss = unwrap @client.dialog_dismiss(@name)
164
235
 
@@ -220,7 +291,7 @@ module Browserctl
220
291
  (defn.retry_count + 1).times do
221
292
  execute_block(ctx, defn)
222
293
  return StepResult.new(name: defn.label, ok: true)
223
- rescue WorkflowError, StandardError => e
294
+ rescue StandardError => e
224
295
  last_error = e
225
296
  end
226
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.2
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: []