chore: optional root for generateSelector (#23692)
This commit is contained in:
parent
51b8f609fb
commit
5b2e8a6a7a
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue