From f5de6e5538bfc0f02ae70a250638c04931ab7d62 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 25 Jan 2024 07:35:58 -0800 Subject: [PATCH] feat(codegen): generate multiple selectors to choose from (#29154) When possible, "pick locator" generates: - default locator; - locator without any text; - locator without css `#id`. Fixes #27875, fixes #5178. --- .../src/server/injected/highlight.css | 25 +++- .../src/server/injected/highlight.ts | 60 +++++++-- .../src/server/injected/recorder/recorder.ts | 123 +++++++++++++----- .../src/server/injected/selectorGenerator.ts | 67 +++++++--- tests/library/selector-generator.spec.ts | 44 +++++++ 5 files changed, 263 insertions(+), 56 deletions(-) diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index e45e45b55ea86..9ee03707208e5 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -33,7 +33,30 @@ x-pw-tooltip { max-width: 600px; position: absolute; top: 0; - padding: 4px; + padding: 0; + flex-direction: column; + overflow: hidden; +} + +x-pw-tooltip-line { + display: flex; + max-width: 600px; + padding: 6px; + user-select: none; + cursor: pointer; +} + +x-pw-tooltip-line.selectable:hover { + background-color: hsl(0, 0%, 95%); + overflow: hidden; +} + +x-pw-tooltip-footer { + display: flex; + max-width: 600px; + padding: 6px; + user-select: none; + color: #777; } x-pw-dialog { diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index 3052846104f52..0487bf04d797a 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -33,6 +33,9 @@ type HighlightEntry = { export type HighlightOptions = { tooltipText?: string; + tooltipList?: string[]; + tooltipFooter?: string; + tooltipListItemSelected?: (index: number | undefined) => void; color?: string; }; @@ -40,6 +43,7 @@ export class Highlight { private _glassPaneElement: HTMLElement; private _glassPaneShadow: ShadowRoot; private _highlightEntries: HighlightEntry[] = []; + private _highlightOptions: HighlightOptions = {}; private _actionPointElement: HTMLElement; private _isUnderTest: boolean; private _injectedScript: InjectedScript; @@ -64,6 +68,8 @@ export class Highlight { this._glassPaneElement.addEventListener(eventName, e => { e.stopPropagation(); e.stopImmediatePropagation(); + if (e.type === 'click' && (e as MouseEvent).button === 0 && this._highlightOptions.tooltipListItemSelected) + this._highlightOptions.tooltipListItemSelected(undefined); }); } this._actionPointElement = document.createElement('x-pw-action-point'); @@ -112,6 +118,8 @@ export class Highlight { entry.tooltipElement?.remove(); } this._highlightEntries = []; + this._highlightOptions = {}; + this._glassPaneElement.style.pointerEvents = 'none'; } updateHighlight(elements: Element[], options: HighlightOptions) { @@ -130,27 +138,48 @@ export class Highlight { // Code below should trigger one layout and leave with the // destroyed layout. - if (this._highlightIsUpToDate(elements, options.tooltipText)) + if (this._highlightIsUpToDate(elements, options)) return; // 1. Destroy the layout this.clearHighlight(); + this._highlightOptions = options; + this._glassPaneElement.style.pointerEvents = options.tooltipListItemSelected ? 'initial' : 'none'; for (let i = 0; i < elements.length; ++i) { const highlightElement = this._createHighlightElement(); this._glassPaneShadow.appendChild(highlightElement); let tooltipElement; - if (options.tooltipText) { + if (options.tooltipList || options.tooltipText || options.tooltipFooter) { tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip'); this._glassPaneShadow.appendChild(tooltipElement); - const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : ''; - tooltipElement.textContent = options.tooltipText + suffix; tooltipElement.style.top = '0'; tooltipElement.style.left = '0'; tooltipElement.style.display = 'flex'; + let lines: string[] = []; + if (options.tooltipList) { + lines = options.tooltipList; + } else if (options.tooltipText) { + const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : ''; + lines = [options.tooltipText + suffix]; + } + for (let index = 0; index < lines.length; index++) { + const element = this._injectedScript.document.createElement('x-pw-tooltip-line'); + element.textContent = lines[index]; + tooltipElement.appendChild(element); + if (options.tooltipListItemSelected) { + element.classList.add('selectable'); + element.addEventListener('click', () => options.tooltipListItemSelected?.(index)); + } + } + if (options.tooltipFooter) { + const footer = this._injectedScript.document.createElement('x-pw-tooltip-footer'); + footer.textContent = options.tooltipFooter; + tooltipElement.appendChild(footer); + } } - this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement, tooltipText: options.tooltipText }); + this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement }); } // 2. Trigger layout while positioning tooltips and computing bounding boxes. @@ -212,12 +241,26 @@ export class Highlight { return { anchorLeft, anchorTop }; } - private _highlightIsUpToDate(elements: Element[], tooltipText: string | undefined): boolean { + private _highlightIsUpToDate(elements: Element[], options: HighlightOptions): boolean { + if (options.tooltipText !== this._highlightOptions.tooltipText) + return false; + if (options.tooltipListItemSelected !== this._highlightOptions.tooltipListItemSelected) + return false; + if (options.tooltipFooter !== this._highlightOptions.tooltipFooter) + return false; + + if (options.tooltipList?.length !== this._highlightOptions.tooltipList?.length) + return false; + if (options.tooltipList && this._highlightOptions.tooltipList) { + for (let i = 0; i < options.tooltipList.length; i++) { + if (options.tooltipList[i] !== this._highlightOptions.tooltipList[i]) + return false; + } + } + if (elements.length !== this._highlightEntries.length) return false; for (let i = 0; i < this._highlightEntries.length; ++i) { - if (tooltipText !== this._highlightEntries[i].tooltipText) - return false; if (elements[i] !== this._highlightEntries[i].targetElement) return false; const oldBox = this._highlightEntries[i].box; @@ -227,6 +270,7 @@ export class Highlight { if (box.top !== oldBox.top || box.right !== oldBox.right || box.bottom !== oldBox.bottom || box.left !== oldBox.left) return false; } + return true; } diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index a5155fe8b5cfc..c338d3b8f0164 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -34,6 +34,7 @@ interface RecorderTool { cursor(): string; cleanup?(): void; onClick?(event: MouseEvent): void; + onContextMenu?(event: MouseEvent): void; onDragStart?(event: DragEvent): void; onInput?(event: Event): void; onKeyDown?(event: KeyboardEvent): void; @@ -59,6 +60,7 @@ class InspectTool implements RecorderTool { private _recorder: Recorder; private _hoveredModel: HighlightModel | null = null; private _hoveredElement: HTMLElement | null = null; + private _hoveredSelectors: string[] | null = null; private _assertVisibility: boolean; constructor(recorder: Recorder, assertVisibility: boolean) { @@ -73,22 +75,31 @@ class InspectTool implements RecorderTool { cleanup() { this._hoveredModel = null; this._hoveredElement = null; + this._hoveredSelectors = null; } onClick(event: MouseEvent) { consumeEvent(event); - if (this._assertVisibility) { - if (this._hoveredModel?.selector) { - this._recorder.delegate.recordAction?.({ - name: 'assertVisible', - selector: this._hoveredModel.selector, - signals: [], - }); - this._recorder.delegate.setMode?.('recording'); - this._recorder.overlay?.flashToolSucceeded('assertingVisibility'); - } - } else { - this._recorder.delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : ''); + if (event.button !== 0) + return; + if (this._hoveredModel?.selector) + this._commit(this._hoveredModel.selector); + } + + onContextMenu(event: MouseEvent) { + if (this._hoveredModel && !this._hoveredModel.tooltipListItemSelected + && this._hoveredSelectors && this._hoveredSelectors.length > 1) { + consumeEvent(event); + const selectors = this._hoveredSelectors; + this._hoveredModel.tooltipFooter = undefined; + this._hoveredModel.tooltipList = selectors.map(selector => this._recorder.injectedScript.utils.asLocator(this._recorder.state.language, selector)); + this._hoveredModel.tooltipListItemSelected = (index: number | undefined) => { + if (index === undefined) + this._reset(true); + else + this._commit(selectors[index]); + }; + this._recorder.updateHighlight(this._hoveredModel, true); } } @@ -116,11 +127,26 @@ class InspectTool implements RecorderTool { if (this._hoveredElement === target) return; this._hoveredElement = target; - const model = this._hoveredElement ? this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null; + + let model: HighlightModel | null = null; + let selectors: string[] = []; + if (this._hoveredElement) { + const generated = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, multiple: true }); + selectors = generated.selectors; + model = { + selector: generated.selector, + elements: generated.elements, + tooltipText: this._recorder.injectedScript.utils.asLocator(this._recorder.state.language, generated.selector), + tooltipFooter: selectors.length > 1 ? `Click to select, right-click for more options` : undefined, + color: this._assertVisibility ? '#8acae480' : undefined, + }; + } + if (this._hoveredModel?.selector === model?.selector) return; this._hoveredModel = model; - this._recorder.updateHighlight(model, true, { color: this._assertVisibility ? '#8acae480' : undefined }); + this._hoveredSelectors = selectors; + this._recorder.updateHighlight(model, true); } onMouseEnter(event: MouseEvent) { @@ -131,17 +157,18 @@ class InspectTool implements RecorderTool { consumeEvent(event); const window = this._recorder.injectedScript.window; // Leaving iframe. - if (window.top !== window && this._recorder.deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) { - this._hoveredElement = null; - this._hoveredModel = null; - this._recorder.updateHighlight(null, true); - } + if (window.top !== window && this._recorder.deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) + this._reset(true); } onKeyDown(event: KeyboardEvent) { consumeEvent(event); - if (this._assertVisibility && event.key === 'Escape') - this._recorder.delegate.setMode?.('recording'); + if (event.key === 'Escape') { + if (this._hoveredModel?.tooltipListItemSelected) + this._reset(true); + else if (this._assertVisibility) + this._recorder.delegate.setMode?.('recording'); + } } onKeyUp(event: KeyboardEvent) { @@ -149,9 +176,28 @@ class InspectTool implements RecorderTool { } onScroll(event: Event) { + this._reset(false); + } + + private _commit(selector: string) { + if (this._assertVisibility) { + this._recorder.delegate.recordAction?.({ + name: 'assertVisible', + selector, + signals: [], + }); + this._recorder.delegate.setMode?.('recording'); + this._recorder.overlay?.flashToolSucceeded('assertingVisibility'); + } else { + this._recorder.delegate.setSelector?.(selector); + } + } + + private _reset(userGesture: boolean) { this._hoveredElement = null; this._hoveredModel = null; - this._recorder.updateHighlight(null, false); + this._hoveredSelectors = null; + this._recorder.updateHighlight(null, userGesture); } } @@ -456,8 +502,8 @@ class RecordActionTool implements RecorderTool { const { selector, elements } = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName }); if (this._hoveredModel && this._hoveredModel.selector === selector) return; - this._hoveredModel = selector ? { selector, elements } : null; - this._recorder.updateHighlight(this._hoveredModel, true, { color: '#dc6f6f7f' }); + this._hoveredModel = selector ? { selector, elements, color: '#dc6f6f7f' } : null; + this._recorder.updateHighlight(this._hoveredModel, true); } } @@ -529,7 +575,9 @@ class TextAssertionTool implements RecorderTool { this._hoverHighlight = this._recorder.injectedScript.utils.elementText(this._textCache, target).full ? { elements: [target], selector: '' } : null; else this._hoverHighlight = this._elementHasValue(target) ? this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null; - this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' }); + if (this._hoverHighlight) + this._hoverHighlight.color = '#8acae480'; + this._recorder.updateHighlight(this._hoverHighlight, true); } onKeyDown(event: KeyboardEvent) { @@ -539,7 +587,7 @@ class TextAssertionTool implements RecorderTool { } onScroll(event: Event) { - this._recorder.updateHighlight(this._hoverHighlight, false, { color: '#8acae480' }); + this._recorder.updateHighlight(this._hoverHighlight, false); } private _elementHasValue(element: Element) { @@ -573,8 +621,9 @@ class TextAssertionTool implements RecorderTool { } } else { this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); + this._hoverHighlight.color = '#8acae480'; // forTextExpect can update the target, re-highlight it. - this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' }); + this._recorder.updateHighlight(this._hoverHighlight, true); return { name: 'assertText', @@ -893,6 +942,7 @@ export class Recorder { this._listeners = [ addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true), + addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true), addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true), addEventListener(this.document, 'input', event => this._onInput(event), true), addEventListener(this.document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true), @@ -965,6 +1015,14 @@ export class Recorder { this._currentTool.onClick?.(event); } + private _onContextMenu(event: MouseEvent) { + if (!event.isTrusted) + return; + if (this._ignoreOverlayEvent(event)) + return; + this._currentTool.onContextMenu?.(event); + } + private _onDragStart(event: DragEvent) { if (!event.isTrusted) return; @@ -1070,10 +1128,11 @@ export class Recorder { this._currentTool.onKeyUp?.(event); } - updateHighlight(model: HighlightModel | null, userGesture: boolean, options: HighlightOptions = {}) { - if (options.tooltipText === undefined && model?.selector) - options.tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector); - this.highlight.updateHighlight(model?.elements || [], options); + updateHighlight(model: HighlightModel | null, userGesture: boolean) { + let tooltipText = model?.tooltipText; + if (tooltipText === undefined && !model?.tooltipList && model?.selector) + tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector); + this.highlight.updateHighlight(model?.elements || [], { ...model, tooltipText }); if (userGesture) this.delegate.highlightUpdated?.(); } @@ -1128,7 +1187,7 @@ function consumeEvent(e: Event) { e.stopImmediatePropagation(); } -type HighlightModel = { +type HighlightModel = HighlightOptions & { selector: string; elements: Element[]; }; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index eccef270f6212..b629db04be260 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -67,17 +67,18 @@ export type GenerateSelectorOptions = { omitInternalEngines?: boolean; root?: Element | Document; forTextExpect?: boolean; + multiple?: boolean; }; -export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } { +export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, selectors: string[], elements: Element[] } { injectedScript._evaluator.begin(); beginAriaCaches(); try { - let targetTokens: SelectorToken[]; + let selectors: string[] = []; if (options.forTextExpect) { - targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options); + let targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options); for (let element: Element | undefined = targetElement; element; element = parentElementOrShadowHost(element)) { - const tokens = generateSelectorFor(injectedScript, element, options); + const tokens = generateSelectorFor(injectedScript, element, { ...options, noText: true }); if (!tokens) continue; const score = combineScores(tokens); @@ -86,14 +87,41 @@ export function generateSelector(injectedScript: InjectedScript, targetElement: break; } } + selectors = [joinTokens(targetTokens)]; } else { targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement; - targetTokens = generateSelectorFor(injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options); + if (options.multiple) { + const withText = generateSelectorFor(injectedScript, targetElement, options); + const withoutText = generateSelectorFor(injectedScript, targetElement, { ...options, noText: true }); + let tokens = [withText, withoutText]; + + // Clear cache to re-generate without css id. + cacheAllowText.clear(); + cacheDisallowText.clear(); + + if (withText && hasCSSIdToken(withText)) + tokens.push(generateSelectorFor(injectedScript, targetElement, { ...options, noCSSId: true })); + if (withoutText && hasCSSIdToken(withoutText)) + tokens.push(generateSelectorFor(injectedScript, targetElement, { ...options, noText: true, noCSSId: true })); + + tokens = tokens.filter(Boolean); + if (!tokens.length) { + const css = cssFallback(injectedScript, targetElement, options); + tokens.push(css); + if (hasCSSIdToken(css)) + tokens.push(cssFallback(injectedScript, targetElement, { ...options, noCSSId: true })); + } + selectors = [...new Set(tokens.map(t => joinTokens(t!)))]; + } else { + const targetTokens = generateSelectorFor(injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options); + selectors = [joinTokens(targetTokens)]; + } } - const selector = joinTokens(targetTokens); + const selector = selectors[0]; const parsedSelector = injectedScript.parseSelector(selector); return { selector, + selectors, elements: injectedScript.querySelectorAll(parsedSelector, options.root ?? targetElement.ownerDocument) }; } finally { @@ -109,7 +137,9 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][] return textCandidates.filter(c => c[0].selector[0] !== '/'); } -function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] | null { +type InternalOptions = GenerateSelectorOptions & { noText?: boolean, noCSSId?: boolean }; + +function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, options: InternalOptions): SelectorToken[] | null { if (options.root && !isInsideScope(options.root, targetElement)) throw new Error(`Target element must belong to the root's subtree`); @@ -188,10 +218,10 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem return value; }; - return calculate(targetElement, !options.forTextExpect); + return calculate(targetElement, !options.noText); } -function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, options: GenerateSelectorOptions): SelectorToken[] { +function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, options: InternalOptions): SelectorToken[] { const candidates: SelectorToken[] = []; // CSS selectors are applicable to elements via locator() and iframes via frameLocator(). @@ -201,9 +231,11 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, candidates.push({ engine: 'css', selector: `[${attr}=${quoteCSSAttributeValue(element.getAttribute(attr)!)}]`, score: kOtherTestIdScore }); } - const idAttr = element.getAttribute('id'); - if (idAttr && !isGuidLike(idAttr)) - candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore }); + if (!options.noCSSId) { + const idAttr = element.getAttribute('id'); + if (idAttr && !isGuidLike(idAttr)) + candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore }); + } candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore }); } @@ -315,7 +347,11 @@ function makeSelectorForId(id: string) { return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${cssEscape(id)}"]`; } -function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] { +function hasCSSIdToken(tokens: SelectorToken[]) { + return tokens.some(token => token.engine === 'css' && (token.selector.startsWith('#') || token.selector.startsWith('[id="'))); +} + +function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: InternalOptions): SelectorToken[] { const root: Node = options.root ?? targetElement.ownerDocument; const tokens: string[] = []; @@ -342,9 +378,10 @@ function cssFallback(injectedScript: InjectedScript, targetElement: Element, opt 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. let bestTokenForLevel: string = ''; - if (element.id) { + + // Element ID is the strongest signal, use it. + if (element.id && !options.noCSSId) { const token = makeSelectorForId(element.id); const selector = uniqueCSSSelector(token); if (selector) diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index f92d0393f5f4b..01809b9611c46 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -21,6 +21,10 @@ async function generate(pageOrFrame: Page | Frame, target: string): Promise (window as any).playwright.selector(e)); } +async function generateMultiple(pageOrFrame: Page | Frame, target: string): Promise { + return pageOrFrame.$eval(target, e => (window as any).__injectedScript.generateSelector(e, { multiple: true, testIdAttributeName: 'data-testid' }).selectors); +} + it.describe('selector generator', () => { it.skip(({ mode }) => mode !== 'default'); @@ -528,4 +532,44 @@ it.describe('selector generator', () => { absolute: `section >> internal:text="Hello"i`, }); }); + + it('should generate multiple: noText in role', async ({ page }) => { + await page.setContent(` + + `); + expect(await generateMultiple(page, 'button')).toEqual([`internal:role=button[name="Click me"i]`, `internal:role=button`]); + }); + + it('should generate multiple: noText in text', async ({ page }) => { + await page.setContent(` +
Some div
+ `); + expect(await generateMultiple(page, 'div')).toEqual([`internal:text="Some div"i`, `div`]); + }); + + it('should generate multiple: noId', async ({ page }) => { + await page.setContent(` +
+
+ `); + expect(await generateMultiple(page, '#second button')).toEqual([ + `#second >> internal:role=button[name="Click me"i]`, + `#second >> internal:role=button`, + `internal:role=button[name="Click me"i] >> nth=1`, + `internal:role=button >> nth=1`, + ]); + }); + + it('should generate multiple: noId noText', async ({ page }) => { + await page.setContent(` +
Some span
+
Some span
+ `); + expect(await generateMultiple(page, '#second span')).toEqual([ + `#second >> internal:text="Some span"i`, + `#second span`, + `internal:text="Some span"i >> nth=1`, + `span >> nth=1`, + ]); + }); });