capybara-lightpanda 0.1.0 → 0.2.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
+ require "uri"
4
5
 
5
6
  module Capybara
6
7
  module Lightpanda
@@ -16,6 +17,7 @@ module Capybara
16
17
  @app = app
17
18
  @options = options
18
19
  @browser = nil
20
+ @started = false
19
21
  end
20
22
 
21
23
  def browser
@@ -30,6 +32,7 @@ module Capybara
30
32
  end
31
33
 
32
34
  def visit(url)
35
+ @started = true
33
36
  browser.go_to(url)
34
37
  end
35
38
 
@@ -84,11 +87,26 @@ module Capybara
84
87
  unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args)))
85
88
  end
86
89
 
90
+ # -- Network Inspection --
91
+
92
+ # Network tracker (lazily auto-enabled). Exposes `traffic`, `clear`,
93
+ # `wait_for_idle`, header overrides, etc. Cuprite parity.
94
+ def network
95
+ browser.network
96
+ end
97
+
98
+ # Block until in-flight HTTP traffic settles. Auto-enables the tracker
99
+ # on first call so callers don't have to remember to flip it on.
100
+ # Returns true on success, false on timeout.
101
+ def wait_for_network_idle(timeout: 5, connections: 0)
102
+ network.enable
103
+ network.wait_for_idle(timeout: timeout, connections: connections)
104
+ end
105
+
87
106
  # -- Cookie Management --
88
107
 
89
108
  def set_cookie(name, value, **options)
90
- cookie_options = {}
91
- cookie_options[:domain] = options[:domain] if options[:domain]
109
+ cookie_options = { domain: options[:domain] || default_domain }
92
110
  cookie_options[:path] = options[:path] if options[:path]
93
111
  cookie_options[:secure] = options[:secure] if options.key?(:secure)
94
112
  if options.key?(:httpOnly) || options.key?(:http_only)
@@ -182,6 +200,7 @@ module Capybara
182
200
  browser.clear_frames
183
201
  browser.reset_modals
184
202
  browser.cookies.clear
203
+ browser.network.clear
185
204
  browser.go_to("about:blank")
186
205
  rescue StandardError
187
206
  @browser&.quit
@@ -232,6 +251,24 @@ module Capybara
232
251
  args.map { |a| a.is_a?(Capybara::Node::Element) ? a.base : a }
233
252
  end
234
253
 
254
+ # Lightpanda's `Network.setCookie` requires either `domain` or `url`
255
+ # (storage.zig → Cookie.parseDomain). When the caller doesn't supply one,
256
+ # use the host of the current page if any, else `Capybara.app_host`,
257
+ # else loopback. Cuprite parity — lets pre-visit cookie setup just work.
258
+ def default_domain
259
+ candidate = (@started && safe_uri_host(browser.current_url)) ||
260
+ safe_uri_host(Capybara.app_host)
261
+ candidate || "127.0.0.1"
262
+ end
263
+
264
+ def safe_uri_host(url)
265
+ return nil if url.nil? || url.empty? || url == "about:blank"
266
+
267
+ URI(url).host
268
+ rescue URI::InvalidURIError
269
+ nil
270
+ end
271
+
235
272
  # Walk through evaluate-script results turning DOM-node markers (the
236
273
  # `{ "__lightpanda_node__" => "..." }` hashes produced by `Browser#unwrap_call_result`)
237
274
  # into Lightpanda::Node instances so Capybara can wrap them as elements.
@@ -9,34 +9,52 @@ module Capybara
9
9
  class BinaryError < Error; end
10
10
  class UnsupportedPlatformError < Error; end
11
11
 
12
- class DeadBrowserError < Error; end
13
12
  class TimeoutError < Error; end
14
13
 
14
+ # Base class for any error originating from a CDP response or live browser
15
+ # state. Lets callers `rescue BrowserError` to catch the whole CDP family
16
+ # in one go (mirrors ferrum's hierarchy). Accepts either a CDP error hash
17
+ # (`{"message" => ..., "code" => ...}`) or a plain string for callsites
18
+ # that raise with a literal message.
15
19
  class BrowserError < Error
16
20
  attr_reader :response
17
21
 
18
- def initialize(response)
19
- @response = response
20
- super(response["message"])
22
+ def initialize(response_or_message = nil)
23
+ if response_or_message.is_a?(Hash)
24
+ @response = response_or_message
25
+ super(response_or_message["message"])
26
+ else
27
+ @response = nil
28
+ super
29
+ end
30
+ end
31
+
32
+ def code
33
+ @response&.dig("code")
34
+ end
35
+
36
+ def data
37
+ @response&.dig("data")
21
38
  end
22
39
  end
23
40
 
24
- class JavaScriptError < Error
25
- attr_reader :class_name, :message
41
+ class DeadBrowserError < BrowserError; end
42
+ class NodeNotFoundError < BrowserError; end
43
+ class NoExecutionContextError < BrowserError; end
44
+
45
+ class JavaScriptError < BrowserError
46
+ attr_reader :class_name, :stack_trace
26
47
 
27
48
  def initialize(response)
28
49
  @class_name = response.dig("exceptionDetails", "exception", "className")
29
- @message = response.dig("exceptionDetails", "exception",
30
- "description") || response.dig("exceptionDetails", "text")
31
-
32
- super(@message)
50
+ @stack_trace = response.dig("exceptionDetails", "stackTrace")
51
+ message = response.dig("exceptionDetails", "exception", "description") ||
52
+ response.dig("exceptionDetails", "text")
53
+ super(message)
33
54
  end
34
55
  end
35
56
 
36
- class NodeNotFoundError < Error; end
37
- class NoExecutionContextError < Error; end
38
-
39
- class ObsoleteNode < Error
57
+ class ObsoleteNode < BrowserError
40
58
  attr_reader :node
41
59
 
42
60
  def initialize(node, message = nil)
@@ -45,7 +63,7 @@ module Capybara
45
63
  end
46
64
  end
47
65
 
48
- class MouseEventFailed < Error
66
+ class MouseEventFailed < BrowserError
49
67
  attr_reader :node, :selector, :position
50
68
 
51
69
  PATTERN = /at position \((\d+),\s*(\d+)\).*selector:\s*(.+)/i
@@ -822,43 +822,36 @@
822
822
  // cascade. Available in iframes too because index.js is registered via
823
823
  // Page.addScriptToEvaluateOnNewDocument.
824
824
 
825
- // Returns true if `el` is visible per Capybara's semantics: not in an
826
- // unrendered tag (HEAD/SCRIPT/TEMPLATE/etc), no `hidden` attribute on
827
- // self/ancestor, no closed-<details> ancestor, not visibility:hidden, and
828
- // (when checkVisibility() is unavailable) not display:none.
825
+ // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
826
+ // UA stylesheet (PR #2294, nightly ≥5918) puts `display:none` on
827
+ // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE and `[type=hidden]`, and
828
+ // `[hidden]` / closed-<details> children also resolve to `display:none`
829
+ // through the cascade — so a single display:none walk catches the
830
+ // unrendered-element cases without an explicit tag list. `visibility`
831
+ // and offsetParent stay explicit because they aren't covered by display.
832
+ // `checkVisibility()` does the parent walk for us when available.
829
833
  //
830
834
  // `opts.checkOffsetParent` (default true): when true, also rejects
