chore: optional root for generateSelector (#23692)

This commit is contained in:
Dmitry Gozman 2023-06-13 21:25:39 -07:00 committed by GitHub
parent 51b8f609fb
commit 5b2e8a6a7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 87 additions and 63 deletions

View file

@ -20,7 +20,6 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
import type { InjectedScript } from './injectedScript';
import { generateSelector } from './selectorGenerator';
const selectorSymbol = Symbol('selector');
const injectedScriptSymbol = Symbol('injectedScript');
@ -121,13 +120,13 @@ class ConsoleAPI {
private _selector(element: Element) {
if (!(element instanceof Element))
throw new Error(`Usage: playwright.selector(element).`);
return generateSelector(this._injectedScript, element, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).selector;
return this._injectedScript.generateSelector(element);
}
private _generateLocator(element: Element, language?: Language) {
if (!(element instanceof Element))
throw new Error(`Usage: playwright.locator(element).`);
const selector = generateSelector(this._injectedScript, element, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).selector;
const selector = this._injectedScript.generateSelector(element);
return asLocator(language || 'javascript', selector);
}

View file

@ -46,9 +46,11 @@ function enclosingShadowHost(element: Element): Element | undefined {
return parentElementOrShadowHost(element);
}
export function closestCrossShadow(element: Element | undefined, css: string): Element | undefined {
export function closestCrossShadow(element: Element | undefined, css: string, scope?: Document | Element): Element | undefined {
while (element) {
const closest = element.closest(css);
if (scope && closest !== scope && closest?.contains(scope))
return;
if (closest)
return closest;
element = enclosingShadowHost(element);

View file

@ -26,7 +26,7 @@ import { type TextMatcher, elementMatchesText, elementText, type ElementText } f
import { SelectorEvaluatorImpl, sortInDOMOrder } from './selectorEvaluator';
import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils';
import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector } from './selectorGenerator';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels';
import { Highlight } from './highlight';
import { getChecked, getAriaDisabled, getAriaLabelledByElements, getAriaRole, getElementAccessibleName } from './roleUtils';
@ -153,8 +153,8 @@ export class InjectedScript {
return result;
}
generateSelector(targetElement: Element, testIdAttributeName: string, omitInternalEngines?: boolean): string {
return generateSelector(this, targetElement, testIdAttributeName).selector;
generateSelector(targetElement: Element, options?: GenerateSelectorOptions): string {
return generateSelector(this, targetElement, { ...options, testIdAttributeName: this._testIdAttributeNameForStrictErrorAndConsoleCodegen }).selector;
}
querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined {
@ -1075,7 +1075,7 @@ export class InjectedScript {
strictModeViolationError(selector: ParsedSelector, matches: Element[]): Error {
const infos = matches.slice(0, 10).map(m => ({
preview: this.previewNode(m),
selector: this.generateSelector(m, this._testIdAttributeNameForStrictErrorAndConsoleCodegen),
selector: this.generateSelector(m),
}));
const lines = infos.map((info, i) => `\n ${i + 1}) ${info.preview} aka ${asLocator(this._sdkLanguage, info.selector)}`);
if (infos.length < matches.length)

View file

@ -16,7 +16,7 @@
import type * as actions from '../recorder/recorderActions';
import type { InjectedScript } from '../injected/injectedScript';
import { generateSelector, querySelector } from '../injected/selectorGenerator';
import { generateSelector } from '../injected/selectorGenerator';
import type { Point } from '../../common/types';
import type { UIState } from '@recorder/recorderTypes';
import { Highlight } from '../injected/highlight';
@ -241,7 +241,7 @@ export class Recorder {
// We'd like to ignore this stray event.
if (userGesture && activeElement === this.document.body)
return;
const result = activeElement ? generateSelector(this._injectedScript, activeElement, this._testIdAttributeName) : null;
const result = activeElement ? generateSelector(this._injectedScript, activeElement, { testIdAttributeName: this._testIdAttributeName }) : null;
this._activeModel = result && result.selector ? result : null;
if (userGesture)
this._hoveredElement = activeElement as HTMLElement | null;
@ -256,7 +256,7 @@ export class Recorder {
return;
}
const hoveredElement = this._hoveredElement;
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement, this._testIdAttributeName);
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement, { testIdAttributeName: this._testIdAttributeName });
if ((this._hoveredModel && this._hoveredModel.selector === selector))
return;
this._hoveredModel = selector ? { selector, elements } : null;
@ -480,6 +480,21 @@ function removeEventListeners(listeners: (() => void)[]) {
listeners.splice(0, listeners.length);
}
function querySelector(injectedScript: InjectedScript, selector: string, ownerDocument: Document): { selector: string, elements: Element[] } {
try {
const parsedSelector = injectedScript.parseSelector(selector);
return {
selector,
elements: injectedScript.querySelectorAll(parsedSelector, ownerDocument)
};
} catch (e) {
return {
selector,
elements: [],
};
}
}
interface Embedder {
__pw_recorderPerformAction(action: actions.Action): Promise<void>;
__pw_recorderRecordAction(action: actions.Action): Promise<void>;

View file

@ -15,6 +15,7 @@
*/
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils';
import type { InjectedScript } from './injectedScript';
import { getAriaRole, getElementAccessibleName } from './roleUtils';
import { elementText } from './selectorUtils';
@ -59,32 +60,22 @@ const kCSSTagNameScore = 530;
const kNthScore = 10000;
const kCSSFallbackScore = 10000000;
export function querySelector(injectedScript: InjectedScript, selector: string, ownerDocument: Document): { selector: string, elements: Element[] } {
try {
const parsedSelector = injectedScript.parseSelector(selector);
return {
selector,
elements: injectedScript.querySelectorAll(parsedSelector, ownerDocument)
};
} catch (e) {
return {
selector,
elements: [],
};
}
}
export type GenerateSelectorOptions = {
testIdAttributeName: string;
omitInternalEngines?: boolean;
root?: Element | Document;
};
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string, omitInternalEngines?: boolean): { selector: string, elements: Element[] } {
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } {
injectedScript._evaluator.begin();
try {
targetElement = targetElement.closest('button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]') || targetElement;
const targetTokens = generateSelectorFor(injectedScript, targetElement, testIdAttributeName, omitInternalEngines);
const bestTokens = targetTokens || cssFallback(injectedScript, targetElement);
const selector = joinTokens(bestTokens);
targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement;
const targetTokens = generateSelectorFor(injectedScript, targetElement, options);
const selector = joinTokens(targetTokens);
const parsedSelector = injectedScript.parseSelector(selector);
return {
selector,
elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument)
elements: injectedScript.querySelectorAll(parsedSelector, options.root ?? targetElement.ownerDocument)
};
} finally {
cacheAllowText.clear();
@ -98,7 +89,12 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][]
return textCandidates.filter(c => c[0].selector[0] !== '/');
}
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string, omitInternalEngines?: boolean): SelectorToken[] | null {
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] {
if (options.root && !isInsideScope(options.root, targetElement))
throw new Error(`Target element must belong to the root's subtree`);
if (targetElement === options.root)
return [{ engine: 'css', selector: ':scope', score: 1 }];
if (targetElement.ownerDocument.documentElement === targetElement)
return [{ engine: 'css', selector: 'html', score: 1 }];
@ -111,12 +107,12 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
// Do not use regex for parent elements (for performance).
textCandidates = filterRegexTokens(textCandidates);
}
const noTextCandidates = buildCandidates(injectedScript, element, testIdAttributeName, accessibleNameCache)
.filter(token => !omitInternalEngines || !token.engine.startsWith('internal:'))
const noTextCandidates = buildCandidates(injectedScript, element, options, accessibleNameCache)
.filter(token => !options.omitInternalEngines || !token.engine.startsWith('internal:'))
.map(token => [token]);
// First check all text and non-text candidates for the element.
let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch);
let result = chooseFirstSelector(injectedScript, options.root ?? targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch);
// Do not use regex for chained selectors (for performance).
textCandidates = filterRegexTokens(textCandidates);
@ -138,7 +134,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
if (!bestPossibleInParent)
return;
for (let parent = parentElementOrShadowHost(element); parent; parent = parentElementOrShadowHost(parent)) {
for (let parent = parentElementOrShadowHost(element); parent && parent !== options.root; parent = parentElementOrShadowHost(parent)) {
const parentTokens = calculateCached(parent, allowParentText);
if (!parentTokens)
continue;
@ -173,16 +169,16 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
return value;
};
return calculateCached(targetElement, true);
return calculateCached(targetElement, true) || cssFallback(injectedScript, targetElement, options);
}
function buildCandidates(injectedScript: InjectedScript, element: Element, testIdAttributeName: string, accessibleNameCache: Map<Element, boolean>): SelectorToken[] {
function buildCandidates(injectedScript: InjectedScript, element: Element, options: GenerateSelectorOptions, accessibleNameCache: Map<Element, boolean>): SelectorToken[] {
const candidates: SelectorToken[] = [];
// Start of generic candidates which are compatible for Locators and FrameLocators:
for (const attr of ['data-testid', 'data-test-id', 'data-test']) {
if (attr !== testIdAttributeName && element.getAttribute(attr))
if (attr !== options.testIdAttributeName && element.getAttribute(attr))
candidates.push({ engine: 'css', selector: `[${attr}=${quoteAttributeValue(element.getAttribute(attr)!)}]`, score: kOtherTestIdScore });
}
@ -198,19 +194,17 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, testI
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[${attribute}=${quoteAttributeValue(element.getAttribute(attribute)!)}]`, score: kIframeByAttributeScore });
}
// Get via testIdAttributeName via CSS selector.
if (element.getAttribute(testIdAttributeName))
candidates.push({ engine: 'css', selector: `[${testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(testIdAttributeName)!, true!)}]`, score: kTestIdScore });
// Locate by testId via CSS selector.
if (element.getAttribute(options.testIdAttributeName))
candidates.push({ engine: 'css', selector: `[${options.testIdAttributeName}=${quoteAttributeValue(element.getAttribute(options.testIdAttributeName)!)}]`, score: kTestIdScore });
penalizeScoreForLength([candidates]);
return candidates;
}
// Everything after that are candidates that are not applicable to iframes and designed for Locators only(getBy* methods):
// Get via testIdAttributeName via GetByTestId().
if (element.getAttribute(testIdAttributeName))
candidates.push({ engine: 'internal:testid', selector: `[${testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(testIdAttributeName)!, true)}]`, score: kTestIdScore });
// Everything below is not applicable to iframes (getBy* methods):
if (element.getAttribute(options.testIdAttributeName))
candidates.push({ engine: 'internal:testid', selector: `[${options.testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(options.testIdAttributeName)!, true)}]`, score: kTestIdScore });
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
const input = element as HTMLInputElement | HTMLTextAreaElement;
@ -297,22 +291,12 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
return candidates;
}
function parentElementOrShadowHost(element: Element): Element | null {
if (element.parentElement)
return element.parentElement;
if (!element.parentNode)
return null;
if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host)
return (element.parentNode as ShadowRoot).host;
return null;
}
function makeSelectorForId(id: string) {
return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${cssEscape(id)}"]`;
}
function cssFallback(injectedScript: InjectedScript, targetElement: Element): SelectorToken[] {
const root: Node = targetElement.ownerDocument;
function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] {
const root: Node = options.root ?? targetElement.ownerDocument;
const tokens: string[] = [];
function uniqueCSSSelector(prefix?: string): string | undefined {
@ -321,21 +305,21 @@ function cssFallback(injectedScript: InjectedScript, targetElement: Element): Se
path.unshift(prefix);
const selector = path.join(' > ');
const parsedSelector = injectedScript.parseSelector(selector);
const node = injectedScript.querySelector(parsedSelector, targetElement.ownerDocument, false);
const node = injectedScript.querySelector(parsedSelector, root, false);
return node === targetElement ? selector : undefined;
}
function makeStrict(selector: string): SelectorToken[] {
const token = { engine: 'css', selector, score: kCSSFallbackScore };
const parsedSelector = injectedScript.parseSelector(selector);
const elements = injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument);
const elements = injectedScript.querySelectorAll(parsedSelector, root);
if (elements.length === 1)
return [token];
const nth = { engine: 'nth', selector: String(elements.indexOf(targetElement)), score: kNthScore };
return [token, nth];
}
for (let element: Element | null = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) {
for (let element: Element | undefined = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) {
const nodeName = element.nodeName.toLowerCase();
// Element ID is the strongest signal, use it.

View file

@ -556,7 +556,7 @@ class ContextRecorder extends EventEmitter {
const utility = await parent._utilityContext();
const injected = await utility.injectedScript();
const selector = await injected.evaluate((injected, element) => {
return injected.generateSelector(element as Element, '', true);
return injected.generateSelector(element as Element, { testIdAttributeName: '', omitInternalEngines: true });
}, frameElement);
return selector;
} catch (e) {

View file

@ -463,4 +463,28 @@ it.describe('selector generator', () => {
`);
expect(await generate(page, 'input')).toBe('internal:label=\"Text\"s');
});
it('should generate relative selector', async ({ page }) => {
await page.setContent(`
<div>
<span>Hello</span>
<span>World</span>
</div>
<section>
<span>Hello</span>
<span>World</span>
</section>
`);
const selectors = await page.evaluate(() => {
const target = document.querySelector('section > span');
const root = document.querySelector('section');
const relative = (window as any).__injectedScript.generateSelector(target, { root });
const absolute = (window as any).__injectedScript.generateSelector(target);
return { relative, absolute };
});
expect(selectors).toEqual({
relative: `internal:text="Hello"i`,
absolute: `section >> internal:text="Hello"i`,
});
});
});