browserctl 0.6.0 → 0.8.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/bin/browserctl +29 -0
  4. data/examples/test_automation_practices/advanced/ab_testing.rb +38 -0
  5. data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
  6. data/examples/test_automation_practices/advanced/file_download.rb +40 -0
  7. data/examples/test_automation_practices/advanced/iframes.rb +37 -0
  8. data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
  9. data/examples/test_automation_practices/{login.rb → auth/login.rb} +4 -4
  10. data/examples/test_automation_practices/{login_negative.rb → auth/login_negative.rb} +4 -4
  11. data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
  12. data/examples/test_automation_practices/{notifications.rb → dialogs/notifications.rb} +7 -7
  13. data/examples/test_automation_practices/{dynamic_elements.rb → dynamic/dynamic_elements.rb} +9 -8
  14. data/examples/test_automation_practices/dynamic/tables.rb +47 -0
  15. data/examples/test_automation_practices/{checkboxes.rb → forms/checkboxes.rb} +6 -6
  16. data/examples/test_automation_practices/forms/file_upload.rb +30 -0
  17. data/examples/test_automation_practices/forms/forms.rb +47 -0
  18. data/examples/test_automation_practices/forms/slider.rb +51 -0
  19. data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
  20. data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
  21. data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
  22. data/examples/test_automation_practices/interactions/hover.rb +30 -0
  23. data/examples/test_automation_practices/interactions/key_press.rb +38 -0
  24. data/lib/browserctl/client.rb +42 -0
  25. data/lib/browserctl/commands/ask.rb +20 -0
  26. data/lib/browserctl/commands/dialog.rb +33 -0
  27. data/lib/browserctl/commands/page.rb +1 -1
  28. data/lib/browserctl/errors.rb +3 -0
  29. data/lib/browserctl/secret_resolver_registry.rb +39 -0
  30. data/lib/browserctl/secret_resolvers/base.rb +17 -0
  31. data/lib/browserctl/secret_resolvers/env.rb +13 -0
  32. data/lib/browserctl/secret_resolvers/macos_keychain.rb +29 -0
  33. data/lib/browserctl/secret_resolvers/one_password.rb +22 -0
  34. data/lib/browserctl/secret_resolvers.rb +14 -0
  35. data/lib/browserctl/server/command_dispatcher.rb +8 -0
  36. data/lib/browserctl/server/handlers/interaction.rb +87 -0
  37. data/lib/browserctl/server/handlers/page_lifecycle.rb +4 -0
  38. data/lib/browserctl/version.rb +1 -1
  39. data/lib/browserctl/workflow.rb +36 -9
  40. data/lib/browserctl.rb +1 -0
  41. metadata +31 -10
  42. data/examples/smoke/params_file.rb +0 -36
  43. data/examples/smoke/store_fetch.rb +0 -39
  44. data/examples/test_automation_practices/key_press.rb +0 -41
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/forms/forms" do
4
+ desc "Forms page: trigger inline validation errors, then submit a valid form"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_forms_forms.png")
8
+
9
+ step "open forms page" do
10
+ open_page(:main, url: "#{base_url}/#/forms")
11
+ page(:main).wait("[data-test='login-form']", timeout: 10)
12
+ end
13
+
14
+ step "submit empty form — all three fields show errors" do
15
+ page(:main).click("[data-test='submit-button']")
16
+ page(:main).wait("[data-test='username-error']", timeout: 5)
17
+ username_err = page(:main).evaluate("document.querySelector('[data-test=\"username-error\"]')?.textContent?.trim()")
18
+ email_err = page(:main).evaluate("document.querySelector('[data-test=\"email-error\"]')?.textContent?.trim()")
19
+ password_err = page(:main).evaluate("document.querySelector('[data-test=\"password-error\"]')?.textContent?.trim()")
20
+ assert username_err&.length&.positive?, "expected username error, got: #{username_err.inspect}"
21
+ assert email_err&.length&.positive?, "expected email error, got: #{email_err.inspect}"
22
+ assert password_err&.length&.positive?, "expected password error, got: #{password_err.inspect}"
23
+ end
24
+
25
+ step "fill invalid email — email error shown" do
26
+ page(:main).fill("[data-test='email-input']", "not-an-email")
27
+ page(:main).click("[data-test='submit-button']")
28
+ page(:main).wait("[data-test='email-error']", timeout: 5)
29
+ email_err = page(:main).evaluate("document.querySelector('[data-test=\"email-error\"]')?.textContent?.trim()")
30
+ assert email_err&.length&.positive?, "expected email format error, got: #{email_err.inspect}"
31
+ end
32
+
33
+ step "fill valid credentials and submit — no errors shown" do
34
+ page(:main).fill("[data-test='username-input']", "testuser")
35
+ page(:main).fill("[data-test='email-input']", "test@example.com")
36
+ page(:main).fill("[data-test='password-input']", "password123")
37
+ page(:main).click("[data-test='submit-button']")
38
+ sleep 0.5
39
+ username_err = page(:main).evaluate("!!document.querySelector('[data-test=\"username-error\"]')")
40
+ email_err = page(:main).evaluate("!!document.querySelector('[data-test=\"email-error\"]')")
41
+ password_err = page(:main).evaluate("!!document.querySelector('[data-test=\"password-error\"]')")
42
+ assert !username_err, "expected no username error on valid submit"
43
+ assert !email_err, "expected no email error on valid submit"
44
+ assert !password_err, "expected no password error on valid submit"
45
+ page(:main).screenshot(path: screenshot_path)
46
+ end
47
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ SET_SLIDER_JS = <<~JS
4
+ (function(val) {
5
+ const el = document.querySelector('[data-test="slider"]');
6
+ Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
7
+ .set.call(el, val);
8
+ el.dispatchEvent(new Event('input', { bubbles: true }));
9
+ el.dispatchEvent(new Event('change', { bubbles: true }));
10
+ })(%<value>d)
11
+ JS
12
+
13
+ Browserctl.workflow "test_automation_practices/forms/slider" do
14
+ desc "Slider page: set slider to specific values via evaluate, verify displayed value updates"
15
+
16
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
17
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_forms_slider.png")
18
+
19
+ step "open slider page" do
20
+ open_page(:main, url: "#{base_url}/#/slider")
21
+ page(:main).wait("[data-test='slider-container']", timeout: 10)
22
+ end
23
+
24
+ step "set slider to 75 and verify display" do
25
+ page(:main).evaluate(format(SET_SLIDER_JS, value: 75))
26
+ sleep 0.1
27
+ displayed = page(:main).evaluate(
28
+ "document.querySelector('[data-test=\"slider-value\"]')?.textContent?.trim()"
29
+ )
30
+ assert displayed&.include?("75"), "expected slider-value to show 75, got: #{displayed.inspect}"
31
+ end
32
+
33
+ step "set slider to minimum (0) and verify" do
34
+ page(:main).evaluate(format(SET_SLIDER_JS, value: 0))
35
+ sleep 0.1
36
+ displayed = page(:main).evaluate(
37
+ "document.querySelector('[data-test=\"slider-value\"]')?.textContent?.trim()"
38
+ )
39
+ assert displayed&.include?("0"), "expected slider-value to show 0, got: #{displayed.inspect}"
40
+ end
41
+
42
+ step "set slider to maximum (100) and screenshot" do
43
+ page(:main).evaluate(format(SET_SLIDER_JS, value: 100))
44
+ sleep 0.1
45
+ displayed = page(:main).evaluate(
46
+ "document.querySelector('[data-test=\"slider-value\"]')?.textContent?.trim()"
47
+ )
48
+ assert displayed&.include?("100"), "expected slider-value to show 100, got: #{displayed.inspect}"
49
+ page(:main).screenshot(path: screenshot_path)
50
+ end
51
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/interactions/context_menu" do
4
+ desc "Context menu page: trigger via dispatched contextmenu event, click each action, dismiss"
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_context_menu.png")
8
+
9
+ step "open context menu page" do
10
+ open_page(:main, url: "#{base_url}/#/context-menu")
11
+ page(:main).wait("[data-test='context-menu-trigger']", timeout: 10)
12
+ end
13
+
14
+ step "trigger context menu via dispatched event — menu appears" do
15
+ page(:main).evaluate(<<~JS)
16
+ document.querySelector('[data-test="context-menu-trigger"]').dispatchEvent(
17
+ new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 200, clientY: 300 })
18
+ )
19
+ JS
20
+ page(:main).wait("[data-test='context-menu']", timeout: 5)
21
+ visible = page(:main).evaluate("!!document.querySelector('[data-test=\"context-menu\"]')")
22
+ assert visible, "expected context-menu to appear after right-click event"
23
+ end
24
+
25
+ step "all three menu items are present" do
26
+ has_edit = page(:main).evaluate("!!document.querySelector('[data-test=\"context-menu-edit\"]')")
27
+ has_delete = page(:main).evaluate("!!document.querySelector('[data-test=\"context-menu-delete\"]')")
28
+ has_properties = page(:main).evaluate("!!document.querySelector('[data-test=\"context-menu-properties\"]')")
29
+ assert has_edit, "expected context-menu-edit to be present"
30
+ assert has_delete, "expected context-menu-delete to be present"
31
+ assert has_properties, "expected context-menu-properties to be present"
32
+ page(:main).screenshot(path: screenshot_path)
33
+ end
34
+
35
+ step "click Edit — menu closes" do
36
+ page(:main).click("[data-test='context-menu-edit']")
37
+ sleep 0.2
38
+ gone = page(:main).evaluate("!document.querySelector('[data-test=\"context-menu\"]')")
39
+ assert gone, "expected context-menu to close after clicking Edit"
40
+ end
41
+
42
+ step "re-open and dismiss by clicking outside" do
43
+ page(:main).evaluate(<<~JS)
44
+ document.querySelector('[data-test="context-menu-trigger"]').dispatchEvent(
45
+ new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 200, clientY: 300 })
46
+ )
47
+ JS
48
+ page(:main).wait("[data-test='context-menu']", timeout: 5)
49
+ page(:main).evaluate("document.querySelector('[data-test=\"context-menu-area\"]').click()")
50
+ sleep 0.2
51
+ gone = page(:main).evaluate("!document.querySelector('[data-test=\"context-menu\"]')")
52
+ assert gone, "expected context-menu to close after clicking outside"
53
+ end
54
+ end
@@ -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
@@ -123,6 +123,11 @@ module Browserctl
123
123
  # @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
