browserctl 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +45 -25
- data/bin/browserd +17 -5
- data/examples/session_reuse.rb +75 -0
- data/examples/the_internet/login.rb +2 -2
- data/lib/browserctl/client.rb +23 -9
- 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/detectors.rb +1 -1
- data/lib/browserctl/driver/base.rb +13 -0
- data/lib/browserctl/driver/cdp.rb +125 -0
- data/lib/browserctl/driver/cdp_page.rb +14 -0
- data/lib/browserctl/driver.rb +5 -0
- data/lib/browserctl/errors.rb +5 -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/command_dispatcher.rb +2 -2
- data/lib/browserctl/server/handlers/devtools.rb +5 -2
- data/lib/browserctl/server/handlers/interaction.rb +15 -6
- data/lib/browserctl/server/handlers/page_lifecycle.rb +2 -4
- data/lib/browserctl/server/handlers/session.rb +3 -3
- data/lib/browserctl/server.rb +8 -17
- data/lib/browserctl/session.rb +2 -2
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +42 -11
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e27f4db200c9e26aaad474df3f55c1a7c91d602724d900d462a969ce4506ba9f
|
|
4
|
+
data.tar.gz: fba2acf3be3adf3a3a5ca9b42026abfcbfbeea7a8f8d48e51cd455b710fb403c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ac208ae09f276efbf624ed297ef725d5144ec9c13624127f7f949491b0156f7a712ee22dc45cbe218addfe88227e7aa9676feaa105ef1813743b30ac3d5cd29
|
|
7
|
+
data.tar.gz: e5de1ed64d9d88e69c59a6de15bcfefaeb17b28cb578a8f6cddc2f5c97a8ee6d752c92efb6eff5d533d5923bfdcf6f29464a9f8bf06400953f6f874681c75689
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ All notable changes to this project will be documented in this file.
|
|
|
10
10
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
11
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
12
|
|
|
13
|
+
## [0.9.0](https://github.com/patrick204nqh/browserctl/compare/v0.8.4...v0.9.0) (2026-05-09)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* split skills into automate + feedback, refresh for v0.9 driver ([#80](https://github.com/patrick204nqh/browserctl/issues/80)) ([65c1a8e](https://github.com/patrick204nqh/browserctl/commit/65c1a8ee25a81aa5337fb8c447781efd8f6ade2e))
|
|
19
|
+
* v0.9 browser-agnostic driver layer + Brave support ([#77](https://github.com/patrick204nqh/browserctl/issues/77)) ([0156c2d](https://github.com/patrick204nqh/browserctl/commit/0156c2defc105d49be038d108fa3da3ed1c4b402))
|
|
20
|
+
|
|
21
|
+
## [0.8.4](https://github.com/patrick204nqh/browserctl/compare/v0.8.3...v0.8.4) (2026-05-01)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* onboarding UX/DX improvements before v0.8.4 ([#73](https://github.com/patrick204nqh/browserctl/issues/73)) ([876dc58](https://github.com/patrick204nqh/browserctl/commit/876dc58c8408ffa5b4c861529db9f9d656204770))
|
|
27
|
+
* OSS review improvements — security, community, and docs ([#76](https://github.com/patrick204nqh/browserctl/issues/76)) ([8e532f9](https://github.com/patrick204nqh/browserctl/commit/8e532f97d257b254fff601b7d2f5a38682f7795a))
|
|
28
|
+
* **test:** clear localStorage before stale session save to prevent test pollution ([#72](https://github.com/patrick204nqh/browserctl/issues/72)) ([170b7be](https://github.com/patrick204nqh/browserctl/commit/170b7be68d4c4e366ad59c042500c3030886d70c))
|
|
29
|
+
* UX/DX and docs improvements (M1–M4) ([#70](https://github.com/patrick204nqh/browserctl/issues/70)) ([747c17e](https://github.com/patrick204nqh/browserctl/commit/747c17e8821a0d1dde798ea8717ce3dd6cc33c63))
|
|
30
|
+
|
|
13
31
|
## [0.8.3](https://github.com/patrick204nqh/browserctl/compare/v0.8.2...v0.8.3) (2026-04-29)
|
|
14
32
|
|
|
15
33
|
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<h1 align="center">browserctl</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
|
|
8
|
+
The browser you delegate to your agents — with a pause button for the parts that still need you.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -29,29 +29,6 @@ browserctl daemon stop
|
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
-
## See it in action
|
|
33
|
-
|
|
34
|
-
<table align="center"><tr>
|
|
35
|
-
<td align="center" width="50%">
|
|
36
|
-
|
|
37
|
-
**Terminal**<br/>
|
|
38
|
-
<sub>CLI commands, live output, session persistence proof</sub>
|
|
39
|
-
|
|
40
|
-
<img src="docs/assets/terminal.gif" alt="browserctl terminal demo"/>
|
|
41
|
-
|
|
42
|
-
</td>
|
|
43
|
-
<td align="center" width="50%">
|
|
44
|
-
|
|
45
|
-
**Browser**<br/>
|
|
46
|
-
<sub>What the browser sees as those commands run</sub>
|
|
47
|
-
|
|
48
|
-
<img src="docs/assets/browser_demo.gif" alt="browserctl browser demo"/>
|
|
49
|
-
|
|
50
|
-
</td>
|
|
51
|
-
</tr></table>
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
32
|
## Quick Start
|
|
56
33
|
|
|
57
34
|
```bash
|
|
@@ -77,6 +54,11 @@ browserctl click main --ref e3
|
|
|
77
54
|
browserctl url main
|
|
78
55
|
browserctl snapshot main --diff # only what changed
|
|
79
56
|
|
|
57
|
+
# Session persistence: save now, pick up later
|
|
58
|
+
browserctl session save my-session
|
|
59
|
+
# On a fresh daemon tomorrow: `browserctl session load my-session`
|
|
60
|
+
# → tabs restored, cookies intact, no re-login needed
|
|
61
|
+
|
|
80
62
|
# 7. Done
|
|
81
63
|
browserctl daemon stop
|
|
82
64
|
```
|
|
@@ -85,6 +67,29 @@ browserctl daemon stop
|
|
|
85
67
|
|
|
86
68
|
---
|
|
87
69
|
|
|
70
|
+
## See it in action
|
|
71
|
+
|
|
72
|
+
<table align="center"><tr>
|
|
73
|
+
<td align="center" width="50%">
|
|
74
|
+
|
|
75
|
+
**Terminal**<br/>
|
|
76
|
+
<sub>CLI commands, live output, session persistence proof</sub>
|
|
77
|
+
|
|
78
|
+
<img src="docs/assets/terminal.gif" alt="browserctl terminal demo"/>
|
|
79
|
+
|
|
80
|
+
</td>
|
|
81
|
+
<td align="center" width="50%">
|
|
82
|
+
|
|
83
|
+
**Browser**<br/>
|
|
84
|
+
<sub>What the browser sees as those commands run</sub>
|
|
85
|
+
|
|
86
|
+
<img src="docs/assets/browser_demo.gif" alt="browserctl browser demo"/>
|
|
87
|
+
|
|
88
|
+
</td>
|
|
89
|
+
</tr></table>
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
88
93
|
## Use cases
|
|
89
94
|
|
|
90
95
|
**AI coding agent authenticating into a staging environment** — the agent logs in once, the session persists, subsequent commands run inside the authenticated context without re-authenticating between steps.
|
|
@@ -119,11 +124,19 @@ Most automation tools are stateless — every script spins up a fresh browser an
|
|
|
119
124
|
|
|
120
125
|
**Requirements:** Ruby >= 3.3 · Chrome or Chromium installed
|
|
121
126
|
|
|
127
|
+
**macOS (Homebrew — recommended)**
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
brew install patrick204nqh/tap/browserctl
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**RubyGems**
|
|
134
|
+
|
|
122
135
|
```bash
|
|
123
136
|
gem install browserctl
|
|
124
137
|
```
|
|
125
138
|
|
|
126
|
-
Or in your `Gemfile
|
|
139
|
+
Or in your `Gemfile` (for projects using the client API directly):
|
|
127
140
|
|
|
128
141
|
```ruby
|
|
129
142
|
gem "browserctl"
|
|
@@ -182,10 +195,13 @@ The daemon shuts itself down after 30 minutes of inactivity.
|
|
|
182
195
|
| | |
|
|
183
196
|
|---|---|
|
|
184
197
|
| [Getting Started](docs/getting-started.md) | Install, first session, first snapshot |
|
|
198
|
+
| [Agent Integration](docs/guides/agent-integration.md) | Call browserctl from Python, shell, or Anthropic tool-use agents |
|
|
185
199
|
| [Concepts](docs/concepts/) | Sessions, snapshots, human-in-the-loop |
|
|
186
200
|
| [Guides](docs/guides/) | Writing workflows, handling challenges, smoke testing |
|
|
201
|
+
| [Examples](examples/) | Runnable scripts: session reuse, Cloudflare HITL, and more |
|
|
187
202
|
| [Command Reference](docs/reference/commands.md) | Every command and flag |
|
|
188
203
|
| [API Stability](docs/reference/api-stability.md) | Wire protocol contract and stability zones |
|
|
204
|
+
| [CHANGELOG](CHANGELOG.md) | Release history |
|
|
189
205
|
| [Product](docs/product.md) | What browserctl is and who it's for |
|
|
190
206
|
| [Vision & Roadmap](docs/vision.md) | Philosophy and release roadmap |
|
|
191
207
|
| [vs. agent-browser](docs/vs-agent-browser.md) | How browserctl differs from Vercel's agent-browser |
|
|
@@ -219,3 +235,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md)
|
|
|
219
235
|
## License
|
|
220
236
|
|
|
221
237
|
[MIT](LICENSE)
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
Built by [Patrick](https://github.com/patrick204nqh) — I built this because I was building AI agents that needed authenticated web sessions, and every automation tool I tried restarted the browser between runs.
|
data/bin/browserd
CHANGED
|
@@ -9,17 +9,24 @@ require "browserctl/logger"
|
|
|
9
9
|
require "browserctl/server"
|
|
10
10
|
require "browserctl/version"
|
|
11
11
|
|
|
12
|
+
SUPPORTED_BROWSERS = %w[chrome chromium brave].freeze
|
|
13
|
+
|
|
12
14
|
opts = Optimist.options do
|
|
13
15
|
version "browserd #{Browserctl::VERSION}"
|
|
14
16
|
opt :headed, "Run with a visible browser window", default: false, short: "-H"
|
|
15
17
|
opt :log_level, "Log verbosity: debug, info, warn, error", default: "info", short: "-l", type: :string
|
|
16
18
|
opt :name, "Daemon instance name for multi-agent use", default: nil, short: "-n", type: :string
|
|
19
|
+
opt :browser, "Browser to use: chrome, chromium, brave", default: "chrome", short: "-b", type: :string
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
if opts[:name] && opts[:name] !~ /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
20
23
|
abort "Invalid daemon name #{opts[:name].inspect} — use only letters, digits, _ or -"
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
unless SUPPORTED_BROWSERS.include?(opts[:browser])
|
|
27
|
+
abort "Unsupported browser #{opts[:browser].inspect} — choose one of: #{SUPPORTED_BROWSERS.join(', ')}"
|
|
28
|
+
end
|
|
29
|
+
|
|
23
30
|
assigned_name = opts[:name] || Browserctl.next_daemon_name
|
|
24
31
|
if assigned_name && !opts[:name]
|
|
25
32
|
warn "browserd: default slot taken — starting as '#{assigned_name}'"
|
|
@@ -29,8 +36,13 @@ end
|
|
|
29
36
|
log_path = Browserctl.log_path(assigned_name)
|
|
30
37
|
warn "browserd starting — log: #{log_path}"
|
|
31
38
|
Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
39
|
+
begin
|
|
40
|
+
Browserctl::Server.new(
|
|
41
|
+
headless: !opts[:headed],
|
|
42
|
+
browser: opts[:browser],
|
|
43
|
+
socket_path: Browserctl.socket_path(assigned_name),
|
|
44
|
+
pid_path: Browserctl.pid_path(assigned_name)
|
|
45
|
+
).run
|
|
46
|
+
rescue Browserctl::BrowserNotFound => e
|
|
47
|
+
abort e.message
|
|
48
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Demonstrates the authenticate-once, reuse-forever pattern.
|
|
4
|
+
#
|
|
5
|
+
# The first run (no saved session) invokes `login_once` to authenticate,
|
|
6
|
+
# which saves the session. Every subsequent run loads the saved session
|
|
7
|
+
# directly — no re-authentication needed.
|
|
8
|
+
#
|
|
9
|
+
# `expired_if:` detects when the saved session exists but server-side auth
|
|
10
|
+
# has lapsed (rotated cookie, token TTL), and automatically re-authenticates.
|
|
11
|
+
#
|
|
12
|
+
# Run:
|
|
13
|
+
# browserctl workflow run examples/session_reuse.rb \
|
|
14
|
+
# --app_url https://the-internet.herokuapp.com \
|
|
15
|
+
# --username tomsmith \
|
|
16
|
+
# --password "SuperSecretPassword!"
|
|
17
|
+
#
|
|
18
|
+
# On the first run: authenticates and saves the session.
|
|
19
|
+
# On subsequent runs: loads the session and skips the login page entirely.
|
|
20
|
+
|
|
21
|
+
# --- Step 1: define the login workflow (run once, triggered automatically on missing/expired session) ---
|
|
22
|
+
|
|
23
|
+
Browserctl.workflow "session_reuse/login_once" do
|
|
24
|
+
desc "Authenticate and save session — called automatically by session_reuse when needed"
|
|
25
|
+
|
|
26
|
+
param :app_url, required: true
|
|
27
|
+
param :username, required: true
|
|
28
|
+
param :password, required: true, secret: true
|
|
29
|
+
|
|
30
|
+
step "open login page" do
|
|
31
|
+
open_page(:main, url: "#{app_url}/login")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
step "fill and submit credentials" do
|
|
35
|
+
page(:main).fill("input#username", username)
|
|
36
|
+
page(:main).fill("input#password", password)
|
|
37
|
+
page(:main).click("button[type=submit]")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
step "verify login succeeded" do
|
|
41
|
+
assert page(:main).url.include?("/secure"), "login failed — still on login page"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
step "save authenticated session" do
|
|
45
|
+
save_session("session_reuse_demo")
|
|
46
|
+
puts " ✓ Session saved — future runs will skip this step"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Step 2: the main workflow that reuses the saved session ---
|
|
51
|
+
|
|
52
|
+
Browserctl.workflow "session_reuse" do
|
|
53
|
+
desc "Authenticate once, reuse forever — demonstrates load_session with fallback and expired_if"
|
|
54
|
+
|
|
55
|
+
param :app_url, default: "https://the-internet.herokuapp.com"
|
|
56
|
+
param :username, default: "tomsmith"
|
|
57
|
+
param :password, default: "SuperSecretPassword!", secret: true
|
|
58
|
+
|
|
59
|
+
step "restore session or log in" do
|
|
60
|
+
load_session("session_reuse_demo",
|
|
61
|
+
fallback: "session_reuse/login_once",
|
|
62
|
+
expired_if: lambda {
|
|
63
|
+
page(:main).navigate("#{app_url}/secure")
|
|
64
|
+
!page(:main).url.include?("/secure")
|
|
65
|
+
})
|
|
66
|
+
puts " ✓ Session ready — authenticated as #{username}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
step "do authenticated work" do
|
|
70
|
+
page(:main).navigate("#{app_url}/secure")
|
|
71
|
+
heading = page(:main).evaluate("document.querySelector('h2')?.textContent?.trim()")
|
|
72
|
+
assert heading&.include?("Secure Area"), "expected to be in secure area, got: #{heading.inspect}"
|
|
73
|
+
puts " ✓ Landed in secure area without re-authenticating"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -20,14 +20,14 @@ Browserctl.workflow "the_internet/login" do
|
|
|
20
20
|
|
|
21
21
|
step "verify secure area" do
|
|
22
22
|
assert page(:main).url.include?("/secure"), "expected redirect to /secure"
|
|
23
|
-
flash =
|
|
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
|
|
@@ -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/detectors.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Browserctl
|
|
|
11
11
|
|
|
12
12
|
# Returns true if the page appears to be showing a Cloudflare challenge.
|
|
13
13
|
# Checks both the current URL and the page body for known Cloudflare signals.
|
|
14
|
-
# @param page [
|
|
14
|
+
# @param page [#current_url, #body] the browser page to inspect
|
|
15
15
|
# @return [Boolean]
|
|
16
16
|
def self.cloudflare?(page)
|
|
17
17
|
url = page.current_url.to_s
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Driver
|
|
5
|
+
class Base
|
|
6
|
+
def create_page = raise NotImplementedError, "#{self.class.name}#create_page not implemented"
|
|
7
|
+
def quit = raise NotImplementedError, "#{self.class.name}#quit not implemented"
|
|
8
|
+
def headed? = raise NotImplementedError, "#{self.class.name}#headed? not implemented"
|
|
9
|
+
def supports?(_) = false
|
|
10
|
+
def devtools_info(_page) = raise NotImplementedError, "#{self.class.name}#devtools_info not implemented"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ferrum"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "cdp_page"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module Driver
|
|
10
|
+
class CDP < Base
|
|
11
|
+
BRAVE_PATHS = {
|
|
12
|
+
darwin: [
|
|
13
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
14
|
+
File.expand_path("~/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")
|
|
15
|
+
],
|
|
16
|
+
linux: [
|
|
17
|
+
"/usr/bin/brave-browser",
|
|
18
|
+
"/usr/bin/brave",
|
|
19
|
+
"/snap/bin/brave"
|
|
20
|
+
],
|
|
21
|
+
windows: [
|
|
22
|
+
"C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe",
|
|
23
|
+
"C:/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe"
|
|
24
|
+
]
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(headless: true, browser: "chrome") # rubocop:disable Lint/MissingSuper
|
|
28
|
+
@headless = headless
|
|
29
|
+
@browser = browser
|
|
30
|
+
@ferrum = Ferrum::Browser.new(**ferrum_options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_page
|
|
34
|
+
CDPPage.new(@ferrum.create_page)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def quit
|
|
38
|
+
@ferrum.quit
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def headed?
|
|
42
|
+
!@headless
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def supports?(capability)
|
|
46
|
+
capability == :devtools
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def devtools_info(page)
|
|
50
|
+
port = @ferrum.process.port
|
|
51
|
+
target_id = page.target_id
|
|
52
|
+
{ port: port, target_id: target_id }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def ferrum_options
|
|
58
|
+
opts = {
|
|
59
|
+
timeout: 30,
|
|
60
|
+
process_timeout: 30,
|
|
61
|
+
browser_options: { "disable-dev-shm-usage" => nil, "disable-gpu" => nil }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if @browser != "chrome" && (path = resolve_browser_path)
|
|
65
|
+
opts[:browser_path] = path
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if ENV["CI"] || ENV["BROWSERCTL_NO_SANDBOX"]
|
|
69
|
+
Browserctl.logger.warn "no-sandbox enabled (CI or BROWSERCTL_NO_SANDBOX set)"
|
|
70
|
+
opts[:browser_options]["no-sandbox"] = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
opts[:headless] = @headless
|
|
74
|
+
opts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_browser_path
|
|
78
|
+
case @browser
|
|
79
|
+
when "chromium"
|
|
80
|
+
resolve_chromium_path
|
|
81
|
+
when "brave"
|
|
82
|
+
resolve_brave_path
|
|
83
|
+
else
|
|
84
|
+
raise ArgumentError, "Unknown browser: #{@browser.inspect}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_chromium_path
|
|
89
|
+
env_override("CHROMIUM_PATH")
|
|
90
|
+
# Returns nil when no override — Ferrum finds Chromium automatically on most platforms.
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resolve_brave_path
|
|
94
|
+
override = env_override("BRAVE_PATH")
|
|
95
|
+
return override if override
|
|
96
|
+
|
|
97
|
+
platform = detect_platform
|
|
98
|
+
candidates = BRAVE_PATHS.fetch(platform, [])
|
|
99
|
+
path = candidates.find { |p| File.executable?(p) }
|
|
100
|
+
unless path
|
|
101
|
+
raise BrowserNotFound,
|
|
102
|
+
"Brave browser not found. Install Brave or set BRAVE_PATH to its executable."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def env_override(var)
|
|
109
|
+
value = ENV.fetch(var, nil)
|
|
110
|
+
return nil if value.nil? || value.empty?
|
|
111
|
+
raise BrowserNotFound, "#{var}=#{value} is not an executable file" unless File.executable?(value)
|
|
112
|
+
|
|
113
|
+
value
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def detect_platform
|
|
117
|
+
case RUBY_PLATFORM
|
|
118
|
+
when /darwin/ then :darwin
|
|
119
|
+
when /mingw|mswin|windows/ then :windows
|
|
120
|
+
else :linux
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Driver
|
|
7
|
+
# Wraps a Ferrum::Page so handlers receive a driver-namespaced object instead of
|
|
8
|
+
# a raw Ferrum type. Currently a transparent delegator — the seam exists so future
|
|
9
|
+
# CDP-specific behaviour (capability checks, instrumentation) can live here without
|
|
10
|
+
# touching every handler. Delete this class if no override lands by the next driver.
|
|
11
|
+
class CDPPage < SimpleDelegator
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -21,8 +21,10 @@ module Browserctl
|
|
|
21
21
|
class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
|
|
22
22
|
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
|
|
23
23
|
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
24
|
-
class KeyNotFound
|
|
24
|
+
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
25
|
+
class DaemonUnavailableError < Error; def self.default_code = "daemon_unavailable" end
|
|
26
|
+
class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
|
|
25
27
|
|
|
26
|
-
class WorkflowError <
|
|
27
|
-
class SecretResolverError < WorkflowError; end
|
|
28
|
+
class WorkflowError < Error; def self.default_code = "workflow_error" end
|
|
29
|
+
class SecretResolverError < WorkflowError; def self.default_code = "secret_resolver_error" end
|
|
28
30
|
end
|
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
|
|
|
@@ -73,9 +73,9 @@ module Browserctl
|
|
|
73
73
|
SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
|
|
74
74
|
SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
|
|
75
75
|
|
|
76
|
-
def initialize(pages,
|
|
76
|
+
def initialize(pages, driver, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
|
|
77
77
|
@pages = pages
|
|
78
|
-
@
|
|
78
|
+
@driver = driver
|
|
79
79
|
@snapshot_builder = snapshot_builder
|
|
80
80
|
@global_mutex = global_mutex
|
|
81
81
|
@kv_store = {}
|
|
@@ -7,11 +7,14 @@ module Browserctl
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def cmd_devtools(req)
|
|
10
|
+
return { error: "devtools is not supported by this driver" } unless @driver.supports?(:devtools)
|
|
11
|
+
|
|
10
12
|
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
13
|
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
info = @driver.devtools_info(session.page)
|
|
16
|
+
port = info[:port]
|
|
17
|
+
target_id = info[:target_id]
|
|
15
18
|
devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
|
|
16
19
|
"?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
|
|
17
20
|
{ ok: true, devtools_url: devtools_url }
|
|
@@ -16,15 +16,18 @@ module Browserctl
|
|
|
16
16
|
|
|
17
17
|
def cmd_hover(req)
|
|
18
18
|
with_page(req[:name]) do |session|
|
|
19
|
+
sel = resolve_selector_from(session, req)
|
|
20
|
+
return sel if sel.is_a?(Hash)
|
|
21
|
+
|
|
19
22
|
coords = session.page.evaluate(
|
|
20
23
|
"(function(sel) { " \
|
|
21
24
|
"var el = document.querySelector(sel); " \
|
|
22
25
|
"if (!el) return null; " \
|
|
23
26
|
"var r = el.getBoundingClientRect(); " \
|
|
24
27
|
"return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
|
|
25
|
-
"})(#{
|
|
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}; " \
|
|
@@ -8,7 +8,7 @@ module Browserctl
|
|
|
8
8
|
|
|
9
9
|
def cmd_page_open(req)
|
|
10
10
|
session = @global_mutex.synchronize do
|
|
11
|
-
@pages[req[:name]] ||= PageSession.new(@
|
|
11
|
+
@pages[req[:name]] ||= PageSession.new(@driver.create_page)
|
|
12
12
|
end
|
|
13
13
|
session.page.go_to(req[:url]) if req[:url]
|
|
14
14
|
{ ok: true, name: req[:name] }
|
|
@@ -25,9 +25,7 @@ module Browserctl
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def cmd_page_focus(req)
|
|
28
|
-
unless @
|
|
29
|
-
return { error: "page focus requires headed mode — start browserd with --headed" }
|
|
30
|
-
end
|
|
28
|
+
return { error: "page focus requires headed mode — start browserd with --headed" } unless @driver.headed?
|
|
31
29
|
|
|
32
30
|
with_page(req[:name]) do |session|
|
|
33
31
|
session.page.activate
|
|
@@ -47,7 +47,7 @@ module Browserctl
|
|
|
47
47
|
if existing
|
|
48
48
|
existing.page.go_to(page_data[:url])
|
|
49
49
|
else
|
|
50
|
-
new_page = @
|
|
50
|
+
new_page = @driver.create_page
|
|
51
51
|
new_page.go_to(page_data[:url])
|
|
52
52
|
@global_mutex.synchronize { @pages[page_name.to_s] = PageSession.new(new_page) }
|
|
53
53
|
end
|
|
@@ -61,7 +61,7 @@ module Browserctl
|
|
|
61
61
|
data[:local_storage].each do |origin, keys|
|
|
62
62
|
next if keys.empty?
|
|
63
63
|
|
|
64
|
-
tmp_page = @
|
|
64
|
+
tmp_page = @driver.create_page
|
|
65
65
|
begin
|
|
66
66
|
tmp_page.go_to(origin)
|
|
67
67
|
keys.each do |k, v|
|
|
@@ -75,7 +75,7 @@ module Browserctl
|
|
|
75
75
|
|
|
76
76
|
{ ok: true, cookies: cookie_count, pages: data[:metadata][:pages].length,
|
|
77
77
|
local_storage_keys: ls_key_count }
|
|
78
|
-
rescue RuntimeError => e
|
|
78
|
+
rescue Browserctl::Error, ArgumentError, JSON::ParserError, RuntimeError => e
|
|
79
79
|
{ error: e.message }
|
|
80
80
|
end
|
|
81
81
|
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "ferrum"
|
|
4
3
|
require "socket"
|
|
5
4
|
require "json"
|
|
6
5
|
require "fileutils"
|
|
7
6
|
require "timeout"
|
|
8
7
|
require_relative "constants"
|
|
9
8
|
require_relative "logger"
|
|
9
|
+
require_relative "driver"
|
|
10
10
|
require_relative "server/command_dispatcher"
|
|
11
11
|
require_relative "server/idle_watcher"
|
|
12
12
|
require_relative "server/page_session"
|
|
13
13
|
|
|
14
14
|
module Browserctl
|
|
15
15
|
class Server
|
|
16
|
-
def initialize(headless: true, socket_path: SOCKET_PATH, pid_path: PID_PATH)
|
|
16
|
+
def initialize(headless: true, browser: "chrome", socket_path: SOCKET_PATH, pid_path: PID_PATH)
|
|
17
17
|
@socket_path = socket_path
|
|
18
18
|
@pid_path = pid_path
|
|
19
|
-
prepare_runtime(headless)
|
|
20
|
-
@dispatcher = CommandDispatcher.new(@pages, @
|
|
19
|
+
prepare_runtime(headless, browser)
|
|
20
|
+
@dispatcher = CommandDispatcher.new(@pages, @driver, global_mutex: @mutex)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def run
|
|
@@ -33,21 +33,12 @@ module Browserctl
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
def prepare_runtime(headless)
|
|
36
|
+
def prepare_runtime(headless, browser)
|
|
37
37
|
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
38
|
-
@
|
|
38
|
+
@driver = Driver::CDP.new(headless: headless, browser: browser)
|
|
39
39
|
init_state
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def init_browser(headless)
|
|
43
|
-
Ferrum::Browser.new(headless: headless, **ferrum_options)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def ferrum_options
|
|
47
|
-
{ timeout: 30, process_timeout: 30,
|
|
48
|
-
browser_options: { "no-sandbox" => nil, "disable-dev-shm-usage" => nil, "disable-gpu" => nil } }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
42
|
def init_state
|
|
52
43
|
@pages = {}
|
|
53
44
|
@last_used = Time.now
|
|
@@ -114,7 +105,7 @@ module Browserctl
|
|
|
114
105
|
def teardown(idle, server)
|
|
115
106
|
idle&.kill
|
|
116
107
|
quietly { server&.close }
|
|
117
|
-
quietly { Timeout.timeout(5) { @
|
|
108
|
+
quietly { Timeout.timeout(5) { @driver.quit } }
|
|
118
109
|
quietly { File.unlink(@socket_path) }
|
|
119
110
|
quietly { File.unlink(@pid_path) }
|
|
120
111
|
end
|
|
@@ -125,7 +116,7 @@ module Browserctl
|
|
|
125
116
|
|
|
126
117
|
def quietly
|
|
127
118
|
yield
|
|
128
|
-
rescue
|
|
119
|
+
rescue StandardError
|
|
129
120
|
nil
|
|
130
121
|
end
|
|
131
122
|
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
|
@@ -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
|
|
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
|
|
114
|
-
"
|
|
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)
|
|
180
|
-
|
|
181
|
-
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
|
+
|
|
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)
|
|
199
|
-
|
|
200
|
-
def
|
|
201
|
-
|
|
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
|
|
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.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -123,7 +123,7 @@ dependencies:
|
|
|
123
123
|
- !ruby/object:Gem::Version
|
|
124
124
|
version: '0.9'
|
|
125
125
|
description: Named browser sessions, Ruby workflow DSL, and a token-efficient DOM
|
|
126
|
-
snapshot format. Built on
|
|
126
|
+
snapshot format. Built on a browser-agnostic driver layer (Ferrum/CDP backend).
|
|
127
127
|
email:
|
|
128
128
|
- patrick204nqh@gmail.com
|
|
129
129
|
executables:
|
|
@@ -139,6 +139,7 @@ files:
|
|
|
139
139
|
- bin/browserd
|
|
140
140
|
- bin/setup
|
|
141
141
|
- examples/cloudflare_hitl.rb
|
|
142
|
+
- examples/session_reuse.rb
|
|
142
143
|
- examples/test_automation_practices/advanced/ab_testing.rb
|
|
143
144
|
- examples/test_automation_practices/advanced/broken_images.rb
|
|
144
145
|
- examples/test_automation_practices/advanced/file_download.rb
|
|
@@ -184,6 +185,10 @@ files:
|
|
|
184
185
|
- lib/browserctl/commands/workflow.rb
|
|
185
186
|
- lib/browserctl/constants.rb
|
|
186
187
|
- lib/browserctl/detectors.rb
|
|
188
|
+
- lib/browserctl/driver.rb
|
|
189
|
+
- lib/browserctl/driver/base.rb
|
|
190
|
+
- lib/browserctl/driver/cdp.rb
|
|
191
|
+
- lib/browserctl/driver/cdp_page.rb
|
|
187
192
|
- lib/browserctl/errors.rb
|
|
188
193
|
- lib/browserctl/logger.rb
|
|
189
194
|
- lib/browserctl/policy.rb
|
|
@@ -221,6 +226,7 @@ metadata:
|
|
|
221
226
|
source_code_uri: https://github.com/patrick204nqh/browserctl
|
|
222
227
|
changelog_uri: https://github.com/patrick204nqh/browserctl/blob/main/CHANGELOG.md
|
|
223
228
|
bug_tracker_uri: https://github.com/patrick204nqh/browserctl/issues
|
|
229
|
+
documentation_uri: https://github.com/patrick204nqh/browserctl/tree/main/docs
|
|
224
230
|
rubygems_mfa_required: 'true'
|
|
225
231
|
post_install_message:
|
|
226
232
|
rdoc_options: []
|