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.
This commit is contained in:
Dmitry Gozman 2024-01-25 07:35:58 -08:00 committed by GitHub
parent bc83d7084c
commit f5de6e5538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 263 additions and 56 deletions

View file

@ -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 {

View file

@ -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;
}

View file

@ -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[];
};

View file

@ -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)

View file

@ -21,6 +21,10 @@ async function generate(pageOrFrame: Page | Frame, target: string): Promise<stri
return pageOrFrame.$eval(target, e => (window as any).playwright.selector(e));
}
async function generateMultiple(pageOrFrame: Page | Frame, target: string): Promise<string> {
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(`
<button>Click me</button>
`);
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(`
<div>Some div</div>
`);
expect(await generateMultiple(page, 'div')).toEqual([`internal:text="Some div"i`, `div`]);
});
it('should generate multiple: noId', async ({ page }) => {
await page.setContent(`
<div id=first><button>Click me</button></div>
<div id=second><button>Click me</button></div>
`);
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(`
<div id=first><span>Some span</span></div>
<div id=second><span>Some span</span></div>
`);
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`,
]);
});
});