124
124
  def devtools(name) = call("devtools", name: name)
125
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)
130
+
126
131
  # Stores a value in the daemon-scoped key-value store.
127
132
  # @param key [String] storage key
128
133
  # @param value [Object] value to store (must be JSON-serialisable)
@@ -225,6 +230,43 @@ module Browserctl
225
230
  call("storage_delete", name: name, stores: stores)
226
231
  end
227
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
+
228
270
  # Saves the current browser state (cookies, localStorage, open pages) to a named session.
229
271
  # @param session_name [String] name for the saved session
230
272
  # @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
@@ -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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Dialog
8
+ extend CliOutput
9
+
10
+ USAGE = "Usage: browserctl dialog <accept|dismiss> <page> [text]"
11
+
12
+ def self.run(client, args)
13
+ sub = args.shift or abort USAGE
14
+ case sub
15
+ when "accept" then run_accept(client, args)
16
+ when "dismiss" then run_dismiss(client, args)
17
+ else abort "unknown dialog subcommand '#{sub}'\n#{USAGE}"
18
+ end
19
+ end
20
+
21
+ def self.run_accept(client, args)
22
+ name = args.shift or abort "usage: browserctl dialog accept <page> [text]"
23
+ text = args.shift
24
+ print_result(client.dialog_accept(name, text: text))
25
+ end
26
+
27
+ def self.run_dismiss(client, args)
28
+ name = args.shift or abort "usage: browserctl dialog dismiss <page>"
29
+ print_result(client.dialog_dismiss(name))
30
+ end
31
+ end
32
+ end
33
+ end
@@ -40,7 +40,7 @@ module Browserctl
40
40
 
