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;
|
max-width: 600px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
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 {
|
x-pw-dialog {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ type HighlightEntry = {
|
||||||
|
|
||||||
export type HighlightOptions = {
|
export type HighlightOptions = {
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
|
tooltipList?: string[];
|
||||||
|
tooltipFooter?: string;
|
||||||
|
tooltipListItemSelected?: (index: number | undefined) => void;
|
||||||
color?: string;
|
color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -40,6 +43,7 @@ export class Highlight {
|
||||||
private _glassPaneElement: HTMLElement;
|
private _glassPaneElement: HTMLElement;
|
||||||
private _glassPaneShadow: ShadowRoot;
|
private _glassPaneShadow: ShadowRoot;
|
||||||
private _highlightEntries: HighlightEntry[] = [];
|
private _highlightEntries: HighlightEntry[] = [];
|
||||||
|
private _highlightOptions: HighlightOptions = {};
|
||||||
private _actionPointElement: HTMLElement;
|
private _actionPointElement: HTMLElement;
|
||||||
private _isUnderTest: boolean;
|
private _isUnderTest: boolean;
|
||||||
private _injectedScript: InjectedScript;
|
private _injectedScript: InjectedScript;
|
||||||
|
|
@ -64,6 +68,8 @@ export class Highlight {
|
||||||
this._glassPaneElement.addEventListener(eventName, e => {
|
this._glassPaneElement.addEventListener(eventName, e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
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');
|
this._actionPointElement = document.createElement('x-pw-action-point');
|
||||||
|
|
@ -112,6 +118,8 @@ export class Highlight {
|
||||||
entry.tooltipElement?.remove();
|
entry.tooltipElement?.remove();
|
||||||
}
|
}
|
||||||
this._highlightEntries = [];
|
this._highlightEntries = [];
|
||||||
|
this._highlightOptions = {};
|
||||||
|
this._glassPaneElement.style.pointerEvents = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHighlight(elements: Element[], options: HighlightOptions) {
|
updateHighlight(elements: Element[], options: HighlightOptions) {
|
||||||
|
|
@ -130,27 +138,48 @@ export class Highlight {
|
||||||
// Code below should trigger one layout and leave with the
|
// Code below should trigger one layout and leave with the
|
||||||
// destroyed layout.
|
// destroyed layout.
|
||||||
|
|
||||||
if (this._highlightIsUpToDate(elements, options.tooltipText))
|
if (this._highlightIsUpToDate(elements, options))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// 1. Destroy the layout
|
// 1. Destroy the layout
|
||||||
this.clearHighlight();
|
this.clearHighlight();
|
||||||
|
this._highlightOptions = options;
|
||||||
|
this._glassPaneElement.style.pointerEvents = options.tooltipListItemSelected ? 'initial' : 'none';
|
||||||
|
|
||||||
for (let i = 0; i < elements.length; ++i) {
|
for (let i = 0; i < elements.length; ++i) {
|
||||||
const highlightElement = this._createHighlightElement();
|
const highlightElement = this._createHighlightElement();
|
||||||
this._glassPaneShadow.appendChild(highlightElement);
|
this._glassPaneShadow.appendChild(highlightElement);
|
||||||
|
|
||||||
let tooltipElement;
|
let tooltipElement;
|
||||||
if (options.tooltipText) {
|
if (options.tooltipList || options.tooltipText || options.tooltipFooter) {
|
||||||
tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip');
|
tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip');
|
||||||
this._glassPaneShadow.appendChild(tooltipElement);
|
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.top = '0';
|
||||||
tooltipElement.style.left = '0';
|
tooltipElement.style.left = '0';
|
||||||
tooltipElement.style.display = 'flex';
|
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.
|
// 2. Trigger layout while positioning tooltips and computing bounding boxes.
|
||||||
|
|
@ -212,12 +241,26 @@ export class Highlight {
|
||||||
return { anchorLeft, anchorTop };
|
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)
|
if (elements.length !== this._highlightEntries.length)
|
||||||
return false;
|
return false;
|
||||||
for (let i = 0; i < this._highlightEntries.length; ++i) {
|
for (let i = 0; i < this._highlightEntries.length; ++i) {
|
||||||
if (tooltipText !== this._highlightEntries[i].tooltipText)
|
|
||||||
return false;
|
|
||||||
if (elements[i] !== this._highlightEntries[i].targetElement)
|
if (elements[i] !== this._highlightEntries[i].targetElement)
|
||||||
return false;
|
return false;
|
||||||
const oldBox = this._highlightEntries[i].box;
|
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)
|
if (box.top !== oldBox.top || box.right !== oldBox.right || box.bottom !== oldBox.bottom || box.left !== oldBox.left)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface RecorderTool {
|
||||||
cursor(): string;
|
cursor(): string;
|
||||||
cleanup?(): void;
|
cleanup?(): void;
|
||||||
onClick?(event: MouseEvent): void;
|
onClick?(event: MouseEvent): void;
|
||||||
|
onContextMenu?(event: MouseEvent): void;
|
||||||
onDragStart?(event: DragEvent): void;
|
onDragStart?(event: DragEvent): void;
|
||||||
onInput?(event: Event): void;
|
onInput?(event: Event): void;
|
||||||
onKeyDown?(event: KeyboardEvent): void;
|
onKeyDown?(event: KeyboardEvent): void;
|
||||||
|
|
@ -59,6 +60,7 @@ class InspectTool implements RecorderTool {
|
||||||
private _recorder: Recorder;
|
private _recorder: Recorder;
|
||||||
private _hoveredModel: HighlightModel | null = null;
|
private _hoveredModel: HighlightModel | null = null;
|
||||||
private _hoveredElement: HTMLElement | null = null;
|
private _hoveredElement: HTMLElement | null = null;
|
||||||
|
private _hoveredSelectors: string[] | null = null;
|
||||||
private _assertVisibility: boolean;
|
private _assertVisibility: boolean;
|
||||||
|
|
||||||
constructor(recorder: Recorder, assertVisibility: boolean) {
|
constructor(recorder: Recorder, assertVisibility: boolean) {
|
||||||
|
|
@ -73,22 +75,31 @@ class InspectTool implements RecorderTool {
|
||||||
cleanup() {
|
cleanup() {
|
||||||
this._hoveredModel = null;
|
this._hoveredModel = null;
|
||||||
this._hoveredElement = null;
|
this._hoveredElement = null;
|
||||||
|
this._hoveredSelectors = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick(event: MouseEvent) {
|
onClick(event: MouseEvent) {
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
if (this._assertVisibility) {
|
if (event.button !== 0)
|
||||||
if (this._hoveredModel?.selector) {
|
return;
|
||||||
this._recorder.delegate.recordAction?.({
|
if (this._hoveredModel?.selector)
|
||||||
name: 'assertVisible',
|
this._commit(this._hoveredModel.selector);
|
||||||
selector: this._hoveredModel.selector,
|
}
|
||||||
signals: [],
|
|
||||||
});
|
onContextMenu(event: MouseEvent) {
|
||||||
this._recorder.delegate.setMode?.('recording');
|
if (this._hoveredModel && !this._hoveredModel.tooltipListItemSelected
|
||||||
this._recorder.overlay?.flashToolSucceeded('assertingVisibility');
|
&& this._hoveredSelectors && this._hoveredSelectors.length > 1) {
|
||||||
}
|
consumeEvent(event);
|
||||||
} else {
|
const selectors = this._hoveredSelectors;
|
||||||
this._recorder.delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : '');
|
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)
|
if (this._hoveredElement === target)
|
||||||
return;
|
return;
|
||||||
this._hoveredElement = target;
|
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)
|
if (this._hoveredModel?.selector === model?.selector)
|
||||||
return;
|
return;
|
||||||
this._hoveredModel = model;
|
this._hoveredModel = model;
|
||||||
this._recorder.updateHighlight(model, true, { color: this._assertVisibility ? '#8acae480' : undefined });
|
this._hoveredSelectors = selectors;
|
||||||
|
this._recorder.updateHighlight(model, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseEnter(event: MouseEvent) {
|
onMouseEnter(event: MouseEvent) {
|
||||||
|
|
@ -131,17 +157,18 @@ class InspectTool implements RecorderTool {
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
const window = this._recorder.injectedScript.window;
|
const window = this._recorder.injectedScript.window;
|
||||||
// Leaving iframe.
|
// Leaving iframe.
|
||||||
if (window.top !== window && this._recorder.deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
|
if (window.top !== window && this._recorder.deepEventTarget(event).nodeType === Node.DOCUMENT_NODE)
|
||||||
this._hoveredElement = null;
|
this._reset(true);
|
||||||
this._hoveredModel = null;
|
|
||||||
this._recorder.updateHighlight(null, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown(event: KeyboardEvent) {
|
onKeyDown(event: KeyboardEvent) {
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
if (this._assertVisibility && event.key === 'Escape')
|
if (event.key === 'Escape') {
|
||||||
this._recorder.delegate.setMode?.('recording');
|
if (this._hoveredModel?.tooltipListItemSelected)
|
||||||
|
this._reset(true);
|
||||||
|
else if (this._assertVisibility)
|
||||||
|
this._recorder.delegate.setMode?.('recording');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyUp(event: KeyboardEvent) {
|
onKeyUp(event: KeyboardEvent) {
|
||||||
|
|
@ -149,9 +176,28 @@ class InspectTool implements RecorderTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
onScroll(event: Event) {
|
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._hoveredElement = null;
|
||||||
this._hoveredModel = 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 });
|
const { selector, elements } = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName });
|
||||||
if (this._hoveredModel && this._hoveredModel.selector === selector)
|
if (this._hoveredModel && this._hoveredModel.selector === selector)
|
||||||
return;
|
return;
|
||||||
this._hoveredModel = selector ? { selector, elements } : null;
|
this._hoveredModel = selector ? { selector, elements, color: '#dc6f6f7f' } : null;
|
||||||
this._recorder.updateHighlight(this._hoveredModel, true, { color: '#dc6f6f7f' });
|
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;
|
this._hoverHighlight = this._recorder.injectedScript.utils.elementText(this._textCache, target).full ? { elements: [target], selector: '' } : null;
|
||||||
else
|
else
|
||||||
this._hoverHighlight = this._elementHasValue(target) ? this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null;
|
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) {
|
onKeyDown(event: KeyboardEvent) {
|
||||||
|
|
@ -539,7 +587,7 @@ class TextAssertionTool implements RecorderTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
onScroll(event: Event) {
|
onScroll(event: Event) {
|
||||||
this._recorder.updateHighlight(this._hoverHighlight, false, { color: '#8acae480' });
|
this._recorder.updateHighlight(this._hoverHighlight, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _elementHasValue(element: Element) {
|
private _elementHasValue(element: Element) {
|
||||||
|
|
@ -573,8 +621,9 @@ class TextAssertionTool implements RecorderTool {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
|
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.
|
// forTextExpect can update the target, re-highlight it.
|
||||||
this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' });
|
this._recorder.updateHighlight(this._hoverHighlight, true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'assertText',
|
name: 'assertText',
|
||||||
|
|
@ -893,6 +942,7 @@ export class Recorder {
|
||||||
this._listeners = [
|
this._listeners = [
|
||||||
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true),
|
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, '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, 'dragstart', event => this._onDragStart(event as DragEvent), true),
|
||||||
addEventListener(this.document, 'input', event => this._onInput(event), true),
|
addEventListener(this.document, 'input', event => this._onInput(event), true),
|
||||||
addEventListener(this.document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true),
|
addEventListener(this.document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true),
|
||||||
|
|
@ -965,6 +1015,14 @@ export class Recorder {
|
||||||
this._currentTool.onClick?.(event);
|
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) {
|
private _onDragStart(event: DragEvent) {
|
||||||
if (!event.isTrusted)
|
if (!event.isTrusted)
|
||||||
return;
|
return;
|
||||||
|
|
@ -1070,10 +1128,11 @@ export class Recorder {
|
||||||
this._currentTool.onKeyUp?.(event);
|
this._currentTool.onKeyUp?.(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHighlight(model: HighlightModel | null, userGesture: boolean, options: HighlightOptions = {}) {
|
updateHighlight(model: HighlightModel | null, userGesture: boolean) {
|
||||||
if (options.tooltipText === undefined && model?.selector)
|
let tooltipText = model?.tooltipText;
|
||||||
options.tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector);
|
if (tooltipText === undefined && !model?.tooltipList && model?.selector)
|
||||||
this.highlight.updateHighlight(model?.elements || [], options);
|
tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector);
|
||||||
|
this.highlight.updateHighlight(model?.elements || [], { ...model, tooltipText });
|
||||||
if (userGesture)
|
if (userGesture)
|
||||||
this.delegate.highlightUpdated?.();
|
this.delegate.highlightUpdated?.();
|
||||||
}
|
}
|
||||||
|
|
@ -1128,7 +1187,7 @@ function consumeEvent(e: Event) {
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
type HighlightModel = {
|
type HighlightModel = HighlightOptions & {
|
||||||
selector: string;
|
selector: string;
|
||||||
elements: Element[];
|
elements: Element[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -67,17 +67,18 @@ export type GenerateSelectorOptions = {
|
||||||
omitInternalEngines?: boolean;
|
omitInternalEngines?: boolean;
|
||||||
root?: Element | Document;
|
root?: Element | Document;
|
||||||
forTextExpect?: boolean;
|
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();
|
injectedScript._evaluator.begin();
|
||||||
beginAriaCaches();
|
beginAriaCaches();
|
||||||
try {
|
try {
|
||||||
let targetTokens: SelectorToken[];
|
let selectors: string[] = [];
|
||||||
if (options.forTextExpect) {
|
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)) {
|
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)
|
if (!tokens)
|
||||||
continue;
|
continue;
|
||||||
const score = combineScores(tokens);
|
const score = combineScores(tokens);
|
||||||
|
|
@ -86,14 +87,41 @@ export function generateSelector(injectedScript: InjectedScript, targetElement:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selectors = [joinTokens(targetTokens)];
|
||||||
} else {
|
} else {
|
||||||
targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement;
|
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);
|
const parsedSelector = injectedScript.parseSelector(selector);
|
||||||
return {
|
return {
|
||||||
selector,
|
selector,
|
||||||
|
selectors,
|
||||||
elements: injectedScript.querySelectorAll(parsedSelector, options.root ?? targetElement.ownerDocument)
|
elements: injectedScript.querySelectorAll(parsedSelector, options.root ?? targetElement.ownerDocument)
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -109,7 +137,9 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][]
|
||||||
return textCandidates.filter(c => c[0].selector[0] !== '/');
|
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))
|
if (options.root && !isInsideScope(options.root, targetElement))
|
||||||
throw new Error(`Target element must belong to the root's subtree`);
|
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 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[] = [];
|
const candidates: SelectorToken[] = [];
|
||||||
|
|
||||||
// CSS selectors are applicable to elements via locator() and iframes via frameLocator().
|
// 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 });
|
candidates.push({ engine: 'css', selector: `[${attr}=${quoteCSSAttributeValue(element.getAttribute(attr)!)}]`, score: kOtherTestIdScore });
|
||||||
}
|
}
|
||||||
|
|
||||||
const idAttr = element.getAttribute('id');
|
if (!options.noCSSId) {
|
||||||
if (idAttr && !isGuidLike(idAttr))
|
const idAttr = element.getAttribute('id');
|
||||||
candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore });
|
if (idAttr && !isGuidLike(idAttr))
|
||||||
|
candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore });
|
||||||
|
}
|
||||||
|
|
||||||
candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore });
|
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)}"]`;
|
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 root: Node = options.root ?? targetElement.ownerDocument;
|
||||||
const tokens: string[] = [];
|
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)) {
|
for (let element: Element | undefined = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) {
|
||||||
const nodeName = element.nodeName.toLowerCase();
|
const nodeName = element.nodeName.toLowerCase();
|
||||||
|
|
||||||
// Element ID is the strongest signal, use it.
|
|
||||||
let bestTokenForLevel: string = '';
|
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 token = makeSelectorForId(element.id);
|
||||||
const selector = uniqueCSSSelector(token);
|
const selector = uniqueCSSSelector(token);
|
||||||
if (selector)
|
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));
|
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.describe('selector generator', () => {
|
||||||
it.skip(({ mode }) => mode !== 'default');
|
it.skip(({ mode }) => mode !== 'default');
|
||||||
|
|
||||||
|
|
@ -528,4 +532,44 @@ it.describe('selector generator', () => {
|
||||||
absolute: `section >> internal:text="Hello"i`,
|
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