browserctl 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +97 -55
- data/bin/browserctl +117 -108
- data/bin/browserd +9 -3
- data/bin/setup +7 -3
- data/examples/cloudflare_hitl.rb +6 -6
- data/examples/smoke/params_file.rb +3 -2
- data/examples/smoke/store_fetch.rb +5 -5
- data/examples/test_automation_practices/checkboxes.rb +39 -0
- data/examples/test_automation_practices/dynamic_elements.rb +40 -0
- data/examples/test_automation_practices/key_press.rb +41 -0
- data/examples/test_automation_practices/login.rb +34 -0
- data/examples/test_automation_practices/login_negative.rb +28 -0
- data/examples/test_automation_practices/notifications.rb +57 -0
- data/examples/the_internet/add_remove_elements.rb +1 -1
- data/examples/the_internet/checkboxes.rb +1 -1
- data/examples/the_internet/dropdown.rb +1 -1
- data/examples/the_internet/dynamic_loading.rb +2 -2
- data/examples/the_internet/login.rb +1 -1
- data/lib/browserctl/client.rb +112 -28
- data/lib/browserctl/commands/cookie.rb +59 -0
- data/lib/browserctl/commands/daemon.rb +77 -0
- data/lib/browserctl/commands/page.rb +47 -0
- data/lib/browserctl/commands/record.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/session.rb +69 -0
- data/lib/browserctl/commands/snapshot.rb +5 -5
- data/lib/browserctl/commands/storage.rb +67 -0
- data/lib/browserctl/commands/workflow.rb +64 -0
- data/lib/browserctl/constants.rb +20 -1
- data/lib/browserctl/detectors.rb +23 -0
- data/lib/browserctl/errors.rb +25 -0
- data/lib/browserctl/logger.rb +4 -4
- data/lib/browserctl/policy.rb +36 -0
- data/lib/browserctl/recording.rb +4 -4
- data/lib/browserctl/runner.rb +4 -4
- data/lib/browserctl/server/command_dispatcher.rb +49 -258
- data/lib/browserctl/server/handlers/cookies.rb +57 -0
- data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
- data/lib/browserctl/server/handlers/devtools.rb +22 -0
- data/lib/browserctl/server/handlers/hitl.rb +31 -0
- data/lib/browserctl/server/handlers/navigation.rb +94 -0
- data/lib/browserctl/server/handlers/observation.rb +87 -0
- data/lib/browserctl/server/handlers/page_lifecycle.rb +36 -0
- data/lib/browserctl/server/handlers/session.rb +93 -0
- data/lib/browserctl/server/handlers/storage.rb +109 -0
- data/lib/browserctl/server.rb +4 -3
- data/lib/browserctl/session.rb +79 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +58 -17
- data/lib/browserctl.rb +12 -2
- metadata +43 -11
- data/lib/browserctl/commands/export_cookies.rb +0 -18
- data/lib/browserctl/commands/import_cookies.rb +0 -23
- data/lib/browserctl/commands/inspect.rb +0 -21
- data/lib/browserctl/commands/open_page.rb +0 -21
- data/lib/browserctl/commands/pause.rb +0 -22
- data/lib/browserctl/commands/status.rb +0 -30
- data/lib/browserctl/commands/watch.rb +0 -27
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# Smoke test for --params file loading (Task 7.5).
|
|
5
5
|
#
|
|
6
6
|
# Run with:
|
|
7
|
-
# browserctl run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
|
|
7
|
+
# browserctl workflow run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
|
|
8
8
|
#
|
|
9
9
|
# The workflow logs in using credentials from the params file and asserts
|
|
10
10
|
# the secure area is reached — proving the params were loaded and available.
|
|
@@ -17,7 +17,7 @@ Browserctl.workflow "smoke/params_file" do
|
|
|
17
17
|
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
18
18
|
|
|
19
19
|
step "open login page" do
|
|
20
|
-
|
|
20
|
+
open_page(:main, url: "#{base_url}/login")
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
step "fill credentials from params file" do
|
|
@@ -29,6 +29,7 @@ Browserctl.workflow "smoke/params_file" do
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
step "assert login succeeded" do
|
|
32
|
+
page(:main).wait(".flash.success", timeout: 10)
|
|
32
33
|
assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
|
|
33
34
|
puts " [ok] reached secure area — params file loaded correctly"
|
|
34
35
|
end
|
|
@@ -12,12 +12,12 @@ Browserctl.workflow "smoke/store_fetch" do
|
|
|
12
12
|
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
13
13
|
|
|
14
14
|
step "open dynamic loading page" do
|
|
15
|
-
|
|
15
|
+
open_page(:main, url: "#{base_url}/dynamic_loading/1")
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
step "click start and capture loaded text" do
|
|
19
19
|
page(:main).click("div#start button")
|
|
20
|
-
page(:main).
|
|
20
|
+
page(:main).wait("div#finish", timeout: 10)
|
|
21
21
|
text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
|
|
22
22
|
assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
|
|
23
23
|
store(:loaded_text, text)
|
|
@@ -32,8 +32,8 @@ Browserctl.workflow "smoke/store_fetch" do
|
|
|
32
32
|
|
|
33
33
|
step "confirm fetch raises for unknown key" do
|
|
34
34
|
fetch(:nonexistent_key)
|
|
35
|
-
assert false, "expected
|
|
36
|
-
rescue
|
|
37
|
-
puts " [ok]
|
|
35
|
+
assert false, "expected WorkflowError was not raised"
|
|
36
|
+
rescue Browserctl::WorkflowError => e
|
|
37
|
+
puts " [ok] WorkflowError raised as expected: #{e.message}"
|
|
38
38
|
end
|
|
39
39
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/checkboxes" do
|
|
4
|
+
desc "Checkboxes: toggle individual, check all, uncheck all — assert state each time"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/test_automation_practices_checkboxes.png")
|
|
8
|
+
|
|
9
|
+
step "open checkboxes page" do
|
|
10
|
+
open_page(:main, url: "#{base_url}/#/checkboxes")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
step "read initial state — checkbox 2 pre-checked" do
|
|
14
|
+
js = "[1,2,3].map(i => document.querySelector(`[data-test=\"checkbox-checkbox${i}\"]`)?.checked)"
|
|
15
|
+
states = client.evaluate("main", js)[:result]
|
|
16
|
+
assert states == [false, true, false], "expected initial state [false, true, false], got: #{states.inspect}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
step "toggle checkbox 1 on" do
|
|
20
|
+
page(:main).click("[data-test='checkbox-checkbox1']")
|
|
21
|
+
checked = client.evaluate("main", "document.querySelector('[data-test=\"checkbox-checkbox1\"]').checked")[:result]
|
|
22
|
+
assert checked, "expected checkbox 1 to be checked after click"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
step "check all and verify" do
|
|
26
|
+
page(:main).click("[data-test='check-all-button']")
|
|
27
|
+
js = "[1,2,3].map(i => document.querySelector(`[data-test=\"checkbox-checkbox${i}\"]`)?.checked)"
|
|
28
|
+
states = client.evaluate("main", js)[:result]
|
|
29
|
+
assert states.all?, "expected all checkboxes checked, got: #{states.inspect}"
|
|
30
|
+
page(:main).screenshot(path: screenshot_path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
step "uncheck all and verify" do
|
|
34
|
+
page(:main).click("[data-test='uncheck-all-button']")
|
|
35
|
+
js = "[1,2,3].map(i => document.querySelector(`[data-test=\"checkbox-checkbox${i}\"]`)?.checked)"
|
|
36
|
+
states = client.evaluate("main", js)[:result]
|
|
37
|
+
assert states.none?, "expected all checkboxes unchecked, got: #{states.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/dynamic_elements" do
|
|
4
|
+
desc "Dynamic elements: trigger reload, wait for items, toggle hidden content"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path,
|
|
8
|
+
default: File.expand_path(".browserctl/screenshots/test_automation_practices_dynamic_elements.png")
|
|
9
|
+
|
|
10
|
+
step "open dynamic elements page" do
|
|
11
|
+
open_page(:main, url: "#{base_url}/#/dynamic-elements")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
step "assert no dynamic items exist before reload" do
|
|
15
|
+
absent = client.evaluate("main", "!document.querySelector('[data-test=\"dynamic-item-0\"]')")[:result]
|
|
16
|
+
assert absent, "expected no dynamic items before triggering reload"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
step "click reload and wait for items" do
|
|
20
|
+
page(:main).click("[data-test='reload-button']")
|
|
21
|
+
# loading indicator appears first; wait for it to clear and items to render
|
|
22
|
+
page(:main).wait("[data-test='dynamic-item-0']", timeout: 15)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
step "verify all three dynamic items are present" do
|
|
26
|
+
count = client.evaluate(
|
|
27
|
+
"main",
|
|
28
|
+
"[0,1,2].filter(i => !!document.querySelector(`[data-test=\"dynamic-item-${i}\"]`)).length"
|
|
29
|
+
)[:result]
|
|
30
|
+
assert count == 3, "expected 3 dynamic items, got: #{count}"
|
|
31
|
+
page(:main).screenshot(path: screenshot_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
step "toggle hidden content on" do
|
|
35
|
+
page(:main).click("[data-test='toggle-hidden-button']")
|
|
36
|
+
page(:main).wait("[data-test='hidden-content']", timeout: 5)
|
|
37
|
+
visible = client.evaluate("main", "!!document.querySelector('[data-test=\"hidden-content\"]')")[:result]
|
|
38
|
+
assert visible, "expected hidden content to appear after toggle"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/key_press" do
|
|
4
|
+
desc "Key press page: dispatch keyboard events, verify last-key display and history list update"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/test_automation_practices_key_press.png")
|
|
8
|
+
|
|
9
|
+
step "open key press page" do
|
|
10
|
+
open_page(:main, url: "#{base_url}/#/key-press")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
step "dispatch key events and verify last-key display" do
|
|
14
|
+
keys = %w[A B C D E]
|
|
15
|
+
keys.each do |key|
|
|
16
|
+
client.evaluate(
|
|
17
|
+
"main",
|
|
18
|
+
"document.dispatchEvent(new KeyboardEvent('keydown', { key: '#{key}', bubbles: true }))"
|
|
19
|
+
)
|
|
20
|
+
sleep 0.1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
last = client.evaluate("main",
|
|
24
|
+
"document.querySelector('[data-test=\"last-key-pressed\"]')?.innerText?.trim()")[:result]
|
|
25
|
+
assert last == keys.last, "expected last key '#{keys.last}', got: #{last.inspect}"
|
|
26
|
+
store(:keys, keys)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
step "verify key history contains all dispatched keys" do
|
|
30
|
+
keys = fetch(:keys)
|
|
31
|
+
history = client.evaluate(
|
|
32
|
+
"main",
|
|
33
|
+
"Array.from(document.querySelectorAll('[data-test^=\"key-\"]')).map(el => el.innerText?.trim())"
|
|
34
|
+
)[:result]
|
|
35
|
+
# History shows most recent first; each entry typically contains the key label
|
|
36
|
+
keys.each do |key|
|
|
37
|
+
assert history.any? { |entry| entry&.include?(key) }, "expected '#{key}' in history, got: #{history.inspect}"
|
|
38
|
+
end
|
|
39
|
+
page(:main).screenshot(path: screenshot_path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/login" do
|
|
4
|
+
desc "Auth page: fill credentials, verify success message, logout"
|
|
5
|
+
|
|
6
|
+
param :username, default: "admin"
|
|
7
|
+
param :password, default: "admin", secret: true
|
|
8
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
9
|
+
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/test_automation_practices_login.png")
|
|
10
|
+
|
|
11
|
+
step "open auth page" do
|
|
12
|
+
open_page(:main, url: "#{base_url}/#/auth")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
step "fill and submit credentials" do
|
|
16
|
+
page(:main).fill("[data-test='username-input']", username)
|
|
17
|
+
page(:main).fill("[data-test='password-input']", password)
|
|
18
|
+
page(:main).click("[data-test='login-button']")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
step "verify successful login" do
|
|
22
|
+
page(:main).wait("[data-test='auth-success']", timeout: 10)
|
|
23
|
+
msg = client.evaluate("main", "document.querySelector('[data-test=\"auth-success\"]')?.innerText?.trim()")[:result]
|
|
24
|
+
assert msg&.length&.positive?, "expected a success message, got: #{msg.inspect}"
|
|
25
|
+
page(:main).screenshot(path: screenshot_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
step "logout and verify form reappears" do
|
|
29
|
+
page(:main).click("[data-test='logout-button']")
|
|
30
|
+
page(:main).wait("[data-test='login-button']", timeout: 5)
|
|
31
|
+
visible = client.evaluate("main", "!!document.querySelector('[data-test=\"login-button\"]')")[:result]
|
|
32
|
+
assert visible, "expected login form to reappear after logout"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Browserctl.workflow "test_automation_practices/login_negative" do
|
|
4
|
+
desc "Auth page: invalid credentials show an error message"
|
|
5
|
+
|
|
6
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
7
|
+
param :screenshot_path,
|
|
8
|
+
default: File.expand_path(".browserctl/screenshots/test_automation_practices_login_negative.png")
|
|
9
|
+
|
|
10
|
+
step "open auth page" do
|
|
11
|
+
open_page(:main, url: "#{base_url}/#/auth")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
step "submit wrong credentials" do
|
|
15
|
+
page(:main).fill("[data-test='username-input']", "wronguser")
|
|
16
|
+
page(:main).fill("[data-test='password-input']", "wrongpass")
|
|
17
|
+
page(:main).click("[data-test='login-button']")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
step "verify error is shown and success is absent" do
|
|
21
|
+
page(:main).wait("[data-test='auth-error']", timeout: 10)
|
|
22
|
+
error = client.evaluate("main", "document.querySelector('[data-test=\"auth-error\"]')?.innerText?.trim()")[:result]
|
|
23
|
+
assert error&.length&.positive?, "expected an error message, got: #{error.inspect}"
|
|
24
|
+
no_success = client.evaluate("main", "!document.querySelector('[data-test=\"auth-success\"]')")[:result]
|
|
25
|
+
assert no_success, "expected no success element when credentials are invalid"
|
|
26
|
+
page(:main).screenshot(path: screenshot_path)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Individual notification items have data-test="notification-{id}".
|
|
4
|
+
# The wrapper has data-test="notification-container" — excluded from counts below.
|
|
5
|
+
NOTIFICATION_ITEMS_JS = <<~JS
|
|
6
|
+
document.querySelectorAll('[data-test^="notification-"]:not([data-test="notification-container"])').length
|
|
7
|
+
JS
|
|
8
|
+
|
|
9
|
+
Browserctl.workflow "test_automation_practices/notifications" do
|
|
10
|
+
desc "Notifications: trigger success, error, and info toasts — verify count increases on each trigger"
|
|
11
|
+
|
|
12
|
+
param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
|
|
13
|
+
param :screenshot_path,
|
|
14
|
+
default: File.expand_path(".browserctl/screenshots/test_automation_practices_notifications.png")
|
|
15
|
+
|
|
16
|
+
step "open notifications page and record baseline" do
|
|
17
|
+
open_page(:main, url: "#{base_url}/#/notifications")
|
|
18
|
+
page(:main).wait("[data-test='notification-container']", timeout: 5)
|
|
19
|
+
# Let any delayed on-load notifications finish appearing before we snapshot the baseline
|
|
20
|
+
sleep 1
|
|
21
|
+
store(:baseline, client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
step "trigger success notification and verify count increased by 1" do
|
|
25
|
+
base = fetch(:baseline)
|
|
26
|
+
page(:main).click("[data-test='add-success']")
|
|
27
|
+
page(:main).wait("[data-test^='notification-']:not([data-test='notification-container'])", timeout: 5)
|
|
28
|
+
count = client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result]
|
|
29
|
+
assert count == base + 1, "expected #{base + 1} notifications after success, got: #{count}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
step "dismiss one notification and verify count returns to baseline" do
|
|
33
|
+
base = fetch(:baseline)
|
|
34
|
+
client.evaluate("main", "document.querySelector('[data-test^=\"close-notification-\"]')?.click()")
|
|
35
|
+
deadline = Time.now + 5
|
|
36
|
+
remaining = nil
|
|
37
|
+
loop do
|
|
38
|
+
remaining = client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result]
|
|
39
|
+
break if remaining <= base || Time.now > deadline
|
|
40
|
+
|
|
41
|
+
sleep 0.2
|
|
42
|
+
end
|
|
43
|
+
assert remaining <= base, "expected count back to #{base} after dismiss, got: #{remaining}"
|
|
44
|
+
# Re-snapshot baseline in case on-load notifications auto-dismissed during this step
|
|
45
|
+
store(:baseline, remaining)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
step "trigger error and info notifications and verify count increased by 2" do
|
|
49
|
+
base = fetch(:baseline)
|
|
50
|
+
page(:main).click("[data-test='add-error']")
|
|
51
|
+
page(:main).click("[data-test='add-info']")
|
|
52
|
+
page(:main).wait("[data-test^='notification-']:not([data-test='notification-container'])", timeout: 5)
|
|
53
|
+
count = client.evaluate("main", NOTIFICATION_ITEMS_JS)[:result]
|
|
54
|
+
assert count == base + 2, "expected #{base + 2} notifications (error + info), got: #{count}"
|
|
55
|
+
page(:main).screenshot(path: screenshot_path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/add_remove_elements" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_add_remove_elements.png")
|
|
8
8
|
|
|
9
9
|
step "open add/remove elements page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/add_remove_elements/")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "add three elements" do
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/checkboxes" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_checkboxes.png")
|
|
8
8
|
|
|
9
9
|
step "open checkboxes page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/checkboxes")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "read initial state" do
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/dropdown" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dropdown.png")
|
|
8
8
|
|
|
9
9
|
step "open dropdown page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/dropdown")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "assert default is unselected" do
|
|
@@ -7,7 +7,7 @@ Browserctl.workflow "the_internet/dynamic_loading" do
|
|
|
7
7
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dynamic_loading.png")
|
|
8
8
|
|
|
9
9
|
step "open dynamic loading page" do
|
|
10
|
-
|
|
10
|
+
open_page(:main, url: "#{base_url}/dynamic_loading/1")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
step "assert finish text is hidden before start" do
|
|
@@ -17,7 +17,7 @@ Browserctl.workflow "the_internet/dynamic_loading" do
|
|
|
17
17
|
|
|
18
18
|
step "click Start and wait for content" do
|
|
19
19
|
page(:main).click("#start button")
|
|
20
|
-
page(:main).
|
|
20
|
+
page(:main).wait("#finish h4", timeout: 10)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
step "assert finish text is correct" do
|
|
@@ -9,7 +9,7 @@ Browserctl.workflow "the_internet/login" do
|
|
|
9
9
|
param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_login.png")
|
|
10
10
|
|
|
11
11
|
step "open login page" do
|
|
12
|
-
|
|
12
|
+
open_page(:main, url: "#{base_url}/login")
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
step "fill and submit credentials" do
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -9,8 +9,8 @@ require_relative "recording"
|
|
|
9
9
|
module Browserctl
|
|
10
10
|
# Thin IPC client that wraps each browserd command as a Ruby method call.
|
|
11
11
|
class Client
|
|
12
|
-
def initialize(socket_path =
|
|
13
|
-
@socket_path = socket_path
|
|
12
|
+
def initialize(socket_path = nil)
|
|
13
|
+
@socket_path = socket_path || auto_discover_socket
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def call(cmd, **params)
|
|
@@ -25,22 +25,22 @@ module Browserctl
|
|
|
25
25
|
# @param name [String] logical page name
|
|
26
26
|
# @param url [String, nil] optional URL to navigate to after opening
|
|
27
27
|
# @return [Hash] `{ ok: true, name: }` or `{ error: }`
|
|
28
|
-
def
|
|
28
|
+
def page_open(name, url: nil) = call("page_open", name: name, url: url)
|
|
29
29
|
|
|
30
30
|
# Closes a named page and removes it from the session.
|
|
31
31
|
# @param name [String] logical page name
|
|
32
32
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
33
|
-
def
|
|
33
|
+
def page_close(name) = call("page_close", name: name)
|
|
34
34
|
|
|
35
35
|
# Lists all open page names.
|
|
36
36
|
# @return [Hash] `{ pages: [String] }`
|
|
37
|
-
def
|
|
37
|
+
def page_list = call("page_list")
|
|
38
38
|
|
|
39
39
|
# Navigates a page to a URL. Returns `challenge: true` when Cloudflare is detected.
|
|
40
40
|
# @param name [String] logical page name
|
|
41
41
|
# @param url [String] destination URL
|
|
42
42
|
# @return [Hash] `{ ok: true, url:, challenge: }` or `{ error: }`
|
|
43
|
-
def
|
|
43
|
+
def navigate(name, url) = call("navigate", name: name, url: url)
|
|
44
44
|
|
|
45
45
|
# Clicks an element identified by CSS selector or snapshot ref.
|
|
46
46
|
# @param name [String] logical page name
|
|
@@ -74,39 +74,30 @@ module Browserctl
|
|
|
74
74
|
|
|
75
75
|
# Takes a DOM snapshot. Returns `challenge: true` when Cloudflare is detected.
|
|
76
76
|
# @param name [String] logical page name
|
|
77
|
-
# @param format [String] "
|
|
77
|
+
# @param format [String] "elements" (interactable elements JSON) or "html" (raw HTML)
|
|
78
78
|
# @param diff [Boolean] return only elements changed since last snapshot
|
|
79
79
|
# @return [Hash] `{ ok: true, snapshot:, challenge: }` or `{ ok: true, html:, challenge: }` or `{ error: }`
|
|
80
|
-
def snapshot(name, format: "
|
|
80
|
+
def snapshot(name, format: "elements", diff: false)
|
|
81
81
|
call("snapshot", name: name, format: format, diff: diff)
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Waits for a CSS selector to appear
|
|
84
|
+
# Waits for a CSS selector to appear within the given timeout.
|
|
85
85
|
# @param name [String] logical page name
|
|
86
86
|
# @param selector [String] CSS selector to wait for
|
|
87
|
-
# @param timeout [Numeric] seconds before giving up (default: 10)
|
|
88
|
-
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
89
|
-
def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
|
|
90
|
-
|
|
91
|
-
# Polls for a CSS selector with a longer timeout (suitable for async operations).
|
|
92
|
-
# @param name [String] logical page name
|
|
93
|
-
# @param selector [String] CSS selector to poll for
|
|
94
87
|
# @param timeout [Numeric] seconds before giving up (default: 30)
|
|
95
88
|
# @return [Hash] `{ ok: true, selector: }` or `{ error: }`
|
|
96
|
-
def
|
|
97
|
-
call("watch", name: name, selector: selector, timeout: timeout)
|
|
98
|
-
end
|
|
89
|
+
def wait(name, selector, timeout: 30) = call("wait", name: name, selector: selector, timeout: timeout)
|
|
99
90
|
|
|
100
91
|
# Returns the current URL of a named page.
|
|
101
92
|
# @param name [String] logical page name
|
|
102
93
|
# @return [Hash] `{ ok: true, url: }` or `{ error: }`
|
|
103
|
-
def url(name) = call("url",
|
|
94
|
+
def url(name) = call("url", name: name)
|
|
104
95
|
|
|
105
96
|
# Evaluates a JavaScript expression and returns the result.
|
|
106
97
|
# @param name [String] logical page name
|
|
107
98
|
# @param expression [String] JavaScript expression
|
|
108
99
|
# @return [Hash] `{ ok: true, result: }` or `{ error: }`
|
|
109
|
-
def evaluate(name, expression) = call("evaluate",
|
|
100
|
+
def evaluate(name, expression) = call("evaluate", name: name, expression: expression)
|
|
110
101
|
|
|
111
102
|
# Checks if browserd is alive.
|
|
112
103
|
# @return [Hash] `{ ok: true, pid: }` or raises if daemon is not running
|
|
@@ -118,23 +109,35 @@ module Browserctl
|
|
|
118
109
|
|
|
119
110
|
# Pauses automation on a page so a human can interact directly.
|
|
120
111
|
# @param name [String] logical page name
|
|
121
|
-
# @
|
|
122
|
-
|
|
112
|
+
# @param message [String, nil] optional message displayed to the human
|
|
113
|
+
# @return [Hash] `{ ok: true, paused: true, message: }` or `{ error: }`
|
|
114
|
+
def pause(name, message: nil) = call("pause", name: name, message: message)
|
|
123
115
|
|
|
124
116
|
# Resumes automation on a paused page.
|
|
125
117
|
# @param name [String] logical page name
|
|
126
118
|
# @return [Hash] `{ ok: true, paused: false }` or `{ error: }`
|
|
127
|
-
def resume(name) = call("resume",
|
|
119
|
+
def resume(name) = call("resume", name: name)
|
|
128
120
|
|
|
129
121
|
# Returns the Chrome DevTools URL for a named page.
|
|
130
122
|
# @param name [String] logical page name
|
|
131
123
|
# @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
|
|
132
|
-
def
|
|
124
|
+
def devtools(name) = call("devtools", name: name)
|
|
125
|
+
|
|
126
|
+
# Stores a value in the daemon-scoped key-value store.
|
|
127
|
+
# @param key [String] storage key
|
|
128
|
+
# @param value [Object] value to store (must be JSON-serialisable)
|
|
129
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
130
|
+
def store(key, value) = call("store", key: key, value: value)
|
|
131
|
+
|
|
132
|
+
# Retrieves a value from the daemon-scoped key-value store.
|
|
133
|
+
# @param key [String] storage key
|
|
134
|
+
# @return [Hash] `{ ok: true, value: }` or `{ error:, code: "key_not_found" }`
|
|
135
|
+
def fetch(key) = call("fetch", key: key)
|
|
133
136
|
|
|
134
137
|
# Returns all cookies for a named page.
|
|
135
138
|
# @param name [String] logical page name
|
|
136
139
|
# @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
|
|
137
|
-
def cookies(name)
|
|
140
|
+
def cookies(name) = call("cookies", name: name)
|
|
138
141
|
|
|
139
142
|
# Sets a cookie on a named page.
|
|
140
143
|
# @param name [String] logical page name
|
|
@@ -148,12 +151,13 @@ module Browserctl
|
|
|
148
151
|
value: value, domain: domain, path: path)
|
|
149
152
|
end
|
|
150
153
|
|
|
151
|
-
#
|
|
154
|
+
# Deletes all cookies for a named page.
|
|
152
155
|
# @param name [String] logical page name
|
|
153
156
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
154
|
-
def
|
|
157
|
+
def delete_cookies(name) = call("delete_cookies", name: name)
|
|
155
158
|
|
|
156
159
|
# Exports all cookies for a named page to a JSON file.
|
|
160
|
+
# File I/O is client-side; daemon provides the cookie data.
|
|
157
161
|
# @param name [String] logical page name
|
|
158
162
|
# @param path [String] file path to write cookies to
|
|
159
163
|
# @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
|
|
@@ -177,8 +181,88 @@ module Browserctl
|
|
|
177
181
|
call("import_cookies", name: name, cookies: cookies)
|
|
178
182
|
end
|
|
179
183
|
|
|
184
|
+
# Returns the value of a localStorage or sessionStorage key.
|
|
185
|
+
# @param name [String] logical page name
|
|
186
|
+
# @param key [String] storage key
|
|
187
|
+
# @param store [String] "local" or "session" (default: "local")
|
|
188
|
+
# @return [Hash] `{ ok: true, value: }` or `{ error: }`
|
|
189
|
+
def storage_get(name, key, store: "local")
|
|
190
|
+
call("storage_get", name: name, key: key, store: store)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Sets a localStorage or sessionStorage key.
|
|
194
|
+
# @param name [String] logical page name
|
|
195
|
+
# @param key [String] storage key
|
|
196
|
+
# @param value [String] storage value
|
|
197
|
+
# @param store [String] "local" or "session" (default: "local")
|
|
198
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
199
|
+
def storage_set(name, key, value, store: "local")
|
|
200
|
+
call("storage_set", name: name, key: key, value: value, store: store)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Exports localStorage and/or sessionStorage to a JSON file.
|
|
204
|
+
# @param name [String] logical page name
|
|
205
|
+
# @param path [String] destination file path
|
|
206
|
+
# @param stores [String] "local", "session", or "all" (default: "all")
|
|
207
|
+
# @return [Hash] `{ ok: true, path:, key_count: }` or `{ error: }`
|
|
208
|
+
def storage_export(name, path, stores: "all")
|
|
209
|
+
call("storage_export", name: name, path: path, stores: stores)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Imports storage keys from a JSON file into the page's localStorage.
|
|
213
|
+
# @param name [String] logical page name
|
|
214
|
+
# @param path [String] source file path
|
|
215
|
+
# @return [Hash] `{ ok: true, origins: N, key_count: M }` or `{ error: }`
|
|
216
|
+
def storage_import(name, path)
|
|
217
|
+
call("storage_import", name: name, path: path)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Clears localStorage and/or sessionStorage for the page.
|
|
221
|
+
# @param name [String] logical page name
|
|
222
|
+
# @param stores [String] "local", "session", or "all" (default: "all")
|
|
223
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
224
|
+
def storage_delete(name, stores: "all")
|
|
225
|
+
call("storage_delete", name: name, stores: stores)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Saves the current browser state (cookies, localStorage, open pages) to a named session.
|
|
229
|
+
# @param session_name [String] name for the saved session
|
|
230
|
+
# @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
|
|
231
|
+
def session_save(session_name)
|
|
232
|
+
call("session_save", session_name: session_name)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Restores a previously saved session into the running daemon.
|
|
236
|
+
# @param session_name [String] name of the session to load
|
|
237
|
+
# @return [Hash] `{ ok: true, cookies: N, pages: N, local_storage_keys: N }` or `{ error: }`
|
|
238
|
+
def session_load(session_name)
|
|
239
|
+
call("session_load", session_name: session_name)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Lists all saved sessions.
|
|
243
|
+
# @return [Hash] `{ ok: true, sessions: [Hash] }` or `{ error: }`
|
|
244
|
+
def session_list
|
|
245
|
+
call("session_list")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Permanently deletes a named session.
|
|
249
|
+
# @param session_name [String] name of the session to delete
|
|
250
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
251
|
+
def session_delete(session_name)
|
|
252
|
+
call("session_delete", session_name: session_name)
|
|
253
|
+
end
|
|
254
|
+
|
|
180
255
|
private
|
|
181
256
|
|
|
257
|
+
def auto_discover_socket
|
|
258
|
+
default = Browserctl.socket_path
|
|
259
|
+
return default if File.exist?(default)
|
|
260
|
+
|
|
261
|
+
# Fall back to the first available auto-indexed daemon, or the default path
|
|
262
|
+
# (which will raise "browserd is not running" at connection time if absent).
|
|
263
|
+
Browserctl.all_daemon_sockets.first || default
|
|
264
|
+
end
|
|
265
|
+
|
|
182
266
|
def communicate(payload)
|
|
183
267
|
UNIXSocket.open(@socket_path) do |sock|
|
|
184
268
|
sock.puts(payload)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Cookie
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
USAGE = "Usage: browserctl cookie <list|set|delete|export|import> [args]"
|
|
11
|
+
|
|
12
|
+
def self.run(client, args)
|
|
13
|
+
sub = args.shift or abort USAGE
|
|
14
|
+
case sub
|
|
15
|
+
when "list" then run_list(client, args)
|
|
16
|
+
when "set" then run_set(client, args)
|
|
17
|
+
when "delete" then run_delete(client, args)
|
|
18
|
+
when "export" then run_export(client, args)
|
|
19
|
+
when "import" then run_import(client, args)
|
|
20
|
+
else abort "unknown cookie subcommand '#{sub}'\n#{USAGE}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.run_list(client, args)
|
|
25
|
+
page = args.shift or abort "usage: browserctl cookie list <page>"
|
|
26
|
+
print_result(client.cookies(page))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.run_set(client, args)
|
|
30
|
+
page = args.shift or abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN [--path /]"
|
|
31
|
+
name = args.shift or abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN [--path /]"
|
|
32
|
+
value = args.shift or abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN [--path /]"
|
|
33
|
+
domain_idx = args.index("--domain")
|
|
34
|
+
domain = domain_idx ? args.delete_at(domain_idx + 1).tap { args.delete_at(domain_idx) } : nil
|
|
35
|
+
abort "usage: browserctl cookie set <page> <name> <value> --domain DOMAIN" unless domain
|
|
36
|
+
path_idx = args.index("--path")
|
|
37
|
+
path = path_idx ? args.delete_at(path_idx + 1).tap { args.delete_at(path_idx) } : "/"
|
|
38
|
+
print_result(client.set_cookie(page, name, value, domain, path: path))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.run_delete(client, args)
|
|
42
|
+
page = args.shift or abort "usage: browserctl cookie delete <page>"
|
|
43
|
+
print_result(client.delete_cookies(page))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.run_export(client, args)
|
|
47
|
+
page = args.shift or abort "usage: browserctl cookie export <page> <path>"
|
|
48
|
+
path = args.shift or abort "usage: browserctl cookie export <page> <path>"
|
|
49
|
+
print_result(client.export_cookies(page, path))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.run_import(client, args)
|
|
53
|
+
page = args.shift or abort "usage: browserctl cookie import <page> <path>"
|
|
54
|
+
path = args.shift or abort "usage: browserctl cookie import <page> <path>"
|
|
55
|
+
print_result(client.import_cookies(page, path))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|