Skip to content

Commit

Permalink
feat(codegen): generate multiple selectors to choose from (#29154)
Browse files Browse the repository at this point in the history
When possible, "pick locator" generates:
- default locator;
- locator without any text;
- locator without css `#id`.

Fixes #27875, fixes #5178.
  • Loading branch information
dgozman authored Jan 25, 2024
1 parent bc83d70 commit f5de6e5
Show file tree
Hide file tree
Showing 5 changed files with 263 additions and 56 deletions.
25 changes: 24 additions & 1 deletion packages/playwright-core/src/server/injected/highlight.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
60 changes: 52 additions & 8 deletions packages/playwright-core/src/server/injected/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@ type HighlightEntry = {

export type HighlightOptions = {
tooltipText?: string;
tooltipList?: string[];
tooltipFooter?: string;
tooltipListItemSelected?: (index: number | undefined) => void;
color?: string;
};

export class Highlight {
private _glassPaneElement: HTMLElement;
private _glassPaneShadow: ShadowRoot;
private _highlightEntries: HighlightEntry[] = [];
private _highlightOptions: HighlightOptions = {};
private _actionPointElement: HTMLElement;
private _isUnderTest: boolean;
private _injectedScript: InjectedScript;
Expand All @@ -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');
Expand Down Expand Up @@ -112,6 +118,8 @@ export class Highlight {
entry.tooltipElement?.remove();
}
this._highlightEntries = [];
this._highlightOptions = {};
this._glassPaneElement.style.pointerEvents = 'none';
}

updateHighlight(elements: Element[], options: HighlightOptions) {
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
123 changes: 91 additions & 32 deletions packages/playwright-core/src/server/injected/recorder/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -131,27 +157,47 @@ 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) {
consumeEvent(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._hoveredModel = null;
this._recorder.updateHighlight(null, false);
this._hoveredSelectors = null;
this._recorder.updateHighlight(null, userGesture);
}
}

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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?.();
}
Expand Down Expand Up @@ -1128,7 +1187,7 @@ function consumeEvent(e: Event) {
e.stopImmediatePropagation();
}

type HighlightModel = {
type HighlightModel = HighlightOptions & {
selector: string;
elements: Element[];
};
Expand Down
Loading

0 comments on commit f5de6e5

Please sign in to comment.