capybara-simulated 0.0.4 → 0.0.6

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.
data/vendor/js/entry.mjs CHANGED
@@ -1,8 +1,23 @@
1
- // Bundle entry: brings happy-dom and an XPath engine into one IIFE that
2
- // exposes them on a single `__csim_bundle` global. happy-dom does not
3
- // implement document.evaluate, so we layer fontoxpath on top.
1
+ // Bundle entry: brings happy-dom + wgxpath into one IIFE that exposes them
2
+ // on a single `__csim_bundle` global. happy-dom does not implement
3
+ // `document.evaluate`, so wgxpath layers it onto `Document.prototype`.
4
4
  import {Window} from 'happy-dom';
5
5
  import {URL, URLSearchParams} from 'whatwg-url';
6
- import {evaluateXPathToNodes} from 'fontoxpath';
6
+ import wgxpath from 'wicked-good-xpath';
7
7
 
8
- export {Window, URL, URLSearchParams, evaluateXPathToNodes};
8
+ // happy-dom 20's `document` is an `HTMLDocument` instance and the proto
9
+ // chain doesn't include `Document.prototype` (where wgxpath plants
10
+ // `evaluate` / `createExpression` / `createNSResolver`). Mirror the three
11
+ // methods onto `HTMLDocument.prototype` so `document.evaluate` works.
12
+ function installXPath(win) {
13
+ wgxpath.install(win, /* opt_force */ true);
14
+ if (win.HTMLDocument && win.Document) {
15
+ for (const m of ['evaluate', 'createExpression', 'createNSResolver']) {
16
+ if (win.Document.prototype[m] && !win.HTMLDocument.prototype[m]) {
17
+ win.HTMLDocument.prototype[m] = win.Document.prototype[m];
18
+ }
19
+ }
20
+ }
21
+ }
22
+
23
+ export {Window, URL, URLSearchParams, installXPath};
data/vendor/js/runtime.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  (function () {
7
7
  const {Window, URL: WhatwgURL, URLSearchParams: WhatwgURLSearchParams,
8
- evaluateXPathToNodes} = globalThis.__csim_bundle;
8
+ installXPath} = globalThis.__csim_bundle;
9
9
  if (!globalThis.URL) globalThis.URL = WhatwgURL;
10
10
  if (!globalThis.URLSearchParams) globalThis.URLSearchParams = WhatwgURLSearchParams;
11
11
 
@@ -169,6 +169,27 @@
169
169
  win.cancelAnimationFrame = (id) => globalThis.clearTimeout(id);
170
170
  installValidationMessages(win);
171
171
  installMutationObserverPin(win);
172
+ installAttrNodeValue(win);
173
+ installXPath(win);
174
+ }
175
+
176
+ // happy-dom 20's `Attr` class implements `value` / `textContent` / `name`
177
+ // but skips `nodeValue`, so it inherits `Node.prototype.nodeValue` which
178
+ // returns `null`. The DOM spec says `Attr.nodeValue === Attr.value`
179
+ // (https://dom.spec.whatwg.org/#dom-node-nodevalue), and any XPath engine
180
+ // (including wgxpath) reads attributes via `nodeValue` to compute their
181
+ // string-value — without this shim every attribute compares as the string
182
+ // `"null"` and predicates like `[@id = //label/@for]` collapse to
183
+ // `"null" === "null"` (matches every element with any id).
184
+ function installAttrNodeValue(win) {
185
+ const proto = win.Attr && win.Attr.prototype;
186
+ if (!proto || proto.__csim_node_value) return;
187
+ Object.defineProperty(proto, 'nodeValue', {
188
+ configurable: true,
189
+ get() { return this.value; },
190
+ set(v) { this.value = v; }
191
+ });
192
+ proto.__csim_node_value = true;
172
193
  }
173
194
 
174
195
  // happy-dom's MutationObserverListener wraps its dispatch arrow in a
@@ -943,7 +964,6 @@
943
964
  patchWindowGlobals(win);
944
965
  installFetchShim(win);
945
966
  installCustomElementUpgrade(win);
946
- installDocumentEvaluate(win.document);
947
967
  installHistoryTracking(win);
948
968
  currentWindow = win;
949
969
  currentDocument = win.document;
@@ -1234,12 +1254,17 @@
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
- const nodes = evaluateXPathToNodes(xpath, root, xpathDomFacade, null, {
1239
- // happy-dom places elements in the XHTML namespace.
1240
- namespaceResolver: () => 'http://www.w3.org/1999/xhtml'
1241
- });
1242
- return nodes.map(track);
1260
+ const result = currentDocument.evaluate(
1261
+ xpath, root, null,
1262
+ /* UNORDERED_NODE_ITERATOR_TYPE */ 4, null
1263
+ );
1264
+ const ids = [];
1265
+ let n;
1266
+ while ((n = result.iterateNext())) ids.push(track(n));
1267
+ return ids;
1243
1268
  } catch (e) {
1244
1269
  return findViaXPathFallback(xpath, root);
1245
1270
  }
@@ -1933,110 +1958,200 @@
1933
1958
  wrap('replaceState');
1934
1959
  }
1935
1960
 
1936
- // happy-dom does not implement `document.evaluate`. Capybara internals
1937
- // and user scripts both reach for it occasionally. Polyfill it on top of
1938
- // fontoxpath so callers see W3C XPathResult semantics.
1939
- function installDocumentEvaluate(doc) {
1940
- if (typeof doc.evaluate === 'function') return;
1941
- const ANY_TYPE = 0;
1942
- const NUMBER_TYPE = 1;
1943
- const STRING_TYPE = 2;
1944
- const BOOLEAN_TYPE = 3;
1945
- const UNORDERED_NODE_ITERATOR_TYPE = 4;
1946
- const ORDERED_NODE_ITERATOR_TYPE = 5;
1947
- const UNORDERED_NODE_SNAPSHOT_TYPE = 6;
1948
- const ORDERED_NODE_SNAPSHOT_TYPE = 7;
1949
- const ANY_UNORDERED_NODE_TYPE = 8;
1950
- const FIRST_ORDERED_NODE_TYPE = 9;
1951
-
1952
- function makeResult(type, items) {
1953
- return {
1954
- resultType: type,
1955
- snapshotLength: items.length,
1956
- snapshotItem: (i) => items[i] || null,
1957
- get singleNodeValue() { return items[0] || null; },
1958
- iterateNext: (() => { let i = 0; return () => items[i++] || null; })()
1959
- };
1961
+ // Capybara's xpath gem produces a small set of stable XPath shapes for
1962
+ // its built-in selectors (link, button, link_or_button, select, option,
1963
+ // field, fillable_field, …). Even after the facade fix fontoxpath spends
1964
+ // ~4ms on each of these — partly because the predicates redundantly walk
1965
+ // the whole document for `//label[text=X]/@for`. When we recognise the
1966
+ // pattern we can skip fontoxpath entirely and resolve the match through
1967
+ // happy-dom's native `getElementById` + `querySelectorAll('label[for]')`,
1968
+ // which is O(label-count) instead of O(elements × labels).
1969
+ //
1970
+ // Returns an array of nodes on a hit, or `null` on miss (caller falls
1971
+ // back to fontoxpath).
1972
+ function tryFastXPath(xpath, root) {
1973
+ if (typeof xpath !== 'string') return null;
1974
+ // Capybara appends `(./@<test_id> = 'X')` to every locator predicate when
1975
+ // `Capybara.test_id` is set. The aria-label clause is the standard last
1976
+ // attr; a `)) or (` immediately after means a 6th (test_id) clause was
1977
+ // injected. Bail to fontoxpath in that case — falling through is just
1978
+ // slower, not broken.
1979
+ if (/aria-label\s*(?:=|,) '[^']+'\)\)\s+or\s+\(/.test(xpath)) return null;
1980
+ const ms = NORM_TEXT_RX.exec(xpath);
1981
+ if (ms) return findByNormText(root, ms[1], ms[3], ms[2] === 'contains');
1982
+ const ll = LOCATE_FIELD_RX.exec(xpath);
1983
+ if (ll) return findByLabel(root, ll[1], ll[2]);
1984
+ const lb = LINK_OR_BUTTON_RX.exec(xpath);
1985
+ if (lb) return findLinkOrButton(root, lb[1], lb[2] === 'contains');
1986
+ return null;
1987
+ }
1988
+
1989
+ // .//TAG[(normalize-space(string(.)) = 'X')]
1990
+ // .//TAG[contains(normalize-space(string(.)), 'X')]
1991
+ // .//TAG[normalize-space(string(.)) = 'X'] (no outer parens — rare)
1992
+ const NORM_TEXT_RX = /^\.\/\/([a-z][\w-]*)\[\(?(contains)?\(?normalize-space\(string\(\.\)\)(?:, ?| = )'((?:[^'\\]|\\.)*)'\)?\)?\]$/;
1993
+
1994
+ function findByNormText(root, tag, text, isContains) {
1995
+ const out = [];
1996
+ const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
1997
+ for (const el of root.querySelectorAll(tag)) {
1998
+ const t = norm(el.textContent);
1999
+ if (isContains ? t.indexOf(text) >= 0 : t === text) out.push(el);
1960
2000
  }
2001
+ return out;
2002
+ }
1961
2003
 
1962
- doc.evaluate = function (xpath, contextNode, _resolver, type, _result) {
1963
- const root = contextNode || doc.documentElement || doc;
1964
- let nodes = [];
1965
- try {
1966
- nodes = evaluateXPathToNodes(xpath, root, xpathDomFacade, null, {
1967
- namespaceResolver: () => 'http://www.w3.org/1999/xhtml'
1968
- });
1969
- } catch (_) {
1970
- nodes = [];
1971
- }
1972
- switch (type) {
1973
- case FIRST_ORDERED_NODE_TYPE:
1974
- case ANY_UNORDERED_NODE_TYPE:
1975
- return makeResult(type, nodes.slice(0, 1));
1976
- case NUMBER_TYPE:
1977
- case STRING_TYPE:
1978
- case BOOLEAN_TYPE:
1979
- // We do not support primitive XPath results; callers fall back.
1980
- return makeResult(type, []);
1981
- default:
1982
- return makeResult(type || ORDERED_NODE_SNAPSHOT_TYPE, nodes);
2004
+ // .//select[((((@id = 'X') or (@name = 'X')) or (@placeholder = 'X'))
2005
+ // or (@id = //label[(normalize-space(string(.)) = 'X')]/@for))
2006
+ // or (@aria-label = 'X'))]
2007
+ // | .//label[(normalize-space(string(.)) = 'X')]//.//select
2008
+ //
2009
+ // Capybara's `:select` selector. The same locator value is repeated in
2010
+ // every clause, so capture once and verify.
2011
+ const LOCATE_FIELD_RX = new RegExp(
2012
+ '^\\.\\/\\/(select|textarea)\\[\\(\\(\\(\\(\\(\\.\\/@id = \'([^\']+)\'\\)' +
2013
+ ' or \\(\\.\\/@name = \'\\2\'\\)\\)' +
2014
+ ' or \\(\\.\\/@placeholder = \'\\2\'\\)\\)' +
2015
+ ' or \\(\\.\\/@id = \\/\\/label\\[\\(normalize-space\\(string\\(\\.\\)\\) = \'\\2\'\\)\\]\\/@for\\)\\)' +
2016
+ ' or \\(\\.\\/@aria-label = \'\\2\'\\)\\)\\]' +
2017
+ '(?: \\| \\.\\/\\/label\\[\\(normalize-space\\(string\\(\\.\\)\\) = \'\\2\'\\)\\]\\/\\/\\.\\/\\/\\1)?$'
2018
+ );
2019
+
2020
+ function findByLabel(root, tag, value) {
2021
+ const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
2022
+ const matches = new Set();
2023
+ for (const el of root.querySelectorAll(tag)) {
2024
+ if (
2025
+ el.getAttribute('id') === value ||
2026
+ el.getAttribute('name') === value ||
2027
+ el.getAttribute('placeholder') === value ||
2028
+ el.getAttribute('aria-label') === value
2029
+ ) matches.add(el);
2030
+ }
2031
+ // <label for="…">text</label> — anywhere in the *document*, then resolve
2032
+ // its target by id and verify the target is inside `root`.
2033
+ const doc = root.ownerDocument || (root.nodeType === 9 ? root : currentDocument);
2034
+ if (doc) {
2035
+ for (const label of doc.querySelectorAll('label[for]')) {
2036
+ if (norm(label.textContent) !== value) continue;
2037
+ const target = doc.getElementById(label.getAttribute('for'));
2038
+ if (
2039
+ target &&
2040
+ (target.tagName || '').toLowerCase() === tag &&
2041
+ (root === doc.documentElement || root === doc || root.contains(target))
2042
+ ) matches.add(target);
1983
2043
  }
1984
- };
2044
+ }
2045
+ // <label>text<select/></label> — descendant of root.
2046
+ for (const label of root.querySelectorAll('label')) {
2047
+ if (norm(label.textContent) !== value) continue;
2048
+ for (const child of label.querySelectorAll(tag)) matches.add(child);
2049
+ }
2050
+ return Array.from(matches);
1985
2051
  }
1986
2052
 
1987
- // Custom DOM facade for fontoxpath that uses childNodes-based traversal
1988
- // so happy-dom quirks around sibling links don't break root-anchored
1989
- // queries. Mirrors the helper we built for linkedom.
1990
- // fontoxpath calls into this facade tens of thousands of times per
1991
- // traversal, so each method needs to delegate to happy-dom's native
1992
- // pointers rather than reconstructing them. Earlier revisions of
1993
- // getNextSibling / getPreviousSibling rebuilt childNodes via
1994
- // Array.from(parent.childNodes) and then indexOf'd the node quadratic
1995
- // in tree size, and the dominant cost on any non-trivial page.
1996
- const xpathDomFacade = {
1997
- getAllAttributes(node) {
1998
- if (!node || node.nodeType !== 1 || !node.attributes) return [];
1999
- return Array.from(node.attributes);
2000
- },
2001
- getAttribute(node, name) {
2002
- return node && node.getAttribute ? node.getAttribute(name) : null;
2003
- },
2004
- getChildNodes(node) {
2005
- if (!node) return [];
2006
- if (node.childNodes) return Array.from(node.childNodes);
2007
- if (node.nodeType === 9 && node.documentElement) return [node.documentElement];
2008
- return [];
2009
- },
2010
- getData(node) {
2011
- if (!node) return '';
2012
- if (node.nodeType === 2) return node.value || '';
2013
- return node.data || '';
2014
- },
2015
- // happy-dom 20 has a quirk on `<template>`: `template.childNodes` is
2016
- // empty (the parsed content lives in `template.content`) but
2017
- // `template.firstChild` / `template.lastChild` still return the first
2018
- // text node of `template.content`. Walking into that ghost child
2019
- // makes fontoxpath traverse content as if it were a regular descendant,
2020
- // which scrambles every predicate downstream. Verify against
2021
- // childNodes.length before trusting firstChild / lastChild.
2022
- getFirstChild(node) {
2023
- const fc = node?.firstChild ?? null;
2024
- if (!fc) return null;
2025
- const cs = node.childNodes;
2026
- if (cs && cs.length === 0) return null;
2027
- return fc;
2028
- },
2029
- getLastChild(node) {
2030
- const lc = node?.lastChild ?? null;
2031
- if (!lc) return null;
2032
- const cs = node.childNodes;
2033
- if (cs && cs.length === 0) return null;
2034
- return lc;
2035
- },
2036
- getNextSibling(node) { return node?.nextSibling ?? null; },
2037
- getPreviousSibling(node) { return node?.previousSibling ?? null; },
2038
- getParentNode(node) { return node?.parentNode ?? null; }
2039
- };
2053
+ function unescXp(s) { return s.replace(/\\(.)/g, '$1'); }
2054
+
2055
+ // .//a[./@href][((((@id = 'X') or [normalize-space(string(.)) = 'X' | contains(...)])
2056
+ // or [@title = 'X' | contains(...)]) or .//img[(@alt = 'X' | contains)]) or [@aria-label = 'X' | contains])]
2057
+ // | .//input[type=submit/...][...] | .//label[X]//*[input-or-button]
2058
+ // | .//input[type=image][...] | .//button[...] | .//label[X]//* (dup) | .//input[image] (dup)
2059
+ //
2060
+ // Capybara's `:link_or_button` selector by far the most common shape we
2061
+ // see (~25% of all xpath calls in a Rails system spec). The detector
2062
+ // anchors on the link clause (5 OR'd predicates ending with @aria-label)
2063
+ // and reads off whether the suite is in exact-match or contains mode.
2064
+ // If the last clause isn't aria-label (e.g. Capybara.test_id is set, or
2065
+ // enable_aria_label is off), we bail and fontoxpath handles it.
2066
+ const LINK_OR_BUTTON_RX = new RegExp(
2067
+ '^\\.\\/\\/a\\[\\.\\/@href\\]' +
2068
+ '\\[\\(\\(\\(\\(\\(' +
2069
+ '\\.\\/@id = \'([^\']+)\'\\)' +
2070
+ ' or (?:\\(normalize-space\\(string\\(\\.\\)\\) = \'\\1\'\\)|(contains)\\(normalize-space\\(string\\(\\.\\)\\), \'\\1\'\\))\\)' +
2071
+ ' or (?:\\(\\.\\/@title = \'\\1\'\\)|contains\\(\\.\\/@title, \'\\1\'\\))\\)' +
2072
+ ' or \\.\\/\\/img\\[(?:\\(\\.\\/@alt = \'\\1\'\\)|contains\\(\\.\\/@alt, \'\\1\'\\))\\]\\)' +
2073
+ ' or (?:\\(\\.\\/@aria-label = \'\\1\'\\)|contains\\(\\.\\/@aria-label, \'\\1\'\\))\\)' +
2074
+ '\\]' +
2075
+ ' \\| \\.\\/\\/input\\['
2076
+ );
2077
+
2078
+ function findLinkOrButton(root, value, isContains) {
2079
+ const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
2080
+ const eq = (s) => isContains ? (s != null && String(s).indexOf(value) >= 0) : (s === value);
2081
+ const eqText = (s) => isContains ? norm(s).indexOf(value) >= 0 : norm(s) === value;
2082
+ const matches = new Set();
2083
+
2084
+ // <a href> by id (always exact), text/title/aria-label, child <img alt>
2085
+ for (const a of root.querySelectorAll('a[href]')) {
2086
+ if (
2087
+ a.getAttribute('id') === value ||
2088
+ eqText(a.textContent) ||
2089
+ eq(a.getAttribute('title')) ||
2090
+ eq(a.getAttribute('aria-label'))
2091
+ ) { matches.add(a); continue; }
2092
+ for (const img of a.querySelectorAll('img')) {
2093
+ if (eq(img.getAttribute('alt'))) { matches.add(a); break; }
2094
+ }
2095
+ }
2096
+
2097
+ // <input type=submit/reset/image/button>
2098
+ for (const input of root.querySelectorAll(
2099
+ 'input[type=submit],input[type=reset],input[type=image],input[type=button]'
2100
+ )) {
2101
+ if (
2102
+ input.getAttribute('id') === value ||
2103
+ input.getAttribute('name') === value ||
2104
+ eq(input.getAttribute('value')) ||
2105
+ eq(input.getAttribute('title')) ||
2106
+ eq(input.getAttribute('aria-label'))
2107
+ ) matches.add(input);
2108
+ }
2109
+
2110
+ // <input type=image> alt / aria-label
2111
+ for (const input of root.querySelectorAll('input[type=image]')) {
2112
+ if (eq(input.getAttribute('alt')) || eq(input.getAttribute('aria-label'))) matches.add(input);
2113
+ }
2114
+
2115
+ // <button> id/name/value/title/aria-label/text/child <img alt>
2116
+ for (const btn of root.querySelectorAll('button')) {
2117
+ if (
2118
+ btn.getAttribute('id') === value ||
2119
+ btn.getAttribute('name') === value ||
2120
+ eq(btn.getAttribute('value')) ||
2121
+ eq(btn.getAttribute('title')) ||
2122
+ eq(btn.getAttribute('aria-label')) ||
2123
+ eqText(btn.textContent)
2124
+ ) { matches.add(btn); continue; }
2125
+ for (const img of btn.querySelectorAll('img')) {
2126
+ if (eq(img.getAttribute('alt'))) { matches.add(btn); break; }
2127
+ }
2128
+ }
2129
+
2130
+ // <label>text<input|button/></label>
2131
+ for (const label of root.querySelectorAll('label')) {
2132
+ if (!eqText(label.textContent)) continue;
2133
+ for (const c of label.querySelectorAll(
2134
+ 'input[type=submit],input[type=reset],input[type=image],input[type=button],button'
2135
+ )) matches.add(c);
2136
+ }
2137
+
2138
+ // <label for="…">text</label> (anywhere in the document) → input/button
2139
+ const doc = root.ownerDocument || (root.nodeType === 9 ? root : currentDocument);
2140
+ if (doc && doc.querySelectorAll) {
2141
+ for (const label of doc.querySelectorAll('label[for]')) {
2142
+ if (!eqText(label.textContent)) continue;
2143
+ const target = doc.getElementById(label.getAttribute('for'));
2144
+ if (!target) continue;
2145
+ const tag = (target.tagName || '').toLowerCase();
2146
+ const type = ((target.getAttribute && target.getAttribute('type')) || '').toLowerCase();
2147
+ const clickable = (tag === 'input' && /^(submit|reset|image|button)$/.test(type)) || tag === 'button';
2148
+ if (!clickable) continue;
2149
+ if (root === doc.documentElement || root === doc || root.contains(target)) matches.add(target);
2150
+ }
2151
+ }
2152
+
2153
+ return Array.from(matches);
2154
+ }
2040
2155
 
2041
2156
  function findViaXPathFallback(xpath, root) {
2042
2157
  if (typeof xpath !== 'string') return [];
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-simulated
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima