browserctl 0.1.1 → 0.2.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 +28 -0
- data/README.md +56 -14
- data/bin/browserctl +21 -1
- data/bin/browserd +14 -2
- data/lib/browserctl/client.rb +30 -7
- data/lib/browserctl/commands/click.rb +13 -3
- data/lib/browserctl/commands/fill.rb +15 -3
- data/lib/browserctl/commands/flag_extractor.rb +9 -1
- data/lib/browserctl/commands/record.rb +59 -0
- data/lib/browserctl/commands/snapshot.rb +3 -2
- data/lib/browserctl/commands/watch.rb +17 -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 +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b5b21accfeb82afa82dc2c12d51ef7da31ed8ab9db073d2b576df0385e5adb6
|
|
4
|
+
data.tar.gz: d9cd601ce2d63b5c3f2c8d539d14dc684dcf3753a7f95002b9c165791ff2f584
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5bc9c337336ac4fb23da1966a9b51e3dffcef8b23ea00a2990310a0aae5978ccf423416118ebe61b83c92ca84f38846ca16ca909a376e2e3e8f13eb97e82b045
|
|
7
|
+
data.tar.gz: 13b8b5fcbc17e328210488ce8e50ead2ea8576419dc5d493c06285d8e191a4be7599a04a58b651f501febb351704609be6dc06c0c8c00c827a7c796667072943
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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.0](https://github.com/patrick204nqh/browserctl/compare/v0.1.1...v0.2.0) (2026-04-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add architecture documentation and decision records with diagrams ([9b67e40](https://github.com/patrick204nqh/browserctl/commit/9b67e40892cb87a075818fd7b166584a48c8edff))
|
|
14
|
+
* add logging functionality and environment variable setup documentation ([feb529c](https://github.com/patrick204nqh/browserctl/commit/feb529c4040c6f84104fa6dc55afc8d0a5d1b1a7))
|
|
15
|
+
* add record command — capture session as replayable workflow ([bb8988b](https://github.com/patrick204nqh/browserctl/commit/bb8988bcf17c0a7689f5a6e53d755d0e6a69b359))
|
|
16
|
+
* add ref registry and diff cache to CommandDispatcher ([217e158](https://github.com/patrick204nqh/browserctl/commit/217e15831b2cf1f0bf693584f47ed5d315594803))
|
|
17
|
+
* add ref-based click and fill using snapshot registry ([f0251c0](https://github.com/patrick204nqh/browserctl/commit/f0251c0732fd535bde8746b2817d3674ec8bc353))
|
|
18
|
+
* add retry_count and timeout options to workflow steps ([7b5e694](https://github.com/patrick204nqh/browserctl/commit/7b5e694cfb246396502a07b02928de79233a715f))
|
|
19
|
+
* add snap --diff returning only changed elements ([0a09558](https://github.com/patrick204nqh/browserctl/commit/0a095583ea23b1cff0df1ecc6cb91a3e35039e2c))
|
|
20
|
+
* add watch command — poll selector and emit when found ([b9a3abf](https://github.com/patrick204nqh/browserctl/commit/b9a3abf15b7e021906dda940f29f029ca3c221c2))
|
|
21
|
+
* named daemon instances via browserd --name and multi-socket support ([8cbc62d](https://github.com/patrick204nqh/browserctl/commit/8cbc62d6c8931b23cbf31e7285ee52c62e280f5c))
|
|
22
|
+
* v0.2 AI-first enhancements ([d3246d4](https://github.com/patrick204nqh/browserctl/commit/d3246d4c861430fda1e7dfa1cb67ce22db33dd68))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* add edited event and workflow_dispatch to release trigger ([b851306](https://github.com/patrick204nqh/browserctl/commit/b85130603986ea5a667f2a89e9af7139c38491f2))
|
|
28
|
+
* add paths-ignore for markdown and docs in CI workflow ([3b7801d](https://github.com/patrick204nqh/browserctl/commit/3b7801dfa536ea0b0cd9bab189f237a2709e1515))
|
|
29
|
+
* describe_workflow after StepDef refactor; skip nil-selector recording for ref commands ([9e437fa](https://github.com/patrick204nqh/browserctl/commit/9e437fa9c18b6c94259a652af238377ce120d12e))
|
|
30
|
+
* fill --ref uses --value flag; add record generate subcommand ([ef3ed43](https://github.com/patrick204nqh/browserctl/commit/ef3ed433e09c0ae8ac6e241949ee9c2c0ccaa03c))
|
|
31
|
+
* remove 'edited' type from release trigger events ([98f63f0](https://github.com/patrick204nqh/browserctl/commit/98f63f0c40cbdcd40e14704379f28c19c436a6c2))
|
|
32
|
+
* rubocop offenses — formatting, guard clauses, predicate rename extract_flag? ([43341be](https://github.com/patrick204nqh/browserctl/commit/43341be50f12ca28699b009efb85b1ff404e4dab))
|
|
33
|
+
* trigger release workflow on GitHub release publication ([55c8faa](https://github.com/patrick204nqh/browserctl/commit/55c8faae8d753f35663ebd3c02996d1a602de091))
|
|
34
|
+
* update brand icon concept and roadmap versioning ([7a97b4b](https://github.com/patrick204nqh/browserctl/commit/7a97b4b0c0eaffbd8743ac30b2fbf13be468c80e))
|
|
35
|
+
|
|
8
36
|
## [0.1.1](https://github.com/patrick204nqh/browserctl/compare/v0.1.0...v0.1.1) (2026-04-19)
|
|
9
37
|
|
|
10
38
|
|
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
|
@@ -10,6 +10,8 @@ require "browserctl/commands/fill"
|
|
|
10
10
|
require "browserctl/commands/click"
|
|
11
11
|
require "browserctl/commands/snapshot"
|
|
12
12
|
require "browserctl/commands/screenshot"
|
|
13
|
+
require "browserctl/commands/watch"
|
|
14
|
+
require "browserctl/commands/record"
|
|
13
15
|
|
|
14
16
|
def usage
|
|
15
17
|
puts <<~USAGE
|
|
@@ -26,6 +28,12 @@ def usage
|
|
|
26
28
|
snap <page> [--format ai|html] Snapshot DOM (default: ai)
|
|
27
29
|
url <page> Print current URL
|
|
28
30
|
eval <page> <expression> Evaluate JS expression
|
|
31
|
+
watch <page> <selector> [--timeout N] Wait for a selector to appear
|
|
32
|
+
|
|
33
|
+
Recording commands:
|
|
34
|
+
record start <name> Start recording browser commands
|
|
35
|
+
record stop [--out path] Stop recording and save workflow
|
|
36
|
+
record status Show active recording name
|
|
29
37
|
|
|
30
38
|
Workflow commands:
|
|
31
39
|
run <name|file> [--key value] Run a workflow
|
|
@@ -35,10 +43,19 @@ def usage
|
|
|
35
43
|
Daemon commands:
|
|
36
44
|
ping Check if browserd is alive
|
|
37
45
|
shutdown Stop browserd
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--daemon <name> Connect to a named daemon instance
|
|
38
49
|
USAGE
|
|
39
50
|
exit 0
|
|
40
51
|
end
|
|
41
52
|
|
|
53
|
+
daemon_idx = ARGV.index("--daemon")
|
|
54
|
+
daemon_name = if daemon_idx
|
|
55
|
+
ARGV.delete_at(daemon_idx)
|
|
56
|
+
ARGV.delete_at(daemon_idx)
|
|
57
|
+
end
|
|
58
|
+
|
|
42
59
|
cmd = ARGV.shift
|
|
43
60
|
args = ARGV.dup
|
|
44
61
|
|
|
@@ -70,8 +87,10 @@ when "describe"
|
|
|
70
87
|
name = args.shift or abort "usage: browserctl describe <workflow_name>"
|
|
71
88
|
puts JSON.pretty_generate(runner.describe_workflow(name))
|
|
72
89
|
|
|
90
|
+
when "record" then Browserctl::Commands::Record.run(args)
|
|
91
|
+
|
|
73
92
|
else
|
|
74
|
-
client = Browserctl::Client.new
|
|
93
|
+
client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
|
|
75
94
|
|
|
76
95
|
case cmd
|
|
77
96
|
when "open" then Browserctl::Commands::OpenPage.run(client, args)
|
|
@@ -84,6 +103,7 @@ else
|
|
|
84
103
|
when "snap" then Browserctl::Commands::Snapshot.run(client, args)
|
|
85
104
|
when "url" then puts client.url(args[0]).to_json
|
|
86
105
|
when "eval" then puts client.evaluate(args[0], args[1]).to_json
|
|
106
|
+
when "watch" then Browserctl::Commands::Watch.run(client, args)
|
|
87
107
|
when "ping" then puts client.ping.to_json
|
|
88
108
|
when "shutdown" then puts client.shutdown.to_json
|
|
89
109
|
else
|
data/bin/browserd
CHANGED
|
@@ -3,8 +3,20 @@
|
|
|
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"
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
opts = Optimist.options do
|
|
12
|
+
opt :headed, "Run with a visible browser window", default: false
|
|
13
|
+
opt :log_level, "Log verbosity: debug, info, warn, error", default: "info", type: :string
|
|
14
|
+
opt :name, "Daemon instance name for multi-agent use", default: nil, type: :string
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Browserctl.logger = Browserctl.build_logger(opts[:log_level])
|
|
18
|
+
Browserctl::Server.new(
|
|
19
|
+
headless: !opts[:headed],
|
|
20
|
+
socket_path: Browserctl.socket_path(opts[:name]),
|
|
21
|
+
pid_path: Browserctl.pid_path(opts[:name])
|
|
22
|
+
).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,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "flag_extractor"
|
|
4
|
+
|
|
3
5
|
module Browserctl
|
|
4
6
|
module Commands
|
|
5
7
|
class Click
|
|
6
8
|
def self.run(client, args)
|
|
7
|
-
name
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
name = args.shift
|
|
10
|
+
ref = FlagExtractor.extract_opt(args, "--ref")
|
|
11
|
+
selector = args.shift unless ref
|
|
12
|
+
|
|
13
|
+
if ref
|
|
14
|
+
abort "usage: browserctl click <page> --ref <ref>" unless name
|
|
15
|
+
puts client.click(name, ref: ref).to_json
|
|
16
|
+
else
|
|
17
|
+
abort "usage: browserctl click <page> <selector>" unless name && selector
|
|
18
|
+
puts client.click(name, selector).to_json
|
|
19
|
+
end
|
|
10
20
|
end
|
|
11
21
|
end
|
|
12
22
|
end
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "flag_extractor"
|
|
4
|
+
|
|
3
5
|
module Browserctl
|
|
4
6
|
module Commands
|
|
5
7
|
class Fill
|
|
6
8
|
def self.run(client, args)
|
|
7
|
-
name
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
name = args.shift
|
|
10
|
+
ref = FlagExtractor.extract_opt(args, "--ref")
|
|
11
|
+
|
|
12
|
+
if ref
|
|
13
|
+
value = FlagExtractor.extract_opt(args, "--value")
|
|
14
|
+
abort "usage: browserctl fill <page> --ref <ref> --value <value>" unless name && value
|
|
15
|
+
puts client.fill(name, nil, value, ref: ref).to_json
|
|
16
|
+
else
|
|
17
|
+
selector = args.shift
|
|
18
|
+
value = args.shift
|
|
19
|
+
abort "usage: browserctl fill <page> <selector> <value>" unless name && selector && value
|
|
20
|
+
puts client.fill(name, selector, value).to_json
|
|
21
|
+
end
|
|
10
22
|
end
|
|
11
23
|
end
|
|
12
24
|
end
|
|
@@ -7,8 +7,16 @@ module Browserctl
|
|
|
7
7
|
i = args.index(flag)
|
|
8
8
|
return unless i
|
|
9
9
|
|
|
10
|
+
sliced = args.slice!(i, 2)
|
|
11
|
+
sliced.length == 2 ? sliced.last : nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.extract_flag?(args, flag)
|
|
15
|
+
i = args.index(flag)
|
|
16
|
+
return false unless i
|
|
17
|
+
|
|
10
18
|
args.delete_at(i)
|
|
11
|
-
|
|
19
|
+
true
|
|
12
20
|
end
|
|
13
21
|
end
|
|
14
22
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "browserctl/recording"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Commands
|
|
8
|
+
class Record
|
|
9
|
+
USAGE = "usage: browserctl record start <name> | stop [--out path] | status"
|
|
10
|
+
|
|
11
|
+
def self.run(args)
|
|
12
|
+
subcmd = args.shift
|
|
13
|
+
case subcmd
|
|
14
|
+
when "start" then run_start(args)
|
|
15
|
+
when "stop" then run_stop(args)
|
|
16
|
+
when "status" then run_status
|
|
17
|
+
else abort USAGE
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def run_start(args)
|
|
25
|
+
name = args.shift or abort "usage: browserctl record start <name>"
|
|
26
|
+
Recording.start(name)
|
|
27
|
+
puts "Recording started: #{name}"
|
|
28
|
+
puts "Run browser commands, then: browserctl record stop"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run_stop(args)
|
|
32
|
+
idx = args.index("--out")
|
|
33
|
+
out = if idx
|
|
34
|
+
args.delete_at(idx)
|
|
35
|
+
args.delete_at(idx)
|
|
36
|
+
end
|
|
37
|
+
name = Recording.stop
|
|
38
|
+
if out
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
40
|
+
Recording.generate_workflow(name, output_path: out)
|
|
41
|
+
puts "Workflow saved: #{out}"
|
|
42
|
+
else
|
|
43
|
+
dest_dir = ".browserctl/workflows"
|
|
44
|
+
dest_file = File.join(dest_dir, "#{name}.rb")
|
|
45
|
+
FileUtils.mkdir_p(dest_dir)
|
|
46
|
+
Recording.generate_workflow(name, output_path: dest_file)
|
|
47
|
+
puts "Workflow saved: #{dest_file}"
|
|
48
|
+
end
|
|
49
|
+
puts "Run with: browserctl run #{name}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_status
|
|
53
|
+
active = Recording.active
|
|
54
|
+
puts active ? "Active recording: #{active}" : "No active recording."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -7,9 +7,10 @@ module Browserctl
|
|
|
7
7
|
module Commands
|
|
8
8
|
class Snapshot
|
|
9
9
|
def self.run(client, args)
|
|
10
|
-
name = args.shift or abort "usage: browserctl
|
|
10
|
+
name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
|
|
11
11
|
format = FlagExtractor.extract_opt(args, "--format") || "ai"
|
|
12
|
-
|
|
12
|
+
diff = FlagExtractor.extract_flag?(args, "--diff")
|
|
13
|
+
res = client.snapshot(name, format: format, diff: diff)
|
|
13
14
|
output_snapshot(res, format)
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "flag_extractor"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
class Watch
|
|
8
|
+
def self.run(client, args)
|
|
9
|
+
name = args.shift
|
|
10
|
+
selector = args.shift
|
|
11
|
+
timeout = (FlagExtractor.extract_opt(args, "--timeout") || 30).to_f
|
|
12
|
+
abort "usage: browserctl watch <page> <selector> [--timeout N]" unless name && selector
|
|
13
|
+
puts client.watch(name, selector, timeout: timeout).to_json
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
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.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-04-
|
|
11
|
+
date: 2026-04-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -107,9 +107,13 @@ files:
|
|
|
107
107
|
- lib/browserctl/commands/fill.rb
|
|
108
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
|