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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -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/errors.rb +3 -0
- data/lib/browserctl/secret_resolver_registry.rb +39 -0
- data/lib/browserctl/secret_resolvers/base.rb +17 -0
- data/lib/browserctl/secret_resolvers/env.rb +13 -0
- data/lib/browserctl/secret_resolvers/macos_keychain.rb +29 -0
- data/lib/browserctl/secret_resolvers/one_password.rb +22 -0
- data/lib/browserctl/secret_resolvers.rb +14 -0
- 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 +36 -9
- data/lib/browserctl.rb +1 -0
- metadata +31 -10
- data/examples/smoke/params_file.rb +0 -36
- data/examples/smoke/store_fetch.rb +0 -39
- 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
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -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,
|