browserctl 0.4.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +97 -55
  4. data/bin/browserctl +117 -108
  5. data/bin/browserd +9 -3
  6. data/bin/setup +7 -3
  7. data/examples/cloudflare_hitl.rb +6 -6
  8. data/examples/smoke/params_file.rb +3 -2
  9. data/examples/smoke/store_fetch.rb +5 -5
  10. data/examples/test_automation_practices/checkboxes.rb +39 -0
  11. data/examples/test_automation_practices/dynamic_elements.rb +40 -0
  12. data/examples/test_automation_practices/key_press.rb +41 -0
  13. data/examples/test_automation_practices/login.rb +34 -0
  14. data/examples/test_automation_practices/login_negative.rb +28 -0
  15. data/examples/test_automation_practices/notifications.rb +57 -0
  16. data/examples/the_internet/add_remove_elements.rb +1 -1
  17. data/examples/the_internet/checkboxes.rb +1 -1
  18. data/examples/the_internet/dropdown.rb +1 -1
  19. data/examples/the_internet/dynamic_loading.rb +2 -2
  20. data/examples/the_internet/login.rb +1 -1
  21. data/lib/browserctl/client.rb +112 -28
  22. data/lib/browserctl/commands/cookie.rb +59 -0
  23. data/lib/browserctl/commands/daemon.rb +77 -0
  24. data/lib/browserctl/commands/page.rb +47 -0
  25. data/lib/browserctl/commands/record.rb +1 -1
  26. data/lib/browserctl/commands/screenshot.rb +2 -2
  27. data/lib/browserctl/commands/session.rb +69 -0
  28. data/lib/browserctl/commands/snapshot.rb +5 -5
  29. data/lib/browserctl/commands/storage.rb +67 -0
  30. data/lib/browserctl/commands/workflow.rb +64 -0
  31. data/lib/browserctl/constants.rb +20 -1
  32. data/lib/browserctl/detectors.rb +23 -0
  33. data/lib/browserctl/errors.rb +25 -0
  34. data/lib/browserctl/logger.rb +4 -4
  35. data/lib/browserctl/policy.rb +36 -0
  36. data/lib/browserctl/recording.rb +4 -4
  37. data/lib/browserctl/runner.rb +4 -4
  38. data/lib/browserctl/server/command_dispatcher.rb +49 -258
  39. data/lib/browserctl/server/handlers/cookies.rb +57 -0
  40. data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
  41. data/lib/browserctl/server/handlers/devtools.rb +22 -0
  42. data/lib/browserctl/server/handlers/hitl.rb +31 -0
  43. data/lib/browserctl/server/handlers/navigation.rb +94 -0
  44. data/lib/browserctl/server/handlers/observation.rb +87 -0
  45. data/lib/browserctl/server/handlers/page_lifecycle.rb +36 -0
  46. data/lib/browserctl/server/handlers/session.rb +93 -0
  47. data/lib/browserctl/server/handlers/storage.rb +109 -0
  48. data/lib/browserctl/server.rb +4 -3
  49. data/lib/browserctl/session.rb +79 -0
  50. data/lib/browserctl/version.rb +1 -1
  51. data/lib/browserctl/workflow.rb +58 -17
  52. data/lib/browserctl.rb +12 -2
  53. metadata +43 -11
  54. data/lib/browserctl/commands/export_cookies.rb +0 -18
  55. data/lib/browserctl/commands/import_cookies.rb +0 -23
  56. data/lib/browserctl/commands/inspect.rb +0 -21
  57. data/lib/browserctl/commands/open_page.rb +0 -21
  58. data/lib/browserctl/commands/pause.rb +0 -22
  59. data/lib/browserctl/commands/status.rb +0 -30
  60. data/lib/browserctl/commands/watch.rb +0 -27
@@ -4,7 +4,7 @@
4
4
  # Smoke test for --params file loading (Task 7.5).
5
5
  #
6
6
  # Run with:
7
- # browserctl run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
7
+ # browserctl workflow run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
8
8
  #
9
9
  # The workflow logs in using credentials from the params file and asserts
10
10
  # the secure area is reached — proving the params were loaded and available.
