capybara-simulated 0.0.4 → 0.0.5
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/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/runtime.js +217 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2fd42015728b77f7d00047652a653b2f5e25f24ed1887912294f391bdad9004
|
|
4
|
+
data.tar.gz: 9005c6bc03bee18e0d38c3a6115415ada9a89bab3f52f7f7e74be890fa2d30c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f5222012752134b6a835796fbcaa86d805b1fa9601d6cee38ba0e33bb524b9d9a36aae5ca365150fd32f58e99e9912130cd6f7692e4fec08bf5519140093ca5
|
|
7
|
+
data.tar.gz: 224718321a97a17de4ea60a706fe3f14fa78c518b2ac2b6c4d713d4b56496220573e4cbd9b92706aaf07397fab9339565d59e2a131353933c5853f4ad5961026
|
data/vendor/js/runtime.js
CHANGED
|
@@ -169,6 +169,26 @@
|
|
|
169
169
|
win.cancelAnimationFrame = (id) => globalThis.clearTimeout(id);
|
|
170
170
|
installValidationMessages(win);
|
|
171
171
|
installMutationObserverPin(win);
|
|
172
|
+
installAttrNodeValue(win);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// happy-dom 20's `Attr` class implements `value` / `textContent` / `name`
|
|
176
|
+
// but skips `nodeValue`, so it inherits `Node.prototype.nodeValue` which
|
|
177
|
+
// returns `null`. The DOM spec says `Attr.nodeValue === Attr.value`
|
|
178
|
+
// (https://dom.spec.whatwg.org/#dom-node-nodevalue), and any XPath engine
|
|
179
|
+
// (including wgxpath) reads attributes via `nodeValue` to compute their
|
|
180
|
+
// string-value — without this shim every attribute compares as the string
|
|
181
|
+
// `"null"` and predicates like `[@id = //label/@for]` collapse to
|
|
182
|
+
// `"null" === "null"` (matches every element with any id).
|
|
183
|
+
function installAttrNodeValue(win) {
|
|
184
|
+
const proto = win.Attr && win.Attr.prototype;
|
|
185
|
+
if (!proto || proto.__csim_node_value) return;
|
|
186
|
+
Object.defineProperty(proto, 'nodeValue', {
|
|
187
|
+
configurable: true,
|
|
188
|
+
get() { return this.value; },
|
|
189
|
+
set(v) { this.value = v; }
|
|
190
|
+
});
|
|
191
|
+
proto.__csim_node_value = true;
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
// happy-dom's MutationObserverListener wraps its dispatch arrow in a
|
|
@@ -1234,6 +1254,8 @@
|
|
|
1234
1254
|
findXPath(xpath, contextId) {
|
|
1235
1255
|
if (!currentDocument) return [];
|
|
1236
1256
|
const root = contextId ? lookup(contextId) : (currentDocument.documentElement || currentDocument);
|
|
1257
|
+
const fast = tryFastXPath(xpath, root);
|
|
1258
|
+
if (fast) return fast.map(track);
|
|
1237
1259
|
try {
|
|
1238
1260
|
const nodes = evaluateXPathToNodes(xpath, root, xpathDomFacade, null, {
|
|
1239
1261
|
// happy-dom places elements in the XHTML namespace.
|
|
@@ -2038,6 +2060,201 @@
|
|
|
2038
2060
|
getParentNode(node) { return node?.parentNode ?? null; }
|
|
2039
2061
|
};
|
|
2040
2062
|
|
|
2063
|
+
// Capybara's xpath gem produces a small set of stable XPath shapes for
|
|
2064
|
+
// its built-in selectors (link, button, link_or_button, select, option,
|
|
2065
|
+
// field, fillable_field, …). Even after the facade fix fontoxpath spends
|
|
2066
|
+
// ~4ms on each of these — partly because the predicates redundantly walk
|
|
2067
|
+
// the whole document for `//label[text=X]/@for`. When we recognise the
|
|
2068
|
+
// pattern we can skip fontoxpath entirely and resolve the match through
|
|
2069
|
+
// happy-dom's native `getElementById` + `querySelectorAll('label[for]')`,
|
|
2070
|
+
// which is O(label-count) instead of O(elements × labels).
|
|
2071
|
+
//
|
|
2072
|
+
// Returns an array of nodes on a hit, or `null` on miss (caller falls
|
|
2073
|
+
// back to fontoxpath).
|
|
2074
|
+
function tryFastXPath(xpath, root) {
|
|
2075
|
+
if (typeof xpath !== 'string') return null;
|
|
2076
|
+
// Capybara appends `(./@<test_id> = 'X')` to every locator predicate when
|
|
2077
|
+
// `Capybara.test_id` is set. The aria-label clause is the standard last
|
|
2078
|
+
// attr; a `)) or (` immediately after means a 6th (test_id) clause was
|
|
2079
|
+
// injected. Bail to fontoxpath in that case — falling through is just
|
|
2080
|
+
// slower, not broken.
|
|
2081
|
+
if (/aria-label\s*(?:=|,) '[^']+'\)\)\s+or\s+\(/.test(xpath)) return null;
|
|
2082
|
+
const ms = NORM_TEXT_RX.exec(xpath);
|
|
2083
|
+
if (ms) return findByNormText(root, ms[1], ms[3], ms[2] === 'contains');
|
|
2084
|
+
const ll = LOCATE_FIELD_RX.exec(xpath);
|
|
2085
|
+
if (ll) return findByLabel(root, ll[1], ll[2]);
|
|
2086
|
+
const lb = LINK_OR_BUTTON_RX.exec(xpath);
|
|
2087
|
+
if (lb) return findLinkOrButton(root, lb[1], lb[2] === 'contains');
|
|
2088
|
+
return null;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// .//TAG[(normalize-space(string(.)) = 'X')]
|
|
2092
|
+
// .//TAG[contains(normalize-space(string(.)), 'X')]
|
|
2093
|
+
// .//TAG[normalize-space(string(.)) = 'X'] (no outer parens — rare)
|
|
2094
|
+
const NORM_TEXT_RX = /^\.\/\/([a-z][\w-]*)\[\(?(contains)?\(?normalize-space\(string\(\.\)\)(?:, ?| = )'((?:[^'\\]|\\.)*)'\)?\)?\]$/;
|
|
2095
|
+
|
|
2096
|
+
function findByNormText(root, tag, text, isContains) {
|
|
2097
|
+
const out = [];
|
|
2098
|
+
const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
2099
|
+
for (const el of root.querySelectorAll(tag)) {
|
|
2100
|
+
const t = norm(el.textContent);
|
|
2101
|
+
if (isContains ? t.indexOf(text) >= 0 : t === text) out.push(el);
|
|
2102
|
+
}
|
|
2103
|
+
return out;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// .//select[((((@id = 'X') or (@name = 'X')) or (@placeholder = 'X'))
|
|
2107
|
+
// or (@id = //label[(normalize-space(string(.)) = 'X')]/@for))
|
|
2108
|
+
// or (@aria-label = 'X'))]
|
|
2109
|
+
// | .//label[(normalize-space(string(.)) = 'X')]//.//select
|
|
2110
|
+
//
|
|
2111
|
+
// Capybara's `:select` selector. The same locator value is repeated in
|
|
2112
|
+
// every clause, so capture once and verify.
|
|
2113
|
+
const LOCATE_FIELD_RX = new RegExp(
|
|
2114
|
+
'^\\.\\/\\/(select|textarea)\\[\\(\\(\\(\\(\\(\\.\\/@id = \'([^\']+)\'\\)' +
|
|
2115
|
+
' or \\(\\.\\/@name = \'\\2\'\\)\\)' +
|
|
2116
|
+
' or \\(\\.\\/@placeholder = \'\\2\'\\)\\)' +
|
|
2117
|
+
' or \\(\\.\\/@id = \\/\\/label\\[\\(normalize-space\\(string\\(\\.\\)\\) = \'\\2\'\\)\\]\\/@for\\)\\)' +
|
|
2118
|
+
' or \\(\\.\\/@aria-label = \'\\2\'\\)\\)\\]' +
|
|
2119
|
+
'(?: \\| \\.\\/\\/label\\[\\(normalize-space\\(string\\(\\.\\)\\) = \'\\2\'\\)\\]\\/\\/\\.\\/\\/\\1)?$'
|
|
2120
|
+
);
|
|
2121
|
+
|
|
2122
|
+
function findByLabel(root, tag, value) {
|
|
2123
|
+
const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
2124
|
+
const matches = new Set();
|
|
2125
|
+
for (const el of root.querySelectorAll(tag)) {
|
|
2126
|
+
if (
|
|
2127
|
+
el.getAttribute('id') === value ||
|
|
2128
|
+
el.getAttribute('name') === value ||
|
|
2129
|
+
el.getAttribute('placeholder') === value ||
|
|
2130
|
+
el.getAttribute('aria-label') === value
|
|
2131
|
+
) matches.add(el);
|
|
2132
|
+
}
|
|
2133
|
+
// <label for="…">text</label> — anywhere in the *document*, then resolve
|
|
2134
|
+
// its target by id and verify the target is inside `root`.
|
|
2135
|
+
const doc = root.ownerDocument || (root.nodeType === 9 ? root : currentDocument);
|
|
2136
|
+
if (doc) {
|
|
2137
|
+
for (const label of doc.querySelectorAll('label[for]')) {
|
|
2138
|
+
if (norm(label.textContent) !== value) continue;
|
|
2139
|
+
const target = doc.getElementById(label.getAttribute('for'));
|
|
2140
|
+
if (
|
|
2141
|
+
target &&
|
|
2142
|
+
(target.tagName || '').toLowerCase() === tag &&
|
|
2143
|
+
(root === doc.documentElement || root === doc || root.contains(target))
|
|
2144
|
+
) matches.add(target);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
// <label>text<select/></label> — descendant of root.
|
|
2148
|
+
for (const label of root.querySelectorAll('label')) {
|
|
2149
|
+
if (norm(label.textContent) !== value) continue;
|
|
2150
|
+
for (const child of label.querySelectorAll(tag)) matches.add(child);
|
|
2151
|
+
}
|
|
2152
|
+
return Array.from(matches);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function unescXp(s) { return s.replace(/\\(.)/g, '$1'); }
|
|
2156
|
+
|
|
2157
|
+
// .//a[./@href][((((@id = 'X') or [normalize-space(string(.)) = 'X' | contains(...)])
|
|
2158
|
+
// or [@title = 'X' | contains(...)]) or .//img[(@alt = 'X' | contains)]) or [@aria-label = 'X' | contains])]
|
|
2159
|
+
// | .//input[type=submit/...][...] | .//label[X]//*[input-or-button]
|
|
2160
|
+
// | .//input[type=image][...] | .//button[...] | .//label[X]//* (dup) | .//input[image] (dup)
|
|
2161
|
+
//
|
|
2162
|
+
// Capybara's `:link_or_button` selector — by far the most common shape we
|
|
2163
|
+
// see (~25% of all xpath calls in a Rails system spec). The detector
|
|
2164
|
+
// anchors on the link clause (5 OR'd predicates ending with @aria-label)
|
|
2165
|
+
// and reads off whether the suite is in exact-match or contains mode.
|
|
2166
|
+
// If the last clause isn't aria-label (e.g. Capybara.test_id is set, or
|
|
2167
|
+
// enable_aria_label is off), we bail and fontoxpath handles it.
|
|
2168
|
+
const LINK_OR_BUTTON_RX = new RegExp(
|
|
2169
|
+
'^\\.\\/\\/a\\[\\.\\/@href\\]' +
|
|
2170
|
+
'\\[\\(\\(\\(\\(\\(' +
|
|
2171
|
+
'\\.\\/@id = \'([^\']+)\'\\)' +
|
|
2172
|
+
' or (?:\\(normalize-space\\(string\\(\\.\\)\\) = \'\\1\'\\)|(contains)\\(normalize-space\\(string\\(\\.\\)\\), \'\\1\'\\))\\)' +
|
|
2173
|
+
' or (?:\\(\\.\\/@title = \'\\1\'\\)|contains\\(\\.\\/@title, \'\\1\'\\))\\)' +
|
|
2174
|
+
' or \\.\\/\\/img\\[(?:\\(\\.\\/@alt = \'\\1\'\\)|contains\\(\\.\\/@alt, \'\\1\'\\))\\]\\)' +
|
|
2175
|
+
' or (?:\\(\\.\\/@aria-label = \'\\1\'\\)|contains\\(\\.\\/@aria-label, \'\\1\'\\))\\)' +
|
|
2176
|
+
'\\]' +
|
|
2177
|
+
' \\| \\.\\/\\/input\\['
|
|
2178
|
+
);
|
|
2179
|
+
|
|
2180
|
+
function findLinkOrButton(root, value, isContains) {
|
|
2181
|
+
const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
2182
|
+
const eq = (s) => isContains ? (s != null && String(s).indexOf(value) >= 0) : (s === value);
|
|
2183
|
+
const eqText = (s) => isContains ? norm(s).indexOf(value) >= 0 : norm(s) === value;
|
|
2184
|
+
const matches = new Set();
|
|
2185
|
+
|
|
2186
|
+
// <a href> by id (always exact), text/title/aria-label, child <img alt>
|
|
2187
|
+
for (const a of root.querySelectorAll('a[href]')) {
|
|
2188
|
+
if (
|
|
2189
|
+
a.getAttribute('id') === value ||
|
|
2190
|
+
eqText(a.textContent) ||
|
|
2191
|
+
eq(a.getAttribute('title')) ||
|
|
2192
|
+
eq(a.getAttribute('aria-label'))
|
|
2193
|
+
) { matches.add(a); continue; }
|
|
2194
|
+
for (const img of a.querySelectorAll('img')) {
|
|
2195
|
+
if (eq(img.getAttribute('alt'))) { matches.add(a); break; }
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// <input type=submit/reset/image/button>
|
|
2200
|
+
for (const input of root.querySelectorAll(
|
|
2201
|
+
'input[type=submit],input[type=reset],input[type=image],input[type=button]'
|
|
2202
|
+
)) {
|
|
2203
|
+
if (
|
|
2204
|
+
input.getAttribute('id') === value ||
|
|
2205
|
+
input.getAttribute('name') === value ||
|
|
2206
|
+
eq(input.getAttribute('value')) ||
|
|
2207
|
+
eq(input.getAttribute('title')) ||
|
|
2208
|
+
eq(input.getAttribute('aria-label'))
|
|
2209
|
+
) matches.add(input);
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// <input type=image> alt / aria-label
|
|
2213
|
+
for (const input of root.querySelectorAll('input[type=image]')) {
|
|
2214
|
+
if (eq(input.getAttribute('alt')) || eq(input.getAttribute('aria-label'))) matches.add(input);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// <button> id/name/value/title/aria-label/text/child <img alt>
|
|
2218
|
+
for (const btn of root.querySelectorAll('button')) {
|
|
2219
|
+
if (
|
|
2220
|
+
btn.getAttribute('id') === value ||
|
|
2221
|
+
btn.getAttribute('name') === value ||
|
|
2222
|
+
eq(btn.getAttribute('value')) ||
|
|
2223
|
+
eq(btn.getAttribute('title')) ||
|
|
2224
|
+
eq(btn.getAttribute('aria-label')) ||
|
|
2225
|
+
eqText(btn.textContent)
|
|
2226
|
+
) { matches.add(btn); continue; }
|
|
2227
|
+
for (const img of btn.querySelectorAll('img')) {
|
|
2228
|
+
if (eq(img.getAttribute('alt'))) { matches.add(btn); break; }
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// <label>text<input|button/></label>
|
|
2233
|
+
for (const label of root.querySelectorAll('label')) {
|
|
2234
|
+
if (!eqText(label.textContent)) continue;
|
|
2235
|
+
for (const c of label.querySelectorAll(
|
|
2236
|
+
'input[type=submit],input[type=reset],input[type=image],input[type=button],button'
|
|
2237
|
+
)) matches.add(c);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// <label for="…">text</label> (anywhere in the document) → input/button
|
|
2241
|
+
const doc = root.ownerDocument || (root.nodeType === 9 ? root : currentDocument);
|
|
2242
|
+
if (doc && doc.querySelectorAll) {
|
|
2243
|
+
for (const label of doc.querySelectorAll('label[for]')) {
|
|
2244
|
+
if (!eqText(label.textContent)) continue;
|
|
2245
|
+
const target = doc.getElementById(label.getAttribute('for'));
|
|
2246
|
+
if (!target) continue;
|
|
2247
|
+
const tag = (target.tagName || '').toLowerCase();
|
|
2248
|
+
const type = ((target.getAttribute && target.getAttribute('type')) || '').toLowerCase();
|
|
2249
|
+
const clickable = (tag === 'input' && /^(submit|reset|image|button)$/.test(type)) || tag === 'button';
|
|
2250
|
+
if (!clickable) continue;
|
|
2251
|
+
if (root === doc.documentElement || root === doc || root.contains(target)) matches.add(target);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
return Array.from(matches);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2041
2258
|
function findViaXPathFallback(xpath, root) {
|
|
2042
2259
|
if (typeof xpath !== 'string') return [];
|
|
2043
2260
|
const m = xpath.match(/^\.?\/\/(?:descendant::)?([A-Za-z*][\w*]*)(?:\[(.+)\])?$/);
|