browserctl 0.1.1 → 0.2.1
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 +38 -0
- data/README.md +56 -14
- data/bin/browserctl +70 -28
- data/bin/browserd +16 -2
- data/lib/browserctl/client.rb +30 -7
- data/lib/browserctl/commands/cli_output.rb +15 -0
- data/lib/browserctl/commands/click.rb +19 -3
- data/lib/browserctl/commands/fill.rb +29 -3
- data/lib/browserctl/commands/open_page.rb +10 -5
- data/lib/browserctl/commands/record.rb +54 -0
- data/lib/browserctl/commands/screenshot.rb +10 -4
- data/lib/browserctl/commands/snapshot.rb +19 -7
- data/lib/browserctl/commands/watch.rb +27 -0
- data/lib/browserctl/constants.rb +14 -3
- data/lib/browserctl/logger.rb +28 -0
- data/lib/browserctl/recording.rb +107 -0
- data/lib/browserctl/runner.rb +1 -1
- data/lib/browserctl/server/command_dispatcher.rb +55 -13
- data/lib/browserctl/server/idle_watcher.rb +1 -1
- data/lib/browserctl/server.rb +13 -14
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +24 -8
- metadata +7 -3
- data/lib/browserctl/commands/flag_extractor.rb +0 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c12ad0cc3d34e7163bab1d678d65b320e946e0fa16fe7bd3e9f5881855d4801f
|
|
4
|
+
data.tar.gz: 84d80f56270dfc7d252d7e63279f7c7c54123067837af457f5e92e82b2f0384e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c95dbbbe2a573635421fe90cd65d2ccc41baf968c32bb65edcea52030e1b0080fd2af0f50ea9d85bc827ff4f537e2c5b4f3a7631c8242380a007df432e820906
|
|
7
|
+
data.tar.gz: 1da50ab443d365e4a4b9ce145671e266fdfb0d83432b7b2313d87233b58b425252d2d9a365b0f8b3c31040961b90f684fe9299867c4cccfcd9600ea88cb93cc1
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.1](https://github.com/patrick204nqh/browserctl/compare/v0.2.0...v0.2.1) (2026-04-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* align CLI with standard conventions ([215c2af](https://github.com/patrick204nqh/browserctl/commit/215c2af3a4c4d27397476932147f3fda39e4039c))
|
|
14
|
+
* remove flag_extractor spec after deleting the module ([cd80045](https://github.com/patrick204nqh/browserctl/commit/cd8004515fcbae68248d7133e10d5c0223be10e1))
|
|
15
|
+
* use GH_PAT for release-please to trigger release workflow ([da83358](https://github.com/patrick204nqh/browserctl/commit/da83358e8ff19e8a30c23b9f5d744cb11ffbc7c6))
|
|
16
|
+
* use PAT for release-please to trigger release workflow ([4a9ea99](https://github.com/patrick204nqh/browserctl/commit/4a9ea99f418cbf719a16e2a971672175e0292221))
|
|
17
|
+
|
|
18
|
+
## [0.2.0](https://github.com/patrick204nqh/browserctl/compare/v0.1.1...v0.2.0) (2026-04-20)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
* add architecture documentation and decision records with diagrams ([9b67e40](https://github.com/patrick204nqh/browserctl/commit/9b67e40892cb87a075818fd7b166584a48c8edff))
|
|
24
|
+
* add logging functionality and environment variable setup documentation ([feb529c](https://github.com/patrick204nqh/browserctl/commit/feb529c4040c6f84104fa6dc55afc8d0a5d1b1a7))
|
|
25
|
+
* add record command — capture session as replayable workflow ([bb8988b](https://github.com/patrick204nqh/browserctl/commit/bb8988bcf17c0a7689f5a6e53d755d0e6a69b359))
|
|
26
|
+
* add ref registry and diff cache to CommandDispatcher ([217e158](https://github.com/patrick204nqh/browserctl/commit/217e15831b2cf1f0bf693584f47ed5d315594803))
|
|
27
|
+
* add ref-based click and fill using snapshot registry ([f0251c0](https://github.com/patrick204nqh/browserctl/commit/f0251c0732fd535bde8746b2817d3674ec8bc353))
|
|
28
|
+
* add retry_count and timeout options to workflow steps ([7b5e694](https://github.com/patrick204nqh/browserctl/commit/7b5e694cfb246396502a07b02928de79233a715f))
|
|
29
|
+
* add snap --diff returning only changed elements ([0a09558](https://github.com/patrick204nqh/browserctl/commit/0a095583ea23b1cff0df1ecc6cb91a3e35039e2c))
|
|
30
|
+
* add watch command — poll selector and emit when found ([b9a3abf](https://github.com/patrick204nqh/browserctl/commit/b9a3abf15b7e021906dda940f29f029ca3c221c2))
|
|
31
|
+
* named daemon instances via browserd --name and multi-socket support ([8cbc62d](https://github.com/patrick204nqh/browserctl/commit/8cbc62d6c8931b23cbf31e7285ee52c62e280f5c))
|
|
32
|
+
* v0.2 AI-first enhancements ([d3246d4](https://github.com/patrick204nqh/browserctl/commit/d3246d4c861430fda1e7dfa1cb67ce22db33dd68))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### Bug Fixes
|
|
36
|
+
|
|
37
|
+
* add edited event and workflow_dispatch to release trigger ([b851306](https://github.com/patrick204nqh/browserctl/commit/b85130603986ea5a667f2a89e9af7139c38491f2))
|
|
38
|
+
* add paths-ignore for markdown and docs in CI workflow ([3b7801d](https://github.com/patrick204nqh/browserctl/commit/3b7801dfa536ea0b0cd9bab189f237a2709e1515))
|
|
39
|
+
* describe_workflow after StepDef refactor; skip nil-selector recording for ref commands ([9e437fa](https://github.com/patrick204nqh/browserctl/commit/9e437fa9c18b6c94259a652af238377ce120d12e))
|
|
40
|
+
* fill --ref uses --value flag; add record generate subcommand ([ef3ed43](https://github.com/patrick204nqh/browserctl/commit/ef3ed433e09c0ae8ac6e241949ee9c2c0ccaa03c))
|
|
41
|
+
* remove 'edited' type from release trigger events ([98f63f0](https://github.com/patrick204nqh/browserctl/commit/98f63f0c40cbdcd40e14704379f28c19c436a6c2))
|
|
42
|
+
* rubocop offenses — formatting, guard clauses, predicate rename extract_flag? ([43341be](https://github.com/patrick204nqh/browserctl/commit/43341be50f12ca28699b009efb85b1ff404e4dab))
|
|
43
|
+
* trigger release workflow on GitHub release publication ([55c8faa](https://github.com/patrick204nqh/browserctl/commit/55c8faae8d753f35663ebd3c02996d1a602de091))
|
|
44
|
+
* update brand icon concept and roadmap versioning ([7a97b4b](https://github.com/patrick204nqh/browserctl/commit/7a97b4b0c0eaffbd8743ac30b2fbf13be468c80e))
|
|
45
|
+
|
|
8
46
|
## [0.1.1](https://github.com/patrick204nqh/browserctl/compare/v0.1.0...v0.1.1) (2026-04-19)
|
|
9
47
|
|
|
10
48
|
|
data/README.md
CHANGED
|
@@ -15,9 +15,9 @@ Unlike tools that restart the browser on every script run, **browserctl keeps a
|
|
|
15
15
|
```bash
|
|
16
16
|
browserd & # start the daemon (headless)
|
|
17
17
|
browserctl open login --url https://example.com/login
|
|
18
|
-
browserctl
|
|
19
|
-
browserctl
|
|
20
|
-
browserctl
|
|
18
|
+
browserctl snap login # AI-friendly JSON snapshot with ref IDs
|
|
19
|
+
browserctl fill login --ref e1 --value me@example.com # interact by ref
|
|
20
|
+
browserctl click login --ref e2
|
|
21
21
|
browserctl shutdown
|
|
22
22
|
```
|
|
23
23
|
|
|
@@ -81,24 +81,34 @@ browserd --headed # visible browser window
|
|
|
81
81
|
browserctl open login --url https://app.example.com/login
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
**3.
|
|
84
|
+
**3. Snapshot the page to discover refs**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
browserctl snap login # AI-friendly JSON with ref IDs (default)
|
|
88
|
+
browserctl snap login --format html
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**4. Interact using refs or selectors**
|
|
85
92
|
|
|
86
93
|
```bash
|
|
94
|
+
browserctl fill login --ref e1 --value user@example.com
|
|
95
|
+
browserctl fill login --ref e2 --value s3cr3t
|
|
96
|
+
browserctl click login --ref e3
|
|
97
|
+
|
|
98
|
+
# or using explicit CSS selectors
|
|
87
99
|
browserctl fill login "input[name=email]" user@example.com
|
|
88
|
-
browserctl fill login "input[name=password]" s3cr3t
|
|
89
100
|
browserctl click login "button[type=submit]"
|
|
90
101
|
```
|
|
91
102
|
|
|
92
|
-
**
|
|
103
|
+
**5. Observe the result**
|
|
93
104
|
|
|
94
105
|
```bash
|
|
95
|
-
browserctl snap login
|
|
96
|
-
browserctl snap login --format html
|
|
106
|
+
browserctl snap login --diff # only changed elements since last snap
|
|
97
107
|
browserctl shot login --out /tmp/after-login.png --full
|
|
98
108
|
browserctl url login
|
|
99
109
|
```
|
|
100
110
|
|
|
101
|
-
**
|
|
111
|
+
**6. Manage pages and daemon**
|
|
102
112
|
|
|
103
113
|
```bash
|
|
104
114
|
browserctl pages
|
|
@@ -119,12 +129,18 @@ browserctl shutdown
|
|
|
119
129
|
| `close <page>` | Close a named page |
|
|
120
130
|
| `pages` | List open pages |
|
|
121
131
|
| `goto <page> <url>` | Navigate a page to a URL |
|
|
122
|
-
| `fill <page> <selector> <value>` | Fill an input field |
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
132
|
+
| `fill <page> <selector> <value>` | Fill an input field by CSS selector |
|
|
133
|
+
| `fill <page> --ref <id> --value <v>` | Fill an input field by snapshot ref |
|
|
134
|
+
| `click <page> <selector>` | Click an element by CSS selector |
|
|
135
|
+
| `click <page> --ref <id>` | Click an element by snapshot ref |
|
|
136
|
+
| `snap <page> [--format ai\|html] [--diff]` | Snapshot DOM; `--diff` returns only changed elements |
|
|
137
|
+
| `watch <page> <selector> [--timeout N]` | Poll until selector appears (default timeout: 30s) |
|
|
125
138
|
| `shot <page> [--out PATH] [--full]` | Take a screenshot |
|
|
126
139
|
| `url <page>` | Print current URL |
|
|
127
140
|
| `eval <page> <expression>` | Evaluate a JS expression |
|
|
141
|
+
| `record start <name>` | Begin recording commands as a replayable workflow |
|
|
142
|
+
| `record stop [--out path]` | End recording; saves to `.browserctl/workflows/` or custom path |
|
|
143
|
+
| `record status` | Show whether a recording is active |
|
|
128
144
|
|
|
129
145
|
### Daemon commands
|
|
130
146
|
|
|
@@ -172,7 +188,24 @@ browserctl shutdown
|
|
|
172
188
|
]
|
|
173
189
|
```
|
|
174
190
|
|
|
175
|
-
Use `
|
|
191
|
+
Use `ref` values directly with `--ref` for zero-fragility interactions, or use `selector` values with `fill` and `click`.
|
|
192
|
+
|
|
193
|
+
### Ref-based interaction
|
|
194
|
+
|
|
195
|
+
After a `snap`, use ref IDs instead of CSS selectors — no selector knowledge required:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
browserctl fill login --ref e1 --value user@example.com
|
|
199
|
+
browserctl click login --ref e2
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Diff snapshots
|
|
203
|
+
|
|
204
|
+
Track only what changed since the last snapshot — useful for AI agents monitoring async updates:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
browserctl snap login --diff
|
|
208
|
+
```
|
|
176
209
|
|
|
177
210
|
---
|
|
178
211
|
|
|
@@ -222,6 +255,7 @@ browserctl run smoke_login --email me@example.com --password s3cr3t
|
|
|
222
255
|
| `desc "text"` | Human-readable description |
|
|
223
256
|
| `param :name, required:, secret:, default:` | Declare a parameter |
|
|
224
257
|
| `step "label" { }` | Add a step (runs in order, halts on failure) |
|
|
258
|
+
| `step "label", retry_count: N, timeout: S { }` | Step with retry and/or timeout |
|
|
225
259
|
| `page(:name)` | Returns a `PageProxy` for the named page |
|
|
226
260
|
| `invoke "other_workflow", **overrides` | Call another workflow |
|
|
227
261
|
| `assert condition, "message"` | Raise `WorkflowError` if condition is false |
|
|
@@ -242,7 +276,15 @@ For a full guide on building your own workflows, see [docs/writing-workflows.md]
|
|
|
242
276
|
|
|
243
277
|
## How it works
|
|
244
278
|
|
|
245
|
-
`browserd` runs as a background process, listening on a Unix socket at `~/.browserctl/browserd.sock`.
|
|
279
|
+
`browserd` runs as a background process, listening on a Unix socket at `~/.browserctl/browserd.sock`. Start multiple named instances for agent isolation:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
browserd --name agent-a &
|
|
283
|
+
browserd --name agent-b &
|
|
284
|
+
browserctl --daemon agent-a open main --url https://app.example.com
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
It manages a Ferrum (Chrome DevTools Protocol) browser instance with named page handles.
|
|
246
288
|
|
|
247
289
|
`browserctl` sends JSON-RPC commands over the socket and prints the result. Workflows run in-process through the same client.
|
|
248
290
|
|
data/bin/browserctl
CHANGED
|
@@ -3,41 +3,80 @@
|
|
|
3
3
|
|
|
4
4
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
5
5
|
|
|
6
|
+
require "browserctl/version"
|
|
7
|
+
|
|
8
|
+
if ARGV.intersect?(%w[--version -v])
|
|
9
|
+
puts "browserctl #{Browserctl::VERSION}"
|
|
10
|
+
exit 0
|
|
11
|
+
end
|
|
12
|
+
|
|
6
13
|
require "json"
|
|
14
|
+
require "optimist"
|
|
7
15
|
require "browserctl"
|
|
16
|
+
require "browserctl/commands/cli_output"
|
|
8
17
|
require "browserctl/commands/open_page"
|
|
9
18
|
require "browserctl/commands/fill"
|
|
10
19
|
require "browserctl/commands/click"
|
|
11
20
|
require "browserctl/commands/snapshot"
|
|
12
21
|
require "browserctl/commands/screenshot"
|
|
22
|
+
require "browserctl/commands/watch"
|
|
23
|
+
require "browserctl/commands/record"
|
|
13
24
|
|
|
25
|
+
def print_result(res)
|
|
26
|
+
if res.is_a?(Hash) && res[:error]
|
|
27
|
+
warn "Error: #{res[:error]}"
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
puts res.to_json
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# rubocop:disable Metrics/MethodLength
|
|
14
34
|
def usage
|
|
15
35
|
puts <<~USAGE
|
|
16
36
|
Usage: browserctl <command> [args]
|
|
17
37
|
|
|
18
38
|
Browser commands (require browserd running):
|
|
19
|
-
open <page> [--url URL]
|
|
20
|
-
close <page>
|
|
21
|
-
pages
|
|
22
|
-
goto <page> <url>
|
|
23
|
-
fill <page> <selector> <value>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
39
|
+
open <page> [--url URL] Open or focus a named page
|
|
40
|
+
close <page> Close a named page
|
|
41
|
+
pages List open pages
|
|
42
|
+
goto <page> <url> Navigate a page
|
|
43
|
+
fill <page> <selector> <value> Fill an input
|
|
44
|
+
<page> --ref <ref> --value <value> Fill via snapshot ref
|
|
45
|
+
click <page> <selector> Click an element
|
|
46
|
+
<page> --ref <ref> Click via snapshot ref
|
|
47
|
+
shot <page> [--out PATH] [--full] Take a screenshot
|
|
48
|
+
snap <page> [--format ai|html] [--diff] Snapshot DOM (default: ai)
|
|
49
|
+
url <page> Print current URL
|
|
50
|
+
eval <page> <expression> Evaluate JS expression
|
|
51
|
+
watch <page> <selector> [--timeout N] Wait for a selector to appear
|
|
52
|
+
|
|
53
|
+
Recording commands:
|
|
54
|
+
record start <name> Start recording browser commands
|
|
55
|
+
record stop [--out PATH] Stop recording and save workflow
|
|
56
|
+
record status Show active recording name
|
|
29
57
|
|
|
30
58
|
Workflow commands:
|
|
31
|
-
run <name|file> [--key value] Run a workflow
|
|
32
|
-
workflows
|
|
33
|
-
describe <name>
|
|
59
|
+
run <name|file> [--key value ...] Run a workflow
|
|
60
|
+
workflows List available workflows
|
|
61
|
+
describe <name> Describe a workflow
|
|
34
62
|
|
|
35
63
|
Daemon commands:
|
|
36
|
-
ping
|
|
37
|
-
shutdown
|
|
64
|
+
ping Check if browserd is alive
|
|
65
|
+
shutdown Stop browserd
|
|
66
|
+
|
|
67
|
+
Options:
|
|
68
|
+
--daemon <name> Connect to a named daemon instance
|
|
69
|
+
--version, -v Print version and exit
|
|
38
70
|
USAGE
|
|
39
71
|
exit 0
|
|
40
72
|
end
|
|
73
|
+
# rubocop:enable Metrics/MethodLength
|
|
74
|
+
|
|
75
|
+
daemon_idx = ARGV.index("--daemon")
|
|
76
|
+
daemon_name = if daemon_idx
|
|
77
|
+
ARGV.delete_at(daemon_idx)
|
|
78
|
+
ARGV.delete_at(daemon_idx)
|
|
79
|
+
end
|
|
41
80
|
|
|
42
81
|
cmd = ARGV.shift
|
|
43
82
|
args = ARGV.dup
|
|
@@ -70,22 +109,25 @@ when "describe"
|
|
|
70
109
|
name = args.shift or abort "usage: browserctl describe <workflow_name>"
|
|
71
110
|
puts JSON.pretty_generate(runner.describe_workflow(name))
|
|
72
111
|
|
|
112
|
+
when "record" then Browserctl::Commands::Record.run(args)
|
|
113
|
+
|
|
73
114
|
else
|
|
74
|
-
client = Browserctl::Client.new
|
|
115
|
+
client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
|
|
75
116
|
|
|
76
117
|
case cmd
|
|
77
|
-
when "open"
|
|
78
|
-
when "close"
|
|
79
|
-
when "pages"
|
|
80
|
-
when "goto"
|
|
81
|
-
when "fill"
|
|
82
|
-
when "click"
|
|
83
|
-
when "shot"
|
|
84
|
-
when "snap"
|
|
85
|
-
when "url"
|
|
86
|
-
when "eval"
|
|
87
|
-
when "
|
|
88
|
-
when "
|
|
118
|
+
when "open" then Browserctl::Commands::OpenPage.run(client, args)
|
|
119
|
+
when "close" then print_result(client.close_page(args[0]))
|
|
120
|
+
when "pages" then print_result(client.list_pages)
|
|
121
|
+
when "goto" then print_result(client.goto(args[0], args[1]))
|
|
122
|
+
when "fill" then Browserctl::Commands::Fill.run(client, args)
|
|
123
|
+
when "click" then Browserctl::Commands::Click.run(client, args)
|
|
124
|
+
when "shot" then Browserctl::Commands::Screenshot.run(client, args)
|
|
125
|
+
when "snap" then Browserctl::Commands::Snapshot.run(client, args)
|
|
126
|
+
when "url" then print_result(client.url(args[0]))
|
|
127
|
+
when "eval" then print_result(client.evaluate(args[0], args[1]))
|
|
128
|
+
when "watch" then Browserctl::Commands::Watch.run(client, args)
|
|
129
|
+
when "ping" then print_result(client.ping)
|
|
130
|
+
when "shutdown" then print_result(client.shutdown)
|
|
89
131
|
else
|
|
90
132
|
abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
|
|
91
133
|
end
|
data/bin/browserd
CHANGED
|
@@ -3,8 +3,22 @@
|
|
|
3
3
|
|
|
4
4
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
5
5
|
|
|
6
|
+
require "optimist"
|
|
6
7
|
require "nokogiri"
|
|
8
|
+
require "browserctl/logger"
|
|
7
9
|
require "browserctl/server"
|
|
10
|
+
require "browserctl/version"
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
Browserctl::
|
|
12
|
+
opts = Optimist.options do
|
|
13
|
+
version "browserd #{Browserctl::VERSION}"
|
|
14
|
+
opt :headed, "Run with a visible browser window", default: false, short: "-H"
|
|
15
|
+
opt :log_level, "Log verbosity: debug, info, warn, error", default: "info", short: "-l", type: :string
|
|
16
|
+
opt :name, "Daemon instance name for multi-agent use", default: nil, short: "-n", type: :string
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Browserctl.logger = Browserctl.build_logger(opts[:log_level])
|
|
20
|
+
Browserctl::Server.new(
|
|
21
|
+
headless: !opts[:headed],
|
|
22
|
+
socket_path: Browserctl.socket_path(opts[:name]),
|
|
23
|
+
pid_path: Browserctl.pid_path(opts[:name])
|
|
24
|
+
).run
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "constants"
|
|
6
|
+
require_relative "recording"
|
|
6
7
|
|
|
7
8
|
module Browserctl
|
|
8
9
|
class Client
|
|
9
|
-
def initialize(socket_path =
|
|
10
|
+
def initialize(socket_path = Browserctl.socket_path)
|
|
10
11
|
@socket_path = socket_path
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def call(cmd, **params)
|
|
14
|
-
communicate(JSON.generate({ cmd: cmd }.merge(params)))
|
|
15
|
+
result = communicate(JSON.generate({ cmd: cmd }.merge(params)))
|
|
16
|
+
Recording.append(cmd, **params) if result[:ok]
|
|
17
|
+
result
|
|
15
18
|
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
16
19
|
raise "browserd is not running — start it with: browserd"
|
|
17
20
|
end
|
|
@@ -21,12 +24,32 @@ module Browserctl
|
|
|
21
24
|
def open_page(name, url: nil) = call("open_page", name: name, url: url)
|
|
22
25
|
def close_page(name) = call("close_page", name: name)
|
|
23
26
|
def list_pages = call("list_pages")
|
|
24
|
-
def goto(name, url) = call("goto",
|
|
25
|
-
|
|
26
|
-
def click(name, selector
|
|
27
|
+
def goto(name, url) = call("goto", name: name, url: url)
|
|
28
|
+
|
|
29
|
+
def click(name, selector = nil, ref: nil)
|
|
30
|
+
raise ArgumentError, "click: provide selector or ref:" unless selector || ref
|
|
31
|
+
|
|
32
|
+
call("click", name: name, selector: selector, ref: ref)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fill(name, selector = nil, value = nil, ref: nil)
|
|
36
|
+
raise ArgumentError, "fill: provide selector or ref:" unless selector || ref
|
|
37
|
+
|
|
38
|
+
call("fill", name: name, selector: selector, ref: ref, value: value)
|
|
39
|
+
end
|
|
40
|
+
|
|
27
41
|
def screenshot(name, path: nil, full: false) = call("screenshot", name: name, path: path, full: full)
|
|
28
|
-
|
|
29
|
-
def
|
|
42
|
+
|
|
43
|
+
def snapshot(name, format: "ai", diff: false)
|
|
44
|
+
call("snapshot", name: name, format: format, diff: diff)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
|
|
48
|
+
|
|
49
|
+
def watch(name, selector, timeout: 30)
|
|
50
|
+
call("watch", name: name, selector: selector, timeout: timeout)
|
|
51
|
+
end
|
|
52
|
+
|
|
30
53
|
def url(name) = call("url", name: name)
|
|
31
54
|
def evaluate(name, expression) = call("evaluate", name: name, expression: expression)
|
|
32
55
|
def ping = call("ping")
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
5
|
+
|
|
3
6
|
module Browserctl
|
|
4
7
|
module Commands
|
|
5
8
|
class Click
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
6
11
|
def self.run(client, args)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl click <page> <selector>\n " \
|
|
14
|
+
"browserctl click <page> --ref <ref>"
|
|
15
|
+
opt :ref, "Snapshot ref to click", type: :string, short: "-r"
|
|
16
|
+
end
|
|
17
|
+
name = args.shift
|
|
18
|
+
if opts[:ref]
|
|
19
|
+
abort "usage: browserctl click <page> --ref <ref>" unless name
|
|
20
|
+
print_result(client.click(name, ref: opts[:ref]))
|
|
21
|
+
else
|
|
22
|
+
selector = args.shift
|
|
23
|
+
abort "usage: browserctl click <page> <selector>" unless name && selector
|
|
24
|
+
print_result(client.click(name, selector))
|
|
25
|
+
end
|
|
10
26
|
end
|
|
11
27
|
end
|
|
12
28
|
end
|
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
5
|
+
|
|
3
6
|
module Browserctl
|
|
4
7
|
module Commands
|
|
5
8
|
class Fill
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
6
11
|
def self.run(client, args)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl fill <page> <selector> <value>\n " \
|
|
14
|
+
"browserctl fill <page> --ref <ref> --value <value>"
|
|
15
|
+
opt :ref, "Snapshot ref to fill", type: :string, short: "-r"
|
|
16
|
+
opt :value, "Value to fill", type: :string, short: "-V"
|
|
17
|
+
end
|
|
18
|
+
name = args.shift
|
|
19
|
+
opts[:ref] ? fill_by_ref(client, name, opts) : fill_by_selector(client, name, args, opts[:value])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def fill_by_ref(client, name, opts)
|
|
26
|
+
abort "usage: browserctl fill <page> --ref <ref> --value <value>" unless name && opts[:value]
|
|
27
|
+
print_result(client.fill(name, nil, opts[:value], ref: opts[:ref]))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fill_by_selector(client, name, args, value_opt)
|
|
31
|
+
selector = args.shift
|
|
32
|
+
value = value_opt || args.shift
|
|
33
|
+
abort "usage: browserctl fill <page> <selector> <value>" unless name && selector && value
|
|
34
|
+
print_result(client.fill(name, selector, value))
|
|
35
|
+
end
|
|
10
36
|
end
|
|
11
37
|
end
|
|
12
38
|
end
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
module Commands
|
|
7
8
|
class OpenPage
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
8
11
|
def self.run(client, args)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl open <page> [--url URL]"
|
|
14
|
+
opt :url, "URL to navigate to", type: :string, short: "-u"
|
|
15
|
+
end
|
|
16
|
+
name = args.shift or abort "usage: browserctl open <page> [--url URL]"
|
|
17
|
+
print_result(client.open_page(name, url: opts[:url]))
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "optimist"
|
|
5
|
+
require "browserctl/recording"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
module Commands
|
|
9
|
+
class Record
|
|
10
|
+
USAGE = "Usage: browserctl record start <name> | stop [--out PATH] | status"
|
|
11
|
+
|
|
12
|
+
def self.run(args)
|
|
13
|
+
subcmd = args.shift
|
|
14
|
+
case subcmd
|
|
15
|
+
when "start" then run_start(args)
|
|
16
|
+
when "stop" then run_stop(args)
|
|
17
|
+
when "status" then run_status
|
|
18
|
+
else
|
|
19
|
+
abort "#{USAGE}\nRun 'browserctl record <subcommand> --help' for details."
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def run_start(args)
|
|
27
|
+
Optimist.options(args) { banner "Usage: browserctl record start <name>" }
|
|
28
|
+
name = args.shift or abort "usage: browserctl record start <name>"
|
|
29
|
+
Recording.start(name)
|
|
30
|
+
puts "Recording started: #{name}"
|
|
31
|
+
puts "Run browser commands, then: browserctl record stop"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run_stop(args)
|
|
35
|
+
opts = Optimist.options(args) do
|
|
36
|
+
banner "Usage: browserctl record stop [--out PATH]"
|
|
37
|
+
opt :out, "Output path for workflow file", type: :string, short: "-o"
|
|
38
|
+
end
|
|
39
|
+
name = Recording.stop
|
|
40
|
+
out = opts[:out] || File.join(".browserctl/workflows", "#{name}.rb")
|
|
41
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
42
|
+
Recording.generate_workflow(name, output_path: out)
|
|
43
|
+
puts "Workflow saved: #{out}"
|
|
44
|
+
puts "Run with: browserctl run #{name}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run_status
|
|
48
|
+
active = Recording.active
|
|
49
|
+
puts active ? "Active recording: #{active}" : "No active recording."
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
module Commands
|
|
7
8
|
class Screenshot
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
8
11
|
def self.run(client, args)
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl shot <page> [--out PATH] [--full]"
|
|
14
|
+
opt :out, "Output file path", type: :string, short: "-o"
|
|
15
|
+
opt :full, "Capture full page", default: false, short: "-f"
|
|
16
|
+
end
|
|
9
17
|
name = args.shift or abort "usage: browserctl shot <page> [--out PATH] [--full]"
|
|
10
|
-
|
|
11
|
-
full = args.delete("--full") ? true : false
|
|
12
|
-
puts client.screenshot(name, path: path, full: full).to_json
|
|
18
|
+
print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
|
|
13
19
|
end
|
|
14
20
|
end
|
|
15
21
|
end
|
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
|
|
4
|
+
require "optimist"
|
|
5
5
|
|
|
6
6
|
module Browserctl
|
|
7
7
|
module Commands
|
|
8
8
|
class Snapshot
|
|
9
|
+
VALID_FORMATS = %w[ai html].freeze
|
|
10
|
+
|
|
9
11
|
def self.run(client, args)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl snap <page> [--format ai|html] [--diff]"
|
|
14
|
+
opt :format, "Output format: ai or html", default: "ai", short: "-f"
|
|
15
|
+
opt :diff, "Return only changed elements", default: false, short: "-d"
|
|
16
|
+
end
|
|
17
|
+
name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
|
|
18
|
+
unless VALID_FORMATS.include?(opts[:format])
|
|
19
|
+
warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
res = client.snapshot(name, format: opts[:format], diff: opts[:diff])
|
|
23
|
+
output_snapshot(res, opts[:format])
|
|
14
24
|
end
|
|
15
25
|
|
|
16
26
|
class << self
|
|
17
27
|
private
|
|
18
28
|
|
|
19
29
|
def output_snapshot(res, format)
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
if res[:error]
|
|
31
|
+
warn "Error: #{res[:error]}"
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
22
34
|
puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
|
|
23
35
|
end
|
|
24
36
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Commands
|
|
8
|
+
class Watch
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
11
|
+
def self.run(client, args)
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl watch <page> <selector> [--timeout N]"
|
|
14
|
+
opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
|
|
15
|
+
end
|
|
16
|
+
name = args.shift
|
|
17
|
+
selector = args.shift
|
|
18
|
+
abort "usage: browserctl watch <page> <selector> [--timeout N]" unless name && selector
|
|
19
|
+
unless opts[:timeout].positive?
|
|
20
|
+
warn "Error: --timeout must be a positive number"
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
print_result(client.watch(name, selector, timeout: opts[:timeout]))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/browserctl/constants.rb
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Browserctl
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
BROWSERCTL_DIR = File.expand_path("~/.browserctl")
|
|
5
|
+
IDLE_TTL = 30 * 60
|
|
6
|
+
|
|
7
|
+
def self.socket_path(name = nil)
|
|
8
|
+
File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.pid_path(name = nil)
|
|
12
|
+
File.join(BROWSERCTL_DIR, name ? "#{name}.pid" : "browserd.pid")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Backward-compatible constants
|
|
16
|
+
SOCKET_PATH = socket_path
|
|
17
|
+
PID_PATH = pid_path
|
|
7
18
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
LEVEL_MAP = {
|
|
7
|
+
"debug" => ::Logger::DEBUG,
|
|
8
|
+
"info" => ::Logger::INFO,
|
|
9
|
+
"warn" => ::Logger::WARN,
|
|
10
|
+
"error" => ::Logger::ERROR
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.logger
|
|
14
|
+
@logger ||= build_logger("info")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.logger=(instance)
|
|
18
|
+
@logger = instance
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.build_logger(level_name)
|
|
22
|
+
log = ::Logger.new($stderr)
|
|
23
|
+
log.level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
|
|
24
|
+
log.progname = "browserd"
|
|
25
|
+
log.formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
|
|
26
|
+
log
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "date"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
class Recording
|
|
10
|
+
RECORDINGS_DIR = File.join(Dir.tmpdir, "browserctl-recordings")
|
|
11
|
+
STATE_FILE = File.expand_path("~/.browserctl/active_recording")
|
|
12
|
+
|
|
13
|
+
RECORDABLE = %w[open_page goto fill click screenshot evaluate].freeze
|
|
14
|
+
|
|
15
|
+
def self.start(name)
|
|
16
|
+
FileUtils.mkdir_p(RECORDINGS_DIR)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(STATE_FILE))
|
|
18
|
+
File.write(STATE_FILE, name)
|
|
19
|
+
FileUtils.rm_f(log_path(name))
|
|
20
|
+
name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.stop
|
|
24
|
+
name = active
|
|
25
|
+
raise "no active recording — run: browserctl record start <name>" unless name
|
|
26
|
+
|
|
27
|
+
File.unlink(STATE_FILE)
|
|
28
|
+
name
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.active
|
|
32
|
+
File.exist?(STATE_FILE) ? File.read(STATE_FILE).strip : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.append(cmd, **attrs)
|
|
36
|
+
name = active
|
|
37
|
+
return unless name
|
|
38
|
+
return unless RECORDABLE.include?(cmd.to_s)
|
|
39
|
+
# ref-based interactions have no replayable selector — skip them
|
|
40
|
+
return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
|
|
41
|
+
|
|
42
|
+
File.open(log_path(name), "a") do |f|
|
|
43
|
+
f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.generate_workflow(name, output_path: nil)
|
|
48
|
+
log = log_path(name)
|
|
49
|
+
raise "no recording found for '#{name}'" unless File.exist?(log)
|
|
50
|
+
|
|
51
|
+
lines = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
|
|
52
|
+
ruby = build_workflow_ruby(name, lines)
|
|
53
|
+
File.write(output_path, ruby) if output_path
|
|
54
|
+
ruby
|
|
55
|
+
ensure
|
|
56
|
+
FileUtils.rm_f(log) if log
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def log_path(name)
|
|
63
|
+
File.join(RECORDINGS_DIR, "#{name}.jsonl")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_workflow_ruby(name, commands)
|
|
67
|
+
steps = commands.map { |c| build_step(c) }.join("\n\n")
|
|
68
|
+
<<~RUBY
|
|
69
|
+
# frozen_string_literal: true
|
|
70
|
+
|
|
71
|
+
Browserctl.workflow #{name.inspect} do
|
|
72
|
+
desc "Recorded on #{Date.today}"
|
|
73
|
+
|
|
74
|
+
#{steps.gsub(/^/, ' ')}
|
|
75
|
+
end
|
|
76
|
+
RUBY
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_step(cmd)
|
|
80
|
+
label, body = step_parts(cmd)
|
|
81
|
+
"step #{label.inspect} do\n #{body}\nend"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def step_parts(cmd)
|
|
85
|
+
page = cmd[:name]
|
|
86
|
+
case cmd[:cmd]
|
|
87
|
+
when "open_page"
|
|
88
|
+
["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
89
|
+
when "goto"
|
|
90
|
+
["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
91
|
+
when "fill"
|
|
92
|
+
["fill #{cmd[:selector]} on #{page}",
|
|
93
|
+
"page(:#{page}).fill(#{cmd[:selector].inspect}, #{cmd[:value].inspect})"]
|
|
94
|
+
when "click"
|
|
95
|
+
["click #{cmd[:selector]} on #{page}",
|
|
96
|
+
"page(:#{page}).click(#{cmd[:selector].inspect})"]
|
|
97
|
+
when "screenshot"
|
|
98
|
+
["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
99
|
+
when "evaluate"
|
|
100
|
+
["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
101
|
+
else
|
|
102
|
+
["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Browserctl
|
|
|
24
24
|
|
|
25
25
|
def describe_workflow(name)
|
|
26
26
|
defn = fetch_workflow(name)
|
|
27
|
-
{ name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:
|
|
27
|
+
{ name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:label) }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
private
|
|
@@ -15,32 +15,33 @@ module Browserctl
|
|
|
15
15
|
"click" => :cmd_click,
|
|
16
16
|
"screenshot" => :cmd_screenshot,
|
|
17
17
|
"wait_for" => :cmd_wait_for,
|
|
18
|
+
"watch" => :cmd_watch,
|
|
18
19
|
"url" => :cmd_url,
|
|
19
20
|
"ping" => :cmd_ping,
|
|
20
21
|
"shutdown" => :cmd_shutdown
|
|
21
22
|
}.freeze
|
|
22
23
|
|
|
23
24
|
def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, mutex: Mutex.new)
|
|
24
|
-
@pages
|
|
25
|
-
@browser
|
|
26
|
-
@snapshot
|
|
27
|
-
@mutex
|
|
25
|
+
@pages = pages
|
|
26
|
+
@browser = browser
|
|
27
|
+
@snapshot = snapshot_builder
|
|
28
|
+
@mutex = mutex
|
|
29
|
+
@ref_registries = {}
|
|
30
|
+
@prev_snapshots = {}
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def dispatch(req)
|
|
31
34
|
handler = COMMAND_MAP[req[:cmd]]
|
|
32
35
|
return { error: "unknown command: #{req[:cmd]}" } unless handler
|
|
33
36
|
|
|
37
|
+
Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
|
|
34
38
|
send(handler, req)
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
private
|
|
38
42
|
|
|
39
43
|
def cmd_open_page(req)
|
|
40
|
-
page = @mutex.synchronize { @pages[req[:name]]
|
|
41
|
-
new_page = @browser.create_page
|
|
42
|
-
@mutex.synchronize { @pages[req[:name]] ||= new_page }
|
|
43
|
-
end
|
|
44
|
+
page = @mutex.synchronize { @pages[req[:name]] ||= @browser.create_page }
|
|
44
45
|
page.go_to(req[:url]) if req[:url]
|
|
45
46
|
{ ok: true, name: req[:name] }
|
|
46
47
|
end
|
|
@@ -62,11 +63,31 @@ module Browserctl
|
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
def cmd_snapshot(req)
|
|
65
|
-
with_page(req[:name]) { |p|
|
|
66
|
+
with_page(req[:name]) { |p| take_snapshot(req[:name], p, req[:format], req[:diff]) }
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
def
|
|
69
|
-
|
|
69
|
+
def take_snapshot(name, page, format, diff)
|
|
70
|
+
return { ok: true, html: page.body } unless format == "ai"
|
|
71
|
+
|
|
72
|
+
snapshot = @snapshot.call(page)
|
|
73
|
+
registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
|
|
74
|
+
|
|
75
|
+
result = @mutex.synchronize do
|
|
76
|
+
prev = @prev_snapshots[name]
|
|
77
|
+
@ref_registries[name] = registry
|
|
78
|
+
@prev_snapshots[name] = snapshot
|
|
79
|
+
diff && prev ? compute_diff(prev, snapshot) : snapshot
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ ok: true, snapshot: result }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def compute_diff(prev, current)
|
|
86
|
+
prev_by_sel = prev.to_h { |el| [el[:selector], el] }
|
|
87
|
+
current.reject do |el|
|
|
88
|
+
old = prev_by_sel[el[:selector]]
|
|
89
|
+
old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
|
|
90
|
+
end
|
|
70
91
|
end
|
|
71
92
|
|
|
72
93
|
def cmd_evaluate(req)
|
|
@@ -74,7 +95,10 @@ module Browserctl
|
|
|
74
95
|
end
|
|
75
96
|
|
|
76
97
|
def cmd_fill(req)
|
|
77
|
-
|
|
98
|
+
sel = resolve_selector(req[:name], req)
|
|
99
|
+
return sel if sel.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
with_page(req[:name]) { |p| type_into(p, sel, req[:value]) }
|
|
78
102
|
end
|
|
79
103
|
|
|
80
104
|
def type_into(page, selector, value)
|
|
@@ -87,7 +111,10 @@ module Browserctl
|
|
|
87
111
|
end
|
|
88
112
|
|
|
89
113
|
def cmd_click(req)
|
|
90
|
-
|
|
114
|
+
sel = resolve_selector(req[:name], req)
|
|
115
|
+
return sel if sel.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
with_page(req[:name]) { |p| click_element(p, sel) }
|
|
91
118
|
end
|
|
92
119
|
|
|
93
120
|
def click_element(page, selector)
|
|
@@ -110,6 +137,13 @@ module Browserctl
|
|
|
110
137
|
with_page(req[:name]) { |p| wait_for_selector(p, req[:selector], req.fetch(:timeout, 10).to_f) }
|
|
111
138
|
end
|
|
112
139
|
|
|
140
|
+
def cmd_watch(req)
|
|
141
|
+
with_page(req[:name]) do |p|
|
|
142
|
+
result = wait_for_selector(p, req[:selector], req.fetch(:timeout, 30).to_f)
|
|
143
|
+
result[:error] ? result : { ok: true, selector: req[:selector] }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
113
147
|
def wait_for_selector(page, selector, timeout)
|
|
114
148
|
deadline = Time.now + timeout
|
|
115
149
|
sleep 0.2 until (found = page.at_css(selector)) || Time.now > deadline
|
|
@@ -135,5 +169,13 @@ module Browserctl
|
|
|
135
169
|
|
|
136
170
|
yield page
|
|
137
171
|
end
|
|
172
|
+
|
|
173
|
+
def resolve_selector(name, req)
|
|
174
|
+
return req[:selector] if req[:selector]
|
|
175
|
+
return { error: "selector or ref required" } unless req[:ref]
|
|
176
|
+
|
|
177
|
+
sel = @mutex.synchronize { @ref_registries.dig(name, req[:ref]) }
|
|
178
|
+
sel || { error: "ref '#{req[:ref]}' not found — run snap first" }
|
|
179
|
+
end
|
|
138
180
|
end
|
|
139
181
|
end
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -6,12 +6,15 @@ require "json"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "timeout"
|
|
8
8
|
require_relative "constants"
|
|
9
|
+
require_relative "logger"
|
|
9
10
|
require_relative "server/command_dispatcher"
|
|
10
11
|
require_relative "server/idle_watcher"
|
|
11
12
|
|
|
12
13
|
module Browserctl
|
|
13
14
|
class Server
|
|
14
|
-
def initialize(headless: true)
|
|
15
|
+
def initialize(headless: true, socket_path: SOCKET_PATH, pid_path: PID_PATH)
|
|
16
|
+
@socket_path = socket_path
|
|
17
|
+
@pid_path = pid_path
|
|
15
18
|
prepare_runtime(headless)
|
|
16
19
|
@dispatcher = CommandDispatcher.new(@pages, @browser, mutex: @mutex)
|
|
17
20
|
end
|
|
@@ -29,7 +32,7 @@ module Browserctl
|
|
|
29
32
|
private
|
|
30
33
|
|
|
31
34
|
def prepare_runtime(headless)
|
|
32
|
-
FileUtils.mkdir_p(File.dirname(
|
|
35
|
+
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
33
36
|
@browser = init_browser(headless)
|
|
34
37
|
init_state
|
|
35
38
|
end
|
|
@@ -56,18 +59,13 @@ module Browserctl
|
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
def setup_socket
|
|
59
|
-
FileUtils.rm_f(
|
|
60
|
-
server = UNIXServer.new(
|
|
61
|
-
File.chmod(0o600,
|
|
62
|
-
|
|
62
|
+
FileUtils.rm_f(@socket_path)
|
|
63
|
+
server = UNIXServer.new(@socket_path)
|
|
64
|
+
File.chmod(0o600, @socket_path)
|
|
65
|
+
Browserctl.logger.info "listening on #{@socket_path}"
|
|
63
66
|
server
|
|
64
67
|
end
|
|
65
68
|
|
|
66
|
-
def announce_socket
|
|
67
|
-
$stdout.puts "browserd listening on #{SOCKET_PATH}"
|
|
68
|
-
$stdout.flush
|
|
69
|
-
end
|
|
70
|
-
|
|
71
69
|
def serve(server)
|
|
72
70
|
loop do
|
|
73
71
|
client = server.accept
|
|
@@ -78,6 +76,7 @@ module Browserctl
|
|
|
78
76
|
def handle(socket)
|
|
79
77
|
dispatch(socket, socket.gets)
|
|
80
78
|
rescue StandardError => e
|
|
79
|
+
Browserctl.logger.error "#{e.class}: #{e.message}"
|
|
81
80
|
quietly { socket.puts JSON.generate({ error: e.message }) }
|
|
82
81
|
ensure
|
|
83
82
|
quietly { socket.close }
|
|
@@ -100,12 +99,12 @@ module Browserctl
|
|
|
100
99
|
idle&.kill
|
|
101
100
|
quietly { server&.close }
|
|
102
101
|
quietly { Timeout.timeout(5) { @browser.quit } }
|
|
103
|
-
quietly { File.unlink(
|
|
104
|
-
quietly { File.unlink(
|
|
102
|
+
quietly { File.unlink(@socket_path) }
|
|
103
|
+
quietly { File.unlink(@pid_path) }
|
|
105
104
|
end
|
|
106
105
|
|
|
107
106
|
def write_pid
|
|
108
|
-
File.write(
|
|
107
|
+
File.write(@pid_path, Process.pid.to_s)
|
|
109
108
|
end
|
|
110
109
|
|
|
111
110
|
def quietly
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
3
4
|
require_relative "client"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
@@ -7,6 +8,7 @@ module Browserctl
|
|
|
7
8
|
|
|
8
9
|
ParamDef = Struct.new(:name, :required, :secret, :default, keyword_init: true)
|
|
9
10
|
StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
|
|
11
|
+
StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
10
12
|
|
|
11
13
|
class WorkflowContext
|
|
12
14
|
attr_reader :client
|
|
@@ -106,8 +108,8 @@ module Browserctl
|
|
|
106
108
|
@param_defs[name] = ParamDef.new(name: name, required: required, secret: secret, default: default)
|
|
107
109
|
end
|
|
108
110
|
|
|
109
|
-
def step(label, &block)
|
|
110
|
-
@steps <<
|
|
111
|
+
def step(label, retry_count: 0, timeout: nil, &block)
|
|
112
|
+
@steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
def call(params, client)
|
|
@@ -118,16 +120,30 @@ module Browserctl
|
|
|
118
120
|
private
|
|
119
121
|
|
|
120
122
|
def execute_steps(ctx)
|
|
121
|
-
@steps.map { |
|
|
123
|
+
@steps.map { |defn| run_step(ctx, defn) }.each do |r|
|
|
122
124
|
raise WorkflowError, "step '#{r.name}' failed: #{r.error}" unless r.ok
|
|
123
125
|
end
|
|
124
126
|
end
|
|
125
127
|
|
|
126
|
-
def run_step(ctx,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
def run_step(ctx, defn)
|
|
129
|
+
last_error = nil
|
|
130
|
+
(defn.retry_count + 1).times do
|
|
131
|
+
execute_block(ctx, defn)
|
|
132
|
+
return StepResult.new(name: defn.label, ok: true)
|
|
133
|
+
rescue WorkflowError, StandardError => e
|
|
134
|
+
last_error = e
|
|
135
|
+
end
|
|
136
|
+
StepResult.new(name: defn.label, ok: false, error: last_error.message)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def execute_block(ctx, defn)
|
|
140
|
+
if defn.timeout
|
|
141
|
+
::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
|
|
142
|
+
else
|
|
143
|
+
ctx.instance_exec(&defn.block)
|
|
144
|
+
end
|
|
145
|
+
rescue ::Timeout::Error
|
|
146
|
+
raise WorkflowError, "step '#{defn.label}' timed out after #{defn.timeout}s"
|
|
131
147
|
end
|
|
132
148
|
|
|
133
149
|
def resolve_params(provided)
|
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.2.1
|
|
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-
|
|
11
|
+
date: 2026-04-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -103,13 +103,17 @@ files:
|
|
|
103
103
|
- examples/the_internet/login.rb
|
|
104
104
|
- lib/browserctl.rb
|
|
105
105
|
- lib/browserctl/client.rb
|
|
106
|
+
- lib/browserctl/commands/cli_output.rb
|
|
106
107
|
- lib/browserctl/commands/click.rb
|
|
107
108
|
- lib/browserctl/commands/fill.rb
|
|
108
|
-
- lib/browserctl/commands/flag_extractor.rb
|
|
109
109
|
- lib/browserctl/commands/open_page.rb
|
|
110
|
+
- lib/browserctl/commands/record.rb
|
|
110
111
|
- lib/browserctl/commands/screenshot.rb
|
|
111
112
|
- lib/browserctl/commands/snapshot.rb
|
|
113
|
+
- lib/browserctl/commands/watch.rb
|
|
112
114
|
- lib/browserctl/constants.rb
|
|
115
|
+
- lib/browserctl/logger.rb
|
|
116
|
+
- lib/browserctl/recording.rb
|
|
113
117
|
- lib/browserctl/runner.rb
|
|
114
118
|
- lib/browserctl/server.rb
|
|
115
119
|
- lib/browserctl/server/command_dispatcher.rb
|