browserctl 0.6.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -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/server/command_dispatcher.rb +8 -0
  29. data/lib/browserctl/server/handlers/interaction.rb +87 -0
  30. data/lib/browserctl/server/handlers/page_lifecycle.rb +4 -0
  31. data/lib/browserctl/version.rb +1 -1
  32. data/lib/browserctl/workflow.rb +12 -0
  33. metadata +24 -7
  34. 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
@@ -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,
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module Interaction
7
+ private
8
+
9
+ def cmd_press(req)
10
+ with_page(req[:name]) do |session|
11
+ session.page.keyboard.down(req[:key])
12
+ session.page.keyboard.up(req[:key])
13
+ { ok: true }
14
+ end
15
+ end
16
+
17
+ def cmd_hover(req)
18
+ with_page(req[:name]) do |session|
19
+ coords = session.page.evaluate(
20
+ "(function(sel) { " \
21
+ "var el = document.querySelector(sel); " \
22
+ "if (!el) return null; " \
23
+ "var r = el.getBoundingClientRect(); " \
24
+ "return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
25
+ "})(#{req[:selector].to_json})"
26
+ )
27
+ return { error: "selector not found: #{req[:selector]}" } unless coords
28
+
29
+ session.page.mouse.move(x: coords["x"], y: coords["y"])
30
+ { ok: true }
31
+ end
32
+ end
33
+
34
+ def cmd_upload(req)
35
+ path = File.expand_path(req[:path])
36
+ return { error: "file not found: #{path}" } unless File.exist?(path)
37
+
38
+ with_page(req[:name]) do |session|
39
+ el = session.page.at_css(req[:selector])
40
+ return { error: "selector not found: #{req[:selector]}" } unless el
41
+
42
+ el.select_file(path)
43
+ { ok: true }
44
+ end
45
+ end
46
+
47
+ def cmd_select(req)
48
+ with_page(req[:name]) do |session|
49
+ el = session.page.at_css(req[:selector])
50
+ return { error: "selector not found: #{req[:selector]}" } unless el
51
+
52
+ el.evaluate(
53
+ "this.value = #{req[:value].to_json}; " \
54
+ "this.dispatchEvent(new Event('change', {bubbles: true}))"
55
+ )
56
+ { ok: true }
57
+ end
58
+ end
59
+
60
+ def cmd_dialog_accept(req)
61
+ session = @global_mutex.synchronize { @pages[req[:name]] }
62
+ return { error: "no page named '#{req[:name]}'" } unless session
63
+
64
+ text = req[:text]
65
+ id = nil
66
+ id = session.page.on(:dialog) do |dialog|
67
+ session.page.off(:dialog, id)
68
+ dialog.accept(text)
69
+ end
70
+ { ok: true }
71
+ end
72
+
73
+ def cmd_dialog_dismiss(req)
74
+ session = @global_mutex.synchronize { @pages[req[:name]] }
75
+ return { error: "no page named '#{req[:name]}'" } unless session
76
+
77
+ id = nil
78
+ id = session.page.on(:dialog) do |dialog|
79
+ session.page.off(:dialog, id)
80
+ dialog.dismiss
81
+ end
82
+ { ok: true }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -25,6 +25,10 @@ module Browserctl
25
25
  end
26
26
 
27
27
  def cmd_page_focus(req)
28
+ unless @browser.options.headless == false
29
+ return { error: "page focus requires headed mode — start browserd with --headed" }
30
+ end
31
+
28
32
  with_page(req[:name]) do |session|
29
33
  session.page.activate
30
34
  { ok: true }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -78,6 +78,11 @@ module Browserctl
78
78
  @client.session_list[:sessions]
79
79
  end
80
80
 
81
+ def ask(prompt)
82
+ $stderr.print("[browserctl] #{prompt} ")
83
+ $stdin.gets.chomp
84
+ end
85
+
81
86
  def invoke(workflow_name, **override_params)
82
87
  name = workflow_name.to_s
83
88
  guard_circular!(name)
@@ -137,6 +142,13 @@ module Browserctl
137
142
  unwrap @client.storage_set(@name, key, value, store: store)
138
143
  end
139
144
 
145
+ def press(key) = unwrap @client.press(@name, key)
146
+ def hover(selector) = unwrap @client.hover(@name, selector)
147
+ def upload(selector, path) = unwrap @client.upload(@name, selector, path)
148
+ def select(selector, value) = unwrap @client.select(@name, selector, value)
149
+ def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
150
+ def dialog_dismiss = unwrap @client.dialog_dismiss(@name)
151
+
140
152
  private
141
153
 
142
154
  def unwrap(res)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -141,12 +141,26 @@ files:
141
141
  - examples/cloudflare_hitl.rb
142
142
  - examples/smoke/params_file.rb
143
143
  - examples/smoke/store_fetch.rb
144
- - examples/test_automation_practices/checkboxes.rb
145
- - examples/test_automation_practices/dynamic_elements.rb
146
- - examples/test_automation_practices/key_press.rb
147
- - examples/test_automation_practices/login.rb
148
- - examples/test_automation_practices/login_negative.rb
149
- - examples/test_automation_practices/notifications.rb
144
+ - examples/test_automation_practices/advanced/ab_testing.rb
145
+ - examples/test_automation_practices/advanced/broken_images.rb
146
+ - examples/test_automation_practices/advanced/file_download.rb
147
+ - examples/test_automation_practices/advanced/iframes.rb
148
+ - examples/test_automation_practices/advanced/shadow_dom.rb
149
+ - examples/test_automation_practices/auth/login.rb
150
+ - examples/test_automation_practices/auth/login_negative.rb
151
+ - examples/test_automation_practices/dialogs/alerts.rb
152
+ - examples/test_automation_practices/dialogs/notifications.rb
153
+ - examples/test_automation_practices/dynamic/dynamic_elements.rb
154
+ - examples/test_automation_practices/dynamic/tables.rb
155
+ - examples/test_automation_practices/forms/checkboxes.rb
156
+ - examples/test_automation_practices/forms/file_upload.rb
157
+ - examples/test_automation_practices/forms/forms.rb
158
+ - examples/test_automation_practices/forms/slider.rb
159
+ - examples/test_automation_practices/interactions/context_menu.rb
160
+ - examples/test_automation_practices/interactions/drag_drop.rb
161
+ - examples/test_automation_practices/interactions/exit_intent.rb
162
+ - examples/test_automation_practices/interactions/hover.rb
163
+ - examples/test_automation_practices/interactions/key_press.rb
150
164
  - examples/the_internet/add_remove_elements.rb
151
165
  - examples/the_internet/checkboxes.rb
152
166
  - examples/the_internet/dropdown.rb
@@ -154,10 +168,12 @@ files:
154
168
  - examples/the_internet/login.rb
155
169
  - lib/browserctl.rb
156
170
  - lib/browserctl/client.rb
171
+ - lib/browserctl/commands/ask.rb
157
172
  - lib/browserctl/commands/cli_output.rb
158
173
  - lib/browserctl/commands/click.rb
159
174
  - lib/browserctl/commands/cookie.rb
160
175
  - lib/browserctl/commands/daemon.rb
176
+ - lib/browserctl/commands/dialog.rb
161
177
  - lib/browserctl/commands/fill.rb
162
178
  - lib/browserctl/commands/init.rb
163
179
  - lib/browserctl/commands/page.rb
@@ -181,6 +197,7 @@ files:
181
197
  - lib/browserctl/server/handlers/daemon_control.rb
182
198
  - lib/browserctl/server/handlers/devtools.rb
183
199
  - lib/browserctl/server/handlers/hitl.rb
200
+ - lib/browserctl/server/handlers/interaction.rb
184
201
  - lib/browserctl/server/handlers/navigation.rb
185
202
  - lib/browserctl/server/handlers/observation.rb
186
203
  - lib/browserctl/server/handlers/page_lifecycle.rb