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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module Interaction
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_press(req)
|
|
10
|
+
with_page(req[:name]) do |session|
|
|
11
|
+
session.page.keyboard.down(req[:key])
|
|
12
|
+
session.page.keyboard.up(req[:key])
|
|
13
|
+
{ ok: true }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cmd_hover(req)
|
|
18
|
+
with_page(req[:name]) do |session|
|
|
19
|
+
coords = session.page.evaluate(
|
|
20
|
+
"(function(sel) { " \
|
|
21
|
+
"var el = document.querySelector(sel); " \
|
|
22
|
+
"if (!el) return null; " \
|
|
23
|
+
"var r = el.getBoundingClientRect(); " \
|
|
24
|
+
"return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
|
|
25
|
+
"})(#{req[:selector].to_json})"
|
|
26
|
+
)
|
|
27
|
+
return { error: "selector not found: #{req[:selector]}" } unless coords
|
|
28
|
+
|
|
29
|
+
session.page.mouse.move(x: coords["x"], y: coords["y"])
|
|
30
|
+
{ ok: true }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def cmd_upload(req)
|
|
35
|
+
path = File.expand_path(req[:path])
|
|
36
|
+
return { error: "file not found: #{path}" } unless File.exist?(path)
|
|
37
|
+
|
|
38
|
+
with_page(req[:name]) do |session|
|
|
39
|
+
el = session.page.at_css(req[:selector])
|
|
40
|
+
return { error: "selector not found: #{req[:selector]}" } unless el
|
|
41
|
+
|
|
42
|
+
el.select_file(path)
|
|
43
|
+
{ ok: true }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cmd_select(req)
|
|
48
|
+
with_page(req[:name]) do |session|
|
|
49
|
+
el = session.page.at_css(req[:selector])
|
|
50
|
+
return { error: "selector not found: #{req[:selector]}" } unless el
|
|
51
|
+
|
|
52
|
+
el.evaluate(
|
|
53
|
+
"this.value = #{req[:value].to_json}; " \
|
|
54
|
+
"this.dispatchEvent(new Event('change', {bubbles: true}))"
|
|
55
|
+
)
|
|
56
|
+
{ ok: true }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cmd_dialog_accept(req)
|
|
61
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
62
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
63
|
+
|
|
64
|
+
text = req[:text]
|
|
65
|
+
id = nil
|
|
66
|
+
id = session.page.on(:dialog) do |dialog|
|
|
67
|
+
session.page.off(:dialog, id)
|
|
68
|
+
dialog.accept(text)
|
|
69
|
+
end
|
|
70
|
+
{ ok: true }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cmd_dialog_dismiss(req)
|
|
74
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
75
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
76
|
+
|
|
77
|
+
id = nil
|
|
78
|
+
id = session.page.on(:dialog) do |dialog|
|
|
79
|
+
session.page.off(:dialog, id)
|
|
80
|
+
dialog.dismiss
|
|
81
|
+
end
|
|
82
|
+
{ ok: true }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -25,6 +25,10 @@ module Browserctl
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def cmd_page_focus(req)
|
|
28
|
+
unless @browser.options.headless == false
|
|
29
|
+
return { error: "page focus requires headed mode — start browserd with --headed" }
|
|
30
|
+
end
|
|
31
|
+
|
|
28
32
|
with_page(req[:name]) do |session|
|
|
29
33
|
session.page.activate
|
|
30
34
|
{ ok: true }
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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-
|
|
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/
|
|
143
|
-
- examples/
|
|
144
|
-
- examples/test_automation_practices/
|
|
145
|
-
- examples/test_automation_practices/
|
|
146
|
-
- examples/test_automation_practices/
|
|
147
|
-
- examples/test_automation_practices/login.rb
|
|
148
|
-
- examples/test_automation_practices/login_negative.rb
|
|
149
|
-
- examples/test_automation_practices/
|
|
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
|