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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
- data/lib/capybara/lightpanda/binary.rb +72 -54
- data/lib/capybara/lightpanda/browser/console.rb +190 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +150 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +158 -813
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +124 -32
- data/lib/capybara/lightpanda/errors.rb +24 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +103 -18
- data/lib/capybara/lightpanda/node.rb +200 -95
- data/lib/capybara/lightpanda/options.rb +44 -3
- data/lib/capybara/lightpanda/process.rb +154 -29
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +14 -3
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// --- Uncaught page errors ---
|
|
2
|
+
// Lightpanda implements no `Runtime.exceptionThrown` (upstream gap, wishlist
|
|
3
|
+
// B19), so an uncaught exception in page JS reaches no CDP client at all. It is
|
|
4
|
+
// invisible to console_logs, which only ever sees explicit console.* calls, so
|
|
5
|
+
// a handler that dies on a TypeError leaves no trace and the failure surfaces
|
|
6
|
+
// far away as ElementNotFound on whatever it was supposed to produce. That is
|
|
7
|
+
// the exact shape of the solidus taxon-tree failure in real-apps causes.yml.
|
|
8
|
+
//
|
|
9
|
+
// These listeners are passive — no preventDefault, so nothing the page would
|
|
10
|
+
// otherwise see is swallowed, and nothing that would have propagated stops
|
|
11
|
+
// propagating. They re-emit through the same console.debug sentinel channel
|
|
12
|
+
// turbo.js uses, and console.rb routes them into Browser#page_errors rather
|
|
13
|
+
// than #console_logs. Keeping the two apart is deliberate: console_logs stays
|
|
14
|
+
// "the console.* calls the page made", which is what Chrome models (an
|
|
15
|
+
// exception is exceptionThrown, not consoleAPICalled) and what Playwright and
|
|
16
|
+
// Puppeteer expose (pageerror, separate from console). Folding exceptions into
|
|
17
|
+
// console_logs would also break every suite already asserting that buffer is
|
|
18
|
+
// free of errors.
|
|
19
|
+
//
|
|
20
|
+
// Delete this file once upstream emits Runtime.exceptionThrown and the floor
|
|
21
|
+
// covers it: subscribe Browser::Console to that event and feed the same buffer.
|
|
22
|
+
function _reportPageError(fields) {
|
|
23
|
+
try {
|
|
24
|
+
console.debug('__lightpanda_page_error_' + JSON.stringify(fields));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// A field that won't serialize (a getter that throws, a cyclic `reason`)
|
|
27
|
+
// must not turn one page error into two, so report the kind and move on.
|
|
28
|
+
try {
|
|
29
|
+
console.debug('__lightpanda_page_error_' + JSON.stringify({
|
|
30
|
+
kind: fields && fields.kind ? fields.kind : 'error',
|
|
31
|
+
message: '(page error could not be serialized)'
|
|
32
|
+
}));
|
|
33
|
+
} catch (e2) {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
window.addEventListener('error', function (e) {
|
|
38
|
+
// A failed <img>/<script>/<link> fetch fires `error` too, but on the element
|
|
39
|
+
// and without bubbling, so it shouldn't reach this listener. Guard anyway
|
|
40
|
+
// (Lightpanda's dispatch need not match Chrome's exactly) and identify those
|
|
41
|
+
// by their missing string `message` — a resource failure is a network fact
|
|
42
|
+
// and Network#traffic already carries it.
|
|
43
|
+
if (!e || typeof e.message !== 'string') return;
|
|
44
|
+
|
|
45
|
+
_reportPageError({
|
|
46
|
+
kind: 'error',
|
|
47
|
+
message: e.message,
|
|
48
|
+
url: typeof e.filename === 'string' ? e.filename : '',
|
|
49
|
+
line: typeof e.lineno === 'number' ? e.lineno : null,
|
|
50
|
+
column: typeof e.colno === 'number' ? e.colno : null,
|
|
51
|
+
// Cap the stack: it rides a console argument over the CDP WebSocket, and a
|
|
52
|
+
// deep recursion stack can be megabytes.
|
|
53
|
+
stack: e.error && typeof e.error.stack === 'string' ? e.error.stack.slice(0, 4000) : null
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
window.addEventListener('unhandledrejection', function (e) {
|
|
58
|
+
var reason = e ? e.reason : null;
|
|
59
|
+
var message;
|
|
60
|
+
try {
|
|
61
|
+
message = reason && reason.message ? String(reason.message) : String(reason);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
message = '(rejection reason could not be stringified)';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_reportPageError({
|
|
67
|
+
kind: 'unhandledrejection',
|
|
68
|
+
message: message,
|
|
69
|
+
url: '',
|
|
70
|
+
line: null,
|
|
71
|
+
column: null,
|
|
72
|
+
stack: reason && typeof reason.stack === 'string' ? reason.stack.slice(0, 4000) : null
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// --- DOM visibility / state predicates ---
|
|
2
|
+
// Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
|
|
3
|
+
// and the visible_text walker share one implementation of the ancestor
|
|
4
|
+
// cascade. Available in iframes too because the bundle is registered via
|
|
5
|
+
// Page.addScriptToEvaluateOnNewDocument.
|
|
6
|
+
//
|
|
7
|
+
// These are bare top-level function declarations — attach.js exposes them on
|
|
8
|
+
// window._lightpanda. They reference each other by name (visibleText calls
|
|
9
|
+
// isVisible), not through `this`, so they keep working when node.rb invokes
|
|
10
|
+
// `_lightpanda.visibleText(this)` with `this` bound to a DOM element by CDP.
|
|
11
|
+
|
|
12
|
+
// Returns true if `el` is visible per Capybara's semantics. Lightpanda's
|
|
13
|
+
// `checkVisibility()` walks ancestors and rejects `display:none` from
|
|
14
|
+
// inline styles, stylesheets, and the UA sheet (PR #2294 — covers
|
|
15
|
+
// HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
|
|
16
|
+
// closed-<details> children). `visibility:hidden|collapse` is handled
|
|
17
|
+
// separately because checkVisibility's defaults (per spec) ignore it.
|
|
18
|
+
//
|
|
19
|
+
// External `<link rel=stylesheet>` ARE fetched and applied — the gem
|
|
20
|
+
// always passes --enable-external-stylesheets (Lightpanda PR #2487,
|
|
21
|
+
// guaranteed by the build floor) — so stylesheet-driven `display:none`
|
|
22
|
+
// participates in this check. The remaining gap is viewport emulation:
|
|
23
|
+
// `@media` rules and `matchMedia()` evaluate against the hardcoded
|
|
24
|
+
// 1920×1080 viewport (no resize), so visibility gated on a non-desktop
|
|
25
|
+
// viewport — e.g. a mobile-only CTA under `@media (max-width: …)` —
|
|
26
|
+
// resolves like desktop Chrome at 1920×1080. Keep those specs on a
|
|
27
|
+
// full-layout driver (README's dual-driver setup).
|
|
28
|
+
function isVisible(el) {
|
|
29
|
+
if (!el || el.nodeType !== 1) return false;
|
|
30
|
+
var win = el.ownerDocument.defaultView || window;
|
|
31
|
+
var style = win.getComputedStyle(el);
|
|
32
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
33
|
+
return el.checkVisibility();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Returns true if the element is obscured at its center point — i.e.
|
|
37
|
+
// hit-testing elementFromPoint at the center returns something that is
|
|
38
|
+
// not the element or its descendant. `visibility:hidden|collapse` is
|
|
39
|
+
// short-circuited explicitly because those elements still produce a
|
|
40
|
+
// layout box (so the rect-zero check below can't catch them); every
|
|
41
|
+
// other "not rendered" case (display:none, [hidden], descendants of
|
|
42
|
+
// either) falls out naturally because getBoundingClientRect returns
|
|
43
|
+
// DOMRect{0,0,0,0} for elements with no layout box.
|
|
44
|
+
function isObscured(el) {
|
|
45
|
+
var doc = el.ownerDocument;
|
|
46
|
+
var win = doc.defaultView || window;
|
|
47
|
+
var style = win.getComputedStyle(el);
|
|
48
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
49
|
+
var r = el.getBoundingClientRect();
|
|
50
|
+
if (r.width === 0 || r.height === 0) return true;
|
|
51
|
+
var cx = r.left + (r.width / 2);
|
|
52
|
+
var cy = r.top + (r.height / 2);
|
|
53
|
+
var w = win.innerWidth || doc.documentElement.clientWidth;
|
|
54
|
+
var h = win.innerHeight || doc.documentElement.clientHeight;
|
|
55
|
+
if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
|
|
56
|
+
var hit = doc.elementFromPoint(cx, cy);
|
|
57
|
+
if (!hit) return true;
|
|
58
|
+
if (hit === el) return false;
|
|
59
|
+
return !el.contains(hit);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
|
|
63
|
+
// an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
|
|
64
|
+
// is reported as disabled, even though those don't match CSS `:disabled`
|
|
65
|
+
// per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
|
|
66
|
+
function isDisabled(el) {
|
|
67
|
+
if (el.matches && el.matches(':disabled')) return true;
|
|
68
|
+
if ((el.tagName || '').toUpperCase() === 'OPTION') {
|
|
69
|
+
var p = el.parentElement;
|
|
70
|
+
while (p) {
|
|
71
|
+
if (p.disabled) return true;
|
|
72
|
+
p = p.parentElement;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// True if the element is the host of a contenteditable region: it (or any
|
|
79
|
+
// ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
|
|
80
|
+
// native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
|
|
81
|
+
// `false` for every element — it has no caret/keyboard editing pipeline —
|
|
82
|
+
// so the IDL property is useless here and we walk ancestors ourselves.
|
|
83
|
+
function isContentEditable(el) {
|
|
84
|
+
var n = el;
|
|
85
|
+
while (n && n.nodeType === 1) {
|
|
86
|
+
if (n.hasAttribute && n.hasAttribute('contenteditable')) {
|
|
87
|
+
var v = (n.getAttribute('contenteditable') || '').toLowerCase();
|
|
88
|
+
return v !== 'false';
|
|
89
|
+
}
|
|
90
|
+
n = n.parentElement;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Capybara's "visible text" = WebDriver getText semantics, which native
|
|
96
|
+
// innerText alone doesn't provide at two edges: a non-rendered element must
|
|
97
|
+
// yield "" (innerText returns textContent per the HTML getter's step 1), and a
|
|
98
|
+
// ShadowRoot/DocumentFragment has no innerText at all. So we gate on visibility
|
|
99
|
+
// and otherwise delegate the rendered-text collection — block line breaks +
|
|
100
|
+
// display:none descendant skipping + whitespace — to native innerText
|
|
101
|
+
// (Lightpanda #2785/#2795, guaranteed by the build floor). node.rb#visible_text
|
|
102
|
+
// normalizes the whitespace on top.
|
|
103
|
+
function visibleText(el) {
|
|
104
|
+
if (el.nodeType === 1) return isVisible(el) ? el.innerText : '';
|
|
105
|
+
// ShadowRoot / DocumentFragment: no innerText of its own. Concatenate the
|
|
106
|
+
// native innerText of each visible element child with the text nodes between
|
|
107
|
+
// them — collapsing the latter's whitespace to a single space so template
|
|
108
|
+
// newlines between inline children render as a separating space, not a break.
|
|
109
|
+
var out = '';
|
|
110
|
+
for (var i = 0; i < el.childNodes.length; i++) {
|
|
111
|
+
var c = el.childNodes[i];
|
|
112
|
+
if (c.nodeType === 3) out += c.nodeValue.replace(/[\t\n\r\f\v ]+/g, ' ');
|
|
113
|
+
else if (c.nodeType === 1 && isVisible(c)) out += c.innerText;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// --- Turbo activity tracking ---
|
|
2
|
+
// Tracks pending Turbo operations so the driver can wait for Turbo to settle.
|
|
3
|
+
// Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
|
|
4
|
+
// Events are not perfectly symmetrical in Turbo, so we track multiple pairs
|
|
5
|
+
// and use a counter to handle overlapping operations.
|
|
6
|
+
//
|
|
7
|
+
// Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
|
|
8
|
+
// sentinels via console.debug. Browser#wait_for_turbo subscribes to those
|
|
9
|
+
// sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
|
|
10
|
+
// Ruby side can wait event-driven instead of polling.
|
|
11
|
+
//
|
|
12
|
+
// Pages without Turbo never trigger _turboStart, so no sentinels fire and the
|
|
13
|
+
// Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
|
|
14
|
+
var _pendingTurboOps = 0;
|
|
15
|
+
function _signalTurbo(state) {
|
|
16
|
+
try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
|
|
17
|
+
}
|
|
18
|
+
function _turboStart() {
|
|
19
|
+
_pendingTurboOps++;
|
|
20
|
+
if (_pendingTurboOps === 1) _signalTurbo('busy');
|
|
21
|
+
}
|
|
22
|
+
function _turboEnd() {
|
|
23
|
+
if (_pendingTurboOps > 0) {
|
|
24
|
+
_pendingTurboOps--;
|
|
25
|
+
if (_pendingTurboOps === 0) _signalTurbo('idle');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fetch requests (covers Drive, Frames, and Form submission fetches)
|
|
30
|
+
document.addEventListener('turbo:before-fetch-request', _turboStart);
|
|
31
|
+
document.addEventListener('turbo:before-fetch-response', _turboEnd);
|
|
32
|
+
document.addEventListener('turbo:fetch-request-error', _turboEnd);
|
|
33
|
+
|
|
34
|
+
// Form submissions (can outlast their underlying fetch)
|
|
35
|
+
document.addEventListener('turbo:submit-start', _turboStart);
|
|
36
|
+
document.addEventListener('turbo:submit-end', _turboEnd);
|
|
37
|
+
|
|
38
|
+
// Frame rendering (can outlast the fetch that triggered it)
|
|
39
|
+
document.addEventListener('turbo:before-frame-render', _turboStart);
|
|
40
|
+
document.addEventListener('turbo:frame-render', _turboEnd);
|
|
41
|
+
|
|
42
|
+
// Stream rendering (no symmetric end event — wrap the render function)
|
|
43
|
+
document.addEventListener('turbo:before-stream-render', function(event) {
|
|
44
|
+
_turboStart();
|
|
45
|
+
if (event.detail && event.detail.render) {
|
|
46
|
+
var originalRender = event.detail.render;
|
|
47
|
+
event.detail.render = function(streamElement) {
|
|
48
|
+
var result = originalRender(streamElement);
|
|
49
|
+
if (result && typeof result.then === 'function') {
|
|
50
|
+
return result.finally(_turboEnd);
|
|
51
|
+
}
|
|
52
|
+
_turboEnd();
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
} else {
|
|
56
|
+
_turboEnd();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Drive page visits: turbo:load fires after the page is fully rendered.
|
|
61
|
+
// Also serves as a safety reset — clears any counter leaks from aborted fetches.
|
|
62
|
+
// Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
|
|
63
|
+
// call dropped on the floor mid-navigation.
|
|
64
|
+
document.addEventListener('turbo:load', function() {
|
|
65
|
+
_pendingTurboOps = 0;
|
|
66
|
+
_signalTurbo('idle');
|
|
67
|
+
});
|
|
@@ -14,6 +14,7 @@ module Capybara
|
|
|
14
14
|
enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
15
15
|
shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 },
|
|
16
16
|
control: { key: "Control", code: "ControlLeft", keyCode: 17 },
|
|
17
|
+
ctrl: { key: "Control", code: "ControlLeft", keyCode: 17 },
|
|
17
18
|
alt: { key: "Alt", code: "AltLeft", keyCode: 18 },
|
|
18
19
|
pause: { key: "Pause", code: "Pause", keyCode: 19 },
|
|
19
20
|
escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
@@ -120,12 +121,14 @@ module Capybara
|
|
|
120
121
|
def press_modifier(mod, active_mods)
|
|
121
122
|
return if active_mods.include?(mod)
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
# key_definition (not KEYS[mod]) so a MODIFIERS entry missing its
|
|
125
|
+
# KEYS counterpart fails as ArgumentError, not NoMethodError on nil.
|
|
126
|
+
send_key_event("keyDown", key_definition(mod))
|
|
124
127
|
active_mods << mod
|
|
125
128
|
end
|
|
126
129
|
|
|
127
130
|
def release_modifiers(active_mods)
|
|
128
|
-
active_mods.reverse_each { |m| send_key_event("keyUp",
|
|
131
|
+
active_mods.reverse_each { |m| send_key_event("keyUp", key_definition(m)) }
|
|
129
132
|
active_mods.clear
|
|
130
133
|
end
|
|
131
134
|
|
|
@@ -133,7 +136,7 @@ module Capybara
|
|
|
133
136
|
return dispatch_key(key) if active_mods.empty?
|
|
134
137
|
|
|
135
138
|
modifier_value = active_mods.sum { |m| MODIFIERS[m] }
|
|
136
|
-
|
|
139
|
+
raw_dispatch(key_definition(key), modifiers: modifier_value)
|
|
137
140
|
end
|
|
138
141
|
|
|
139
142
|
def dispatch_char_with_mods(char, active_mods)
|
|
@@ -144,31 +147,28 @@ module Capybara
|
|
|
144
147
|
end
|
|
145
148
|
|
|
146
149
|
def dispatch_key(key)
|
|
147
|
-
|
|
148
|
-
raw_dispatch(definition)
|
|
150
|
+
raw_dispatch(key_definition(key))
|
|
149
151
|
end
|
|
150
152
|
|
|
151
153
|
def dispatch_char(char)
|
|
152
154
|
@browser.page_command("Input.insertText", text: char)
|
|
153
155
|
end
|
|
154
156
|
|
|
157
|
+
# An array groups modifiers with the keys they modify —
|
|
158
|
+
# `send_keys([:ctrl, "a"])` — scoped to the array instead of the rest
|
|
159
|
+
# of the call. Same press/dispatch/release machinery as held modifiers.
|
|
155
160
|
def type_with_modifiers(keys)
|
|
156
|
-
modifiers,
|
|
157
|
-
modifier_value = modifiers.sum { |m| MODIFIERS[m] }
|
|
161
|
+
modifiers, rest = keys.partition { |k| k.is_a?(Symbol) && MODIFIERS.key?(k) }
|
|
158
162
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
when Symbol
|
|
167
|
-
definition = KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
|
|
168
|
-
raw_dispatch(definition, modifiers: modifier_value)
|
|
169
|
-
when String
|
|
170
|
-
key.each_char { |char| dispatch_modified_char(char, modifier_value, modifiers) }
|
|
163
|
+
active_mods = []
|
|
164
|
+
modifiers.each { |m| press_modifier(m, active_mods) }
|
|
165
|
+
rest.each do |key|
|
|
166
|
+
case key
|
|
167
|
+
when Symbol then dispatch_key_with_mods(key, active_mods)
|
|
168
|
+
when String then key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
|
|
169
|
+
end
|
|
171
170
|
end
|
|
171
|
+
release_modifiers(active_mods)
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def dispatch_modified_char(char, modifier_value, modifiers)
|
|
@@ -177,6 +177,10 @@ module Capybara
|
|
|
177
177
|
send_key_event("keyUp", { key: text }, modifiers: modifier_value)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
def key_definition(key)
|
|
181
|
+
KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
180
184
|
def raw_dispatch(definition, modifiers: 0)
|
|
181
185
|
type = definition[:text] ? "keyDown" : "rawKeyDown"
|
|
182
186
|
send_key_event(type, definition, modifiers: modifiers)
|
|
@@ -3,25 +3,54 @@
|
|
|
3
3
|
module Capybara
|
|
4
4
|
module Lightpanda
|
|
5
5
|
class Network
|
|
6
|
+
# Tracking is always on (create_page enables it), so the buffer needs
|
|
7
|
+
# the same unbounded-growth cap as Browser's console ring buffer —
|
|
8
|
+
# one very long session would otherwise grow @traffic indefinitely.
|
|
9
|
+
TRAFFIC_LIMIT = 1_000
|
|
10
|
+
|
|
6
11
|
attr_reader :browser
|
|
7
12
|
|
|
13
|
+
# Status/headers of the last top-level document navigation; nil before
|
|
14
|
+
# the first navigation completes. Backs Browser#status_code /
|
|
15
|
+
# #response_headers. Captured by #subscribe's handlers.
|
|
16
|
+
attr_reader :last_navigation_response
|
|
17
|
+
|
|
8
18
|
def initialize(browser)
|
|
9
19
|
@browser = browser
|
|
10
20
|
@traffic = []
|
|
11
21
|
@traffic_mutex = Mutex.new
|
|
12
22
|
@enabled = false
|
|
13
|
-
@request_handler = nil
|
|
14
|
-
@
|
|
23
|
+
@request_handler = @response_handler = nil
|
|
24
|
+
@last_navigation_response = nil
|
|
25
|
+
@document_request_id = nil
|
|
15
26
|
end
|
|
16
27
|
|
|
28
|
+
# The domain toggle is connection-scoped (browser.command), while
|
|
29
|
+
# setExtraHTTPHeaders is session-scoped (browser.page_command) — see
|
|
30
|
+
# #headers=. Browser#create_page calls this, so tracking (traffic AND
|
|
31
|
+
# the navigation-response capture) is on for every page.
|
|
17
32
|
def enable
|
|
18
33
|
return if @enabled
|
|
19
34
|
|
|
20
|
-
|
|
35
|
+
# Subscribe BEFORE flipping the wire toggle (mirror image of
|
|
36
|
+
# #disable's ordering): events can't be emitted while the domain is
|
|
37
|
+
# off, so this order can never miss one. If the command fails
|
|
38
|
+
# (Lightpanda can block commands mid-navigation), roll the handlers
|
|
39
|
+
# back — orphaned duplicates would double-count every request and
|
|
40
|
+
# wedge pending_connections above zero for the session.
|
|
21
41
|
subscribe
|
|
42
|
+
begin
|
|
43
|
+
browser.command("Network.enable")
|
|
44
|
+
rescue StandardError
|
|
45
|
+
unsubscribe
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
22
48
|
@enabled = true
|
|
23
49
|
end
|
|
24
50
|
|
|
51
|
+
# Caveat: disabling the domain also stops the navigation-response
|
|
52
|
+
# capture, so Browser#status_code / #response_headers freeze at their
|
|
53
|
+
# last values until the next #enable.
|
|
25
54
|
def disable
|
|
26
55
|
return unless @enabled
|
|
27
56
|
|
|
@@ -51,8 +80,11 @@ module Capybara
|
|
|
51
80
|
# cleared the Subscriber first.
|
|
52
81
|
def reset
|
|
53
82
|
unsubscribe
|
|
54
|
-
|
|
83
|
+
clear
|
|
55
84
|
@enabled = false
|
|
85
|
+
@extra_headers = nil # the fresh context never received setExtraHTTPHeaders
|
|
86
|
+
@last_navigation_response = nil
|
|
87
|
+
@document_request_id = nil
|
|
56
88
|
end
|
|
57
89
|
|
|
58
90
|
# Headers applied via headers= / add_headers. Backs Driver#headers.
|
|
@@ -111,11 +143,41 @@ module Capybara
|
|
|
111
143
|
|
|
112
144
|
private
|
|
113
145
|
|
|
146
|
+
# CDP events arrive on the message thread while wait_for_idle /
|
|
147
|
+
# pending_connections read from the main thread; serialize all
|
|
148
|
+
# @traffic mutations and reads through @traffic_mutex.
|
|
149
|
+
#
|
|
150
|
+
# The handlers also capture the last top-level navigation response
|
|
151
|
+
# (mirrors capybara-playwright-driver's navigation_request? hook). CDP
|
|
152
|
+
# normally marks the main-document response via
|
|
153
|
+
# `Network.responseReceived.type`. Lightpanda only started emitting that
|
|
154
|
+
# field on responses in build 8318 (#3037) — far above the gem's floor —
|
|
155
|
+
# so match the long way instead: remember the document requestId, store
|
|
156
|
+
# the response whose requestId equals it. Works on every supported build;
|
|
157
|
+
# don't "simplify" it to read response.type until the floor clears 8318.
|
|
114
158
|
def subscribe
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
159
|
+
@request_handler = build_request_handler
|
|
160
|
+
@response_handler = build_response_handler
|
|
161
|
+
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
162
|
+
browser.on("Network.responseReceived", &@response_handler)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Redirects follow Chrome's shape (Lightpanda since #3175, build ≥8602):
|
|
166
|
+
# every followed hop re-emits requestWillBeSent with the SAME requestId
|
|
167
|
+
# and the 3xx riding along as `redirectResponse`; the 3xx never gets a
|
|
168
|
+
# responseReceived of its own. So the hop's event is what closes the
|
|
169
|
+
# previous entry — without it that entry stays `response: nil` forever,
|
|
170
|
+
# pending_connections wedges at ≥1 and every wait_for_idle burns its
|
|
171
|
+
# full timeout after the first redirect (Ferrum's
|
|
172
|
+
# subscribe_request_will_be_sent does the same close). Deliberately NOT
|
|
173
|
+
# fed into @last_navigation_response: status_code must report the final
|
|
174
|
+
# hop, and the redirected requestWillBeSent resets it below anyway.
|
|
175
|
+
def build_request_handler
|
|
176
|
+
lambda do |params|
|
|
177
|
+
if params["type"] == "Document"
|
|
178
|
+
@document_request_id = params["requestId"]
|
|
179
|
+
@last_navigation_response = nil
|
|
180
|
+
end
|
|
119
181
|
entry = {
|
|
120
182
|
request_id: params["requestId"],
|
|
121
183
|
url: params.dig("request", "url"),
|
|
@@ -123,24 +185,47 @@ module Capybara
|
|
|
123
185
|
timestamp: params["timestamp"],
|
|
124
186
|
response: nil,
|
|
125
187
|
}
|
|
126
|
-
@traffic_mutex.synchronize
|
|
188
|
+
@traffic_mutex.synchronize do
|
|
189
|
+
if (redirect = params["redirectResponse"]) && (previous = last_open_entry(params["requestId"]))
|
|
190
|
+
previous[:response] = response_summary(redirect)
|
|
191
|
+
end
|
|
192
|
+
@traffic << entry
|
|
193
|
+
@traffic.shift(@traffic.size - TRAFFIC_LIMIT) if @traffic.size > TRAFFIC_LIMIT
|
|
194
|
+
end
|
|
127
195
|
end
|
|
196
|
+
end
|
|
128
197
|
|
|
129
|
-
|
|
198
|
+
def build_response_handler
|
|
199
|
+
lambda do |params|
|
|
200
|
+
if params["requestId"] == @document_request_id
|
|
201
|
+
@last_navigation_response = {
|
|
202
|
+
status: params.dig("response", "status"),
|
|
203
|
+
headers: params.dig("response", "headers") || {},
|
|
204
|
+
}
|
|
205
|
+
end
|
|
130
206
|
@traffic_mutex.synchronize do
|
|
131
|
-
|
|
207
|
+
# Last open entry, not `find`: after a redirect chain several
|
|
208
|
+
# entries share the requestId and only the newest is still open.
|
|
209
|
+
request = last_open_entry(params["requestId"])
|
|
132
210
|
next unless request
|
|
133
211
|
|
|
134
|
-
request[:response] =
|
|
135
|
-
status: params.dig("response", "status"),
|
|
136
|
-
headers: params.dig("response", "headers"),
|
|
137
|
-
mime_type: params.dig("response", "mimeType"),
|
|
138
|
-
}
|
|
212
|
+
request[:response] = response_summary(params["response"])
|
|
139
213
|
end
|
|
140
214
|
end
|
|
215
|
+
end
|
|
141
216
|
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
# Caller holds @traffic_mutex.
|
|
218
|
+
def last_open_entry(request_id)
|
|
219
|
+
@traffic.reverse_each.find { |t| t[:request_id] == request_id && t[:response].nil? }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def response_summary(response)
|
|
223
|
+
response ||= {}
|
|
224
|
+
{
|
|
225
|
+
status: response["status"],
|
|
226
|
+
headers: response["headers"],
|
|
227
|
+
mime_type: response["mimeType"],
|
|
228
|
+
}
|
|
144
229
|
end
|
|
145
230
|
|
|
146
231
|
def unsubscribe
|