mirror of
https://github.com/microsoft/playwright.git
synced 2024-10-27 05:46:28 +03:00
chore: speedup frequent element text normalization (#29113)
We cache `ElementText` for frequent operations, but then call `normalizeWhitespace` on it every time which burns a lot of CPU.
This commit is contained in:
parent
fbf87ef904
commit
9b974e0026
@ -1385,7 +1385,7 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text
|
|||||||
selector = normalizeWhiteSpace(selector);
|
selector = normalizeWhiteSpace(selector);
|
||||||
if (strict) {
|
if (strict) {
|
||||||
if (internal)
|
if (internal)
|
||||||
return { kind: 'strict', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full) === selector };
|
return { kind: 'strict', matcher: (elementText: ElementText) => elementText.normalized === selector };
|
||||||
|
|
||||||
const strictTextNodeMatcher = (elementText: ElementText) => {
|
const strictTextNodeMatcher = (elementText: ElementText) => {
|
||||||
if (!selector && !elementText.immediate.length)
|
if (!selector && !elementText.immediate.length)
|
||||||
@ -1395,7 +1395,7 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text
|
|||||||
return { matcher: strictTextNodeMatcher, kind: 'strict' };
|
return { matcher: strictTextNodeMatcher, kind: 'strict' };
|
||||||
}
|
}
|
||||||
selector = selector.toLowerCase();
|
selector = selector.toLowerCase();
|
||||||
return { kind: 'lax', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(selector) };
|
return { kind: 'lax', matcher: (elementText: ElementText) => elementText.normalized.toLowerCase().includes(selector) };
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExpectedTextMatcher {
|
class ExpectedTextMatcher {
|
||||||
|
@ -585,7 +585,7 @@ class TextAssertionTool implements RecorderTool {
|
|||||||
name: 'assertText',
|
name: 'assertText',
|
||||||
selector: this._hoverHighlight.selector,
|
selector: this._hoverHighlight.selector,
|
||||||
signals: [],
|
signals: [],
|
||||||
text: normalizeWhiteSpace(elementText(this._textCache, target).full),
|
text: elementText(this._textCache, target).normalized,
|
||||||
substring: true,
|
substring: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -653,7 +653,7 @@ class TextAssertionTool implements RecorderTool {
|
|||||||
if (!target)
|
if (!target)
|
||||||
return;
|
return;
|
||||||
action.text = newValue;
|
action.text = newValue;
|
||||||
const targetText = normalizeWhiteSpace(elementText(this._textCache, target).full);
|
const targetText = elementText(this._textCache, target).normalized;
|
||||||
const matches = newValue && targetText.includes(newValue);
|
const matches = newValue && targetText.includes(newValue);
|
||||||
textElement.classList.toggle('does-not-match', !matches);
|
textElement.classList.toggle('does-not-match', !matches);
|
||||||
};
|
};
|
||||||
|
@ -450,7 +450,7 @@ const textEngine: SelectorEngine = {
|
|||||||
if (args.length !== 1 || typeof args[0] !== 'string')
|
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||||
throw new Error(`"text" engine expects a single string`);
|
throw new Error(`"text" engine expects a single string`);
|
||||||
const text = normalizeWhiteSpace(args[0]).toLowerCase();
|
const text = normalizeWhiteSpace(args[0]).toLowerCase();
|
||||||
const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text);
|
const matcher = (elementText: ElementText) => elementText.normalized.toLowerCase().includes(text);
|
||||||
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
|
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -486,7 +486,7 @@ const hasTextEngine: SelectorEngine = {
|
|||||||
if (shouldSkipForTextMatching(element))
|
if (shouldSkipForTextMatching(element))
|
||||||
return false;
|
return false;
|
||||||
const text = normalizeWhiteSpace(args[0]).toLowerCase();
|
const text = normalizeWhiteSpace(args[0]).toLowerCase();
|
||||||
const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text);
|
const matcher = (elementText: ElementText) => elementText.normalized.toLowerCase().includes(text);
|
||||||
return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element));
|
return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils';
|
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils';
|
||||||
import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils';
|
import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils';
|
||||||
import type { InjectedScript } from './injectedScript';
|
import type { InjectedScript } from './injectedScript';
|
||||||
import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils';
|
import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils';
|
||||||
@ -237,7 +237,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element,
|
|||||||
|
|
||||||
const labels = getElementLabels(injectedScript._evaluator._cacheText, element);
|
const labels = getElementLabels(injectedScript._evaluator._cacheText, element);
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
const labelText = label.full.trim();
|
const labelText = label.normalized;
|
||||||
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact });
|
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact });
|
||||||
for (const alternative of suitableTextAlternatives(labelText))
|
for (const alternative of suitableTextAlternatives(labelText))
|
||||||
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBouns });
|
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBouns });
|
||||||
@ -281,7 +281,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
|
|||||||
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBouns }]);
|
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBouns }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full);
|
const text = elementText(injectedScript._evaluator._cacheText, element).normalized;
|
||||||
if (text) {
|
if (text) {
|
||||||
const alternatives = suitableTextAlternatives(text);
|
const alternatives = suitableTextAlternatives(text);
|
||||||
if (isTargetNode) {
|
if (isTargetNode) {
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AttributeSelectorPart } from '../../utils/isomorphic/selectorParser';
|
import type { AttributeSelectorPart } from '../../utils/isomorphic/selectorParser';
|
||||||
|
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
|
||||||
import { getAriaLabelledByElements } from './roleUtils';
|
import { getAriaLabelledByElements } from './roleUtils';
|
||||||
|
|
||||||
export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) {
|
export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) {
|
||||||
@ -56,17 +57,17 @@ export function shouldSkipForTextMatching(element: Element | ShadowRoot) {
|
|||||||
return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
|
return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ElementText = { full: string, immediate: string[] };
|
export type ElementText = { full: string, normalized: string, immediate: string[] };
|
||||||
export type TextMatcher = (text: ElementText) => boolean;
|
export type TextMatcher = (text: ElementText) => boolean;
|
||||||
|
|
||||||
export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root: Element | ShadowRoot): ElementText {
|
export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root: Element | ShadowRoot): ElementText {
|
||||||
let value = cache.get(root);
|
let value = cache.get(root);
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
value = { full: '', immediate: [] };
|
value = { full: '', normalized: '', immediate: [] };
|
||||||
if (!shouldSkipForTextMatching(root)) {
|
if (!shouldSkipForTextMatching(root)) {
|
||||||
let currentImmediate = '';
|
let currentImmediate = '';
|
||||||
if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) {
|
if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) {
|
||||||
value = { full: root.value, immediate: [root.value] };
|
value = { full: root.value, normalized: normalizeWhiteSpace(root.value), immediate: [root.value] };
|
||||||
} else {
|
} else {
|
||||||
for (let child = root.firstChild; child; child = child.nextSibling) {
|
for (let child = root.firstChild; child; child = child.nextSibling) {
|
||||||
if (child.nodeType === Node.TEXT_NODE) {
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
@ -84,6 +85,8 @@ export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root:
|
|||||||
value.immediate.push(currentImmediate);
|
value.immediate.push(currentImmediate);
|
||||||
if ((root as Element).shadowRoot)
|
if ((root as Element).shadowRoot)
|
||||||
value.full += elementText(cache, (root as Element).shadowRoot!).full;
|
value.full += elementText(cache, (root as Element).shadowRoot!).full;
|
||||||
|
if (value.full)
|
||||||
|
value.normalized = normalizeWhiteSpace(value.full);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache.set(root, value);
|
cache.set(root, value);
|
||||||
@ -111,7 +114,7 @@ export function getElementLabels(textCache: Map<Element | ShadowRoot, ElementTex
|
|||||||
return labels.map(label => elementText(textCache, label));
|
return labels.map(label => elementText(textCache, label));
|
||||||
const ariaLabel = element.getAttribute('aria-label');
|
const ariaLabel = element.getAttribute('aria-label');
|
||||||
if (ariaLabel !== null && !!ariaLabel.trim())
|
if (ariaLabel !== null && !!ariaLabel.trim())
|
||||||
return [{ full: ariaLabel, immediate: [ariaLabel] }];
|
return [{ full: ariaLabel, normalized: normalizeWhiteSpace(ariaLabel), immediate: [ariaLabel] }];
|
||||||
|
|
||||||
// https://html.spec.whatwg.org/multipage/forms.html#category-label
|
// https://html.spec.whatwg.org/multipage/forms.html#category-label
|
||||||
const isNonHiddenInput = element.nodeName === 'INPUT' && (element as HTMLInputElement).type !== 'hidden';
|
const isNonHiddenInput = element.nodeName === 'INPUT' && (element as HTMLInputElement).type !== 'hidden';
|
||||||
|
Loading…
Reference in New Issue
Block a user