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:
parent
bc83d7084c
commit
f5de6e5538
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue