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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/bin/browserctl +29 -0
- data/examples/test_automation_practices/advanced/ab_testing.rb +38 -0
- data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
- data/examples/test_automation_practices/advanced/file_download.rb +40 -0
- data/examples/test_automation_practices/advanced/iframes.rb +37 -0
- data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
- data/examples/test_automation_practices/{login.rb → auth/login.rb} +4 -4
- data/examples/test_automation_practices/{login_negative.rb → auth/login_negative.rb} +4 -4
- data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
- data/examples/test_automation_practices/{notifications.rb → dialogs/notifications.rb} +7 -7
- data/examples/test_automation_practices/{dynamic_elements.rb → dynamic/dynamic_elements.rb} +9 -8
- data/examples/test_automation_practices/dynamic/tables.rb +47 -0
- data/examples/test_automation_practices/{checkboxes.rb → forms/checkboxes.rb} +6 -6
- data/examples/test_automation_practices/forms/file_upload.rb +30 -0
- data/examples/test_automation_practices/forms/forms.rb +47 -0
- data/examples/test_automation_practices/forms/slider.rb +51 -0
- data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
- data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
- data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
- data/examples/test_automation_practices/interactions/hover.rb +30 -0
- data/examples/test_automation_practices/interactions/key_press.rb +38 -0
- data/lib/browserctl/client.rb +42 -0
- data/lib/browserctl/commands/ask.rb +20 -0
- data/lib/browserctl/commands/dialog.rb +33 -0
- data/lib/browserctl/commands/page.rb +1 -1
- data/lib/browserctl/server/command_dispatcher.rb +8 -0
- data/lib/browserctl/server/handlers/interaction.rb +87 -0
- data/lib/browserctl/server/handlers/page_lifecycle.rb +4 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +12 -0
- metadata +24 -7
- 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
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -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
|
|
@@ -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 }
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -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.
|
|
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/
|
|
145
|
-
- examples/test_automation_practices/
|
|
146
|
-
- examples/test_automation_practices/
|
|
147
|
-
- examples/test_automation_practices/
|
|
148
|
-
- examples/test_automation_practices/
|
|
149
|
-
- examples/test_automation_practices/
|
|
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
|