browserctl 0.1.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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +272 -0
- data/bin/browserctl +92 -0
- data/bin/browserd +10 -0
- data/bin/setup +16 -0
- data/examples/the_internet/add_remove_elements.rb +31 -0
- data/examples/the_internet/checkboxes.rb +35 -0
- data/examples/the_internet/dropdown.rb +33 -0
- data/examples/the_internet/dynamic_loading.rb +33 -0
- data/examples/the_internet/login.rb +33 -0
- data/lib/browserctl/client.rb +53 -0
- data/lib/browserctl/commands/click.rb +13 -0
- data/lib/browserctl/commands/fill.rb +13 -0
- data/lib/browserctl/commands/flag_extractor.rb +15 -0
- data/lib/browserctl/commands/open_page.rb +16 -0
- data/lib/browserctl/commands/screenshot.rb +16 -0
- data/lib/browserctl/commands/snapshot.rb +27 -0
- data/lib/browserctl/constants.rb +7 -0
- data/lib/browserctl/runner.rb +75 -0
- data/lib/browserctl/server/command_dispatcher.rb +135 -0
- data/lib/browserctl/server/idle_watcher.rb +39 -0
- data/lib/browserctl/server/snapshot_builder.rb +55 -0
- data/lib/browserctl/server.rb +112 -0
- data/lib/browserctl/version.rb +5 -0
- data/lib/browserctl/workflow.rb +150 -0
- data/lib/browserctl.rb +7 -0
- metadata +148 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: db03c20d156ddbfc0c81267889ce46be79cb9973f2ff55af65739ddfe370f9f6
|
|
4
|
+
data.tar.gz: 712a9ffd6206cd0c1e3614d0d7230bb1c998beed56d315313328b0c768c5d3ea
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7780abfa304abcbfb252fee4a41ff5bd592275e8fe6ac66b30ab3e2022b8e0a48c94b046458f5d10b215cef89f75c94691de16b0158378357f863bf6a2dc5a15
|
|
7
|
+
data.tar.gz: d7b0051c9b2c13bf79313cb0e5edb58062ebe4a9ae9a77a836a2e1394a5f537d72aa2bf61bbb3003dcb30d8eef66cd873d68b6cda394eb9a5c6f078ae6143c90
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Thread safety: all server dispatch calls now protected by a Mutex
|
|
12
|
+
- `fill` no longer queries the DOM twice; returns an error if selector is not found
|
|
13
|
+
- `click` now returns an error when the selector is not found instead of silently succeeding
|
|
14
|
+
- `wait_for` now polls for the selector until timeout instead of checking once after network idle
|
|
15
|
+
- Circular workflow `invoke` calls now raise a descriptive `WorkflowError`
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- GitHub Actions CI (lint + test across Ruby 3.2–3.4)
|
|
19
|
+
- Gemspec metadata: `changelog_uri`, `source_code_uri`, `bug_tracker_uri`, `rubygems_mfa_required`
|
|
20
|
+
|
|
21
|
+
## [0.1.0] - 2025-01-01
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Initial release: persistent browser daemon (`browserd`) over Unix socket
|
|
25
|
+
- CLI (`browserctl`) with commands: open, close, pages, goto, fill, click, shot, snap, url, eval, ping, shutdown
|
|
26
|
+
- Ruby workflow DSL with params, steps, assertions, and `invoke`
|
|
27
|
+
- AI-optimized DOM snapshot format (JSON with ref IDs)
|
|
28
|
+
- Ferrum-backed Chrome/Chromium automation
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 patrick204nqh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/logo.svg" width="96" height="96" alt="browserctl logo"/>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# browserctl
|
|
6
|
+
|
|
7
|
+
[](https://github.com/patrick204nqh/browserctl/actions/workflows/ci.yml)
|
|
8
|
+
[](https://badge.fury.io/rb/browserctl)
|
|
9
|
+
[](https://rubygems.org/gems/browserctl)
|
|
10
|
+
|
|
11
|
+
A persistent browser automation daemon and CLI, purpose-built for AI agents and developer workflows.
|
|
12
|
+
|
|
13
|
+
Unlike tools that restart the browser on every script run, **browserctl keeps a named browser session alive** — preserving cookies, localStorage, open tabs, and page state across discrete commands.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
browserd & # start the daemon (headless)
|
|
17
|
+
browserctl open login --url https://example.com/login
|
|
18
|
+
browserctl fill login "input[name=email]" me@example.com
|
|
19
|
+
browserctl click login "button[type=submit]"
|
|
20
|
+
browserctl snap login # AI-friendly JSON snapshot
|
|
21
|
+
browserctl shutdown
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
<p align="center"><sub>Login flow captured with <code>browserctl shot</code></sub></p>
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Why browserctl?
|
|
30
|
+
|
|
31
|
+
Most automation tools are stateless — every script spins up a fresh browser and tears it down. browserctl doesn't.
|
|
32
|
+
|
|
33
|
+
| | browserctl | Playwright / Selenium |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| Session persists across commands | ✓ | ✗ (per-script lifecycle) |
|
|
36
|
+
| Named page handles | ✓ | ✗ |
|
|
37
|
+
| AI-friendly DOM snapshot | ✓ | ✗ |
|
|
38
|
+
| Lightweight CLI interface | ✓ | ✗ |
|
|
39
|
+
| Full browser automation API | — | ✓ |
|
|
40
|
+
| Parallel multi-browser testing | — | ✓ |
|
|
41
|
+
|
|
42
|
+
**Use browserctl when** you need a browser that stays alive and remembers state — for AI agents, iterative dev workflows, or lightweight smoke tests.
|
|
43
|
+
|
|
44
|
+
**Use Playwright/Selenium when** you need parallel test suites, multi-browser support, or a full programmatic API.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- Ruby >= 3.2
|
|
51
|
+
- Chrome or Chromium installed and on `PATH`
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
gem install browserctl
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or in your `Gemfile`:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
gem "browserctl"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
**1. Start the daemon**
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
browserd # headless (default)
|
|
75
|
+
browserd --headed # visible browser window
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**2. Open a named page**
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
browserctl open login --url https://app.example.com/login
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**3. Interact with the page**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
browserctl fill login "input[name=email]" user@example.com
|
|
88
|
+
browserctl fill login "input[name=password]" s3cr3t
|
|
89
|
+
browserctl click login "button[type=submit]"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**4. Observe the result**
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
browserctl snap login # AI-friendly JSON (default)
|
|
96
|
+
browserctl snap login --format html
|
|
97
|
+
browserctl shot login --out /tmp/after-login.png --full
|
|
98
|
+
browserctl url login
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**5. Manage pages and daemon**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
browserctl pages
|
|
105
|
+
browserctl close login
|
|
106
|
+
browserctl ping
|
|
107
|
+
browserctl shutdown
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## All Commands
|
|
113
|
+
|
|
114
|
+
### Browser commands _(require `browserd` running)_
|
|
115
|
+
|
|
116
|
+
| Command | Description |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `open <page> [--url URL]` | Open or focus a named page |
|
|
119
|
+
| `close <page>` | Close a named page |
|
|
120
|
+
| `pages` | List open pages |
|
|
121
|
+
| `goto <page> <url>` | Navigate a page to a URL |
|
|
122
|
+
| `fill <page> <selector> <value>` | Fill an input field |
|
|
123
|
+
| `click <page> <selector>` | Click an element |
|
|
124
|
+
| `snap <page> [--format ai\|html]` | Snapshot DOM (default: ai) |
|
|
125
|
+
| `shot <page> [--out PATH] [--full]` | Take a screenshot |
|
|
126
|
+
| `url <page>` | Print current URL |
|
|
127
|
+
| `eval <page> <expression>` | Evaluate a JS expression |
|
|
128
|
+
|
|
129
|
+
### Daemon commands
|
|
130
|
+
|
|
131
|
+
| Command | Description |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `ping` | Check if `browserd` is alive |
|
|
134
|
+
| `shutdown` | Stop `browserd` |
|
|
135
|
+
|
|
136
|
+
### Workflow commands
|
|
137
|
+
|
|
138
|
+
| Command | Description |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `run <name\|file.rb> [--key value ...]` | Run a named workflow or workflow file |
|
|
141
|
+
| `workflows` | List available workflows |
|
|
142
|
+
| `describe <name>` | Show workflow params and steps |
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## AI Snapshot Format
|
|
147
|
+
|
|
148
|
+
`browserctl snap <page>` returns a compact JSON array of interactable elements — designed to be token-efficient for AI agents:
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
[
|
|
152
|
+
{
|
|
153
|
+
"ref": "e1",
|
|
154
|
+
"tag": "input",
|
|
155
|
+
"text": "",
|
|
156
|
+
"selector": "form > input[name=email]",
|
|
157
|
+
"attrs": {
|
|
158
|
+
"type": "email",
|
|
159
|
+
"name": "email",
|
|
160
|
+
"placeholder": "Enter email"
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"ref": "e2",
|
|
165
|
+
"tag": "button",
|
|
166
|
+
"text": "Sign in",
|
|
167
|
+
"selector": "form > button",
|
|
168
|
+
"attrs": {
|
|
169
|
+
"type": "submit"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Use `selector` values directly with `fill` and `click`.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Workflows
|
|
180
|
+
|
|
181
|
+
Workflows are Ruby files using the `Browserctl.workflow` DSL. Place them in any of:
|
|
182
|
+
|
|
183
|
+
- `./.browserctl/workflows/`
|
|
184
|
+
- `~/.browserctl/workflows/`
|
|
185
|
+
|
|
186
|
+
### Example
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# .browserctl/workflows/smoke_login.rb
|
|
190
|
+
Browserctl.workflow "smoke_login" do
|
|
191
|
+
desc "Log in and confirm the dashboard loads"
|
|
192
|
+
|
|
193
|
+
param :email, required: true
|
|
194
|
+
param :password, required: true, secret: true
|
|
195
|
+
param :base_url, default: "https://app.example.com"
|
|
196
|
+
|
|
197
|
+
step "open login page" do
|
|
198
|
+
page(:login).goto("#{base_url}/login")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
step "submit credentials" do
|
|
202
|
+
page(:login).fill("input[name=email]", email)
|
|
203
|
+
page(:login).fill("input[name=password]", password)
|
|
204
|
+
page(:login).click("button[type=submit]")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
step "verify dashboard" do
|
|
208
|
+
page(:login).wait_for("[data-test=dashboard]", timeout: 10)
|
|
209
|
+
assert page(:login).url.include?("/dashboard")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
browserctl run smoke_login --email me@example.com --password s3cr3t
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Workflow DSL reference
|
|
219
|
+
|
|
220
|
+
| Method | Description |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `desc "text"` | Human-readable description |
|
|
223
|
+
| `param :name, required:, secret:, default:` | Declare a parameter |
|
|
224
|
+
| `step "label" { }` | Add a step (runs in order, halts on failure) |
|
|
225
|
+
| `page(:name)` | Returns a `PageProxy` for the named page |
|
|
226
|
+
| `invoke "other_workflow", **overrides` | Call another workflow |
|
|
227
|
+
| `assert condition, "message"` | Raise `WorkflowError` if condition is false |
|
|
228
|
+
|
|
229
|
+
### PageProxy methods
|
|
230
|
+
|
|
231
|
+
`goto(url)` · `fill(selector, value)` · `click(selector)` · `snapshot(**opts)` · `screenshot(**opts)` · `wait_for(selector, timeout: 10)` · `url` · `evaluate(expression)`
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Examples
|
|
236
|
+
|
|
237
|
+
Ready-to-run smoke tests against [the-internet.herokuapp.com](https://the-internet.herokuapp.com) are included in `examples/the_internet/`. See [docs/smoke-testing-the-internet.md](docs/smoke-testing-the-internet.md) for annotated output and auto-generated screenshots of each scenario.
|
|
238
|
+
|
|
239
|
+
For a full guide on building your own workflows, see [docs/writing-workflows.md](docs/writing-workflows.md).
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## How it works
|
|
244
|
+
|
|
245
|
+
`browserd` runs as a background process, listening on a Unix socket at `~/.browserctl/browserd.sock`. It manages a Ferrum (Chrome DevTools Protocol) browser instance with named page handles.
|
|
246
|
+
|
|
247
|
+
`browserctl` sends JSON-RPC commands over the socket and prints the result. Workflows run in-process through the same client.
|
|
248
|
+
|
|
249
|
+
The daemon shuts itself down after 30 minutes of inactivity.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Development
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
git clone https://github.com/patrick204nqh/browserctl
|
|
257
|
+
cd browserctl
|
|
258
|
+
bin/setup # install deps + check for Chrome
|
|
259
|
+
|
|
260
|
+
bundle exec rspec # run tests
|
|
261
|
+
bundle exec rubocop # lint
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Contributing
|
|
267
|
+
|
|
268
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md)
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
[MIT](LICENSE)
|
data/bin/browserctl
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
require "browserctl"
|
|
8
|
+
require "browserctl/commands/open_page"
|
|
9
|
+
require "browserctl/commands/fill"
|
|
10
|
+
require "browserctl/commands/click"
|
|
11
|
+
require "browserctl/commands/snapshot"
|
|
12
|
+
require "browserctl/commands/screenshot"
|
|
13
|
+
|
|
14
|
+
def usage
|
|
15
|
+
puts <<~USAGE
|
|
16
|
+
Usage: browserctl <command> [args]
|
|
17
|
+
|
|
18
|
+
Browser commands (require browserd running):
|
|
19
|
+
open <page> [--url URL] Open or focus a named page
|
|
20
|
+
close <page> Close a named page
|
|
21
|
+
pages List open pages
|
|
22
|
+
goto <page> <url> Navigate a page
|
|
23
|
+
fill <page> <selector> <value> Fill an input
|
|
24
|
+
click <page> <selector> Click an element
|
|
25
|
+
shot <page> [--out PATH] [--full] Take a screenshot
|
|
26
|
+
snap <page> [--format ai|html] Snapshot DOM (default: ai)
|
|
27
|
+
url <page> Print current URL
|
|
28
|
+
eval <page> <expression> Evaluate JS expression
|
|
29
|
+
|
|
30
|
+
Workflow commands:
|
|
31
|
+
run <name|file> [--key value] Run a workflow
|
|
32
|
+
workflows List available workflows
|
|
33
|
+
describe <name> Describe a workflow
|
|
34
|
+
|
|
35
|
+
Daemon commands:
|
|
36
|
+
ping Check if browserd is alive
|
|
37
|
+
shutdown Stop browserd
|
|
38
|
+
USAGE
|
|
39
|
+
exit 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
cmd = ARGV.shift
|
|
43
|
+
args = ARGV.dup
|
|
44
|
+
|
|
45
|
+
usage if cmd.nil? || %w[-h --help help].include?(cmd)
|
|
46
|
+
|
|
47
|
+
runner = Browserctl::Runner.new
|
|
48
|
+
|
|
49
|
+
case cmd
|
|
50
|
+
when "run"
|
|
51
|
+
name = args.shift or abort "usage: browserctl run <workflow_name|file.rb> [--key value ...]"
|
|
52
|
+
if File.exist?(name)
|
|
53
|
+
before = Browserctl::REGISTRY.keys.dup
|
|
54
|
+
load File.expand_path(name)
|
|
55
|
+
name = (Browserctl::REGISTRY.keys - before).first || File.basename(name, ".rb")
|
|
56
|
+
end
|
|
57
|
+
params = {}
|
|
58
|
+
args.each_slice(2) do |flag, val|
|
|
59
|
+
key = flag.sub(/\A--/, "").to_sym
|
|
60
|
+
params[key] = val
|
|
61
|
+
end
|
|
62
|
+
success = runner.run_workflow(name, **params)
|
|
63
|
+
exit(success ? 0 : 1)
|
|
64
|
+
|
|
65
|
+
when "workflows"
|
|
66
|
+
list = runner.list_workflows
|
|
67
|
+
list.each { |w| puts "#{w[:name].ljust(24)} #{w[:desc]}" }
|
|
68
|
+
|
|
69
|
+
when "describe"
|
|
70
|
+
name = args.shift or abort "usage: browserctl describe <workflow_name>"
|
|
71
|
+
puts JSON.pretty_generate(runner.describe_workflow(name))
|
|
72
|
+
|
|
73
|
+
else
|
|
74
|
+
client = Browserctl::Client.new
|
|
75
|
+
|
|
76
|
+
case cmd
|
|
77
|
+
when "open" then Browserctl::Commands::OpenPage.run(client, args)
|
|
78
|
+
when "close" then puts client.close_page(args[0]).to_json
|
|
79
|
+
when "pages" then puts client.list_pages.to_json
|
|
80
|
+
when "goto" then puts client.goto(args[0], args[1]).to_json
|
|
81
|
+
when "fill" then Browserctl::Commands::Fill.run(client, args)
|
|
82
|
+
when "click" then Browserctl::Commands::Click.run(client, args)
|
|
83
|
+
when "shot" then Browserctl::Commands::Screenshot.run(client, args)
|
|
84
|
+
when "snap" then Browserctl::Commands::Snapshot.run(client, args)
|
|
85
|
+
when "url" then puts client.url(args[0]).to_json
|
|
86
|
+
when "eval" then puts client.evaluate(args[0], args[1]).to_json
|
|
87
|
+
when "ping" then puts client.ping.to_json
|
|
88
|
+
when "shutdown" then puts client.shutdown.to_json
|
|
89
|
+
else
|
|
90
|
+
abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
|
|
91
|
+
end
|
|
92
|
+
end
|
data/bin/browserd
ADDED
data/bin/setup
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
echo "==> Installing dependencies..."
|
|
5
|
+
bundle install
|
|
6
|
+
|
|
7
|
+
echo "==> Checking for Chrome/Chromium..."
|
|
8
|
+
if ! command -v google-chrome &>/dev/null && ! command -v chromium-browser &>/dev/null && ! command -v chromium &>/dev/null; then
|
|
9
|
+
echo "WARNING: Chrome/Chromium not found on PATH."
|
|
10
|
+
echo " Install it before running browserctl."
|
|
11
|
+
echo " macOS: brew install --cask google-chrome"
|
|
12
|
+
echo " Ubuntu: sudo apt-get install -y chromium-browser"
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
echo ""
|
|
16
|
+
echo "Done. Try: bundle exec browserd &"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "the_internet/add_remove_elements" do
|
|
4
|
+
desc "Add/Remove Elements: add several elements, remove some, assert final count"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
|
|
8
|
+
step "open add/remove elements page" do
|
|
9
|
+
client.open_page("main", url: "#{base_url}/add_remove_elements/")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
step "add three elements" do
|
|
13
|
+
3.times { page(:main).click("button[onclick]") }
|
|
14
|
+
count = client.evaluate("main", "document.querySelectorAll('#elements button').length")[:result]
|
|
15
|
+
assert count == 3, "expected 3 elements, got: #{count}"
|
|
16
|
+
screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
|
|
17
|
+
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_add_remove_elements.png")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
step "remove one element" do
|
|
21
|
+
page(:main).click("#elements button:first-child")
|
|
22
|
+
count = client.evaluate("main", "document.querySelectorAll('#elements button').length")[:result]
|
|
23
|
+
assert count == 2, "expected 2 elements after removal, got: #{count}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
step "remove all remaining elements" do
|
|
27
|
+
2.times { page(:main).click("#elements button:first-child") }
|
|
28
|
+
count = client.evaluate("main", "document.querySelectorAll('#elements button').length")[:result]
|
|
29
|
+
assert count.zero?, "expected 0 elements, got: #{count}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "the_internet/checkboxes" do
|
|
4
|
+
desc "Checkboxes: read state, toggle each, verify both checked"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
|
|
8
|
+
step "open checkboxes page" do
|
|
9
|
+
client.open_page("main", url: "#{base_url}/checkboxes")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
step "read initial state" do
|
|
13
|
+
js = "Array.from(document.querySelectorAll('input[type=checkbox]')).map(c => c.checked)"
|
|
14
|
+
states = client.evaluate("main", js)[:result]
|
|
15
|
+
assert states == [false, true], "expected initial state [false, true], got: #{states.inspect}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
step "toggle first checkbox on" do
|
|
19
|
+
page(:main).click("form#checkboxes input:first-child")
|
|
20
|
+
js = "document.querySelector('form#checkboxes input:first-child').checked"
|
|
21
|
+
checked = client.evaluate("main", js)[:result]
|
|
22
|
+
assert checked == true, "expected first checkbox to be checked"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
step "verify both checkboxes are now checked" do
|
|
26
|
+
js = "Array.from(document.querySelectorAll('input[type=checkbox]')).map(c => c.checked)"
|
|
27
|
+
states = client.evaluate("main", js)[:result]
|
|
28
|
+
assert states.all?, "expected both checkboxes checked, got: #{states.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
step "capture screenshot" do
|
|
32
|
+
screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
|
|
33
|
+
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_checkboxes.png")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "the_internet/dropdown" do
|
|
4
|
+
desc "Dropdown: select each option via JS, assert selected value"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
|
|
8
|
+
step "open dropdown page" do
|
|
9
|
+
client.open_page("main", url: "#{base_url}/dropdown")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
step "assert default is unselected" do
|
|
13
|
+
value = client.evaluate("main", "document.querySelector('select#dropdown').value")[:result]
|
|
14
|
+
assert value == "", "expected empty default, got: #{value.inspect}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
step "select Option 1" do
|
|
18
|
+
client.evaluate("main", "document.querySelector('select#dropdown').value = '1'")
|
|
19
|
+
selected = client.evaluate("main", "document.querySelector('select#dropdown option:checked').text")[:result]
|
|
20
|
+
assert selected == "Option 1", "expected 'Option 1', got: #{selected.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
step "select Option 2" do
|
|
24
|
+
client.evaluate("main", "document.querySelector('select#dropdown').value = '2'")
|
|
25
|
+
selected = client.evaluate("main", "document.querySelector('select#dropdown option:checked').text")[:result]
|
|
26
|
+
assert selected == "Option 2", "expected 'Option 2', got: #{selected.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
step "capture screenshot" do
|
|
30
|
+
screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
|
|
31
|
+
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_dropdown.png")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "the_internet/dynamic_loading" do
|
|
4
|
+
desc "Dynamic loading: click Start, wait for hidden element to appear"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
|
|
8
|
+
step "open dynamic loading page" do
|
|
9
|
+
client.open_page("main", url: "#{base_url}/dynamic_loading/1")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
step "assert finish text is hidden before start" do
|
|
13
|
+
visible = client.evaluate("main", "document.querySelector('#finish').style.display !== 'none'")[:result]
|
|
14
|
+
assert visible == false, "expected #finish to be hidden before start"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
step "click Start and wait for content" do
|
|
18
|
+
page(:main).click("#start button")
|
|
19
|
+
page(:main).wait_for("#finish h4", timeout: 10)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
step "assert finish text is correct" do
|
|
23
|
+
text = client.evaluate("main", "document.querySelector('#finish h4')?.innerText?.trim()")[:result]
|
|
24
|
+
assert text == "Hello World!", "expected 'Hello World!', got: #{text.inspect}"
|
|
25
|
+
# wait for #loading to disappear before screenshotting so the rendered state is correct
|
|
26
|
+
deadline = Time.now + 5
|
|
27
|
+
sleep 0.2 until client.evaluate("main",
|
|
28
|
+
"document.querySelector('#loading')?.style?.display")[:result] == "none" ||
|
|
29
|
+
Time.now > deadline
|
|
30
|
+
screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
|
|
31
|
+
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_dynamic_loading.png")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "the_internet/login" do
|
|
4
|
+
desc "Form authentication: fill credentials, submit, assert secure area"
|
|
5
|
+
|
|
6
|
+
param :username, default: "tomsmith"
|
|
7
|
+
param :password, default: "SuperSecretPassword!", secret: true
|
|
8
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
9
|
+
|
|
10
|
+
step "open login page" do
|
|
11
|
+
client.open_page("main", url: "#{base_url}/login")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
step "fill and submit credentials" do
|
|
15
|
+
page(:main).fill("input#username", username)
|
|
16
|
+
page(:main).fill("input#password", password)
|
|
17
|
+
page(:main).click("button[type=submit]")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
step "verify secure area" do
|
|
21
|
+
assert page(:main).url.include?("/secure"), "expected redirect to /secure"
|
|
22
|
+
flash = client.evaluate("main", "document.querySelector('.flash.success')?.innerText?.trim()")[:result]
|
|
23
|
+
assert flash&.include?("You logged into a secure area!"), "expected success flash, got: #{flash.inspect}"
|
|
24
|
+
screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
|
|
25
|
+
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_login.png")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
step "logout and verify" do
|
|
29
|
+
page(:main).click("a[href='/logout']")
|
|
30
|
+
flash = client.evaluate("main", "document.querySelector('.flash.success')?.innerText?.trim()")[:result]
|
|
31
|
+
assert flash&.include?("You logged out"), "expected logout flash, got: #{flash.inspect}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "constants"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(socket_path = SOCKET_PATH)
|
|
10
|
+
@socket_path = socket_path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(cmd, **params)
|
|
14
|
+
communicate(JSON.generate({ cmd: cmd }.merge(params)))
|
|
15
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
16
|
+
raise "browserd is not running — start it with: browserd"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Convenience wrappers matching CLI command vocabulary
|
|
20
|
+
|
|
21
|
+
def open_page(name, url: nil) = call("open_page", name: name, url: url)
|
|
22
|
+
def close_page(name) = call("close_page", name: name)
|
|
23
|
+
def list_pages = call("list_pages")
|
|
24
|
+
def goto(name, url) = call("goto", name: name, url: url)
|
|
25
|
+
def fill(name, selector, val) = call("fill", name: name, selector: selector, value: val)
|
|
26
|
+
def click(name, selector) = call("click", name: name, selector: selector)
|
|
27
|
+
def screenshot(name, path: nil, full: false) = call("screenshot", name: name, path: path, full: full)
|
|
28
|
+
def snapshot(name, format: "ai") = call("snapshot", name: name, format: format)
|
|
29
|
+
def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
|
|
30
|
+
def url(name) = call("url", name: name)
|
|
31
|
+
def evaluate(name, expression) = call("evaluate", name: name, expression: expression)
|
|
32
|
+
def ping = call("ping")
|
|
33
|
+
def shutdown = call("shutdown")
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def communicate(payload)
|
|
38
|
+
UNIXSocket.open(@socket_path) do |sock|
|
|
39
|
+
sock.puts(payload)
|
|
40
|
+
read_response(sock)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read_response(sock)
|
|
45
|
+
raise "browserd response timeout after 60s" unless sock.wait_readable(60)
|
|
46
|
+
|
|
47
|
+
raw = sock.gets
|
|
48
|
+
raise "browserd closed connection" unless raw
|
|
49
|
+
|
|
50
|
+
JSON.parse(raw.chomp, symbolize_names: true)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|