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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/bin/browserctl +29 -0
  4. data/examples/test_automation_practices/advanced/ab_testing.rb +38 -0
  5. data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
  6. data/examples/test_automation_practices/advanced/file_download.rb +40 -0
  7. data/examples/test_automation_practices/advanced/iframes.rb +37 -0
  8. data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
  9. data/examples/test_automation_practices/{login.rb → auth/login.rb} +4 -4
  10. data/examples/test_automation_practices/{login_negative.rb → auth/login_negative.rb} +4 -4
  11. data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
  12. data/examples/test_automation_practices/{notifications.rb → dialogs/notifications.rb} +7 -7
  13. data/examples/test_automation_practices/{dynamic_elements.rb → dynamic/dynamic_elements.rb} +9 -8
  14. data/examples/test_automation_practices/dynamic/tables.rb +47 -0
  15. data/examples/test_automation_practices/{checkboxes.rb → forms/checkboxes.rb} +6 -6
  16. data/examples/test_automation_practices/forms/file_upload.rb +30 -0
  17. data/examples/test_automation_practices/forms/forms.rb +47 -0
  18. data/examples/test_automation_practices/forms/slider.rb +51 -0
  19. data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
  20. data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
  21. data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
  22. data/examples/test_automation_practices/interactions/hover.rb +30 -0
  23. data/examples/test_automation_practices/interactions/key_press.rb +38 -0
  24. data/lib/browserctl/client.rb +42 -0
  25. data/lib/browserctl/commands/ask.rb +20 -0
  26. data/lib/browserctl/commands/dialog.rb +33 -0
  27. data/lib/browserctl/commands/page.rb +1 -1
  28. data/lib/browserctl/errors.rb +3 -0
  29. data/lib/browserctl/secret_resolver_registry.rb +39 -0
  30. data/lib/browserctl/secret_resolvers/base.rb +17 -0
  31. data/lib/browserctl/secret_resolvers/env.rb +13 -0
  32. data/lib/browserctl/secret_resolvers/macos_keychain.rb +29 -0
  33. data/lib/browserctl/secret_resolvers/one_password.rb +22 -0
  34. data/lib/browserctl/secret_resolvers.rb +14 -0
  35. data/lib/browserctl/server/command_dispatcher.rb +8 -0
  36. data/lib/browserctl/server/handlers/interaction.rb +87 -0
  37. data/lib/browserctl/server/handlers/page_lifecycle.rb +4 -0
  38. data/lib/browserctl/version.rb +1 -1
  39. data/lib/browserctl/workflow.rb +36 -9
  40. data/lib/browserctl.rb +1 -0
  41. metadata +31 -10
  42. data/examples/smoke/params_file.rb +0 -36
  43. data/examples/smoke/store_fetch.rb +0 -39
  44. data/examples/test_automation_practices/key_press.rb +0 -41
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module Interaction
7
+ private
8
+
9
+ def cmd_press(req)
10
+ with_page(req[:name]) do |session|
11
+ session.page.keyboard.down(req[:key])
12
+ session.page.keyboard.up(req[:key])
13
+ { ok: true }
14
+ end
15
+ end
16
+
17
+ def cmd_hover(req)
18
+ with_page(req[:name]) do |session|
19
+ coords = session.page.evaluate(
20
+ "(function(sel) { " \
21
+ "var el = document.querySelector(sel); " \
22
+ "if (!el) return null; " \
23
+ "var r = el.getBoundingClientRect(); " \
24
+ "return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
25
+ "})(#{req[:selector].to_json})"
26
+ )
27
+ return { error: "selector not found: #{req[:selector]}" } unless coords
28
+
29
+ session.page.mouse.move(x: coords["x"], y: coords["y"])
30
+ { ok: true }
31
+ end
32
+ end
33
+
34
+ def cmd_upload(req)
35
+ path = File.expand_path(req[:path])
36
+ return { error: "file not found: #{path}" } unless File.exist?(path)
37
+
38
+ with_page(req[:name]) do |session|
39
+ el = session.page.at_css(req[:selector])
40
+ return { error: "selector not found: #{req[:selector]}" } unless el
41
+
42
+ el.select_file(path)
43
+ { ok: true }
44
+ end
45
+ end
46
+
47
+ def cmd_select(req)
48
+ with_page(req[:name]) do |session|
49
+ el = session.page.at_css(req[:selector])
50
+ return { error: "selector not found: #{req[:selector]}" } unless el
51
+
52
+ el.evaluate(
53
+ "this.value = #{req[:value].to_json}; " \
54
+ "this.dispatchEvent(new Event('change', {bubbles: true}))"
55
+ )
56
+ { ok: true }
57
+ end
58
+ end
59
+
60
+ def cmd_dialog_accept(req)
61
+ session = @global_mutex.synchronize { @pages[req[:name]] }
62
+ return { error: "no page named '#{req[:name]}'" } unless session
63
+
64
+ text = req[:text]
65
+ id = nil
66
+ id = session.page.on(:dialog) do |dialog|
67
+ session.page.off(:dialog, id)
68
+ dialog.accept(text)
69
+ end
70
+ { ok: true }
71
+ end
72
+
73
+ def cmd_dialog_dismiss(req)
74
+ session = @global_mutex.synchronize { @pages[req[:name]] }
75
+ return { error: "no page named '#{req[:name]}'" } unless session
76
+
77
+ id = nil
78
+ id = session.page.on(:dialog) do |dialog|
79
+ session.page.off(:dialog, id)
80
+ dialog.dismiss
81
+ end
82
+ { ok: true }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -25,6 +25,10 @@ module Browserctl
25
25
  end