831
- // elements with offsetParent === null as a fallback for ancestor display:none.
832
- // The visible_text walker passes false because it already short-circuits
833
- // when descending into hidden subtrees.
835
+ // elements with `offsetParent === null` as a fallback for ancestor
836
+ // `display:none`. The visible_text walker passes false because it
837
+ // already short-circuits when descending into hidden subtrees.
834
838
  isVisible: function(el, opts) {
835
839
  if (!el || el.nodeType !== 1) return false;
836
840
  var checkOffsetParent = !opts || opts.checkOffsetParent !== false;
837
841
  var TAG = (el.tagName || '').toUpperCase();
838
- if (TAG === 'HEAD' || TAG === 'TEMPLATE' || TAG === 'NOSCRIPT' ||
839
- TAG === 'SCRIPT' || TAG === 'STYLE' || TAG === 'TITLE') return false;
840
- if (TAG === 'INPUT' && (el.type || '').toLowerCase() === 'hidden') return false;
841
-
842
- var node = el;
843
- while (node && node.nodeType === 1) {
844
- if (node.hasAttribute && node.hasAttribute('hidden')) return false;
845
- var parent = node.parentNode;
846
- if (parent && parent.nodeType === 1) {
847
- var ptag = (parent.tagName || '').toUpperCase();
848
- if (ptag === 'HEAD' || ptag === 'TEMPLATE' || ptag === 'NOSCRIPT') return false;
849
- if (ptag === 'DETAILS' && !parent.open) {
850
- var ntag = (node.tagName || '').toUpperCase();
851
- if (ntag !== 'SUMMARY') return false;
852
- }
853
- }
854
- node = parent;
855
- }
856
842
 
857
843
  var win = el.ownerDocument.defaultView || window;
858
844
  var style = win.getComputedStyle(el);
859
845
  if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
860
- if (typeof el.checkVisibility === 'function') return el.checkVisibility();
861
- if (style.display === 'none') return false;
846
+ if (typeof el.checkVisibility === 'function') {
847
+ if (!el.checkVisibility()) return false;
848
+ } else {
849
+ var node = el;
850
+ while (node && node.nodeType === 1) {
851
+ if (win.getComputedStyle(node).display === 'none') return false;
852
+ node = node.parentElement;
853
+ }
854
+ }
862
855
  if (checkOffsetParent && el.offsetParent === null && style.position !== 'fixed' &&
863
856
  TAG !== 'BODY' && TAG !== 'HTML') return false;
864
857
  return true;
@@ -893,36 +886,18 @@
893
886
  return !el.contains(hit);
894
887
  },
895
888
 
896
- // HTML defines a disabled form control as one whose own `disabled`
897
- // attribute is set OR whose ancestor select/optgroup/fieldset is disabled
898
- // (with a fieldset-disabled exception for descendants of its first legend).
899
- // `el.disabled` only reflects the element's own attribute, so we walk
900
- // up the tree to honor the inherited cases.
889
+ // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
890
+ // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
891
+ // is reported as disabled, even though those don't match CSS `:disabled`
892
+ // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
901
893
  isDisabled: function(el) {
902
- if (el.disabled) return true;
903
- var tag = (el.tagName || '').toUpperCase();
904
- if (tag === 'OPTION') {
894
+ if (el.matches && el.matches(':disabled')) return true;
895
+ if ((el.tagName || '').toUpperCase() === 'OPTION') {
905
896
  var p = el.parentElement;
906
- while (p && (p.tagName || '').toUpperCase() === 'OPTGROUP') {
897
+ while (p) {
907
898
  if (p.disabled) return true;
908
899
  p = p.parentElement;
909
900
  }
910
- if (p && (p.tagName || '').toUpperCase() === 'SELECT' && p.disabled) return true;
911
- }
912
- var FORM = { INPUT:1, BUTTON:1, SELECT:1, TEXTAREA:1, OPTION:1 };
913
- if (FORM[tag]) {
914
- var node = el.parentElement;
915
- while (node) {
916
- if ((node.tagName || '').toUpperCase() === 'FIELDSET' && node.disabled) {
917
- var firstLegend = null;
918
- for (var c = node.firstElementChild; c; c = c.nextElementSibling) {
919
- if ((c.tagName || '').toUpperCase() === 'LEGEND') { firstLegend = c; break; }
920
- }
921
- if (firstLegend && firstLegend.contains(el)) return false;
922
- return true;
923
- }
924
- node = node.parentElement;
925
- }
926
901
  }
927
902
  return false;
928
903
  },
@@ -988,7 +963,12 @@
988
963
  for (var i = 0; i < node.childNodes.length; i++) {
989
964
  out += walk(node.childNodes[i]);
990
965
  }
991
- if (isBlock) out = '\n' + out + '\n';
966
+ // Block-level elements get wrapped in \n…\n only when they actually
967
+ // contribute visible text. An empty <div> between two inline siblings
968
+ // would otherwise introduce a phantom line break that Chrome's
969
+ // innerText algorithm collapses out (required line breaks around
970
+ // empty blocks coalesce in the line-collapse pass).
971
+ if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
992
972
  return out;
993
973
  }
994
974
 
@@ -1015,94 +995,4 @@
1015
995
  };
