capybara-lightpanda 0.8.0 → 0.10.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +14 -1
  4. data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
  5. data/lib/capybara/lightpanda/binary.rb +72 -54
  6. data/lib/capybara/lightpanda/browser/console.rb +190 -0
  7. data/lib/capybara/lightpanda/browser/finder.rb +196 -0
  8. data/lib/capybara/lightpanda/browser/modals.rb +150 -0
  9. data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
  10. data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
  11. data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
  12. data/lib/capybara/lightpanda/browser.rb +158 -813
  13. data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
  14. data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
  15. data/lib/capybara/lightpanda/client.rb +5 -4
  16. data/lib/capybara/lightpanda/downloads.rb +176 -0
  17. data/lib/capybara/lightpanda/driver.rb +124 -32
  18. data/lib/capybara/lightpanda/errors.rb +24 -10
  19. data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
  20. data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
  21. data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
  22. data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
  23. data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
  24. data/lib/capybara/lightpanda/keyboard.rb +23 -19
  25. data/lib/capybara/lightpanda/network.rb +103 -18
  26. data/lib/capybara/lightpanda/node.rb +200 -95
  27. data/lib/capybara/lightpanda/options.rb +44 -3
  28. data/lib/capybara/lightpanda/process.rb +154 -29
  29. data/lib/capybara/lightpanda/version.rb +1 -1
  30. data/lib/capybara-lightpanda.rb +1 -0
  31. metadata +14 -3
  32. data/lib/capybara/lightpanda/javascripts/index.js +0 -226
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Browser
6
+ # JS evaluation and RemoteObject plumbing: Runtime.evaluate /
7
+ # callFunctionOn dispatch, result serialization (Ferrum's
8
+ # Frame::Runtime is the peer-gem equivalent).
9
+ module Runtime
10
+ # Evaluate JS and return a serialized value.
11
+ # No-args fast path uses Runtime.evaluate; with args we wrap as a function
12
+ # and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
13
+ # Both paths use `returnByValue: false` and unwrap so DOM-node returns
14
+ # come back as `{ "__lightpanda_node__" => ... }` for the Driver to wrap.
15
+ #
16
+ # The no-args path sends the user's text verbatim with `replMode: true`
17
+ # (V8's DevTools-console REPL mode — Lightpanda forwards Runtime.evaluate
18
+ # to the V8 inspector, which handles the flag natively). Without it,
19
+ # top-level `const`/`let` persist in the global lexical environment
20
+ # across classic scripts — per spec, and Chrome behaves identically —
21
+ # so a second `const sel = ...` raises `SyntaxError: Identifier 'sel'
22
+ # has already been declared`. REPL mode keeps the bindings (visible to
23
+ # later calls, like the DevTools console) but allows redeclaration.
24
+ # Completion-value semantics cover a bare expression (`'foo'`), a
25
+ # `throw` statement, and multi-statement scripts alike.
26
+ def evaluate(expression, *args)
27
+ if args.empty?
28
+ response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
29
+ awaitPromise: true, replMode: true)
30
+ raise_on_js_error!("evaluate", expression, response)
31
+
32
+ return unwrap_call_result(response["result"])
33
+ end
34
+
35
+ wrapped = "function() { return #{expression} }"
36
+ call_with_args(wrapped, args)
37
+ end
38
+
39
+ # Execute JS without returning a value.
40
+ #
41
+ # Like `evaluate`, the no-args path uses `replMode: true` so top-level
42
+ # `const`/`let` redeclarations across calls don't raise. Also raises
43
+ # on JS exceptions so silent failures don't mask test bugs (the
44
+ # previous fast path swallowed them because `awaitPromise: false` was
45
+ # checked but `exceptionDetails` was not).
46
+ def execute(expression, *args)
47
+ if args.empty?
48
+ response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
49
+ awaitPromise: false, replMode: true)
50
+ raise_on_js_error!("execute", expression, response)
51
+ return nil
52
+ end
53
+
54
+ wrapped = "function() { #{expression} }"
55
+ call_with_args(wrapped, args, return_by_value: false)
56
+ nil
57
+ end
58
+
59
+ # Single home for the exceptionDetails check on Runtime responses:
60
+ # optional LIGHTPANDA_DEBUG dump, then JavaScriptError.
61
+ def raise_on_js_error!(site, expression, response)
62
+ return unless response["exceptionDetails"]
63
+
64
+ debug_js_failure(site, expression, response)
65
+ raise JavaScriptError, response
66
+ end
67
+
68
+ # When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
69
+ # response for every JsException to STDERR. Invaluable for isolating
70
+ # which exact JS triggers an upstream Lightpanda bug.
71
+ def debug_js_failure(site, expression, response)
72
+ return unless ENV["LIGHTPANDA_DEBUG"]
73
+
74
+ warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
75
+ end
76
+
77
+ # Evaluate async JS with a callback. The user's script receives
78
+ # the callback as its last argument (`arguments[arguments.length - 1]`),
79
+ # matching Capybara's evaluate_async_script contract.
80
+ def evaluate_async(expression, *args, wait: @options.timeout)
81
+ timeout_ms = (wait * 1000).to_i
82
+ wrapped = <<~JS
83
+ function() {
84
+ var __args = Array.prototype.slice.call(arguments);
85
+ return new Promise(function(__resolve, __reject) {
86
+ var __timer = setTimeout(function() {
87
+ __reject(new Error('Async script timeout after #{timeout_ms}ms'));
88
+ }, #{timeout_ms});
89
+ var __done = function(val) { clearTimeout(__timer); __resolve(val); };
90
+ __args.push(__done);
91
+ (function() { #{expression} }).apply(null, __args);
92
+ });
93
+ }
94
+ JS
95
+ call_with_args(wrapped, args)
96
+ end
97
+
98
+ # Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
99
+ def evaluate_with_ref(expression)
100
+ response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
101
+ raise_on_js_error!("evaluate_with_ref", expression, response)
102
+
103
+ result = response["result"]
104
+ return nil if result["type"] == "undefined"
105
+
106
+ result
107
+ end
108
+
109
+ # Call a function on a remote object via Runtime.callFunctionOn.
110
+ # Binds `this` to the DOM element referenced by remote_object_id.
111
+ def call_function_on(remote_object_id, function_declaration, *args, return_by_value: true)
112
+ params = {
113
+ objectId: remote_object_id,
114
+ functionDeclaration: function_declaration,
115
+ returnByValue: return_by_value,
116
+ awaitPromise: true,
117
+ }
118
+ params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
119
+
120
+ response = page_command("Runtime.callFunctionOn", **params)
121
+ raise_on_js_error!("call_function_on", function_declaration, response)
122
+
123
+ result = response["result"]
124
+ return nil if result["type"] == "undefined"
125
+
126
+ return_by_value ? result["value"] : result
127
+ end
128
+
129
+ # Get properties of a remote object (used to extract array elements).
130
+ def get_object_properties(remote_object_id)
131
+ page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
132
+ end
133
+
134
+ # Release a remote object reference to free V8 memory. Cleanup is
135
+ # best-effort: callers wrap their work in `ensure release_object(...)`,
136
+ # so a TimeoutError or transport hiccup here must not propagate out of
137
+ # the ensure block and bury the original failure.
138
+ def release_object(remote_object_id)
139
+ page_command("Runtime.releaseObject", objectId: remote_object_id)
140
+ rescue Error
141
+ # Object may already be released, context destroyed, or the CDP call
142
+ # itself timed out / failed in transport.
143
+ end
144
+
145
+ private
146
+
147
+ def serialize_argument(arg)
148
+ if arg.respond_to?(:remote_object_id)
149
+ { objectId: arg.remote_object_id }
150
+ else
151
+ { value: arg }
152
+ end
153
+ end
154
+
155
+ # Extract the by-value result of an already-issued Runtime call.
156
+ def handle_evaluate_response(response, expression)
157
+ raise_on_js_error!("handle_evaluate_response", expression, response)
158
+
159
+ result = response["result"]
160
+ return nil if result["type"] == "undefined"
161
+
162
+ result["value"]
163
+ end
164
+
165
+ # Run a wrapped function via Runtime.callFunctionOn with `arguments` bound.
166
+ # `args` is converted via `serialize_argument` (Nodes → objectId, scalars → value).
167
+ # When `return_by_value: false` (the default) the return value is unwrapped via
168
+ # `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
169
+ # hashes the Driver can wrap as Capybara nodes.
170
+ def call_with_args(function_declaration, args, return_by_value: false)
171
+ # document_object_id returns a fresh RemoteObject handle every call.
172
+ # Release it on the way out so long-running shared-spec sessions don't
173
+ # accumulate orphaned V8 handles between resets.
174
+ doc_oid = document_object_id
175
+ params = {
176
+ objectId: doc_oid,
177
+ functionDeclaration: function_declaration,
178
+ returnByValue: return_by_value,
179
+ awaitPromise: true,
180
+ arguments: args.map { |a| serialize_argument(a) },
181
+ }
182
+ response = page_command("Runtime.callFunctionOn", **params)
183
+ raise_on_js_error!("call_with_args", function_declaration, response)
184
+
185
+ return unwrap_call_result(response["result"]) unless return_by_value
186
+
187
+ handle_evaluate_response(response, function_declaration)
188
+ ensure
189
+ release_object(doc_oid) if doc_oid
190
+ end
191
+
192
+ # Translate a non-by-value Runtime result into a plain Ruby value, surfacing
193
+ # DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
194
+ # them. The sentinel key (rather than a plain "objectId") prevents
195
+ # misclassifying user JS that legitimately returns `{ objectId: "x" }`.
196
+ #
197
+ # When the result carries an objectId we can't unwrap (function, regexp,
198
+ # date, …), release the handle before falling back to `result["value"]`
199
+ # so V8 doesn't accumulate orphaned references across long sessions.
200
+ def unwrap_call_result(result)
201
+ return nil if result["type"] == "undefined"
202
+ return nil if result["subtype"] == "null"
203
+
204
+ object_id = result["objectId"]
205
+ if object_id
206
+ return { NODE_MARKER => object_id } if result["subtype"] == "node"
207
+ return serialize_remote_array(object_id) if result["subtype"] == "array"
208
+ return serialize_remote_object(object_id) if result["type"] == "object"
209
+
210
+ release_object(object_id)
211
+ end
212
+
213
+ result["value"]
214
+ end
215
+
216
+ # Re-fetch a remote object as JSON-serializable value for plain objects/arrays.
217
+ # Cheaper than walking properties and good enough for shared specs. Releases
218
+ # the original handle so long-lived sessions don't accumulate leaked objectIds.
219
+ def serialize_remote_object(object_id)
220
+ json = page_command(
221
+ "Runtime.callFunctionOn",
222
+ objectId: object_id,
223
+ functionDeclaration: "function() { return this }",
224
+ returnByValue: true
225
+ )
226
+ handle_evaluate_response(json, "function() { return this }")
227
+ ensure
228
+ release_object(object_id)
229
+ end
230
+
231
+ # Walk an array's own indexed properties via `Runtime.getProperties`,
232
+ # unwrapping each element through the regular result pipeline so that
233
+ # DOM-node entries surface as `{ "__lightpanda_node__" => ... }` instead
234
+ # of being flattened to `{}` by `returnByValue: true`. Releases the
235
+ # outer array's objectId once we've harvested its elements.
236
+ def serialize_remote_array(object_id)
237
+ properties = get_object_properties(object_id).fetch("result", [])
238
+ properties
239
+ .select { |p| p["enumerable"] && p["name"] =~ /\A\d+\z/ }
240
+ .sort_by { |p| p["name"].to_i }
241
+ .map { |p| unwrap_call_result(p["value"] || {}) }
242
+ ensure
243
+ release_object(object_id)
244
+ end
245
+
246
+ # objectId of `document`, used as the `this` context for callFunctionOn when
247
+ # we need `arguments` binding but don't care about `this`. Re-resolved per
248
+ # call because the document objectId is invalidated by navigation.
249
+ def document_object_id
250
+ result = page_command("Runtime.evaluate", expression: "document", returnByValue: false)
251
+ result.dig("result", "objectId")
252
+ end
253
+
254
+ private :raise_on_js_error!, :debug_js_failure, :get_object_properties
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Browser
6
+ # Selenium-shaped entry points onto capability the gem already has under
7
+ # its own names.
8
+ #
9
+ # Rails suites reach through `page.driver.browser.<x>` in *shared* helpers
10
+ # — `expect_no_js_errors` (the widely-copied "catch JavaScript errors in
11
+ # your system tests" helper) calls `browser.logs.get(:browser)`, and the
12
+ # axe-core accessibility matchers call `browser.execute_async_script`.
13
+ # Both are driver-agnostic in intent but Selenium-named in fact, so on
14
+ # this driver they raised NoMethodError — which takes out every example in
15
+ # the file before a single real assertion runs. Two aliases buy back whole
16
+ # spec files (decidim's `account_spec.rb` + the shared "accessible page"
17
+ # examples, real-apps run 30116365373).
18
+ #
19
+ # Scope is deliberately narrow: alias what maps cleanly onto an existing
20
+ # API, and raise something *actionable* where the underlying model
21
+ # genuinely differs (see #switch_to). We do not grow a Selenium
22
+ # emulation layer.
23
+ module SeleniumCompat
24
+ # Selenium's `Logs#get(:browser)` shape over Browser#console_logs.
25
+ # `level` is the Selenium severity string ("SEVERE"/"WARNING"/…), not
26
+ # the CDP console type, because that is what callers compare against.
27
+ LogEntry = Struct.new(:level, :message, :timestamp) do
28
+ def to_s
29
+ "#{timestamp} #{level} #{message}"
30
+ end
31
+ end
32
+
33
+ # CDP `Runtime.consoleAPICalled` type -> Selenium log level. Anything
34
+ # unlisted (log, info, dir, table, …) is INFO, matching Chrome. "warn"
35
+ # is mapped alongside "warning" because Lightpanda emitted the former
36
+ # before upstream #2731.
37
+ CONSOLE_LEVELS = {
38
+ "error" => "SEVERE",
39
+ "assert" => "SEVERE",
40
+ "warning" => "WARNING",
41
+ "warn" => "WARNING",
42
+ "debug" => "DEBUG",
43
+ "trace" => "DEBUG",
44
+ }.freeze
45
+
46
+ # Selenium's `driver.logs` accessor. Only the `:browser` type has a
47
+ # meaning here; other Selenium log types (`:driver`, `:client`,
48
+ # `:server`) have no analogue and read as empty rather than raising, so
49
+ # a helper that probes several types still works.
50
+ class Logs
51
+ def initialize(browser)
52
+ @browser = browser
53
+ end
54
+
55
+ def get(type = :browser)
56
+ return [] unless type.to_sym == :browser
57
+
58
+ @browser.console_logs.map do |entry|
59
+ LogEntry.new(
60
+ CONSOLE_LEVELS.fetch(entry[:type], "INFO"),
61
+ entry[:text],
62
+ entry[:timestamp]
63
+ )
64
+ end
65
+ end
66
+
67
+ def available_types
68
+ [:browser]
69
+ end
70
+ end
71
+
72
+ # NOTE: unlike Chrome's `getLog`, this does NOT drain the buffer —
73
+ # repeated calls re-report the same entries. Draining would let an
74
+ # earlier reader silently swallow a JS error from a later assertion,
75
+ # and the buffer is already scoped to the session (cleared by
76
+ # Driver#reset!). Use #clear_console_logs for an explicit reset.
77
+ #
78
+ # Not memoized on purpose: Logs holds nothing but a back-reference, and
79
+ # an ivar here would have to be declared in Browser#initialize per the
80
+ # "all ivars initialized in the constructor" rule for these modules.
81
+ def logs
82
+ Logs.new(self)
83
+ end
84
+
85
+ # Selenium's `execute_async_script`: the script receives a completion
86
+ # callback as its last argument. Same contract as
87
+ # Driver#evaluate_async_script, minus the DOM-node unwrapping — callers
88
+ # on this path (axe-core et al.) hand back plain JSON.
89
+ def execute_async_script(script, *)
90
+ evaluate_async(script.to_s.strip, *)
91
+ end
92
+
93
+ # Selenium's raw-CDP escape hatch, as used by
94
+ # `page.driver.browser.execute_cdp("Network.setBlockedURLs", urls: [...])`.
95
+ # Scoped to the page session, matching Selenium — that covers the
96
+ # Page / Runtime / DOM / Network / Emulation domains a suite would
97
+ # reach for. Browser-scoped commands (Target.*, Browser.*) still go
98
+ # through Browser#command.
99
+ #
100
+ # Deliberately unvalidated: the point is to reach CDP surface the gem
101
+ # has not wrapped yet, so an unknown method should fail with
102
+ # Lightpanda's own error rather than one of ours.
103
+ def execute_cdp(method, **params)
104
+ page_command(method.to_s, **params)
105
+ end
106
+
107
+ # Selenium's post-hoc `switch_to.alert` cannot be honored: Lightpanda
108
+ # requires the accept/dismiss response to be armed BEFORE the action
109
+ # that opens the dialog (LP.handleJavaScriptDialog, upstream #2261 —
110
+ # Page.handleJavaScriptDialog deliberately errors), so by the time a
111
+ # caller could ask for the alert there is nothing left to answer. Fake
112
+ # it and the dialog silently keeps whatever default it already took.
113
+ # Raise with the migration instead of NoMethodError.
114
+ def switch_to
115
+ raise ::Capybara::NotSupportedByDriverError,
116
+ "Lightpanda arms dialog responses before the triggering action, so Selenium's " \
117
+ "post-hoc switch_to.alert has nothing to act on. Wrap the action instead: " \
118
+ "accept_alert/accept_confirm/accept_prompt (or Driver#accept_modal/#dismiss_modal), " \
119
+ "which pre-arm the response and still expose the dialog text."
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end