26
26
 
27
27
  def cmd_page_focus(req)
28
+ unless @browser.options.headless == false
29
+ return { error: "page focus requires headed mode — start browserd with --headed" }
30
+ end
31
+
28
32
  with_page(req[:name]) do |session|
29
33
  session.page.activate
30
34
  { ok: true }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -2,11 +2,11 @@
2
2
 
3
3
  require "timeout"
4
4
  require_relative "client"
5
+ require_relative "errors"
6
+ require_relative "secret_resolvers"
5
7
 
6
8
  module Browserctl
7
- class WorkflowError < StandardError; end
8
-
9
- ParamDef = Struct.new(:name, :required, :secret, :default, keyword_init: true)
9
+ ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
10
10
  StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
11
11
  StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
12
12
 
@@ -67,17 +67,31 @@ module Browserctl
67
67
  res
68
68
  end
69
69
 
70
- def load_session(session_name)
70
+ def load_session(session_name, fallback: nil)
71
71
  res = @client.session_load(session_name)
72
- raise WorkflowError, res[:error] if res[:error]
72
+ return res unless res[:error]
73
73
 
74
- res
74
+ raise WorkflowError, res[:error] unless fallback
75
+
76
+ invoke(fallback.to_s)
77
+ res2 = @client.session_load(session_name)
78
+ if res2[:error]
79
+ raise WorkflowError,
80
+ "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
81
+ end
82
+
83
+ res2
75
84
  end
76
85
 
77
86
  def list_sessions
78
87
  @client.session_list[:sessions]
79
88
  end
80
89
 
90
+ def ask(prompt)
91
+ $stderr.print("[browserctl] #{prompt} ")
92
+ $stdin.gets.chomp
93
+ end
94
+
81
95
  def invoke(workflow_name, **override_params)
82
96
  name = workflow_name.to_s
83
97
  guard_circular!(name)
@@ -137,6 +151,13 @@ module Browserctl
137
151
  unwrap @client.storage_set(@name, key, value, store: store)
138
152
  end
139
153
 
154
+ def press(key) = unwrap @client.press(@name, key)
155
+ def hover(selector) = unwrap @client.hover(@name, selector)
156
+ def upload(selector, path) = unwrap @client.upload(@name, selector, path)
157
+ def select(selector, value) = unwrap @client.select(@name, selector, value)
158
+ def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
159
+ def dialog_dismiss = unwrap @client.dialog_dismiss(@name)
160
+
140
161
  private
141
162
 
142
163
  def unwrap(res)
@@ -160,8 +181,10 @@ module Browserctl
160
181
  @description = text
161
182
  end
162
183
 
