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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +27 -32
  4. data/bin/browserctl +146 -108
  5. data/bin/browserd +9 -3
  6. data/examples/cloudflare_hitl.rb +5 -5
  7. data/examples/smoke/params_file.rb +3 -2
  8. data/examples/smoke/store_fetch.rb +5 -5
  9. data/examples/test_automation_practices/advanced/ab_testing.rb +38 -0
  10. data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
  11. data/examples/test_automation_practices/advanced/file_download.rb +40 -0
  12. data/examples/test_automation_practices/advanced/iframes.rb +37 -0
  13. data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
  14. data/examples/test_automation_practices/auth/login.rb +34 -0
  15. data/examples/test_automation_practices/auth/login_negative.rb +28 -0
  16. data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
  17. data/examples/test_automation_practices/dialogs/notifications.rb +57 -0
  18. data/examples/test_automation_practices/dynamic/dynamic_elements.rb +41 -0
  19. data/examples/test_automation_practices/dynamic/tables.rb +47 -0
  20. data/examples/test_automation_practices/forms/checkboxes.rb +39 -0
  21. data/examples/test_automation_practices/forms/file_upload.rb +30 -0
  22. data/examples/test_automation_practices/forms/forms.rb +47 -0
  23. data/examples/test_automation_practices/forms/slider.rb +51 -0
  24. data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
  25. data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
  26. data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
  27. data/examples/test_automation_practices/interactions/hover.rb +30 -0
  28. data/examples/test_automation_practices/interactions/key_press.rb +38 -0
  29. data/examples/the_internet/add_remove_elements.rb +1 -1
  30. data/examples/the_internet/checkboxes.rb +1 -1
  31. data/examples/the_internet/dropdown.rb +1 -1
  32. data/examples/the_internet/dynamic_loading.rb +2 -2
  33. data/examples/the_internet/login.rb +1 -1
  34. data/lib/browserctl/client.rb +143 -28
  35. data/lib/browserctl/commands/ask.rb +20 -0
  36. data/lib/browserctl/commands/cookie.rb +59 -0
  37. data/lib/browserctl/commands/daemon.rb +77 -0
  38. data/lib/browserctl/commands/dialog.rb +33 -0
  39. data/lib/browserctl/commands/page.rb +47 -0
  40. data/lib/browserctl/commands/record.rb +1 -1
  41. data/lib/browserctl/commands/screenshot.rb +2 -2
  42. data/lib/browserctl/commands/session.rb +69 -0
  43. data/lib/browserctl/commands/snapshot.rb +2 -2
  44. data/lib/browserctl/commands/storage.rb +67 -0
  45. data/lib/browserctl/commands/workflow.rb +64 -0
  46. data/lib/browserctl/constants.rb +20 -1
  47. data/lib/browserctl/logger.rb +4 -4
  48. data/lib/browserctl/recording.rb +4 -4
  49. data/lib/browserctl/server/command_dispatcher.rb +30 -9
  50. data/lib/browserctl/server/handlers/cookies.rb +1 -1
  51. data/lib/browserctl/server/handlers/devtools.rb +1 -1
  52. data/lib/browserctl/server/handlers/hitl.rb +2 -1
  53. data/lib/browserctl/server/handlers/interaction.rb +87 -0
  54. data/lib/browserctl/server/handlers/navigation.rb +24 -2
  55. data/lib/browserctl/server/handlers/observation.rb +0 -26
  56. data/lib/browserctl/server/handlers/page_lifecycle.rb +14 -3
  57. data/lib/browserctl/server/handlers/session.rb +93 -0
  58. data/lib/browserctl/server/handlers/storage.rb +109 -0
  59. data/lib/browserctl/server.rb +2 -2
  60. data/lib/browserctl/session.rb +79 -0
  61. data/lib/browserctl/version.rb +1 -1
  62. data/lib/browserctl/workflow.rb +50 -11
  63. metadata +36 -11
  64. data/lib/browserctl/commands/export_cookies.rb +0 -18
  65. data/lib/browserctl/commands/import_cookies.rb +0 -23
  66. data/lib/browserctl/commands/inspect.rb +0 -21
  67. data/lib/browserctl/commands/open_page.rb +0 -21
  68. data/lib/browserctl/commands/pause.rb +0 -22
  69. data/lib/browserctl/commands/status.rb +0 -30
  70. 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
- client.open_page("main", url: "#{base_url}/add_remove_elements/")
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
- client.open_page("main", url: "#{base_url}/checkboxes")
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
- client.open_page("main", url: "#{base_url}/dropdown")
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
- client.open_page("main", url: "#{base_url}/dynamic_loading/1")
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).wait_for("#finish h4", timeout: 10)
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
- client.open_page("main", url: "#{base_url}/login")
12
+ open_page(:main, url: "#{base_url}/login")
13
13
  end
14
14
 
15
15
  step "fill and submit credentials" do
@@ -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 = Browserctl.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 open_page(name, url: nil) = call("open_page", name: name, url: url)
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 close_page(name) = call("close_page", name: name)
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 list_pages = call("list_pages")
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 goto(name, url) = call("goto", name: name, url: url)
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 (short timeout).
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 watch(name, selector, timeout: 30)
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", name: name)
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", name: name, expression: expression)
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
- # @return [Hash] `{ ok: true, paused: true }` or `{ error: }`
122
- def pause(name) = call("pause", name: name)
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", name: name)
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 inspect_page(name) = call("inspect", name: name)
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) = call("store", key: key, value: 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) = call("fetch", key: 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) = call("cookies", name: 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
- # Clears all cookies for a named page.
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 clear_cookies(name) = call("clear_cookies", name: name)
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