41
41
  def self.run_focus(client, args)
42
42
  name = args.shift or abort "usage: browserctl page focus <name>"
43
- print_result(client.call("page_focus", name: name))
43
+ print_result(client.page_focus(name))
44
44
  end
45
45
  end
46
46
  end
@@ -22,4 +22,7 @@ module Browserctl
22
22
  class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
23
  class TimeoutError < Error; def self.default_code = "timeout" end
24
24
  class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
+
26
+ class WorkflowError < StandardError; end
27
+ class SecretResolverError < WorkflowError; end
25
28
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Browserctl
6
+ class SecretResolverRegistry
7
+ @mutex = Mutex.new
8
+ @registry = {}
9
+
10
+ def self.register(resolver_class)
11
+ instance = resolver_class.new
12
+ @mutex.synchronize { @registry[resolver_class.scheme] = instance }
13
+ end
14
+
15
+ def self.resolve(secret_ref)
16
+ scheme, reference = secret_ref.split("://", 2)
17
+ resolver = @mutex.synchronize { @registry[scheme] }
18
+ raise SecretResolverError, "unknown secret resolver scheme '#{scheme}'" unless resolver
19
+ unless resolver.available?
20
+ raise SecretResolverError,
21
+ "'#{scheme}://' resolver is not available in this environment"
22
+ end
23
+
24
+ resolver.resolve(reference)
25
+ rescue SecretResolverError
26
+ raise
27
+ rescue StandardError => e
28
+ raise SecretResolverError, "secret resolution failed for #{secret_ref.inspect}: #{e.message}"
29
+ end
30
+
31
+ def self.registered?(scheme)
32
+ @mutex.synchronize { @registry.key?(scheme) }
33
+ end
34
+
35
+ def self.reset!
36
+ @mutex.synchronize { @registry.clear }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module SecretResolvers
5
+ class Base
6
+ def self.scheme
7
+ raise NotImplementedError, "#{name}.scheme not implemented"
8
+ end
9
+
10
+ def available? = true
11
+
12
+ def resolve(_reference)
13
+ raise NotImplementedError, "#{self.class.name}#resolve not implemented"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module SecretResolvers
5
+ class Env < Base
6
+ def self.scheme = "env"
7
+
8
+ def resolve(reference)
9
+ ENV.fetch(reference) { raise SecretResolverError, "env var '#{reference}' is not set" }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Browserctl
6
+ module SecretResolvers
7
+ class MacOSKeychain < Base
8
+ def self.scheme = "keychain"
9
+
10
+ def available?
11
+ RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
12
+ end
13
+
14
+ def resolve(reference)
15
+ service, account = reference.split("/", 2)
16
+ if account.nil?
17
+ raise SecretResolverError,
18
+ "keychain reference must be 'service/account', got: #{reference.inspect}"
19
+ end
20
+
21
+ result, status = Open3.capture2("security", "find-generic-password",
22
+ "-a", account, "-s", service, "-w")
23
+ raise SecretResolverError, "keychain item not found: #{reference}" unless status.success?
24
+
25
+ result.chomp
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Browserctl
6
+ module SecretResolvers
7
+ class OnePassword < Base
8
+ def self.scheme = "op"
9
+
10
+ def available?
11
+ system("which op > /dev/null 2>&1")
12
+ end
13
+
14
+ def resolve(reference)
15
+ result, status = Open3.capture2("op", "read", "op://#{reference}")
16
+ raise SecretResolverError, "1Password item not found: op://#{reference}" unless status.success?
17
+
18
+ result.chomp
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "secret_resolver_registry"
4
+ require_relative "secret_resolvers/base"
5
+ require_relative "secret_resolvers/env"
6
+ require_relative "secret_resolvers/macos_keychain"
7
+ require_relative "secret_resolvers/one_password"
8
+
9
+ Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::Env)
10
+ Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::MacOSKeychain)
11
+ Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::OnePassword)
12
+
13
+ user_resolvers = File.expand_path("~/.browserctl/resolvers.rb")
14
+ load user_resolvers if File.exist?(user_resolvers)
@@ -11,6 +11,7 @@ require_relative "handlers/devtools"
11
11
  require_relative "handlers/daemon_control"
12
12
  require_relative "handlers/storage"
13
13
  require_relative "handlers/session"
14
+ require_relative "handlers/interaction"
14
15
  require_relative "../detectors"
15
16
  require_relative "../policy"
16
17
 
@@ -25,6 +26,7 @@ module Browserctl
25
26
  include Handlers::DaemonControl
26
27
  include Handlers::Storage
27
28
  include Handlers::Session
29
+ include Handlers::Interaction
28
30
 
29
31
  COMMAND_MAP = {
30
32
  "page_open" => :cmd_page_open,
@@ -55,6 +57,12 @@ module Browserctl
55
57
  "storage_export" => :cmd_storage_export,
56
58
  "storage_import" => :cmd_storage_import,
57
59
  "storage_delete" => :cmd_storage_delete,
60
+ "press" => :cmd_press,
61
+ "hover" => :cmd_hover,
62
+ "upload" => :cmd_upload,
63
+ "select" => :cmd_select,
64
+ "dialog_accept" => :cmd_dialog_accept,
65
+ "dialog_dismiss" => :cmd_dialog_dismiss,
58
66
  "session_save" => :cmd_session_save,
59
67
  "session_load" => :cmd_session_load,
60
68
  "session_list" => :cmd_session_list,