163
- def param(name, required: false, secret: false, default: nil)
164
- @param_defs[name] = ParamDef.new(name: name, required: required, secret: secret, default: default)
184
+ def param(name, required: false, secret: false, default: nil, secret_ref: nil)
185
+ secret = true if secret_ref
186
+ @param_defs[name] =
187
+ ParamDef.new(name: name, required: required, secret: secret, default: default, secret_ref: secret_ref)
165
188
  end
166
189
 
167
190
  def step(label, retry_count: 0, timeout: nil, &block)
@@ -211,7 +234,11 @@ module Browserctl
211
234
 
212
235
  def resolve_params(provided)
213
236
  @param_defs.each_with_object({}) do |(name, defn), out|
214
- val = provided[name] || defn.default
237
+ val = if defn.secret_ref
238
+ SecretResolverRegistry.resolve(defn.secret_ref)
239
+ else
240
+ provided[name] || defn.default
241
+ end
215
242
  raise WorkflowError, "required param '#{name}' missing" if defn.required && val.nil?
216
243
 
217
244
  out[name] = val
data/lib/browserctl.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "browserctl/version"
4
4
  require_relative "browserctl/constants"
5
5
  require_relative "browserctl/errors"
6
+ require_relative "browserctl/secret_resolvers"
6
7
  require_relative "browserctl/workflow"
7
8
  require_relative "browserctl/runner"
8
9
  require_relative "browserctl/client"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-28 00:00:00.000000000 Z
11
+ date: 2026-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ferrum
@@ -139,14 +139,26 @@ files:
139
139
  - bin/browserd
140
140
  - bin/setup
141
141
  - examples/cloudflare_hitl.rb
142
- - examples/smoke/params_file.rb
143
- - examples/smoke/store_fetch.rb
144
- - examples/test_automation_practices/checkboxes.rb
145
- - examples/test_automation_practices/dynamic_elements.rb
146
- - examples/test_automation_practices/key_press.rb
147
- - examples/test_automation_practices/login.rb
148
- - examples/test_automation_practices/login_negative.rb
149
- - examples/test_automation_practices/notifications.rb
142
+ - examples/test_automation_practices/advanced/ab_testing.rb
143
+ - examples/test_automation_practices/advanced/broken_images.rb
144
+ - examples/test_automation_practices/advanced/file_download.rb
145
+ - examples/test_automation_practices/advanced/iframes.rb
146
+ - examples/test_automation_practices/advanced/shadow_dom.rb
147
+ - examples/test_automation_practices/auth/login.rb
148
+ - examples/test_automation_practices/auth/login_negative.rb
149
+ - examples/test_automation_practices/dialogs/alerts.rb
150
+ - examples/test_automation_practices/dialogs/notifications.rb
151
+ - examples/test_automation_practices/dynamic/dynamic_elements.rb
152
+ - examples/test_automation_practices/dynamic/tables.rb
153
+ - examples/test_automation_practices/forms/checkboxes.rb
154
+ - examples/test_automation_practices/forms/file_upload.rb
155
+ - examples/test_automation_practices/forms/forms.rb
156
+ - examples/test_automation_practices/forms/slider.rb
157
+ - examples/test_automation_practices/interactions/context_menu.rb
158
+ - examples/test_automation_practices/interactions/drag_drop.rb
159
+ - examples/test_automation_practices/interactions/exit_intent.rb
160
+ - examples/test_automation_practices/interactions/hover.rb
161
+ - examples/test_automation_practices/interactions/key_press.rb
150
162
  - examples/the_internet/add_remove_elements.rb
151
163
  - examples/the_internet/checkboxes.rb
152
164
  - examples/the_internet/dropdown.rb
@@ -154,10 +166,12 @@ files:
154
166
  - examples/the_internet/login.rb
155
167
  - lib/browserctl.rb
156
168
  - lib/browserctl/client.rb
169
+ - lib/browserctl/commands/ask.rb
157
170
  - lib/browserctl/commands/cli_output.rb
158
171
  - lib/browserctl/commands/click.rb
159
172
  - lib/browserctl/commands/cookie.rb
160
173
  - lib/browserctl/commands/daemon.rb
174
+ - lib/browserctl/commands/dialog.rb
161
175
  - lib/browserctl/commands/fill.rb
