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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +22 -179
- data/lib/capybara/lightpanda/browser.rb +45 -69
- data/lib/capybara/lightpanda/client/web_socket.rb +4 -2
- data/lib/capybara/lightpanda/cookies.rb +14 -53
- data/lib/capybara/lightpanda/driver.rb +39 -2
- data/lib/capybara/lightpanda/errors.rb +33 -15
- data/lib/capybara/lightpanda/javascripts/index.js +33 -143
- data/lib/capybara/lightpanda/keyboard.rb +45 -4
- data/lib/capybara/lightpanda/node.rb +22 -217
- data/lib/capybara/lightpanda/options.rb +9 -1
- data/lib/capybara/lightpanda/process.rb +52 -13
- data/lib/capybara/lightpanda/utils/attempt.rb +30 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +2 -1
|
@@ -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(
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
25
|
-
|
|
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
|
-
@
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
super(
|
|
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
|
|
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 <
|
|
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
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
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
|
|
832
|
-
// The visible_text walker passes false because it
|
|
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')
|
|
861
|
-
|
|
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
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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("
|
|
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
|