@@ -17,7 +17,7 @@ Browserctl.workflow "smoke/params_file" do
17
17
  param :base_url, default: "https://the-internet.herokuapp.com"
18
18
 
19
19
  step "open login page" do
20
- client.open_page("main", url: "#{base_url}/login")
20
+ open_page(:main, url: "#{base_url}/login")
21
21
  end
22
22
 
23
23
  step "fill credentials from params file" do
@@ -29,6 +29,7 @@ Browserctl.workflow "smoke/params_file" do
29
29
  end
30
30
 
31
31
  step "assert login succeeded" do
32
+ page(:main).wait(".flash.success", timeout: 10)
32
33
  assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
33
34
  puts " [ok] reached secure area — params file loaded correctly"
34
35
  end
@@ -12,12 +12,12 @@ Browserctl.workflow "smoke/store_fetch" do
12
12
  param :base_url, default: "https://the-internet.herokuapp.com"
13
13
 
14
14
  step "open dynamic loading page" do
15
- client.open_page("main", url: "#{base_url}/dynamic_loading/1")
15
+ open_page(:main, url: "#{base_url}/dynamic_loading/1")
16
16
  end
17
17
 
18
18
  step "click start and capture loaded text" do
19
19
  page(:main).click("div#start button")
20
- page(:main).wait_for("div#finish", timeout: 10)
20
+ page(:main).wait("div#finish", timeout: 10)
21
21
  text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
22
22
  assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
23
23
  store(:loaded_text, text)
@@ -32,8 +32,8 @@ Browserctl.workflow "smoke/store_fetch" do
32
32
 
33
33
  step "confirm fetch raises for unknown key" do
34
34
  fetch(:nonexistent_key)
35
- assert false, "expected KeyError was not raised"
36
- rescue KeyError => e
37
- puts " [ok] KeyError raised as expected: #{e.message}"
35
+ assert false, "expected WorkflowError was not raised"
36
+ rescue Browserctl::WorkflowError => e
37
+ puts " [ok] WorkflowError raised as expected: #{e.message}"
38
38
  end
39
39
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/checkboxes" do
4
+ desc "Checkboxes: toggle individual, check all, uncheck all — assert state each time"
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_checkboxes.png")
8
+
9
+ step "open checkboxes page" do
10
+ open_page(:main, url: "#{base_url}/#/checkboxes")
11
+ end
12
+
13
+ step "read initial state — checkbox 2 pre-checked" do
14
+ js = "[1,2,3].map(i => document.querySelector(`[data-test=\"checkbox-checkbox${i}\"]`)?.checked)"
15
+ states = client.evaluate("main", js)[:result]
16
+ assert states == [false, true, false], "expected initial state [false, true, false], got: #{states.inspect}"
17
+ end
18
+
19
+ step "toggle checkbox 1 on" do
20
+ page(:main).click("[data-test='checkbox-checkbox1']")
21
+ checked = client.evaluate("main", "document.querySelector('[data-test=\"checkbox-checkbox1\"]').checked")[:result]
22
+ assert checked, "expected checkbox 1 to be checked after click"
23
+ end
24
+
25
+ step "check all and verify" do
26
+ page(:main).click("[data-test='check-all-button']")
27
+ js = "[1,2,3].map(i => document.querySelector(`[data-test=\"checkbox-checkbox${i}\"]`)?.checked)"
28
+ states = client.evaluate("main", js)[:result]
29
+ assert states.all?, "expected all checkboxes checked, got: #{states.inspect}"
30
+ page(:main).screenshot(path: screenshot_path)
31
+ end
32
+
33
+ step "uncheck all and verify" do
34
+ page(:main).click("[data-test='uncheck-all-button']")
35
+ js = "[1,2,3].map(i => document.querySelector(`[data-test=\"checkbox-checkbox${i}\"]`)?.checked)"
36
+ states = client.evaluate("main", js)[:result]
37
+ assert states.none?, "expected all checkboxes unchecked, got: #{states.inspect}"
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/dynamic_elements" do
4
+ desc "Dynamic elements: trigger reload, wait for items, toggle hidden content"
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_dynamic_elements.png")
9
+
10
+ step "open dynamic elements page" do
11
+ open_page(:main, url: "#{base_url}/#/dynamic-elements")
12
+ end
13
+
14
+ step "assert no dynamic items exist before reload" do
15
+ absent = client.evaluate("main", "!document.querySelector('[data-test=\"dynamic-item-0\"]')")[:result]
16
+ assert absent, "expected no dynamic items before triggering reload"
17
+ end
18
+
19
+ step "click reload and wait for items" do
20
+ page(:main).click("[data-test='reload-button']")
21
+ # loading indicator appears first; wait for it to clear and items to render
22
+ page(:main).wait("[data-test='dynamic-item-0']", timeout: 15)
23
+ end
24
+
25
+ step "verify all three dynamic items are present" do
26
+ count = client.evaluate(
27
+ "main",
28
+ "[0,1,2].filter(i => !!document.querySelector(`[data-test=\"dynamic-item-${i}\"]`)).length"
29
+ )[:result]
30
+ assert count == 3, "expected 3 dynamic items, got: #{count}"
31
+ page(:main).screenshot(path: screenshot_path)
32
+ end
33
+
34
+ step "toggle hidden content on" do
35
+ page(:main).click("[data-test='toggle-hidden-button']")
36
+ page(:main).wait("[data-test='hidden-content']", timeout: 5)
37
+ visible = client.evaluate("main", "!!document.querySelector('[data-test=\"hidden-content\"]')")[:result]
38
+ assert visible, "expected hidden content to appear after toggle"
39
+ end
40
+ end
@@ -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
- 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
@@ -74,39 +74,30 @@ module Browserctl
74
74
 