162
176
  - lib/browserctl/commands/init.rb
163
177
  - lib/browserctl/commands/page.rb
@@ -175,12 +189,19 @@ files:
175
189
  - lib/browserctl/policy.rb
176
190
  - lib/browserctl/recording.rb
177
191
  - lib/browserctl/runner.rb
192
+ - lib/browserctl/secret_resolver_registry.rb
193
+ - lib/browserctl/secret_resolvers.rb
194
+ - lib/browserctl/secret_resolvers/base.rb
195
+ - lib/browserctl/secret_resolvers/env.rb
196
+ - lib/browserctl/secret_resolvers/macos_keychain.rb
197
+ - lib/browserctl/secret_resolvers/one_password.rb
178
198
  - lib/browserctl/server.rb
179
199
  - lib/browserctl/server/command_dispatcher.rb
180
200
  - lib/browserctl/server/handlers/cookies.rb
181
201
  - lib/browserctl/server/handlers/daemon_control.rb
182
202
  - lib/browserctl/server/handlers/devtools.rb
183
203
  - lib/browserctl/server/handlers/hitl.rb
204
+ - lib/browserctl/server/handlers/interaction.rb
184
205
  - lib/browserctl/server/handlers/navigation.rb
185
206
  - lib/browserctl/server/handlers/observation.rb
186
207
  - lib/browserctl/server/handlers/page_lifecycle.rb
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- #
4
- # Smoke test for --params file loading (Task 7.5).
5
- #
6
- # Run with:
7
- # browserctl workflow run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
8
- #
9
- # The workflow logs in using credentials from the params file and asserts
10
- # the secure area is reached — proving the params were loaded and available.
11
-
12
- Browserctl.workflow "smoke/params_file" do
13
- desc "Smoke: load credentials from a --params file and use them in a workflow"
14
-
15
- param :username, required: true
16
- param :password, required: true, secret: true
17
- param :base_url, default: "https://the-internet.herokuapp.com"
18
-
19
- step "open login page" do
20
- open_page(:main, url: "#{base_url}/login")
21
- end
22
-
23
- step "fill credentials from params file" do
24
- puts " [params] username = #{username.inspect}"
25
- puts " [params] password = (#{password.length} chars, secret)"
26
- page(:main).fill("input#username", username)
27
- page(:main).fill("input#password", password)
28
- page(:main).click("button[type=submit]")
29
- end
30
-
31
- step "assert login succeeded" do
32
- page(:main).wait(".flash.success", timeout: 10)
33
- assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
34
- puts " [ok] reached secure area — params file loaded correctly"
35
- end
36
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- #
4
- # Smoke test for WorkflowContext#store / #fetch (Task 7.3).
5
- #
6
- # Uses the-internet's dynamic loading example: click Start, wait for "Hello World!",
7
- # capture the text in step 1, assert it is still accessible in step 2 via fetch.
8
-
9
- Browserctl.workflow "smoke/store_fetch" do
10
- desc "Smoke: store a value in one step and retrieve it in a later step"
11
-
12
- param :base_url, default: "https://the-internet.herokuapp.com"
13
-
14
- step "open dynamic loading page" do
15
- open_page(:main, url: "#{base_url}/dynamic_loading/1")
16
- end
17
-
18
- step "click start and capture loaded text" do
19
- page(:main).click("div#start button")
20
- page(:main).wait("div#finish", timeout: 10)
21
- text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
22
- assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
23
- store(:loaded_text, text)
24
- puts " [store] loaded_text = #{text.inspect}"
25
- end
26
-
27
- step "fetch value from previous step and assert" do
28
- text = fetch(:loaded_text)
29
- puts " [fetch] loaded_text = #{text.inspect}"
30
- assert text == "Hello World!", "expected 'Hello World!', got: #{text.inspect}"
31
- end
32
-
33
- step "confirm fetch raises for unknown key" do
34
- fetch(:nonexistent_key)
35
- assert false, "expected WorkflowError was not raised"
36
- rescue Browserctl::WorkflowError => e
37
- puts " [ok] WorkflowError raised as expected: #{e.message}"
38
- end
39
- end
@@ -1,41 +0,0 @@
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