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 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
+ [![CI](https://github.com/patrick204nqh/browserctl/actions/workflows/ci.yml/badge.svg)](https://github.com/patrick204nqh/browserctl/actions/workflows/ci.yml)
8
+ [![Gem Version](https://badge.fury.io/rb/browserctl.svg)](https://badge.fury.io/rb/browserctl)
9
+ [![Downloads](https://img.shields.io/gem/dt/browserctl)](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
+ ![browserctl capturing a login flow](docs/screenshots/the_internet_login.png)
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
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "nokogiri"
7
+ require "browserctl/server"
8
+
9
+ headless = !ARGV.include?("--headed")
10
+ Browserctl::Server.new(headless: headless).run
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