browserctl 0.5.0 → 0.7.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 +31 -0
- data/README.md +27 -32
- data/bin/browserctl +146 -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/advanced/ab_testing.rb +38 -0
- data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
- data/examples/test_automation_practices/advanced/file_download.rb +40 -0
- data/examples/test_automation_practices/advanced/iframes.rb +37 -0
- data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
- data/examples/test_automation_practices/auth/login.rb +34 -0
- data/examples/test_automation_practices/auth/login_negative.rb +28 -0
- data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
- data/examples/test_automation_practices/dialogs/notifications.rb +57 -0
- data/examples/test_automation_practices/dynamic/dynamic_elements.rb +41 -0
- data/examples/test_automation_practices/dynamic/tables.rb +47 -0
- data/examples/test_automation_practices/forms/checkboxes.rb +39 -0
- data/examples/test_automation_practices/forms/file_upload.rb +30 -0
- data/examples/test_automation_practices/forms/forms.rb +47 -0
- data/examples/test_automation_practices/forms/slider.rb +51 -0
- data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
- data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
- data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
- data/examples/test_automation_practices/interactions/hover.rb +30 -0
- data/examples/test_automation_practices/interactions/key_press.rb +38 -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 +143 -28
- data/lib/browserctl/commands/ask.rb +20 -0
- data/lib/browserctl/commands/cookie.rb +59 -0
- data/lib/browserctl/commands/daemon.rb +77 -0
- data/lib/browserctl/commands/dialog.rb +33 -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 +30 -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/interaction.rb +87 -0
- 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 +14 -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 +50 -11
- metadata +36 -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/interactions/drag_drop" do
|
|
4
|
+
desc "Drag-drop page: reorder items via keyboard (Space to pick up, Arrow to move, Space to drop)"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_interactions_drag_drop.png")
|
|
8
|
+
|
|
9
|
+
step "open drag-drop page and read initial order" do
|
|
10
|
+
open_page(:main, url: "#{base_url}/#/drag-drop")
|
|
11
|
+
page(:main).wait("[data-test='drag-drop-list']", timeout: 10)
|
|
12
|
+
items = page(:main).evaluate(
|
|
13
|
+
"Array.from(document.querySelectorAll('[data-test=\"drag-drop-list\"] li')).map(el => el.textContent?.trim())"
|
|
14
|
+
)
|
|
15
|
+
store(:initial_order, items)
|
|
16
|
+
assert items.length == 5, "expected 5 items in drag-drop list, got: #{items.length}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
step "focus first item and move it down one position via keyboard" do
|
|
20
|
+
page(:main).evaluate(
|
|
21
|
+
"document.querySelectorAll('[data-test=\"drag-drop-list\"] li')[0].focus()"
|
|
22
|
+
)
|
|
23
|
+
sleep 0.1
|
|
24
|
+
page(:main).press("Space")
|
|
25
|
+
sleep 0.1
|
|
26
|
+
page(:main).press("ArrowDown")
|
|
27
|
+
sleep 0.1
|
|
28
|
+
page(:main).press("Space")
|
|
29
|
+
sleep 0.2
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
step "verify first item moved to second position" do
|
|
33
|
+
initial = fetch(:initial_order)
|
|
34
|
+
current = page(:main).evaluate(
|
|
35
|
+
"Array.from(document.querySelectorAll('[data-test=\"drag-drop-list\"] li')).map(el => el.textContent?.trim())"
|
|
36
|
+
)
|
|
37
|
+
assert current[1] == initial[0],
|
|
38
|
+
"expected '#{initial[0]}' at index 1 after move down, got order: #{current.inspect}"
|
|
39
|
+
page(:main).screenshot(path: screenshot_path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/interactions/exit_intent" do
|
|
4
|
+
desc "Exit intent page: trigger modal by dispatching mouseleave at viewport top, test all dismiss paths"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_interactions_exit_intent.png")
|
|
8
|
+
|
|
9
|
+
step "open exit intent page" do
|
|
10
|
+
open_page(:main, url: "#{base_url}/#/exit-intent")
|
|
11
|
+
sleep 0.5
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
step "trigger exit intent — modal appears" do
|
|
15
|
+
page(:main).evaluate(<<~JS)
|
|
16
|
+
document.dispatchEvent(
|
|
17
|
+
new MouseEvent('mouseleave', { bubbles: true, cancelable: true, clientY: -1 })
|
|
18
|
+
)
|
|
19
|
+
JS
|
|
20
|
+
page(:main).wait("[data-test='exit-modal']", timeout: 5)
|
|
21
|
+
visible = page(:main).evaluate("!!document.querySelector('[data-test=\"exit-modal\"]')")
|
|
22
|
+
assert visible, "expected exit-modal to appear after mouseleave event"
|
|
23
|
+
page(:main).screenshot(path: screenshot_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
step "dismiss via X close button — modal disappears" do
|
|
27
|
+
page(:main).click("[data-test='close-modal']")
|
|
28
|
+
sleep 0.2
|
|
29
|
+
gone = page(:main).evaluate("!document.querySelector('[data-test=\"exit-modal\"]')")
|
|
30
|
+
assert gone, "expected exit-modal to close after clicking X"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
step "re-trigger and dismiss via No thanks" do
|
|
34
|
+
page(:main).navigate("#{base_url}/#/exit-intent")
|
|
35
|
+
sleep 0.5
|
|
36
|
+
page(:main).evaluate(<<~JS)
|
|
37
|
+
document.dispatchEvent(
|
|
38
|
+
new MouseEvent('mouseleave', { bubbles: true, cancelable: true, clientY: -1 })
|
|
39
|
+
)
|
|
40
|
+
JS
|
|
41
|
+
page(:main).wait("[data-test='exit-modal']", timeout: 5)
|
|
42
|
+
page(:main).click("[data-test='modal-no']")
|
|
43
|
+
sleep 0.2
|
|
44
|
+
gone = page(:main).evaluate("!document.querySelector('[data-test=\"exit-modal\"]')")
|
|
45
|
+
assert gone, "expected exit-modal to close after clicking No thanks"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/interactions/hover" do
|
|
4
|
+
desc "Hover states page: move mouse over each figure, verify overlay content appears on each"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_interactions_hover.png")
|
|
8
|
+
|
|
9
|
+
step "open hover states page" do
|
|
10
|
+
open_page(:main, url: "#{base_url}/#/hover")
|
|
11
|
+
page(:main).wait("[data-test='hover-example']", timeout: 10)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
step "hover over each figure and verify caption appears" do
|
|
15
|
+
[1, 2, 3].each do |i|
|
|
16
|
+
page(:main).hover("[data-test='hover-figure-#{i}']")
|
|
17
|
+
sleep 0.2
|
|
18
|
+
caption = page(:main).evaluate(
|
|
19
|
+
"!!document.querySelector('[data-test=\"hover-caption-#{i}\"]')"
|
|
20
|
+
)
|
|
21
|
+
assert caption, "expected caption to appear on figure #{i} after hover"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
step "screenshot the hovered state" do
|
|
26
|
+
page(:main).hover("[data-test='hover-figure-2']")
|
|
27
|
+
sleep 0.2
|
|
28
|
+
page(:main).screenshot(path: screenshot_path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/interactions/key_press" do
|
|
4
|
+
desc "Key press page: fire real keyboard events via press, 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/tap_interactions_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 "fire key events and verify last-key display" do
|
|
14
|
+
keys = %w[A B C D E]
|
|
15
|
+
keys.each do |key|
|
|
16
|
+
page(:main).press(key)
|
|
17
|
+
sleep 0.1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
last = page(:main).evaluate(
|
|
21
|
+
"document.querySelector('[data-test=\"last-key-pressed\"]')?.innerText?.trim()"
|
|
22
|
+
)
|
|
23
|
+
assert last == keys.last, "expected last key '#{keys.last}', got: #{last.inspect}"
|
|
24
|
+
store(:keys, keys)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
step "verify key history contains the most recent keys" do
|
|
28
|
+
keys = fetch(:keys)
|
|
29
|
+
history = page(:main).evaluate(
|
|
30
|
+
"Array.from(document.querySelectorAll('[data-test^=\"key-\"]')).map(el => el.innerText?.trim())"
|
|
31
|
+
)
|
|
32
|
+
# The site retains only the last 4 keys in the history list; assert those
|
|
33
|
+
keys.last(4).each do |key|
|
|
34
|
+
assert history.any? { |entry| entry&.include?(key) }, "expected '#{key}' in history, got: #{history.inspect}"
|
|
35
|
+
end
|
|
36
|
+
page(:main).screenshot(path: screenshot_path)
|
|
37
|
+
end
|
|
38
|
+
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,40 @@ 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)
|
|
125
|
+
|
|
126
|
+
# Brings the named page's tab to front. Only works when browserd was started with --headed.
|
|
127
|
+
# @param name [String] logical page name
|
|
128
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
129
|
+
def page_focus(name) = call("page_focus", name: name)
|
|
133
130
|
|
|
134
131
|
# Stores a value in the daemon-scoped key-value store.
|
|
135
132
|
# @param key [String] storage key
|
|
136
133
|
# @param value [Object] value to store (must be JSON-serialisable)
|
|
137
134
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
138
|
-
def store(key, value)
|
|
135
|
+
def store(key, value) = call("store", key: key, value: value)
|
|
139
136
|
|
|
140
137
|
# Retrieves a value from the daemon-scoped key-value store.
|
|
141
138
|
# @param key [String] storage key
|
|
142
139
|
# @return [Hash] `{ ok: true, value: }` or `{ error:, code: "key_not_found" }`
|
|
143
|
-
def fetch(key)
|
|
140
|
+
def fetch(key) = call("fetch", key: key)
|
|
144
141
|
|
|
145
142
|
# Returns all cookies for a named page.
|
|
146
143
|
# @param name [String] logical page name
|
|
147
144
|
# @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
|
|
148
|
-
def cookies(name)
|
|
145
|
+
def cookies(name) = call("cookies", name: name)
|
|
149
146
|
|
|
150
147
|
# Sets a cookie on a named page.
|
|
151
148
|
# @param name [String] logical page name
|
|
@@ -159,12 +156,13 @@ module Browserctl
|
|
|
159
156
|
value: value, domain: domain, path: path)
|
|
160
157
|
end
|
|
161
158
|
|
|
162
|
-
#
|
|
159
|
+
# Deletes all cookies for a named page.
|
|
163
160
|
# @param name [String] logical page name
|
|
164
161
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
165
|
-
def
|
|
162
|
+
def delete_cookies(name) = call("delete_cookies", name: name)
|
|
166
163
|
|
|
167
164
|
# Exports all cookies for a named page to a JSON file.
|
|
165
|
+
# File I/O is client-side; daemon provides the cookie data.
|
|
168
166
|
# @param name [String] logical page name
|
|
169
167
|
# @param path [String] file path to write cookies to
|
|
170
168
|
# @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
|
|
@@ -188,8 +186,125 @@ module Browserctl
|
|
|
188
186
|
call("import_cookies", name: name, cookies: cookies)
|
|
189
187
|
end
|
|
190
188
|
|
|
189
|
+
# Returns the value of a localStorage or sessionStorage key.
|
|
190
|
+
# @param name [String] logical page name
|
|
191
|
+
# @param key [String] storage key
|
|
192
|
+
# @param store [String] "local" or "session" (default: "local")
|
|
193
|
+
# @return [Hash] `{ ok: true, value: }` or `{ error: }`
|
|
194
|
+
def storage_get(name, key, store: "local")
|
|
195
|
+
call("storage_get", name: name, key: key, store: store)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Sets a localStorage or sessionStorage key.
|
|
199
|
+
# @param name [String] logical page name
|
|
200
|
+
# @param key [String] storage key
|
|
201
|
+
# @param value [String] storage value
|
|
202
|
+
# @param store [String] "local" or "session" (default: "local")
|
|
203
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
204
|
+
def storage_set(name, key, value, store: "local")
|
|
205
|
+
call("storage_set", name: name, key: key, value: value, store: store)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Exports localStorage and/or sessionStorage to a JSON file.
|
|
209
|
+
# @param name [String] logical page name
|
|
210
|
+
# @param path [String] destination file path
|
|
211
|
+
# @param stores [String] "local", "session", or "all" (default: "all")
|
|
212
|
+
# @return [Hash] `{ ok: true, path:, key_count: }` or `{ error: }`
|
|
213
|
+
def storage_export(name, path, stores: "all")
|
|
214
|
+
call("storage_export", name: name, path: path, stores: stores)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Imports storage keys from a JSON file into the page's localStorage.
|
|
218
|
+
# @param name [String] logical page name
|
|
219
|
+
# @param path [String] source file path
|
|
220
|
+
# @return [Hash] `{ ok: true, origins: N, key_count: M }` or `{ error: }`
|
|
221
|
+
def storage_import(name, path)
|
|
222
|
+
call("storage_import", name: name, path: path)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Clears localStorage and/or sessionStorage for the page.
|
|
226
|
+
# @param name [String] logical page name
|
|
227
|
+
# @param stores [String] "local", "session", or "all" (default: "all")
|
|
228
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
229
|
+
def storage_delete(name, stores: "all")
|
|
230
|
+
call("storage_delete", name: name, stores: stores)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Fires a keydown + keyup event for the given key name on a page.
|
|
234
|
+
# @param name [String] logical page name
|
|
235
|
+
# @param key [String] key name e.g. "Enter", "Tab", "Escape", "ArrowDown"
|
|
236
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
237
|
+
def press(name, key) = call("press", name: name, key: key)
|
|
238
|
+
|
|
239
|
+
# Moves the mouse to the centre of the element matched by selector.
|
|
240
|
+
# @param name [String] logical page name
|
|
241
|
+
# @param selector [String] CSS selector
|
|
242
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
243
|
+
def hover(name, selector) = call("hover", name: name, selector: selector)
|
|
244
|
+
|
|
245
|
+
# Sets a file-input element to the given file path.
|
|
246
|
+
# @param name [String] logical page name
|
|
247
|
+
# @param selector [String] CSS selector for the file input
|
|
248
|
+
# @param path [String] absolute or relative file path
|
|
249
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
250
|
+
def upload(name, selector, path) = call("upload", name: name, selector: selector, path: path)
|
|
251
|
+
|
|
252
|
+
# Sets a <select> element's value and fires a change event.
|
|
253
|
+
# @param name [String] logical page name
|
|
254
|
+
# @param selector [String] CSS selector for the select element
|
|
255
|
+
# @param value [String] option value to select
|
|
256
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
257
|
+
def select(name, selector, value) = call("select", name: name, selector: selector, value: value)
|
|
258
|
+
|
|
259
|
+
# Pre-registers a one-shot handler to accept the next JS dialog on a page.
|
|
260
|
+
# @param name [String] logical page name
|
|
261
|
+
# @param text [String, nil] prompt text for window.prompt dialogs (ignored for alert/confirm)
|
|
262
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
263
|
+
def dialog_accept(name, text: nil) = call("dialog_accept", name: name, text: text)
|
|
264
|
+
|
|
265
|
+
# Pre-registers a one-shot handler to dismiss the next JS dialog on a page.
|
|
266
|
+
# @param name [String] logical page name
|
|
267
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
268
|
+
def dialog_dismiss(name) = call("dialog_dismiss", name: name)
|
|
269
|
+
|
|
270
|
+
# Saves the current browser state (cookies, localStorage, open pages) to a named session.
|
|
271
|
+
# @param session_name [String] name for the saved session
|
|
272
|
+
# @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
|
|
273
|
+
def session_save(session_name)
|
|
274
|
+
call("session_save", session_name: session_name)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Restores a previously saved session into the running daemon.
|
|
278
|
+
# @param session_name [String] name of the session to load
|
|
279
|
+
# @return [Hash] `{ ok: true, cookies: N, pages: N, local_storage_keys: N }` or `{ error: }`
|
|
280
|
+
def session_load(session_name)
|
|
281
|
+
call("session_load", session_name: session_name)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Lists all saved sessions.
|
|
285
|
+
# @return [Hash] `{ ok: true, sessions: [Hash] }` or `{ error: }`
|
|
286
|
+
def session_list
|
|
287
|
+
call("session_list")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Permanently deletes a named session.
|
|
291
|
+
# @param session_name [String] name of the session to delete
|
|
292
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
293
|
+
def session_delete(session_name)
|
|
294
|
+
call("session_delete", session_name: session_name)
|
|
295
|
+
end
|
|
296
|
+
|
|
191
297
|
private
|
|
192
298
|
|
|
299
|
+
def auto_discover_socket
|
|
300
|
+
default = Browserctl.socket_path
|
|
301
|
+
return default if File.exist?(default)
|
|
302
|
+
|
|
303
|
+
# Fall back to the first available auto-indexed daemon, or the default path
|
|
304
|
+
# (which will raise "browserd is not running" at connection time if absent).
|
|
305
|
+
Browserctl.all_daemon_sockets.first || default
|
|
306
|
+
end
|
|
307
|
+
|
|
193
308
|
def communicate(payload)
|
|
194
309
|
UNIXSocket.open(@socket_path) do |sock|
|
|
195
310
|
sock.puts(payload)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Ask
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
def self.run(args)
|
|
11
|
+
abort "usage: browserctl ask <prompt>" if args.empty?
|
|
12
|
+
|
|
13
|
+
prompt = args.join(" ")
|
|
14
|
+
$stderr.print("[browserctl] #{prompt} ")
|
|
15
|
+
value = $stdin.gets.chomp
|
|
16
|
+
print_result({ ok: true, value: value })
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -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
|