From 5b2e8a6a7a80c161fa26f62e86eeb732908b7183 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 13 Jun 2023 21:25:39 -0700 Subject: [PATCH] chore: optional root for generateSelector (#23692) --- .../src/server/injected/consoleApi.ts | 5 +- .../src/server/injected/domUtils.ts | 4 +- .../src/server/injected/injectedScript.ts | 8 +- .../src/server/injected/recorder.ts | 21 ++++- .../src/server/injected/selectorGenerator.ts | 86 ++++++++----------- .../playwright-core/src/server/recorder.ts | 2 +- tests/library/selector-generator.spec.ts | 24 ++++++ 7 files changed, 87 insertions(+), 63 deletions(-) diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index f9ca10b3fe..cce789021f 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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); } diff --git a/packages/playwright-core/src/server/injected/domUtils.ts b/packages/playwright-core/src/server/injected/domUtils.ts index 5e6f98bd23..b00dd91dfd 100644 --- a/packages/playwright-core/src/server/injected/domUtils.ts +++ b/packages/playwright-core/src/server/injected/domUtils.ts @@ -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); diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 99caa7f40d..aa7e2bc1cf 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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) diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 1c90065fb2..0db439e73a 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -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; __pw_recorderRecordAction(action: actions.Action): Promise; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 95946f85d7..e667308529 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -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): SelectorToken[] { +function buildCandidates(injectedScript: InjectedScript, element: Element, options: GenerateSelectorOptions, accessibleNameCache: Map): 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. diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 6c0602bf38..64ffb379ad 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -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) { diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index 4044d69899..acc9759c9f 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -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(` +
+ Hello + World +
+
+ Hello + World +
+ `); + 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`, + }); + }); });