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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edfc03fddab459b5cace9bb3bb3b05ba0ff48adc978eb2c8b401d9311f62d569
4
- data.tar.gz: 74237a169f1ce04b4fa579936f86622fab1f6fa98190d81bf98caf72974cbcb5
3
+ metadata.gz: a2fd42015728b77f7d00047652a653b2f5e25f24ed1887912294f391bdad9004
4
+ data.tar.gz: 9005c6bc03bee18e0d38c3a6115415ada9a89bab3f52f7f7e74be890fa2d30c0
5
5
  SHA512:
6
- metadata.gz: 80e00f33f9994046daaa5e62b6b08e1b5db4ac425bb94761f8aaa0d9be328fd9ff292f43459d1dd51a2e10a0f20e217f399ce71815d2076cbe121cda0ed29791
7
- data.tar.gz: 8d0d3309b0bfb141040005a535bef99fa97a01aaf81b7f1e6b247997f9d13b0ae821d6ce53c8ba3ea62e2c10c2aa7b140a0d369e7ebe6bf77c331a58cc5de3ae
6
+ metadata.gz: 5f5222012752134b6a835796fbcaa86d805b1fa9601d6cee38ba0e33bb524b9d9a36aae5ca365150fd32f58e99e9912130cd6f7692e4fec08bf5519140093ca5
7
+ data.tar.gz: 224718321a97a17de4ea60a706fe3f14fa78c518b2ac2b6c4d713d4b56496220573e4cbd9b92706aaf07397fab9339565d59e2a131353933c5853f4ad5961026
@@ -1,5 +1,5 @@
1
1
  module Capybara
2
2
  module Simulated
3
- VERSION = '0.0.4'
3
+ VERSION = '0.0.5'
4
4
  end
5
5
  end
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*]*)(?:\[(.+)\])?$/);
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.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima