browserctl 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +27 -32
  4. data/bin/browserctl +146 -108
  5. data/bin/browserd +9 -3
  6. data/examples/cloudflare_hitl.rb +5 -5
  7. data/examples/smoke/params_file.rb +3 -2
  8. data/examples/smoke/store_fetch.rb +5 -5
  9. data/examples/test_automation_practices/advanced/ab_testing.rb +38 -0
  10. data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
  11. data/examples/test_automation_practices/advanced/file_download.rb +40 -0
  12. data/examples/test_automation_practices/advanced/iframes.rb +37 -0
  13. data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
  14. data/examples/test_automation_practices/auth/login.rb +34 -0
  15. data/examples/test_automation_practices/auth/login_negative.rb +28 -0
  16. data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
  17. data/examples/test_automation_practices/dialogs/notifications.rb +57 -0
  18. data/examples/test_automation_practices/dynamic/dynamic_elements.rb +41 -0
  19. data/examples/test_automation_practices/dynamic/tables.rb +47 -0
  20. data/examples/test_automation_practices/forms/checkboxes.rb +39 -0
  21. data/examples/test_automation_practices/forms/file_upload.rb +30 -0
  22. data/examples/test_automation_practices/forms/forms.rb +47 -0
  23. data/examples/test_automation_practices/forms/slider.rb +51 -0
  24. data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
  25. data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
  26. data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
  27. data/examples/test_automation_practices/interactions/hover.rb +30 -0
  28. data/examples/test_automation_practices/interactions/key_press.rb +38 -0
  29. data/examples/the_internet/add_remove_elements.rb +1 -1
  30. data/examples/the_internet/checkboxes.rb +1 -1
  31. data/examples/the_internet/dropdown.rb +1 -1
  32. data/examples/the_internet/dynamic_loading.rb +2 -2
  33. data/examples/the_internet/login.rb +1 -1
  34. data/lib/browserctl/client.rb +143 -28
  35. data/lib/browserctl/commands/ask.rb +20 -0
  36. data/lib/browserctl/commands/cookie.rb +59 -0
  37. data/lib/browserctl/commands/daemon.rb +77 -0
  38. data/lib/browserctl/commands/dialog.rb +33 -0
  39. data/lib/browserctl/commands/page.rb +47 -0
  40. data/lib/browserctl/commands/record.rb +1 -1
  41. data/lib/browserctl/commands/screenshot.rb +2 -2
  42. data/lib/browserctl/commands/session.rb +69 -0
  43. data/lib/browserctl/commands/snapshot.rb +2 -2
  44. data/lib/browserctl/commands/storage.rb +67 -0
  45. data/lib/browserctl/commands/workflow.rb +64 -0
  46. data/lib/browserctl/constants.rb +20 -1
  47. data/lib/browserctl/logger.rb +4 -4
  48. data/lib/browserctl/recording.rb +4 -4
  49. data/lib/browserctl/server/command_dispatcher.rb +30 -9
  50. data/lib/browserctl/server/handlers/cookies.rb +1 -1
  51. data/lib/browserctl/server/handlers/devtools.rb +1 -1
  52. data/lib/browserctl/server/handlers/hitl.rb +2 -1
  53. data/lib/browserctl/server/handlers/interaction.rb +87 -0
  54. data/lib/browserctl/server/handlers/navigation.rb +24 -2
  55. data/lib/browserctl/server/handlers/observation.rb +0 -26
  56. data/lib/browserctl/server/handlers/page_lifecycle.rb +14 -3
  57. data/lib/browserctl/server/handlers/session.rb +93 -0
  58. data/lib/browserctl/server/handlers/storage.rb +109 -0
  59. data/lib/browserctl/server.rb +2 -2
  60. data/lib/browserctl/session.rb +79 -0
  61. data/lib/browserctl/version.rb +1 -1
  62. data/lib/browserctl/workflow.rb +50 -11
  63. metadata +36 -11
  64. data/lib/browserctl/commands/export_cookies.rb +0 -18
  65. data/lib/browserctl/commands/import_cookies.rb +0 -23
  66. data/lib/browserctl/commands/inspect.rb +0 -21
  67. data/lib/browserctl/commands/open_page.rb +0 -21
  68. data/lib/browserctl/commands/pause.rb +0 -22
  69. data/lib/browserctl/commands/status.rb +0 -30
  70. data/lib/browserctl/commands/watch.rb +0 -27
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/advanced/broken_images" do
4
+ desc "Broken images page: verify one image loads successfully and two are broken (naturalWidth == 0)"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_advanced_broken_images.png")
8
+
9
+ step "open broken images page" do
10
+ open_page(:main, url: "#{base_url}/#/broken-images")
11
+ page(:main).wait("[data-test='image-container-0']", timeout: 10)
12
+ sleep 1
13
+ end
14
+
15
+ step "check which images loaded and which are broken" do
16
+ widths = page(:main).evaluate(
17
+ "[0,1,2].map(i => document.querySelector(`[data-test=\"image-${i}\"]`)?.naturalWidth ?? -1)"
18
+ )
19
+ loaded = widths.count(&:positive?)
20
+ broken = widths.count(&:zero?)
21
+ assert loaded == 1, "expected exactly 1 valid image, got #{loaded} (widths: #{widths.inspect})"
22
+ assert broken == 2, "expected exactly 2 broken images, got #{broken} (widths: #{widths.inspect})"
23
+ page(:main).screenshot(path: screenshot_path)
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/advanced/file_download" do
4
+ desc "File download page: trigger each download, verify button enters disabled state, verify no error shown"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_advanced_file_download.png")
8
+
9
+ step "open file download page" do
10
+ open_page(:main, url: "#{base_url}/#/file-download")
11
+ page(:main).wait("[data-test='download-card-0']", timeout: 10)
12
+ end
13
+
14
+ step "click TXT download — button becomes disabled during download" do
15
+ page(:main).click("[data-test='download-button-0']")
16
+ page(:main).wait("[data-test='download-button-0'][disabled]", timeout: 5)
17
+ disabled = page(:main).evaluate(
18
+ "document.querySelector('[data-test=\"download-button-0\"]')?.disabled"
19
+ )
20
+ assert disabled, "expected download-button-0 to be disabled while downloading"
21
+ page(:main).wait("[data-test='download-button-0']:not([disabled])", timeout: 10)
22
+ no_error = page(:main).evaluate("!document.querySelector('[data-test=\"error-message\"]')")
23
+ assert no_error, "expected no error-message after TXT download"
24
+ end
25
+
26
+ step "click CSV download — completes without error" do
27
+ page(:main).click("[data-test='download-button-1']")
28
+ page(:main).wait("[data-test='download-button-1']:not([disabled])", timeout: 10)
29
+ no_error = page(:main).evaluate("!document.querySelector('[data-test=\"error-message\"]')")
30
+ assert no_error, "expected no error-message after CSV download"
31
+ end
32
+
33
+ step "click PDF download — completes without error" do
34
+ page(:main).click("[data-test='download-button-2']")
35
+ page(:main).wait("[data-test='download-button-2']:not([disabled])", timeout: 10)
36
+ no_error = page(:main).evaluate("!document.querySelector('[data-test=\"error-message\"]')")
37
+ assert no_error, "expected no error-message after PDF download"
38
+ page(:main).screenshot(path: screenshot_path)
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/advanced/iframes" do
4
+ desc "Iframes page: click inside sandboxed iframes, verify postMessage received in parent"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_advanced_iframes.png")
8
+
9
+ step "open iframes page" do
10
+ open_page(:main, url: "#{base_url}/#/iframes")
11
+ page(:main).wait("[data-test='iframe-container']", timeout: 10)
12
+ sleep 0.5
13
+ end
14
+
15
+ step "click button inside iframe1 — message appears in parent" do
16
+ page(:main).evaluate(
17
+ "document.querySelector('[data-test=\"iframe-iframe1\"]').contentDocument.querySelector('button').click()"
18
+ )
19
+ page(:main).wait("[data-test='iframe-message-0']", timeout: 5)
20
+ msg = page(:main).evaluate(
21
+ "document.querySelector('[data-test=\"iframe-message-0\"]')?.textContent?.trim()"
22
+ )
23
+ assert msg&.length&.positive?, "expected a message after clicking iframe1 button, got: #{msg.inspect}"
24
+ page(:main).screenshot(path: screenshot_path)
25
+ end
26
+
27
+ step "click button inside iframe2 — second message appears" do
28
+ page(:main).evaluate(
29
+ "document.querySelector('[data-test=\"iframe-iframe2\"]').contentDocument.querySelector('button').click()"
30
+ )
31
+ page(:main).wait("[data-test='iframe-message-1']", timeout: 5)
32
+ count = page(:main).evaluate(
33
+ "document.querySelectorAll('[data-test^=\"iframe-message-\"]').length"
34
+ )
35
+ assert count >= 2, "expected at least 2 messages after both iframe clicks, got: #{count}"
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/advanced/shadow_dom" do
4
+ desc "Shadow DOM page: query inside shadow root, click shadow-button, accept its alert"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_advanced_shadow_dom.png")
8
+
9
+ step "open shadow DOM page" do
10
+ open_page(:main, url: "#{base_url}/#/shadow-dom")
11
+ page(:main).wait("[data-test='shadow-host']", timeout: 10)
12
+ end
13
+
14
+ step "verify shadow content is accessible via shadowRoot" do
15
+ text = page(:main).evaluate(
16
+ "document.querySelector('[data-test=\"shadow-host\"]').shadowRoot" \
17
+ "?.querySelector('[data-test=\"shadow-content\"]')?.textContent?.trim()"
18
+ )
19
+ assert text&.length&.positive?, "expected shadow-content text via shadowRoot, got: #{text.inspect}"
20
+ page(:main).screenshot(path: screenshot_path)
21
+ end
22
+
23
+ step "click shadow-button — accept the alert — page stays responsive" do
24
+ page(:main).dialog_accept
25
+ page(:main).evaluate(
26
+ "document.querySelector('[data-test=\"shadow-host\"]').shadowRoot" \
27
+ ".querySelector('[data-test=\"shadow-button\"]').click()"
28
+ )
29
+ sleep 0.3
30
+ still_present = page(:main).evaluate(
31
+ "!!document.querySelector('[data-test=\"shadow-host\"]')"
32
+ )
33
+ assert still_present, "expected shadow-host to remain in DOM after alert was accepted"
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/auth/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/tap_auth_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 = page(:main).evaluate("document.querySelector('[data-test=\"auth-success\"]')?.innerText?.trim()")
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 = page(:main).evaluate("!!document.querySelector('[data-test=\"login-button\"]')")
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/auth/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/tap_auth_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 = page(:main).evaluate("document.querySelector('[data-test=\"auth-error\"]')?.innerText?.trim()")
23
+ assert error&.length&.positive?, "expected an error message, got: #{error.inspect}"
24
+ no_success = page(:main).evaluate("!document.querySelector('[data-test=\"auth-success\"]')")
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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/dialogs/alerts" do
4
+ desc "Alerts page: pre-register accept/dismiss handlers before triggering each JS dialog type"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_dialogs_alerts.png")
8
+
9
+ step "open alerts page" do
10
+ open_page(:main, url: "#{base_url}/#/alerts")
11
+ end
12
+
13
+ step "accept an alert — result-container shows confirmation" do
14
+ page(:main).dialog_accept
15
+ page(:main).click("[data-test='alert-button']")
16
+ page(:main).wait("[data-test='result-container']", timeout: 5)
17
+ result = page(:main).evaluate("document.querySelector('[data-test=\"result-container\"]')?.textContent?.trim()")
18
+ assert result == "Last action: Alert shown", "expected alert result, got: #{result.inspect}"
19
+ end
20
+
21
+ step "accept a confirm — result-container shows true" do
22
+ page(:main).dialog_accept
23
+ page(:main).click("[data-test='confirm-button']")
24
+ page(:main).wait("[data-test='result-container']", timeout: 5)
25
+ result = page(:main).evaluate("document.querySelector('[data-test=\"result-container\"]')?.textContent?.trim()")
26
+ assert result == "Last action: Confirm dialog: true", "expected confirm true result, got: #{result.inspect}"
27
+ end
28
+
29
+ step "dismiss a confirm — result-container shows false" do
30
+ page(:main).dialog_dismiss
31
+ page(:main).click("[data-test='confirm-button']")
32
+ page(:main).wait("[data-test='result-container']", timeout: 5)
33
+ result = page(:main).evaluate("document.querySelector('[data-test=\"result-container\"]')?.textContent?.trim()")
34
+ assert result == "Last action: Confirm dialog: false", "expected confirm false result, got: #{result.inspect}"
35
+ end
36
+
37
+ step "accept a prompt with text — result-container shows entered text" do
38
+ page(:main).dialog_accept(text: "browserctl")
39
+ page(:main).click("[data-test='prompt-button']")
40
+ page(:main).wait("[data-test='result-container']", timeout: 5)
41
+ result = page(:main).evaluate("document.querySelector('[data-test=\"result-container\"]')?.textContent?.trim()")
42
+ assert result&.include?("browserctl"), "expected prompt text in result, got: #{result.inspect}"
43
+ page(:main).screenshot(path: screenshot_path)
44
+ end
45
+ 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/dialogs/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/tap_dialogs_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, page(:main).evaluate(NOTIFICATION_ITEMS_JS))
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 = page(:main).evaluate(NOTIFICATION_ITEMS_JS)
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
+ page(:main).evaluate("document.querySelector('[data-test^=\"close-notification-\"]')?.click()")
35
+ deadline = Time.now + 5
36
+ remaining = nil
37
+ loop do
38
+ remaining = page(:main).evaluate(NOTIFICATION_ITEMS_JS)
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 = page(:main).evaluate(NOTIFICATION_ITEMS_JS)
54
+ assert count == base + 2, "expected #{base + 2} notifications (error + info), got: #{count}"
55
+ page(:main).screenshot(path: screenshot_path)
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/dynamic/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/tap_dynamic_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 = page(:main).evaluate("!document.querySelector('[data-test=\"dynamic-item-0\"]')")
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 = page(:main).evaluate(
27
+ "[0,1,2].filter(i => !!document.querySelector(`[data-test=\"dynamic-item-${i}\"]`)).length"
28
+ )
29
+ assert count == 3, "expected 3 dynamic items, got: #{count}"
30
+ page(:main).screenshot(path: screenshot_path)
31
+ end
32
+
33
+ step "toggle hidden content on" do
34
+ page(:main).click("[data-test='toggle-hidden-button']")
35
+ page(:main).wait("[data-test='hidden-content']", timeout: 5)
36
+ visible = page(:main).evaluate(
37
+ "!document.querySelector('[data-test=\"hidden-content\"]')?.classList.contains('hidden')"
38
+ )
39
+ assert visible, "expected hidden-content to become visible after toggle (hidden class should be absent)"
40
+ end
41
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/dynamic/tables" do
4
+ desc "Tables page: verify initial data, sort by column ascending and descending"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/tap_dynamic_tables.png")
8
+
9
+ step "open tables page and verify initial rows" do
10
+ open_page(:main, url: "#{base_url}/#/tables")
11
+ page(:main).wait("[data-test='dynamic-table']", timeout: 10)
12
+ rows = page(:main).evaluate(
13
+ "Array.from(document.querySelectorAll('[data-test^=\"table-row-\"]')).map(r => r.dataset.test)"
14
+ )
15
+ assert rows.length == 3, "expected 3 rows, got: #{rows.length}"
16
+ end
17
+
18
+ step "sort by name ascending — alphabetical order" do
19
+ page(:main).click("[data-test='table-header-name']")
20
+ sleep 0.2
21
+ names = page(:main).evaluate(
22
+ "Array.from(document.querySelectorAll('[data-test^=\"table-cell-name-\"]')).map(el => el.textContent?.trim())"
23
+ )
24
+ sorted = names.sort
25
+ assert names == sorted, "expected names sorted ascending, got: #{names.inspect}"
26
+ page(:main).screenshot(path: screenshot_path)
27
+ end
28
+
29
+ step "sort by name descending — click header again" do
30
+ page(:main).click("[data-test='table-header-name']")
31
+ sleep 0.2
32
+ names = page(:main).evaluate(
33
+ "Array.from(document.querySelectorAll('[data-test^=\"table-cell-name-\"]')).map(el => el.textContent?.trim())"
34
+ )
35
+ sorted_desc = names.sort.reverse
36
+ assert names == sorted_desc, "expected names sorted descending, got: #{names.inspect}"
37
+ end
38
+
39
+ step "sort by status — Active/Inactive grouping" do
40
+ page(:main).click("[data-test='table-header-status']")
41
+ sleep 0.2
42
+ statuses = page(:main).evaluate(
43
+ "Array.from(document.querySelectorAll('[data-test^=\"table-cell-status-\"]')).map(el => el.textContent?.trim())"
44
+ )
45
+ assert statuses == statuses.sort, "expected statuses sorted ascending, got: #{statuses.inspect}"
46
+ end
47
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/forms/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/tap_forms_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 = page(:main).evaluate(js)
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 = page(:main).evaluate("document.querySelector('[data-test=\"checkbox-checkbox1\"]').checked")
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 = page(:main).evaluate(js)
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 = page(:main).evaluate(js)
37
+ assert states.none?, "expected all checkboxes unchecked, got: #{states.inspect}"
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ Browserctl.workflow "test_automation_practices/forms/file_upload" do
4
+ desc "File upload page: attach a local file via the hidden file input, verify the file is selected"
5
+
6
+ param :base_url, default: "https://moatazeldebsy.github.io/test-automation-practices"
7
+ param :screenshot_path,
8
+ default: File.expand_path(".browserctl/screenshots/tap_forms_file_upload.png")
9
+
10
+ step "open file upload page" do
11
+ open_page(:main, url: "#{base_url}/#/file-upload")
12
+ page(:main).wait("[data-test='file-uploader']", timeout: 10)
13
+ end
14
+
15
+ step "upload a file and verify it is selected" do
16
+ Dir.mktmpdir do |dir|
17
+ path = File.join(dir, "browserctl_test.txt")
18
+ File.write(path, "browserctl v0.7 upload test")
19
+
20
+ page(:main).upload("[data-test='file-input']", path)
21
+ page(:main).wait("[data-test='uploaded-file-0']", timeout: 10)
22
+
23
+ filename = page(:main).evaluate(
24
+ "document.querySelector('[data-test=\"uploaded-file-0\"]')?.querySelector('span.truncate')?.textContent?.trim()"
25
+ )
26
+ assert filename == "browserctl_test.txt", "expected filename in list, got: #{filename.inspect}"
27
+ end
28
+ page(:main).screenshot(path: screenshot_path)
29
+ end
30
+ end
@@ -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