75
75
  # Takes a DOM snapshot. Returns `challenge: true` when Cloudflare is detected.
76
76
  # @param name [String] logical page name
77
- # @param format [String] "ai" (token-efficient JSON) or "html" (raw HTML)
77
+ # @param format [String] "elements" (interactable elements JSON) or "html" (raw HTML)
78
78
  # @param diff [Boolean] return only elements changed since last snapshot
79
79
  # @return [Hash] `{ ok: true, snapshot:, challenge: }` or `{ ok: true, html:, challenge: }` or `{ error: }`
80
- def snapshot(name, format: "ai", diff: false)
80
+ def snapshot(name, format: "elements", diff: false)
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,23 +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
- # @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
+ # Stores a value in the daemon-scoped key-value store.
127
+ # @param key [String] storage key
128
+ # @param value [Object] value to store (must be JSON-serialisable)
129
+ # @return [Hash] `{ ok: true }` or `{ error: }`
130
+ def store(key, value) = call("store", key: key, value: value)
131
+
132
+ # Retrieves a value from the daemon-scoped key-value store.
133
+ # @param key [String] storage key
134
+ # @return [Hash] `{ ok: true, value: }` or `{ error:, code: "key_not_found" }`
135
+ def fetch(key) = call("fetch", key: key)
133
136
 
134
137
  # Returns all cookies for a named page.
135
138
  # @param name [String] logical page name
136
139
  # @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
137
- def cookies(name) = call("cookies", name: name)
140
+ def cookies(name) = call("cookies", name: name)
138
141
 
139
142
  # Sets a cookie on a named page.
140
143
  # @param name [String] logical page name
@@ -148,12 +151,13 @@ module Browserctl
148
151
  value: value, domain: domain, path: path)
149
152
  end
150
153
 
151
- # Clears all cookies for a named page.
154
+ # Deletes all cookies for a named page.
152
155
  # @param name [String] logical page name
153
156
  # @return [Hash] `{ ok: true }` or `{ error: }`
154
- def clear_cookies(name) = call("clear_cookies", name: name)
157
+ def delete_cookies(name) = call("delete_cookies", name: name)
155
158
 
156
159
  # Exports all cookies for a named page to a JSON file.
160
+ # File I/O is client-side; daemon provides the cookie data.
157
161
  # @param name [String] logical page name
158
162
  # @param path [String] file path to write cookies to
159
163
  # @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
@@ -177,8 +181,88 @@ module Browserctl
177
181
  call("import_cookies", name: name, cookies: cookies)
178
182
  end
179
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
+
180
255
  private
181
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
+
182
266
  def communicate(payload)
183
267
  UNIXSocket.open(@socket_path) do |sock|
184
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