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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +45 -25
- data/examples/session_reuse.rb +75 -0
- data/examples/the_internet/login.rb +2 -2
- data/lib/browserctl/client.rb +25 -11
- data/lib/browserctl/commands/daemon.rb +9 -5
- data/lib/browserctl/commands/record.rb +4 -5
- data/lib/browserctl/commands/session.rb +7 -1
- data/lib/browserctl/commands/workflow.rb +2 -1
- data/lib/browserctl/errors.rb +4 -3
- data/lib/browserctl/runner.rb +2 -1
- data/lib/browserctl/secret_resolvers/macos_keychain.rb +1 -1
- data/lib/browserctl/secret_resolvers/one_password.rb +7 -1
- data/lib/browserctl/server/handlers/interaction.rb +15 -6
- data/lib/browserctl/server/handlers/session.rb +3 -2
- data/lib/browserctl/server.rb +8 -3
- data/lib/browserctl/session.rb +2 -2
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +91 -20
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dbfd167f7b8ad961fa91c544eb2636fa1bdc200b40f4738bbf27d9fa5a835cae
|
|
4
|
+
data.tar.gz: d3fdf8af59edd0c73ea1c415926baefbbe22405343898b46c60b1a18f2e623ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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)
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -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
|
|
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 <
|
|
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
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -67,7 +67,8 @@ module Browserctl
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def fetch_workflow(name)
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
-
"})(#{
|
|
28
|
+
"})(#{sel.to_json})"
|
|
26
29
|
)
|
|
27
|
-
return { error: "selector not found: #{
|
|
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
|
-
|
|
40
|
-
return
|
|
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
|
-
|
|
50
|
-
return
|
|
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
|
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -44,8 +44,13 @@ module Browserctl
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def ferrum_options
|
|
47
|
-
{ timeout: 30, process_timeout: 30,
|
|
48
|
-
|
|
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
|
|
133
|
+
rescue StandardError
|
|
129
134
|
nil
|
|
130
135
|
end
|
|
131
136
|
end
|
data/lib/browserctl/session.rb
CHANGED
|
@@ -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
|
|
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.
|
|
190
|
+
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
|
|
191
191
|
end
|
|
192
192
|
private_class_method :write_json
|
|
193
193
|
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -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
|
-
|
|
76
|
+
if res[:error]
|
|
77
|
+
raise WorkflowError, res[:error] unless fallback_name
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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)
|
|
140
|
-
|
|
141
|
-
def
|
|
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)
|
|
159
|
-
|
|
160
|
-
def
|
|
161
|
-
|
|
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
|
|
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.
|
|
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-
|
|
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: []
|