1016
996
  };
1017
997
  }
1018
-
1019
- // --- ID-shorthand rewriter for querySelector/querySelectorAll ---
1020
- // Lightpanda has a CSS-engine bug where `#id` selectors fail after the body is
1021
- // modified via innerHTML and then replaced (e.g. Turbo Drive's snapshot+swap).
1022
- // `[id="..."]` always works, so rewrite `#foo` -> `[id="foo"]` outside of
1023
- // brackets and quoted strings before delegating to the native engine.
1024
-
1025
- (function() {
1026
- if (typeof Document === 'undefined' || typeof Element === 'undefined') return;
1027
-
1028
- function rewriteIdShorthand(selector) {
1029
- if (typeof selector !== 'string' || selector.indexOf('#') < 0) return selector;
1030
- var out = '', i = 0, n = selector.length, depth = 0, quote = null;
1031
- while (i < n) {
1032
- var c = selector.charAt(i);
1033
- if (quote) {
1034
- out += c;
1035
- if (c === '\\' && i + 1 < n) { out += selector.charAt(i + 1); i += 2; continue; }
1036
- if (c === quote) quote = null;
1037
- i++; continue;
1038
- }
1039
- if (c === '"' || c === "'") { quote = c; out += c; i++; continue; }
1040
- if (c === '[') { depth++; out += c; i++; continue; }
1041
- if (c === ']') { if (depth > 0) depth--; out += c; i++; continue; }
1042
- if (c === '\\' && i + 1 < n) { out += c + selector.charAt(i + 1); i += 2; continue; }
1043
- if (c === '#' && depth === 0) {
1044
- var j = i + 1, start = j;
1045
- while (j < n) {
1046
- var cc = selector.charCodeAt(j);
1047
- if (cc === 92 && j + 1 < n) { j += 2; continue; }
1048
- // CSS identifier chars: A-Z a-z 0-9 _ - or non-ASCII (>= 128)
1049
- if ((cc >= 48 && cc <= 57) || (cc >= 65 && cc <= 90) ||
1050
- (cc >= 97 && cc <= 122) || cc === 45 || cc === 95 || cc >= 128) { j++; continue; }
1051
- break;
1052
- }
1053
- if (j > start) {
1054
- var id = selector.substring(start, j);
1055
- out += '[id="' + id.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
1056
- i = j; continue;
1057
- }
1058
- }
1059
- out += c; i++;
1060
- }
1061
- return out;
1062
- }
1063
-
1064
- function patch(proto) {
1065
- if (!proto) return;
1066
- var origQS = proto.querySelector;
1067
- var origQSA = proto.querySelectorAll;
1068
- if (typeof origQS === 'function') {
1069
- proto.querySelector = function(s) { return origQS.call(this, rewriteIdShorthand(s)); };
1070
- }
1071
- if (typeof origQSA === 'function') {
1072
- proto.querySelectorAll = function(s) { return origQSA.call(this, rewriteIdShorthand(s)); };
1073
- }
1074
- }
1075
-
1076
- patch(Document.prototype);
1077
- patch(Element.prototype);
1078
- if (typeof DocumentFragment !== 'undefined') patch(DocumentFragment.prototype);
1079
- })();
1080
-
1081
- // --- requestSubmit polyfill ---
1082
- // Required for Turbo form interception. Turbo listens for the `submit` event,
1083
- // but form.submit() doesn't fire it. requestSubmit() does.
1084
-
1085
- if (typeof HTMLFormElement !== 'undefined' && !HTMLFormElement.prototype.requestSubmit) {
1086
- HTMLFormElement.prototype.requestSubmit = function(submitter) {
1087
- if (submitter) {
1088
- var validTypes = {submit: 1, image: 1};
1089
- if (!validTypes[(submitter.type || '').toLowerCase()]) {
1090
- throw new TypeError('The specified element is not a submit button.');
1091
- }
1092
- if (submitter.form !== this) {
1093
- throw new DOMException('The specified element is not owned by this form element.', 'NotFoundError');
1094
- }
1095
- }
1096
- var event;
1097
- if (typeof SubmitEvent === 'function') {
1098
- event = new SubmitEvent('submit', {bubbles: true, cancelable: true, submitter: submitter || null});
1099
- } else {
1100
- event = new Event('submit', {bubbles: true, cancelable: true});
1101
- event.submitter = submitter || null;
1102
- }
1103
- if (this.dispatchEvent(event)) {
1104
- this.submit();
1105
- }
1106
- };
1107
- }
1108
998
  })();
@@ -73,18 +73,59 @@ module Capybara
73
73
  @browser = browser
74
74
  end
75
75
 
76
+ # A top-level modifier symbol (`:shift`, `:ctrl`, `:alt`, `:meta`) is
77
+ # held for the remainder of the call — `send_keys('ocean', :shift, 'side')`
78
+ # types `oceanSIDE`. Modifier presses fire `keyDown` (not `rawKeyDown`)
79
+ # so JS keydown handlers see the modifier event; CDP's `rawKeyDown` is
80
+ # documented as "no JS keyDown event is generated" and would hide the
81
+ # modifier from listeners that count keydown events.
76
82
  def type(*keys)
83
+ active_mods = []
77
84
  keys.each do |key|
78
85
  case key
79
- when Symbol then dispatch_key(key)
80
- when String then key.each_char { |char| dispatch_char(char) }
81
- when Array then type_with_modifiers(key)
86
+ when Symbol
87
+ if MODIFIERS.key?(key)
88
+ press_modifier(key, active_mods)
89
+ else
90
+ dispatch_key_with_mods(key, active_mods)
91
+ end
92
+ when String
93
+ key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
94
+ when Array
95
+ type_with_modifiers(key)
82
96
  end
83
97
  end
98
+ release_modifiers(active_mods)
84
99
  end
85
100
 
86
101
  private
87
102
 
103
+ def press_modifier(mod, active_mods)
104
+ return if active_mods.include?(mod)
105
+
106
+ send_key_event("keyDown", KEYS[mod])
107
+ active_mods << mod
108
+ end
109
+
110
+ def release_modifiers(active_mods)
111
+ active_mods.reverse_each { |m| send_key_event("keyUp", KEYS[m]) }
112
+ active_mods.clear
113
+ end
114
+
115
+ def dispatch_key_with_mods(key, active_mods)
116
+ return dispatch_key(key) if active_mods.empty?
117
+
118
+ modifier_value = active_mods.sum { |m| MODIFIERS[m] }
119
+ dispatch_modified(key, modifier_value, active_mods)
120
+ end
121
+
122
+ def dispatch_char_with_mods(char, active_mods)
123
+ return dispatch_char(char) if active_mods.empty?
124
+
125
+ modifier_value = active_mods.sum { |m| MODIFIERS[m] }
126
+ dispatch_modified_char(char, modifier_value, active_mods)
127
+ end
128
+
88
129
  def dispatch_key(key)
89
130
  definition = KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
90
131
  raw_dispatch(definition)
@@ -98,7 +139,7 @@ module Capybara
98
139
  modifiers, chars = keys.partition { |k| k.is_a?(Symbol) && MODIFIERS.key?(k) }
99
140
  modifier_value = modifiers.sum { |m| MODIFIERS[m] }
100
141
 
101
- modifiers.each { |m| send_key_event("rawKeyDown", KEYS[m]) }
142
+ modifiers.each { |m| send_key_event("keyDown", KEYS[m]) }
102
143
  chars.each { |key| dispatch_modified(key, modifier_value, modifiers) }
103
144
  modifiers.reverse_each { |m| send_key_event("keyUp", KEYS[m]) }
104
145
  end