browserctl 0.5.0 → 0.6.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 +24 -0
- data/README.md +27 -32
- data/bin/browserctl +117 -108
- data/bin/browserd +9 -3
- data/examples/cloudflare_hitl.rb +5 -5
- data/examples/smoke/params_file.rb +3 -2
- data/examples/smoke/store_fetch.rb +5 -5
- data/examples/test_automation_practices/checkboxes.rb +39 -0
- data/examples/test_automation_practices/dynamic_elements.rb +40 -0
- data/examples/test_automation_practices/key_press.rb +41 -0
- data/examples/test_automation_practices/login.rb +34 -0
- data/examples/test_automation_practices/login_negative.rb +28 -0
- data/examples/test_automation_practices/notifications.rb +57 -0
- data/examples/the_internet/add_remove_elements.rb +1 -1
- data/examples/the_internet/checkboxes.rb +1 -1
- data/examples/the_internet/dropdown.rb +1 -1
- data/examples/the_internet/dynamic_loading.rb +2 -2
- data/examples/the_internet/login.rb +1 -1
- data/lib/browserctl/client.rb +101 -28
- data/lib/browserctl/commands/cookie.rb +59 -0
- data/lib/browserctl/commands/daemon.rb +77 -0
- data/lib/browserctl/commands/page.rb +47 -0
- data/lib/browserctl/commands/record.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/session.rb +69 -0
- data/lib/browserctl/commands/snapshot.rb +2 -2
- data/lib/browserctl/commands/storage.rb +67 -0
- data/lib/browserctl/commands/workflow.rb +64 -0
- data/lib/browserctl/constants.rb +20 -1
- data/lib/browserctl/logger.rb +4 -4
- data/lib/browserctl/recording.rb +4 -4
- data/lib/browserctl/server/command_dispatcher.rb +22 -9
- data/lib/browserctl/server/handlers/cookies.rb +1 -1
- data/lib/browserctl/server/handlers/devtools.rb +1 -1
- data/lib/browserctl/server/handlers/hitl.rb +2 -1
- data/lib/browserctl/server/handlers/navigation.rb +24 -2
- data/lib/browserctl/server/handlers/observation.rb +0 -26
- data/lib/browserctl/server/handlers/page_lifecycle.rb +10 -3
- data/lib/browserctl/server/handlers/session.rb +93 -0
- data/lib/browserctl/server/handlers/storage.rb +109 -0
- data/lib/browserctl/server.rb +2 -2
- data/lib/browserctl/session.rb +79 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +38 -11
- metadata +19 -11
- data/lib/browserctl/commands/export_cookies.rb +0 -18
- data/lib/browserctl/commands/import_cookies.rb +0 -23
- data/lib/browserctl/commands/inspect.rb +0 -21
- data/lib/browserctl/commands/open_page.rb +0 -21
- data/lib/browserctl/commands/pause.rb +0 -22
- data/lib/browserctl/commands/status.rb +0 -30
- data/lib/browserctl/commands/watch.rb +0 -27
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/key_press" do
|
|
4
|
+
desc "Key press page: dispatch keyboard events, verify last-key display and history list update"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/test_automation_practices_key_press.png")
|
|
8
|
+
|
|
9
|
+
step "open key press page" do
|
|
10
|
+
open_page(:main, url: "#{base_url}/#/key-press")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
step "dispatch key events and verify last-key display" do
|
|
14
|
+
keys = %w[A B C D E]
|
|
15
|
+
keys.each do |key|
|
|
16
|
+
client.evaluate(
|
|
17
|
+
"main",
|
|
18
|
+
"document.dispatchEvent(new KeyboardEvent('keydown', { key: '#{key}', bubbles: true }))"
|
|
19
|
+
)
|
|
20
|
+
sleep 0.1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
last = client.evaluate("main",
|
|
24
|
+
"document.querySelector('[data-test=\"last-key-pressed\"]')?.innerText?.trim()")[:result]
|
|
25
|
+
assert last == keys.last, "expected last key '#{keys.last}', got: #{last.inspect}"
|
|
26
|
+
store(:keys, keys)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
step "verify key history contains all dispatched keys" do
|
|
30
|
+
keys = fetch(:keys)
|
|
31
|
+
history = client.evaluate(
|
|
32
|
+
"main",
|
|
33
|
+
"Array.from(document.querySelectorAll('[data-test^=\"key-\"]')).map(el => el.innerText?.trim())"
|
|
34
|
+
)[:result]
|
|
35
|
+
# History shows most recent first; each entry typically contains the key label
|
|
36
|
+
keys.each do |key|
|
|
37
|
+
assert history.any? { |entry| entry&.include?(key) }, "expected '#{key}' in history, got: #{history.inspect}"
|
|
38
|
+
end
|
|
39
|
+
page(:main).screenshot(path: screenshot_path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/login" do
|
|
4
|
+
desc "Auth page: fill credentials, verify success message, logout"
|
|
5
|
+
|
|
6
|
+
param :username, default: "admin"
|
|
7
|
+
param :password, default: "admin", secret: true
|
|
8
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
9
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/test_automation_practices_login.png")
|
|
10
|
+
|
|
11
|
+
step "open auth page" do
|
|
12
|
+
open_page(:main, url: "#{base_url}/#/auth")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
step "fill and submit credentials" do
|
|
16
|
+
page(:main).fill("[data-test='username-input']", username)
|
|
17
|
+
page(:main).fill("[data-test='password-input']", password)
|
|
18
|
+
page(:main).click("[data-test='login-button']")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
step "verify successful login" do
|
|
22
|
+
page(:main).wait("[data-test='auth-success']", timeout: 10)
|
|
23
|
+
msg = client.evaluate("main", "document.querySelector('[data-test=\"auth-success\"]')?.innerText?.trim()")[:result]
|
|
24
|
+
assert msg&.length&.positive?, "expected a success message, got: #{msg.inspect}"
|
|
25
|
+
page(:main).screenshot(path: screenshot_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
step "logout and verify form reappears" do
|
|
29
|
+
page(:main).click("[data-test='logout-button']")
|
|
30
|
+
page(:main).wait("[data-test='login-button']", timeout: 5)
|
|
31
|
+
visible = client.evaluate("main", "!!document.querySelector('[data-test=\"login-button\"]')")[:result]
|
|
32
|
+
assert visible, "expected login form to reappear after logout"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/login_negative" do
|
|
4
|
+
desc "Auth page: invalid credentials show an error message"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path,
|
|
8
|
+
default: File.expand_path(".browserctl/screenshots/test_automation_practices_login_negative.png")
|
|
9
|
+
|
|
10
|
+
step "open auth page" do
|
|
11
|
+
open_page(:main, url: "#{base_url}/#/auth")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
step "submit wrong credentials" do
|
|
15
|
+
page(:main).fill("[data-test='username-input']", "wronguser")
|
|
16
|
+
page(:main).fill("[data-test='password-input']", "wrongpass")
|
|
17
|
+
page(:main).click("[data-test='login-button']")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
step "verify error is shown and success is absent" do
|
|
21
|
+
page(:main).wait("[data-test='auth-error']", timeout: 10)
|
|
22
|
+
error = client.evaluate("main", "document.querySelector('[data-test=\"auth-error\"]')?.innerText?.trim()")[:result]
|
|
23
|
+
assert error&.length&.positive?, "expected an error message, got: #{error.inspect}"
|
|
24
|
+
no_success = client.evaluate("main", "!document.querySelector('[data-test=\"auth-success\"]')")[:result]
|
|
25
|
+
assert no_success, "expected no success element when credentials are invalid"
|
|
26
|
+
page(:main).screenshot(path: screenshot_path)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Individual notification items have data-test="notification-{id}".
|
|
4
|
+
# The wrapper has data-test="notification-container" — excluded from counts below.
|
|
5
|
+
NOTIFICATION_ITEMS_JS = <<~JS
|
|
6
|
+
document.querySelectorAll('[data-test^="notification-"]:not([data-test="notification-container"])').length
|
|
7
|
+
JS
|
|
8
|
+
|
|
9
|
+
Browserctl.workflow "test_automation_practices/notifications" do
|
|
10
|
+
desc "Notifications: trigger success, error, and info toasts — verify count increases on each trigger"
|
|
11
|
+
|
|
12
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
13
|
+
param :screenshot_path,
|
|
14
|
+
default: File.expand_path(".browserctl/screenshots/test_automation_practices_notifications.png")
|
|
15
|
+
|
|
16
|
+
step "open notifications page and record baseline" do
|
|
17
|
+
open_page(:main, url: "#{base_url}/#/notifications")
|
|
18
|
+
page(:main).wait("[data-test='notification-container']", timeout: 5)
|
|
19
|
+
# Let any delayed on-load notifications finish appearing before we snapshot the baseline
|
|
20
|
+
sleep 1
|
|
21
|
+
store(:baseline, client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
step "trigger success notification and verify count increased by 1" do
|
|
25
|
+
base = fetch(:baseline)
|
|
26
|
+
page(:main).click("[data-test='add-success']")
|
|
27
|
+
page(:main).wait("[data-test^='notification-']:not([data-test='notification-container'])", timeout: 5)
|
|
28
|
+
count = client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result]
|
|
29
|
+
assert count == base + 1, "expected #{base + 1} notifications after success, got: #{count}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
step "dismiss one notification and verify count returns to baseline" do
|
|
33
|
+
base = fetch(:baseline)
|
|
34
|
+
client.evaluate("main", "document.querySelector('[data-test^=\"close-notification-\"]')?.click()")
|
|
35
|
+
deadline = Time.now + 5
|
|
36
|
+
remaining = nil
|
|
37
|
+
loop do
|
|
38
|
+
remaining = client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result]
|
|
39
|
+
break if remaining <= base || Time.now > deadline
|
|
40
|
+
|
|
41
|
+
sleep 0.2
|
|
42
|
+
end
|
|
43
|
+
assert remaining <= base, "expected count back to #{base} after dismiss, got: #{remaining}"
|
|
44
|
+
# Re-snapshot baseline in case on-load notifications auto-dismissed during this step
|
|
45
|
+
store(:baseline, remaining)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
step "trigger error and info notifications and verify count increased by 2" do
|
|
49
|
+
base = fetch(:baseline)
|
|
50
|
+
page(:main).click("[data-test='add-error']")
|
|
51
|
+
page(:main).click("[data-test='add-info']")
|
|
52
|
+
page(:main).wait("[data-test^='notification-']:not([data-test='notification-container'])", timeout: 5)
|
|
53
|
+
count = client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result]
|
|
54
|
+
assert count == base + 2, "expected #{base + 2} notifications (error + info), got: #{count}"
|
|
55
|
+
page(:main).screenshot(path: screenshot_path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/add_remove_elements" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_add_remove_elements.png")
|
|
8
8
|
|
|
9
9
|
step "open add/remove elements page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/add_remove_elements/")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "add three elements" do
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/checkboxes" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_checkboxes.png")
|
|
8
8
|
|
|
9
9
|
step "open checkboxes page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/checkboxes")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "read initial state" do
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/dropdown" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dropdown.png")
|
|
8
8
|
|
|
9
9
|
step "open dropdown page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/dropdown")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "assert default is unselected" do
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/dynamic_loading" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dynamic_loading.png")
|
|
8
8
|
|
|
9
9
|
step "open dynamic loading page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/dynamic_loading/1")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "assert finish text is hidden before start" do
|
|
@@ -17,7 +17,7 @@ Browserctl.workflow "the_internet/dynamic_loading" do
|
|
|
17
17
|
|
|
18
18
|
step "click Start and wait for content" do
|
|
19
19
|
page(:main).click("#start button")
|
|
20
|
-
page(:main).
|
|
20
|
+
page(:main).wait("#finish h4", timeout: 10)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
step "assert finish text is correct" do
|
|
@@ -9,7 +9,7 @@ Browserctl.workflow "the_internet/login" do
|
|
|
9
9
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_login.png")
|
|
10
10
|
|
|
11
11
|
step "open login page" do
|
|
12
|
-
|
|
12
|
+
open_page(:main, url: "#{base_url}/login")
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
step "fill and submit credentials" do
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -9,8 +9,8 @@ require_relative "recording"
|
|
|
9
9
|
module Browserctl
|
|
10
10
|
# Thin IPC client that wraps each browserd command as a Ruby method call.
|
|
11
11
|
class Client
|
|
12
|
-
def initialize(socket_path =
|
|
13
|
-
@socket_path = socket_path
|
|
12
|
+
def initialize(socket_path = nil)
|
|
13
|
+
@socket_path = socket_path || auto_discover_socket
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def call(cmd, **params)
|
|
@@ -25,22 +25,22 @@ module Browserctl
|
|
|
25
25
|
# @param name [String] logical page name
|
|
26
26
|
# @param url [String, nil] optional URL to navigate to after opening
|
|
27
27
|
# @return [Hash] `{ ok: true, name: }` or `{ error: }`
|
|
28
|
-
def
|
|
28
|
+
def page_open(name, url: nil) = call("page_open", name: name, url: url)
|
|
29
29
|
|
|
30
30
|
# Closes a named page and removes it from the session.
|
|
31
31
|
# @param name [String] logical page name
|
|
32
32
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
33
|
-
def
|
|
33
|
+
def page_close(name) = call("page_close", name: name)
|
|
34
34
|
|
|
35
35
|
# Lists all open page names.
|
|
36
36
|
# @return [Hash] `{ pages: [String] }`
|
|
37
|
-
def
|
|
37
|
+
def page_list = call("page_list")
|
|
38
38
|
|
|
39
39
|
# Navigates a page to a URL. Returns `challenge: true` when Cloudflare is detected.
|
|
40
40
|
# @param name [String] logical page name
|
|
41
41
|
# @param url [String] destination URL
|
|
42
42
|
# @return [Hash] `{ ok: true, url:, challenge: }` or `{ error: }`
|
|
43
|
-
def
|
|
43
|
+
def navigate(name, url) = call("navigate", name: name, url: url)
|
|
44
44
|
|
|
45
45
|
# Clicks an element identified by CSS selector or snapshot ref.
|
|
46
46
|
# @param name [String] logical page name
|
|
@@ -81,32 +81,23 @@ module Browserctl
|
|
|
81
81
|
call("snapshot", name: name, format: format, diff: diff)
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Waits for a CSS selector to appear
|
|
84
|
+
# Waits for a CSS selector to appear within the given timeout.
|
|
85
85
|
# @param name [String] logical page name
|
|
86
86
|
# @param selector [String] CSS selector to wait for
|
|
87
|
-
# @param timeout [Numeric] seconds before giving up (default: 10)
|
|
88
|
-
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
89
|
-
def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
|
|
90
|
-
|
|
91
|
-
# Polls for a CSS selector with a longer timeout (suitable for async operations).
|
|
92
|
-
# @param name [String] logical page name
|
|
93
|
-
# @param selector [String] CSS selector to poll for
|
|
94
87
|
# @param timeout [Numeric] seconds before giving up (default: 30)
|
|
95
88
|
# @return [Hash] `{ ok: true, selector: }` or `{ error: }`
|
|
96
|
-
def
|
|
97
|
-
call("watch", name: name, selector: selector, timeout: timeout)
|
|
98
|
-
end
|
|
89
|
+
def wait(name, selector, timeout: 30) = call("wait", name: name, selector: selector, timeout: timeout)
|
|
99
90
|
|
|
100
91
|
# Returns the current URL of a named page.
|
|
101
92
|
# @param name [String] logical page name
|
|
102
93
|
# @return [Hash] `{ ok: true, url: }` or `{ error: }`
|
|
103
|
-
def url(name) = call("url",
|
|
94
|
+
def url(name) = call("url", name: name)
|
|
104
95
|
|
|
105
96
|
# Evaluates a JavaScript expression and returns the result.
|
|
106
97
|
# @param name [String] logical page name
|
|
107
98
|
# @param expression [String] JavaScript expression
|
|
108
99
|
# @return [Hash] `{ ok: true, result: }` or `{ error: }`
|
|
109
|
-
def evaluate(name, expression) = call("evaluate",
|
|
100
|
+
def evaluate(name, expression) = call("evaluate", name: name, expression: expression)
|
|
110
101
|
|
|
111
102
|
# Checks if browserd is alive.
|
|
112
103
|
# @return [Hash] `{ ok: true, pid: }` or raises if daemon is not running
|
|
@@ -118,34 +109,35 @@ module Browserctl
|
|
|
118
109
|
|
|
119
110
|
# Pauses automation on a page so a human can interact directly.
|
|
120
111
|
# @param name [String] logical page name
|
|
121
|
-
# @
|
|
122
|
-
|
|
112
|
+
# @param message [String, nil] optional message displayed to the human
|
|
113
|
+
# @return [Hash] `{ ok: true, paused: true, message: }` or `{ error: }`
|
|
114
|
+
def pause(name, message: nil) = call("pause", name: name, message: message)
|
|
123
115
|
|
|
124
116
|
# Resumes automation on a paused page.
|
|
125
117
|
# @param name [String] logical page name
|
|
126
118
|
# @return [Hash] `{ ok: true, paused: false }` or `{ error: }`
|
|
127
|
-
def resume(name) = call("resume",
|
|
119
|
+
def resume(name) = call("resume", name: name)
|
|
128
120
|
|
|
129
121
|
# Returns the Chrome DevTools URL for a named page.
|
|
130
122
|
# @param name [String] logical page name
|
|
131
123
|
# @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
|
|
132
|
-
def
|
|
124
|
+
def devtools(name) = call("devtools", name: name)
|
|
133
125
|
|
|
134
126
|
# Stores a value in the daemon-scoped key-value store.
|
|
135
127
|
# @param key [String] storage key
|
|
136
128
|
# @param value [Object] value to store (must be JSON-serialisable)
|
|
137
129
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
138
|
-
def store(key, value)
|
|
130
|
+
def store(key, value) = call("store", key: key, value: value)
|
|
139
131
|
|
|
140
132
|
# Retrieves a value from the daemon-scoped key-value store.
|
|
141
133
|
# @param key [String] storage key
|
|
142
134
|
# @return [Hash] `{ ok: true, value: }` or `{ error:, code: "key_not_found" }`
|
|
143
|
-
def fetch(key)
|
|
135
|
+
def fetch(key) = call("fetch", key: key)
|
|
144
136
|
|
|
145
137
|
# Returns all cookies for a named page.
|
|
146
138
|
# @param name [String] logical page name
|
|
147
139
|
# @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
|
|
148
|
-
def cookies(name)
|
|
140
|
+
def cookies(name) = call("cookies", name: name)
|
|
149
141
|
|
|
150
142
|
# Sets a cookie on a named page.
|
|
151
143
|
# @param name [String] logical page name
|
|
@@ -159,12 +151,13 @@ module Browserctl
|
|
|
159
151
|
value: value, domain: domain, path: path)
|
|
160
152
|
end
|
|
161
153
|
|
|
162
|
-
#
|
|
154
|
+
# Deletes all cookies for a named page.
|
|
163
155
|
# @param name [String] logical page name
|
|
164
156
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
165
|
-
def
|
|
157
|
+
def delete_cookies(name) = call("delete_cookies", name: name)
|
|
166
158
|
|
|
167
159
|
# Exports all cookies for a named page to a JSON file.
|
|
160
|
+
# File I/O is client-side; daemon provides the cookie data.
|
|
168
161
|
# @param name [String] logical page name
|
|
169
162
|
# @param path [String] file path to write cookies to
|
|
170
163
|
# @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
|
|
@@ -188,8 +181,88 @@ module Browserctl
|
|
|
188
181
|
call("import_cookies", name: name, cookies: cookies)
|
|
189
182
|
end
|
|
190
183
|
|
|
184
|
+
# Returns the value of a localStorage or sessionStorage key.
|
|
185
|
+
# @param name [String] logical page name
|
|
186
|
+
# @param key [String] storage key
|
|
187
|
+
# @param store [String] "local" or "session" (default: "local")
|
|
188
|
+
# @return [Hash] `{ ok: true, value: }` or `{ error: }`
|
|
189
|
+
def storage_get(name, key, store: "local")
|
|
190
|
+
call("storage_get", name: name, key: key, store: store)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Sets a localStorage or sessionStorage key.
|
|
194
|
+
# @param name [String] logical page name
|
|
195
|
+
# @param key [String] storage key
|
|
196
|
+
# @param value [String] storage value
|
|
197
|
+
# @param store [String] "local" or "session" (default: "local")
|
|
198
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
199
|
+
def storage_set(name, key, value, store: "local")
|
|
200
|
+
call("storage_set", name: name, key: key, value: value, store: store)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Exports localStorage and/or sessionStorage to a JSON file.
|
|
204
|
+
# @param name [String] logical page name
|
|
205
|
+
# @param path [String] destination file path
|
|
206
|
+
# @param stores [String] "local", "session", or "all" (default: "all")
|
|
207
|
+
# @return [Hash] `{ ok: true, path:, key_count: }` or `{ error: }`
|
|
208
|
+
def storage_export(name, path, stores: "all")
|
|
209
|
+
call("storage_export", name: name, path: path, stores: stores)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Imports storage keys from a JSON file into the page's localStorage.
|
|
213
|
+
# @param name [String] logical page name
|
|
214
|
+
# @param path [String] source file path
|
|
215
|
+
# @return [Hash] `{ ok: true, origins: N, key_count: M }` or `{ error: }`
|
|
216
|
+
def storage_import(name, path)
|
|
217
|
+
call("storage_import", name: name, path: path)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Clears localStorage and/or sessionStorage for the page.
|
|
221
|
+
# @param name [String] logical page name
|
|
222
|
+
# @param stores [String] "local", "session", or "all" (default: "all")
|
|
223
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
224
|
+
def storage_delete(name, stores: "all")
|
|
225
|
+
call("storage_delete", name: name, stores: stores)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Saves the current browser state (cookies, localStorage, open pages) to a named session.
|
|
229
|
+
# @param session_name [String] name for the saved session
|
|
230
|
+
# @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
|
|
231
|
+
def session_save(session_name)
|
|
232
|
+
call("session_save", session_name: session_name)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Restores a previously saved session into the running daemon.
|
|
236
|
+
# @param session_name [String] name of the session to load
|
|
237
|
+
# @return [Hash] `{ ok: true, cookies: N, pages: N, local_storage_keys: N }` or `{ error: }`
|
|
238
|
+
def session_load(session_name)
|
|
239
|
+
call("session_load", session_name: session_name)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Lists all saved sessions.
|
|
243
|
+
# @return [Hash] `{ ok: true, sessions: [Hash] }` or `{ error: }`
|
|
244
|
+
def session_list
|
|
245
|
+
call("session_list")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Permanently deletes a named session.
|
|
249
|
+
# @param session_name [String] name of the session to delete
|
|
250
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
251
|
+
def session_delete(session_name)
|
|
252
|
+
call("session_delete", session_name: session_name)
|
|
253
|
+
end
|
|
254
|
+
|
|
191
255
|
private
|
|
192
256
|
|
|
257
|
+
def auto_discover_socket
|
|
258
|
+
default = Browserctl.socket_path
|
|
259
|
+
return default if File.exist?(default)
|
|
260
|
+
|
|
261
|
+
# Fall back to the first available auto-indexed daemon, or the default path
|
|
262
|
+
# (which will raise "browserd is not running" at connection time if absent).
|
|
263
|
+
Browserctl.all_daemon_sockets.first || default
|
|
264
|
+
end
|
|
265
|
+
|
|
193
266
|
def communicate(payload)
|
|
194
267
|
UNIXSocket.open(@socket_path) do |sock|
|
|
195
268
|
sock.puts(payload)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Cookie
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
USAGE = "Usage: browserctl cookie <list|set|delete|export|import> [args]"
|
|
11
|
+
|
|
12
|
+
def self.run(client, args)
|
|
13
|
+
sub = args.shift or abort USAGE
|
|
14
|
+
case sub
|
|
15
|
+
when "list" then run_list(client, args)
|
|
16
|
+
when "set" then run_set(client, args)
|
|
17
|
+
when "delete" then run_delete(client, args)
|
|
18
|
+
when "export" then run_export(client, args)
|
|
19
|
+
when "import" then run_import(client, args)
|
|
20
|
+
else abort "unknown cookie subcommand '#{sub}'\n#{USAGE}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.run_list(client, args)
|
|
25
|
+
page = args.shift or abort "usage: browserctl cookie list <page>"
|
|
26
|
+
print_result(client.cookies(page))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.run_set(client, args)
|
|
30
|
+
page = args.shift or abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN [--path /]"
|
|
31
|
+
name = args.shift or abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN [--path /]"
|
|
32
|
+
value = args.shift or abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN [--path /]"
|
|
33
|
+
domain_idx = args.index("--domain")
|
|
34
|
+
domain = domain_idx ? args.delete_at(domain_idx + 1).tap { args.delete_at(domain_idx) } : nil
|
|
35
|
+
abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN" unless domain
|
|
36
|
+
path_idx = args.index("--path")
|
|
37
|
+
path = path_idx ? args.delete_at(path_idx + 1).tap { args.delete_at(path_idx) } : "/"
|
|
38
|
+
print_result(client.set_cookie(page, name, value, domain, path: path))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.run_delete(client, args)
|
|
42
|
+
page = args.shift or abort "usage: browserctl cookie delete <page>"
|
|
43
|
+
print_result(client.delete_cookies(page))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.run_export(client, args)
|
|
47
|
+
page = args.shift or abort "usage: browserctl cookie export <page> <path>"
|
|
48
|
+
path = args.shift or abort "usage: browserctl cookie export <page> <path>"
|
|
49
|
+
print_result(client.export_cookies(page, path))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.run_import(client, args)
|
|
53
|
+
page = args.shift or abort "usage: browserctl cookie import <page> <path>"
|
|
54
|
+
path = args.shift or abort "usage: browserctl cookie import <page> <path>"
|
|
55
|
+
print_result(client.import_cookies(page, path))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optimist"
|
|
5
|
+
require_relative "cli_output"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
module Commands
|
|
9
|
+
module Daemon
|
|
10
|
+
extend CliOutput
|
|
11
|
+
|
|
12
|
+
USAGE = "Usage: browserctl daemon <ping|status|start|stop|list> [args]"
|
|
13
|
+
|
|
14
|
+
def self.run(client, args)
|
|
15
|
+
sub = args.shift or abort USAGE
|
|
16
|
+
case sub
|
|
17
|
+
when "ping" then print_result(client.ping)
|
|
18
|
+
when "status" then run_status(client)
|
|
19
|
+
when "start" then run_start(args)
|
|
20
|
+
when "stop" then print_result(client.shutdown)
|
|
21
|
+
when "list" then run_list
|
|
22
|
+
else abort "unknown daemon subcommand '#{sub}'\n#{USAGE}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.run_status(client)
|
|
27
|
+
ping = client.ping
|
|
28
|
+
pages = client.page_list[:pages] || []
|
|
29
|
+
page_info = pages.map do |name|
|
|
30
|
+
url_res = client.url(name)
|
|
31
|
+
{ name: name, url: url_res[:url] || url_res[:error] }
|
|
32
|
+
end
|
|
33
|
+
puts JSON.pretty_generate(
|
|
34
|
+
daemon: "online",
|
|
35
|
+
pid: ping[:pid],
|
|
36
|
+
protocol_version: ping[:protocol_version],
|
|
37
|
+
pages: page_info
|
|
38
|
+
)
|
|
39
|
+
rescue RuntimeError => e
|
|
40
|
+
raise unless e.message.include?("browserd is not running")
|
|
41
|
+
|
|
42
|
+
puts JSON.pretty_generate(daemon: "offline", error: e.message)
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.run_start(args)
|
|
47
|
+
opts = Optimist.options(args) do
|
|
48
|
+
opt :headed, "Run with visible browser", default: false
|
|
49
|
+
opt :name, "Daemon name", type: :string
|
|
50
|
+
end
|
|
51
|
+
flags = []
|
|
52
|
+
flags << "--headed" if opts[:headed]
|
|
53
|
+
flags += ["--name", opts[:name]] if opts[:name]
|
|
54
|
+
pid = Process.spawn("browserd", *flags, out: File::NULL, err: File::NULL)
|
|
55
|
+
Process.detach(pid)
|
|
56
|
+
puts "browserd started (pid #{pid})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.run_list
|
|
60
|
+
sockets = Dir[File.join(Browserctl::BROWSERCTL_DIR, "*.sock")]
|
|
61
|
+
rows = sockets.map do |sock_path|
|
|
62
|
+
daemon_name = File.basename(sock_path, ".sock")
|
|
63
|
+
display_name = daemon_name == "browserd" ? "default" : daemon_name
|
|
64
|
+
socket_name = daemon_name == "browserd" ? nil : daemon_name
|
|
65
|
+
client = Browserctl::Client.new(Browserctl.socket_path(socket_name))
|
|
66
|
+
status = client.ping
|
|
67
|
+
next unless status&.dig(:ok)
|
|
68
|
+
|
|
69
|
+
{ name: display_name, pid: status[:pid], pages: (client.page_list[:pages] || []).length }
|
|
70
|
+
rescue RuntimeError
|
|
71
|
+
nil
|
|
72
|
+
end.compact
|
|
73
|
+
puts({ daemons: rows }.to_json)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Commands
|
|
8
|
+
module Page
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
11
|
+
USAGE = "Usage: browserctl page <open|close|list|focus> [args]"
|
|
12
|
+
|
|
13
|
+
def self.run(client, args)
|
|
14
|
+
sub = args.shift or abort USAGE
|
|
15
|
+
case sub
|
|
16
|
+
when "open" then run_open(client, args)
|
|
17
|
+
when "close" then run_close(client, args)
|
|
18
|
+
when "list" then run_list(client)
|
|
19
|
+
when "focus" then run_focus(client, args)
|
|
20
|
+
else abort "unknown page subcommand '#{sub}'\n#{USAGE}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.run_open(client, args)
|
|
25
|
+
opts = Optimist.options(args) do
|
|
26
|
+
opt :url, "URL to navigate to", type: :string, short: "-u"
|
|
27
|
+
end
|
|
28
|
+
name = args.shift or abort "usage: browserctl page open <name> [--url URL]"
|
|
29
|
+
print_result(client.page_open(name, url: opts[:url]))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.run_close(client, args)
|
|
33
|
+
name = args.shift or abort "usage: browserctl page close <name>"
|
|
34
|
+
print_result(client.page_close(name))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.run_list(client)
|
|
38
|
+
print_result(client.page_list)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.run_focus(client, args)
|
|
42
|
+
name = args.shift or abort "usage: browserctl page focus <name>"
|
|
43
|
+
print_result(client.call("page_focus", name: name))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -43,7 +43,7 @@ module Browserctl
|
|
|
43
43
|
FileUtils.mkdir_p(File.dirname(out))
|
|
44
44
|
Recording.generate_workflow(name, output_path: out)
|
|
45
45
|
puts "Workflow saved: #{out}"
|
|
46
|
-
puts "Run with: browserctl run #{name}"
|
|
46
|
+
puts "Run with: browserctl workflow run #{name}"
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def run_status
|
|
@@ -10,11 +10,11 @@ module Browserctl
|
|
|
10
10
|
|
|
11
11
|
def self.run(client, args)
|
|
12
12
|
opts = Optimist.options(args) do
|
|
13
|
-
banner "Usage: browserctl
|
|
13
|
+
banner "Usage: browserctl screenshot <page> [--out PATH] [--full]"
|
|
14
14
|
opt :out, "Output file path", type: :string, short: "-o"
|
|
15
15
|
opt :full, "Capture full page", default: false, short: "-f"
|
|
16
16
|
end
|
|
17
|
-
name = args.shift or abort "usage: browserctl
|
|
17
|
+
name = args.shift or abort "usage: browserctl screenshot <page> [--out PATH] [--full]"
|
|
18
18
|
print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
|
|
19
19
|
end
|
|
20
20
|
end
|