browserctl 0.3.1 → 0.5.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 +47 -0
- data/README.md +120 -214
- data/bin/browserctl +35 -13
- data/bin/browserd +7 -1
- data/bin/setup +7 -3
- data/examples/cloudflare_hitl.rb +1 -1
- data/examples/smoke/params_file.rb +35 -0
- data/examples/smoke/store_fetch.rb +39 -0
- data/examples/the_internet/add_remove_elements.rb +3 -3
- data/examples/the_internet/checkboxes.rb +3 -3
- data/examples/the_internet/dropdown.rb +3 -3
- data/examples/the_internet/dynamic_loading.rb +3 -3
- data/examples/the_internet/login.rb +5 -5
- data/lib/browserctl/client.rb +38 -2
- data/lib/browserctl/commands/export_cookies.rb +18 -0
- data/lib/browserctl/commands/import_cookies.rb +23 -0
- data/lib/browserctl/commands/init.rb +11 -0
- data/lib/browserctl/commands/{pause_resume.rb → pause.rb} +2 -12
- data/lib/browserctl/commands/record.rb +2 -0
- data/lib/browserctl/commands/resume.rb +21 -0
- data/lib/browserctl/commands/snapshot.rb +5 -5
- data/lib/browserctl/commands/status.rb +30 -0
- data/lib/browserctl/constants.rb +9 -2
- data/lib/browserctl/detectors.rb +23 -0
- data/lib/browserctl/errors.rb +25 -0
- data/lib/browserctl/logger.rb +40 -5
- data/lib/browserctl/policy.rb +36 -0
- data/lib/browserctl/recording.rb +81 -15
- data/lib/browserctl/runner.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +31 -234
- data/lib/browserctl/server/handlers/cookies.rb +57 -0
- data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
- data/lib/browserctl/server/handlers/devtools.rb +22 -0
- data/lib/browserctl/server/handlers/hitl.rb +30 -0
- data/lib/browserctl/server/handlers/navigation.rb +72 -0
- data/lib/browserctl/server/handlers/observation.rb +113 -0
- data/lib/browserctl/server/handlers/page_lifecycle.rb +29 -0
- data/lib/browserctl/server.rb +18 -2
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +41 -3
- data/lib/browserctl.rb +12 -2
- metadata +48 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# Smoke test for --params file loading (Task 7.5).
|
|
5
|
+
#
|
|
6
|
+
# Run with:
|
|
7
|
+
# browserctl run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
|
|
8
|
+
#
|
|
9
|
+
# The workflow logs in using credentials from the params file and asserts
|
|
10
|
+
# the secure area is reached — proving the params were loaded and available.
|
|
11
|
+
|
|
12
|
+
Browserctl.workflow "smoke/params_file" do
|
|
13
|
+
desc "Smoke: load credentials from a --params file and use them in a workflow"
|
|
14
|
+
|
|
15
|
+
param :username, required: true
|
|
16
|
+
param :password, required: true, secret: true
|
|
17
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
18
|
+
|
|
19
|
+
step "open login page" do
|
|
20
|
+
client.open_page("main", url: "#{base_url}/login")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
step "fill credentials from params file" do
|
|
24
|
+
puts " [params] username = #{username.inspect}"
|
|
25
|
+
puts " [params] password = (#{password.length} chars, secret)"
|
|
26
|
+
page(:main).fill("input#username", username)
|
|
27
|
+
page(:main).fill("input#password", password)
|
|
28
|
+
page(:main).click("button[type=submit]")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
step "assert login succeeded" do
|
|
32
|
+
assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
|
|
33
|
+
puts " [ok] reached secure area — params file loaded correctly"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# Smoke test for WorkflowContext#store / #fetch (Task 7.3).
|
|
5
|
+
#
|
|
6
|
+
# Uses the-internet's dynamic loading example: click Start, wait for "Hello World!",
|
|
7
|
+
# capture the text in step 1, assert it is still accessible in step 2 via fetch.
|
|
8
|
+
|
|
9
|
+
Browserctl.workflow "smoke/store_fetch" do
|
|
10
|
+
desc "Smoke: store a value in one step and retrieve it in a later step"
|
|
11
|
+
|
|
12
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
13
|
+
|
|
14
|
+
step "open dynamic loading page" do
|
|
15
|
+
client.open_page("main", url: "#{base_url}/dynamic_loading/1")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
step "click start and capture loaded text" do
|
|
19
|
+
page(:main).click("div#start button")
|
|
20
|
+
page(:main).wait_for("div#finish", timeout: 10)
|
|
21
|
+
text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
|
|
22
|
+
assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
|
|
23
|
+
store(:loaded_text, text)
|
|
24
|
+
puts " [store] loaded_text = #{text.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
step "fetch value from previous step and assert" do
|
|
28
|
+
text = fetch(:loaded_text)
|
|
29
|
+
puts " [fetch] loaded_text = #{text.inspect}"
|
|
30
|
+
assert text == "Hello World!", "expected 'Hello World!', got: #{text.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
step "confirm fetch raises for unknown key" do
|
|
34
|
+
fetch(:nonexistent_key)
|
|
35
|
+
assert false, "expected KeyError was not raised"
|
|
36
|
+
rescue KeyError => e
|
|
37
|
+
puts " [ok] KeyError raised as expected: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Browserctl.workflow "the_internet/add_remove_elements" do
|
|
4
4
|
desc "Add/Remove Elements: add several elements, remove some, assert final count"
|
|
5
5
|
|
|
6
|
-
param :base_url,
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_add_remove_elements.png")
|
|
7
8
|
|
|
8
9
|
step "open add/remove elements page" do
|
|
9
10
|
client.open_page("main", url: "#{base_url}/add_remove_elements/")
|
|
@@ -13,8 +14,7 @@ Browserctl.workflow "the_internet/add_remove_elements" do
|
|
|
13
14
|
3.times { page(:main).click("button[onclick]") }
|
|
14
15
|
count = client.evaluate("main", "document.querySelectorAll('#elements button').length")[:result]
|
|
15
16
|
assert count == 3, "expected 3 elements, got: #{count}"
|
|
16
|
-
|
|
17
|
-
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_add_remove_elements.png")
|
|
17
|
+
page(:main).screenshot(path: screenshot_path)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
step "remove one element" do
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Browserctl.workflow "the_internet/checkboxes" do
|
|
4
4
|
desc "Checkboxes: read state, toggle each, verify both checked"
|
|
5
5
|
|
|
6
|
-
param :base_url,
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_checkboxes.png")
|
|
7
8
|
|
|
8
9
|
step "open checkboxes page" do
|
|
9
10
|
client.open_page("main", url: "#{base_url}/checkboxes")
|
|
@@ -29,7 +30,6 @@ Browserctl.workflow "the_internet/checkboxes" do
|
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
step "capture screenshot" do
|
|
32
|
-
|
|
33
|
-
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_checkboxes.png")
|
|
33
|
+
page(:main).screenshot(path: screenshot_path)
|
|
34
34
|
end
|
|
35
35
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Browserctl.workflow "the_internet/dropdown" do
|
|
4
4
|
desc "Dropdown: select each option via JS, assert selected value"
|
|
5
5
|
|
|
6
|
-
param :base_url,
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dropdown.png")
|
|
7
8
|
|
|
8
9
|
step "open dropdown page" do
|
|
9
10
|
client.open_page("main", url: "#{base_url}/dropdown")
|
|
@@ -27,7 +28,6 @@ Browserctl.workflow "the_internet/dropdown" do
|
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
step "capture screenshot" do
|
|
30
|
-
|
|
31
|
-
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_dropdown.png")
|
|
31
|
+
page(:main).screenshot(path: screenshot_path)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Browserctl.workflow "the_internet/dynamic_loading" do
|
|
4
4
|
desc "Dynamic loading: click Start, wait for hidden element to appear"
|
|
5
5
|
|
|
6
|
-
param :base_url,
|
|
6
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dynamic_loading.png")
|
|
7
8
|
|
|
8
9
|
step "open dynamic loading page" do
|
|
9
10
|
client.open_page("main", url: "#{base_url}/dynamic_loading/1")
|
|
@@ -27,7 +28,6 @@ Browserctl.workflow "the_internet/dynamic_loading" do
|
|
|
27
28
|
sleep 0.2 until client.evaluate("main",
|
|
28
29
|
"document.querySelector('#loading')?.style?.display")[:result] == "none" ||
|
|
29
30
|
Time.now > deadline
|
|
30
|
-
|
|
31
|
-
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_dynamic_loading.png")
|
|
31
|
+
page(:main).screenshot(path: screenshot_path)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
Browserctl.workflow "the_internet/login" do
|
|
4
4
|
desc "Form authentication: fill credentials, submit, assert secure area"
|
|
5
5
|
|
|
6
|
-
param :username,
|
|
7
|
-
param :password,
|
|
8
|
-
param :base_url,
|
|
6
|
+
param :username, default: "tomsmith"
|
|
7
|
+
param :password, default: "SuperSecretPassword!", secret: true
|
|
8
|
+
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
9
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_login.png")
|
|
9
10
|
|
|
10
11
|
step "open login page" do
|
|
11
12
|
client.open_page("main", url: "#{base_url}/login")
|
|
@@ -21,8 +22,7 @@ Browserctl.workflow "the_internet/login" do
|
|
|
21
22
|
assert page(:main).url.include?("/secure"), "expected redirect to /secure"
|
|
22
23
|
flash = client.evaluate("main", "document.querySelector('.flash.success')?.innerText?.trim()")[:result]
|
|
23
24
|
assert flash&.include?("You logged into a secure area!"), "expected success flash, got: #{flash.inspect}"
|
|
24
|
-
|
|
25
|
-
page(:main).screenshot(path: "#{screenshots_dir}/the_internet_login.png")
|
|
25
|
+
page(:main).screenshot(path: screenshot_path)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
step "logout and verify" do
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "socket"
|
|
4
5
|
require "json"
|
|
5
6
|
require_relative "constants"
|
|
@@ -73,10 +74,10 @@ module Browserctl
|
|
|
73
74
|
|
|
74
75
|
# Takes a DOM snapshot. Returns `challenge: true` when Cloudflare is detected.
|
|
75
76
|
# @param name [String] logical page name
|
|
76
|
-
# @param format [String] "
|
|
77
|
+
# @param format [String] "elements" (interactable elements JSON) or "html" (raw HTML)
|
|
77
78
|
# @param diff [Boolean] return only elements changed since last snapshot
|
|
78
79
|
# @return [Hash] `{ ok: true, snapshot:, challenge: }` or `{ ok: true, html:, challenge: }` or `{ error: }`
|
|
79
|
-
def snapshot(name, format: "
|
|
80
|
+
def snapshot(name, format: "elements", diff: false)
|
|
80
81
|
call("snapshot", name: name, format: format, diff: diff)
|
|
81
82
|
end
|
|
82
83
|
|
|
@@ -130,6 +131,17 @@ module Browserctl
|
|
|
130
131
|
# @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
|
|
131
132
|
def inspect_page(name) = call("inspect", name: name)
|
|
132
133
|
|
|
134
|
+
# Stores a value in the daemon-scoped key-value store.
|
|
135
|
+
# @param key [String] storage key
|
|
136
|
+
# @param value [Object] value to store (must be JSON-serialisable)
|
|
137
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
138
|
+
def store(key, value) = call("store", key: key, value: value)
|
|
139
|
+
|
|
140
|
+
# Retrieves a value from the daemon-scoped key-value store.
|
|
141
|
+
# @param key [String] storage key
|
|
142
|
+
# @return [Hash] `{ ok: true, value: }` or `{ error:, code: "key_not_found" }`
|
|
143
|
+
def fetch(key) = call("fetch", key: key)
|
|
144
|
+
|
|
133
145
|
# Returns all cookies for a named page.
|
|
134
146
|
# @param name [String] logical page name
|
|
135
147
|
# @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
|
|
@@ -152,6 +164,30 @@ module Browserctl
|
|
|
152
164
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
153
165
|
def clear_cookies(name) = call("clear_cookies", name: name)
|
|
154
166
|
|
|
167
|
+
# Exports all cookies for a named page to a JSON file.
|
|
168
|
+
# @param name [String] logical page name
|
|
169
|
+
# @param path [String] file path to write cookies to
|
|
170
|
+
# @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
|
|
171
|
+
def export_cookies(name, path)
|
|
172
|
+
result = call("cookies", name: name)
|
|
173
|
+
return result unless result[:ok]
|
|
174
|
+
|
|
175
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
176
|
+
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(result[:cookies])) }
|
|
177
|
+
{ ok: true, path: path, count: result[:cookies].length }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Imports cookies from a JSON file into a named page.
|
|
181
|
+
# @param name [String] logical page name
|
|
182
|
+
# @param path [String] file path to read cookies from
|
|
183
|
+
# @return [Hash] `{ ok: true, count: }` or `{ error: }`
|
|
184
|
+
def import_cookies(name, path)
|
|
185
|
+
raise "cookie file not found: #{path}" unless File.exist?(path)
|
|
186
|
+
|
|
187
|
+
cookies = JSON.parse(File.read(path), symbolize_names: true)
|
|
188
|
+
call("import_cookies", name: name, cookies: cookies)
|
|
189
|
+
end
|
|
190
|
+
|
|
155
191
|
private
|
|
156
192
|
|
|
157
193
|
def communicate(payload)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Commands
|
|
5
|
+
class ExportCookies
|
|
6
|
+
def self.run(client, args)
|
|
7
|
+
page = args.shift or abort "usage: browserctl export-cookies <page> <path>"
|
|
8
|
+
path = args.shift or abort "usage: browserctl export-cookies <page> <path>"
|
|
9
|
+
result = client.export_cookies(page, path)
|
|
10
|
+
if result[:error]
|
|
11
|
+
warn "Error: #{result[:error]}"
|
|
12
|
+
exit 1
|
|
13
|
+
end
|
|
14
|
+
puts result.to_json
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Commands
|
|
5
|
+
class ImportCookies
|
|
6
|
+
def self.run(client, args)
|
|
7
|
+
page = args.shift or abort "usage: browserctl import-cookies <page> <path>"
|
|
8
|
+
path = args.shift or abort "usage: browserctl import-cookies <page> <path>"
|
|
9
|
+
begin
|
|
10
|
+
result = client.import_cookies(page, path)
|
|
11
|
+
rescue StandardError => e
|
|
12
|
+
warn "Error: #{e.message}"
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
if result[:error]
|
|
16
|
+
warn "Error: #{result[:error]}"
|
|
17
|
+
exit 1
|
|
18
|
+
end
|
|
19
|
+
puts result.to_json
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -13,15 +13,26 @@ module Browserctl
|
|
|
13
13
|
# workflows_dir: .browserctl/workflows
|
|
14
14
|
YAML
|
|
15
15
|
|
|
16
|
+
GITIGNORE_CONTENT = <<~GITIGNORE
|
|
17
|
+
# Cookie session exports — contain credentials, never commit
|
|
18
|
+
sessions/
|
|
19
|
+
GITIGNORE
|
|
20
|
+
|
|
16
21
|
def self.run(_args)
|
|
17
22
|
FileUtils.mkdir_p(".browserctl/workflows")
|
|
18
23
|
FileUtils.touch(".browserctl/workflows/.keep")
|
|
19
24
|
|
|
25
|
+
FileUtils.mkdir_p(".browserctl/sessions")
|
|
26
|
+
|
|
27
|
+
gitignore_path = ".browserctl/.gitignore"
|
|
28
|
+
File.write(gitignore_path, GITIGNORE_CONTENT) unless File.exist?(gitignore_path)
|
|
29
|
+
|
|
20
30
|
config_path = ".browserctl/config.yml"
|
|
21
31
|
File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
|
|
22
32
|
|
|
23
33
|
puts "Initialised browserctl project:"
|
|
24
34
|
puts " .browserctl/workflows/ (place workflow .rb files here)"
|
|
35
|
+
puts " .browserctl/sessions/ (cookie exports — git-ignored)"
|
|
25
36
|
puts " .browserctl/config.yml (project settings)"
|
|
26
37
|
end
|
|
27
38
|
end
|
|
@@ -4,10 +4,10 @@ require_relative "cli_output"
|
|
|
4
4
|
|
|
5
5
|
module Browserctl
|
|
6
6
|
module Commands
|
|
7
|
-
module
|
|
7
|
+
module Pause
|
|
8
8
|
extend CliOutput
|
|
9
9
|
|
|
10
|
-
def self.
|
|
10
|
+
def self.run(client, args)
|
|
11
11
|
name = args.shift or abort "usage: browserctl pause <page>"
|
|
12
12
|
res = client.pause(name)
|
|
13
13
|
if res[:error]
|
|
@@ -17,16 +17,6 @@ module Browserctl
|
|
|
17
17
|
puts "Page '#{name}' paused. Browser is live — interact freely."
|
|
18
18
|
puts "When done: browserctl resume #{name}"
|
|
19
19
|
end
|
|
20
|
-
|
|
21
|
-
def self.resume(client, args)
|
|
22
|
-
name = args.shift or abort "usage: browserctl resume <page>"
|
|
23
|
-
res = client.resume(name)
|
|
24
|
-
if res[:error]
|
|
25
|
-
warn "Error: #{res[:error]}"
|
|
26
|
-
exit 1
|
|
27
|
-
end
|
|
28
|
-
puts "Page '#{name}' resumed."
|
|
29
|
-
end
|
|
30
20
|
end
|
|
31
21
|
end
|
|
32
22
|
end
|
|
@@ -26,6 +26,8 @@ module Browserctl
|
|
|
26
26
|
def run_start(args)
|
|
27
27
|
Optimist.options(args) { banner "Usage: browserctl record start <name>" }
|
|
28
28
|
name = args.shift or abort "usage: browserctl record start <name>"
|
|
29
|
+
abort "Invalid recording name #{name.inspect} — use only letters, digits, _ or -" \
|
|
30
|
+
unless name =~ /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
29
31
|
Recording.start(name)
|
|
30
32
|
puts "Recording started: #{name}"
|
|
31
33
|
puts "Run browser commands, then: browserctl record stop"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Resume
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
def self.run(client, args)
|
|
11
|
+
name = args.shift or abort "usage: browserctl resume <page>"
|
|
12
|
+
res = client.resume(name)
|
|
13
|
+
if res[:error]
|
|
14
|
+
warn "Error: #{res[:error]}"
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
puts "Page '#{name}' resumed."
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -6,15 +6,15 @@ require "optimist"
|
|
|
6
6
|
module Browserctl
|
|
7
7
|
module Commands
|
|
8
8
|
class Snapshot
|
|
9
|
-
VALID_FORMATS = %w[
|
|
9
|
+
VALID_FORMATS = %w[elements html].freeze
|
|
10
10
|
|
|
11
11
|
def self.run(client, args)
|
|
12
12
|
opts = Optimist.options(args) do
|
|
13
|
-
banner "Usage: browserctl snap <page> [--format
|
|
14
|
-
opt :format, "Output format:
|
|
13
|
+
banner "Usage: browserctl snap <page> [--format elements|html] [--diff]"
|
|
14
|
+
opt :format, "Output format: elements (default) or html", default: "elements", short: "-f"
|
|
15
15
|
opt :diff, "Return only changed elements", default: false, short: "-d"
|
|
16
16
|
end
|
|
17
|
-
name = args.shift or abort "usage: browserctl snap <page> [--format
|
|
17
|
+
name = args.shift or abort "usage: browserctl snap <page> [--format elements|html] [--diff]"
|
|
18
18
|
unless VALID_FORMATS.include?(opts[:format])
|
|
19
19
|
warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
|
|
20
20
|
exit 1
|
|
@@ -31,7 +31,7 @@ module Browserctl
|
|
|
31
31
|
warn "Error: #{res[:error]}"
|
|
32
32
|
exit 1
|
|
33
33
|
end
|
|
34
|
-
puts(format == "
|
|
34
|
+
puts(format == "elements" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Status
|
|
8
|
+
def self.run(client)
|
|
9
|
+
ping = client.ping
|
|
10
|
+
pages = client.list_pages[:pages] || []
|
|
11
|
+
page_info = pages.map do |name|
|
|
12
|
+
url_res = client.url(name)
|
|
13
|
+
{ name: name, url: url_res[:url] || url_res[:error] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
puts JSON.pretty_generate(
|
|
17
|
+
daemon: "online",
|
|
18
|
+
pid: ping[:pid],
|
|
19
|
+
protocol_version: ping[:protocol_version],
|
|
20
|
+
pages: page_info
|
|
21
|
+
)
|
|
22
|
+
rescue RuntimeError => e
|
|
23
|
+
raise unless e.message.include?("browserd is not running")
|
|
24
|
+
|
|
25
|
+
puts JSON.pretty_generate(daemon: "offline", error: e.message)
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/browserctl/constants.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Browserctl
|
|
4
|
-
BROWSERCTL_DIR
|
|
5
|
-
IDLE_TTL
|
|
4
|
+
BROWSERCTL_DIR = File.expand_path("~/.browserctl")
|
|
5
|
+
IDLE_TTL = 30 * 60
|
|
6
|
+
# Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes).
|
|
7
|
+
# Clients read this from `ping` to verify compatibility before sending commands.
|
|
8
|
+
PROTOCOL_VERSION = "1"
|
|
6
9
|
|
|
7
10
|
def self.socket_path(name = nil)
|
|
8
11
|
File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
|
|
@@ -12,6 +15,10 @@ module Browserctl
|
|
|
12
15
|
File.join(BROWSERCTL_DIR, name ? "#{name}.pid" : "browserd.pid")
|
|
13
16
|
end
|
|
14
17
|
|
|
18
|
+
def self.log_path(name = nil)
|
|
19
|
+
File.join(BROWSERCTL_DIR, name ? "#{name}.log" : "browserd.log")
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
# Backward-compatible constants
|
|
16
23
|
SOCKET_PATH = socket_path
|
|
17
24
|
PID_PATH = pid_path
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Detectors
|
|
5
|
+
CLOUDFLARE_SIGNALS = [
|
|
6
|
+
"cf-challenge-running",
|
|
7
|
+
"cf_chl_opt",
|
|
8
|
+
"__cf_chl_f_tk",
|
|
9
|
+
"Just a moment..."
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
# Returns true if the page appears to be showing a Cloudflare challenge.
|
|
13
|
+
# Checks both the current URL and the page body for known Cloudflare signals.
|
|
14
|
+
# @param page [Ferrum::Page] the browser page to inspect
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def self.cloudflare?(page)
|
|
17
|
+
url = page.current_url.to_s
|
|
18
|
+
body = page.body.to_s
|
|
19
|
+
url.include?("challenge-platform") ||
|
|
20
|
+
CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
# Base error class for all browserctl daemon errors.
|
|
5
|
+
# Subclasses carry a machine-readable `code` that appears in wire responses.
|
|
6
|
+
# @attr_reader code [String] machine-readable error code
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
def self.default_code = "error"
|
|
9
|
+
|
|
10
|
+
attr_reader :code
|
|
11
|
+
|
|
12
|
+
def initialize(msg = nil, code: self.class.default_code)
|
|
13
|
+
@code = code
|
|
14
|
+
super(msg)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class PageNotFound < Error; def self.default_code = "page_not_found" end
|
|
19
|
+
class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
|
|
20
|
+
class RefNotFound < Error; def self.default_code = "ref_not_found" end
|
|
21
|
+
class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
|
|
22
|
+
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
|
|
23
|
+
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
24
|
+
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
25
|
+
end
|
data/lib/browserctl/logger.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "logger"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
LEVEL_MAP = {
|
|
@@ -10,6 +11,25 @@ module Browserctl
|
|
|
10
11
|
"error" => ::Logger::ERROR
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
14
|
+
class MultiLogger
|
|
15
|
+
def initialize(*loggers)
|
|
16
|
+
@loggers = loggers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Delegate to each logger; swallow individual write failures so a broken file
|
|
20
|
+
# logger never crashes the daemon or drops a client response.
|
|
21
|
+
def debug(msg = nil, &) = @loggers.each { |l| l.debug(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
|
|
22
|
+
def info(msg = nil, &) = @loggers.each { |l| l.info(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
|
|
23
|
+
def warn(msg = nil, &) = @loggers.each { |l| l.warn(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
|
|
24
|
+
def error(msg = nil, &) = @loggers.each { |l| l.error(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
|
|
25
|
+
|
|
26
|
+
def level = @loggers.first&.level
|
|
27
|
+
|
|
28
|
+
def level=(lvl)
|
|
29
|
+
@loggers.each { |l| l.level = lvl }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
13
33
|
def self.logger
|
|
14
34
|
@logger ||= build_logger("info")
|
|
15
35
|
end
|
|
@@ -18,11 +38,26 @@ module Browserctl
|
|
|
18
38
|
@logger = instance
|
|
19
39
|
end
|
|
20
40
|
|
|
21
|
-
def self.build_logger(level_name)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
def self.build_logger(level_name, log_path: nil)
|
|
42
|
+
level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
|
|
43
|
+
formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
|
|
44
|
+
|
|
45
|
+
stderr_log = make_logger($stderr, level, formatter)
|
|
46
|
+
return stderr_log unless log_path
|
|
47
|
+
|
|
48
|
+
FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
|
|
49
|
+
FileUtils.touch(log_path)
|
|
50
|
+
File.chmod(0o600, log_path)
|
|
51
|
+
file_log = make_logger(log_path, level, formatter)
|
|
52
|
+
MultiLogger.new(stderr_log, file_log)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.make_logger(device, level, formatter)
|
|
56
|
+
log = ::Logger.new(device)
|
|
57
|
+
log.level = level
|
|
58
|
+
log.progname = "browserd"
|
|
59
|
+
log.formatter = formatter
|
|
26
60
|
log
|
|
27
61
|
end
|
|
62
|
+
private_class_method :make_logger
|
|
28
63
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Policy
|
|
7
|
+
# Returns true if the URL is permitted by the domain policy.
|
|
8
|
+
# When BROWSERCTL_ALLOWED_DOMAINS is unset, all URLs are allowed.
|
|
9
|
+
# @param url [String] the URL to check
|
|
10
|
+
# @return [Boolean]
|
|
11
|
+
def self.allowed_navigation?(url)
|
|
12
|
+
domains = allowed_domains
|
|
13
|
+
return true if domains.empty?
|
|
14
|
+
|
|
15
|
+
host_matches?(URI.parse(url).host, domains)
|
|
16
|
+
rescue URI::InvalidURIError
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.allowed_domains
|
|
21
|
+
raw = ENV.fetch("BROWSERCTL_ALLOWED_DOMAINS", nil)
|
|
22
|
+
return [] unless raw&.match?(/\S/)
|
|
23
|
+
|
|
24
|
+
raw.split(",").map(&:strip).reject(&:empty?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.host_matches?(host, domains)
|
|
28
|
+
return false unless host
|
|
29
|
+
|
|
30
|
+
normalised = host.downcase
|
|
31
|
+
domains.any? { |d| normalised == d.downcase || normalised.end_with?(".#{d.downcase}") }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :allowed_domains, :host_matches?
|
|
35
|
+
end
|
|
36
|
+
end
|