chore: simplify highlight and recorder (#35218)

This commit is contained in:
Dmitry Gozman 2025-03-18 09:58:41 +00:00 committed by GitHub
parent 7ce4f88a5b
commit 66b3efa826
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 87 additions and 143 deletions

View File

@ -23,8 +23,9 @@ import type { Language } from '../../utils/isomorphic/locatorGenerators';
import type { ParsedSelector } from '../../utils/isomorphic/selectorParser';
type HighlightEntry = {
type RenderedHighlightEntry = {
targetElement: Element,
color: string,
highlightElement: HTMLElement,
tooltipElement?: HTMLElement,
box?: DOMRect,
@ -33,19 +34,16 @@ type HighlightEntry = {
tooltipText?: string,
};
export type HighlightOptions = {
tooltipText?: string;
tooltipList?: string[];
tooltipFooter?: string;
tooltipListItemSelected?: (index: number | undefined) => void;
color?: string;
export type HighlightEntry = {
element: Element,
color: string,
tooltipText?: string,
};
export class Highlight {
private _glassPaneElement: HTMLElement;
private _glassPaneShadow: ShadowRoot;
private _highlightEntries: HighlightEntry[] = [];
private _highlightOptions: HighlightOptions = {};
private _renderedEntries: RenderedHighlightEntry[] = [];
private _actionPointElement: HTMLElement;
private _isUnderTest: boolean;
private _injectedScript: InjectedScript;
@ -70,8 +68,6 @@ 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');
@ -104,7 +100,13 @@ export class Highlight {
runHighlightOnRaf(selector: ParsedSelector) {
if (this._rafRequest)
cancelAnimationFrame(this._rafRequest);
this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), { tooltipText: asLocator(this._language, stringifySelector(selector)) });
const elements = this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement);
const locator = asLocator(this._language, stringifySelector(selector));
const color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
this.updateHighlight(elements.map((element, index) => {
const suffix = elements.length > 1 ? ` [${index + 1} of ${elements.length}]` : '';
return { element, color, tooltipText: locator + suffix };
}));
this._rafRequest = this._injectedScript.builtinRequestAnimationFrame(() => this.runHighlightOnRaf(selector));
}
@ -125,77 +127,47 @@ export class Highlight {
}
clearHighlight() {
for (const entry of this._highlightEntries) {
for (const entry of this._renderedEntries) {
entry.highlightElement?.remove();
entry.tooltipElement?.remove();
}
this._highlightEntries = [];
this._highlightOptions = {};
this._glassPaneElement.style.pointerEvents = 'none';
}
updateHighlight(elements: Element[], options: HighlightOptions) {
this._innerUpdateHighlight(elements, options);
this._renderedEntries = [];
}
maskElements(elements: Element[], color: string) {
this._innerUpdateHighlight(elements, { color: color });
this.updateHighlight(elements.map(element => ({ element, color })));
}
private _innerUpdateHighlight(elements: Element[], options: HighlightOptions) {
let color = options.color;
if (!color)
color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
updateHighlight(entries: HighlightEntry[]) {
// Code below should trigger one layout and leave with the
// destroyed layout.
if (this._highlightIsUpToDate(elements, options))
if (this._highlightIsUpToDate(entries))
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) {
for (const entry of entries) {
const highlightElement = this._createHighlightElement();
this._glassPaneShadow.appendChild(highlightElement);
let tooltipElement;
if (options.tooltipList || options.tooltipText || options.tooltipFooter) {
if (entry.tooltipText) {
tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip');
this._glassPaneShadow.appendChild(tooltipElement);
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);
}
const lineElement = this._injectedScript.document.createElement('x-pw-tooltip-line');
lineElement.textContent = entry.tooltipText;
tooltipElement.appendChild(lineElement);
}
this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement });
this._renderedEntries.push({ targetElement: entry.element, color: entry.color, tooltipElement, highlightElement });
}
// 2. Trigger layout while positioning tooltips and computing bounding boxes.
for (const entry of this._highlightEntries) {
for (const entry of this._renderedEntries) {
entry.box = entry.targetElement.getBoundingClientRect();
if (!entry.tooltipElement)
continue;
@ -207,15 +179,13 @@ export class Highlight {
}
// 3. Destroy the layout again.
// If there are more than 1 box - we are evaluating a non-unique (potentially bad) selector.
for (const entry of this._highlightEntries) {
for (const entry of this._renderedEntries) {
if (entry.tooltipElement) {
entry.tooltipElement.style.top = entry.tooltipTop + 'px';
entry.tooltipElement.style.left = entry.tooltipLeft + 'px';
}
const box = entry.box!;
entry.highlightElement.style.backgroundColor = color;
entry.highlightElement.style.backgroundColor = entry.color;
entry.highlightElement.style.left = box.x + 'px';
entry.highlightElement.style.top = box.y + 'px';
entry.highlightElement.style.width = box.width + 'px';
@ -228,7 +198,7 @@ export class Highlight {
}
firstBox(): DOMRect | undefined {
return this._highlightEntries[0]?.box;
return this._renderedEntries[0]?.box;
}
tooltipPosition(box: DOMRect, tooltipElement: HTMLElement) {
@ -253,36 +223,21 @@ export class Highlight {
return { anchorLeft, anchorTop };
}
private _highlightIsUpToDate(elements: Element[], options: HighlightOptions): boolean {
if (options.tooltipText !== this._highlightOptions.tooltipText)
private _highlightIsUpToDate(entries: HighlightEntry[]): boolean {
if (entries.length !== this._renderedEntries.length)
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 (elements[i] !== this._highlightEntries[i].targetElement)
for (let i = 0; i < this._renderedEntries.length; ++i) {
if (entries[i].element !== this._renderedEntries[i].targetElement)
return false;
const oldBox = this._highlightEntries[i].box;
if (entries[i].color !== this._renderedEntries[i].color)
return false;
const oldBox = this._renderedEntries[i].box;
if (!oldBox)
return false;
const box = elements[i].getBoundingClientRect();
const box = entries[i].element.getBoundingClientRect();
if (box.top !== oldBox.top || box.right !== oldBox.right || box.bottom !== oldBox.bottom || box.left !== oldBox.left)
return false;
}
return true;
}

View File

@ -17,11 +17,19 @@
import clipPaths from './clipPaths';
import type { Point } from '../../../utils/isomorphic/types';
import type { Highlight, HighlightOptions } from '../highlight';
import type { Highlight, HighlightEntry } from '../highlight';
import type { InjectedScript } from '../injectedScript';
import type { ElementText } from '../selectorUtils';
import type * as actions from '@recorder/actions';
import type { ElementInfo, Mode, OverlayState, UIState } from '@recorder/recorderTypes';
import type { Language } from '@isomorphic/locatorGenerators';
const HighlightColors = {
multiple: '#f6b26b7f',
single: '#6fa8dc7f',
assert: '#8acae480',
action: '#dc6f6f7f',
};
export interface RecorderDelegate {
performAction?(action: actions.PerformOnRecordAction): Promise<void>;
@ -63,7 +71,6 @@ 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) {
@ -78,7 +85,6 @@ class InspectTool implements RecorderTool {
cleanup() {
this._hoveredModel = null;
this._hoveredElement = null;
this._hoveredSelectors = null;
}
onClick(event: MouseEvent) {
@ -89,24 +95,6 @@ class InspectTool implements RecorderTool {
this._commit(this._hoveredModel.selector, this._hoveredModel);
}
onContextMenu(event: MouseEvent) {
if (this._hoveredModel && !this._hoveredModel.tooltipListItemSelected
&& this._hoveredSelectors && this._hoveredSelectors.length > 1) {
consumeEvent(event);
const selectors = this._hoveredSelectors;
const hoveredModel = this._hoveredModel;
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], hoveredModel);
};
this._recorder.updateHighlight(this._hoveredModel, true);
}
}
onPointerDown(event: PointerEvent) {
consumeEvent(event);
}
@ -133,23 +121,19 @@ class InspectTool implements RecorderTool {
this._hoveredElement = target;
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: false });
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,
color: this._assertVisibility ? HighlightColors.assert : HighlightColors.single,
};
}
if (this._hoveredModel?.selector === model?.selector)
return;
this._hoveredModel = model;
this._hoveredSelectors = selectors;
this._recorder.updateHighlight(model, true);
}
@ -168,9 +152,7 @@ class InspectTool implements RecorderTool {
onKeyDown(event: KeyboardEvent) {
consumeEvent(event);
if (event.key === 'Escape') {
if (this._hoveredModel?.tooltipListItemSelected)
this._reset(true);
else if (this._assertVisibility)
if (this._assertVisibility)
this._recorder.setMode('recording');
}
}
@ -200,7 +182,6 @@ class InspectTool implements RecorderTool {
private _reset(userGesture: boolean) {
this._hoveredElement = null;
this._hoveredModel = null;
this._hoveredSelectors = null;
this._recorder.updateHighlight(null, userGesture);
}
}
@ -492,7 +473,7 @@ class RecordActionTool implements RecorderTool {
if (userGesture && activeElement === this._recorder.document.body)
return;
const result = activeElement ? this._recorder.injectedScript.generateSelector(activeElement, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null;
this._activeModel = result && result.selector ? result : null;
this._activeModel = result && result.selector ? { ...result, color: HighlightColors.action } : null;
if (userGesture) {
this._hoveredElement = activeElement as HTMLElement | null;
this._updateModelForHoveredElement();
@ -602,7 +583,7 @@ 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, color: '#dc6f6f7f' } : null;
this._hoveredModel = selector ? { selector, elements, color: HighlightColors.action } : null;
this._recorder.updateHighlight(this._hoveredModel, true);
}
}
@ -661,12 +642,14 @@ class TextAssertionTool implements RecorderTool {
const target = this._recorder.deepEventTarget(event);
if (this._hoverHighlight?.elements[0] === target)
return;
if (this._kind === 'text' || this._kind === 'snapshot')
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;
if (this._hoverHighlight)
this._hoverHighlight.color = '#8acae480';
if (this._kind === 'text' || this._kind === 'snapshot') {
this._hoverHighlight = this._recorder.injectedScript.utils.elementText(this._textCache, target).full ? { elements: [target], selector: '', color: HighlightColors.assert } : null;
} else if (this._elementHasValue(target)) {
const generated = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
this._hoverHighlight = { selector: generated.selector, elements: generated.elements, color: HighlightColors.assert };
} else {
this._hoverHighlight = null;
}
this._recorder.updateHighlight(this._hoverHighlight, true);
}
@ -710,8 +693,8 @@ class TextAssertionTool implements RecorderTool {
};
}
} else if (this._kind === 'snapshot') {
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
this._hoverHighlight.color = '#8acae480';
const generated = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
this._hoverHighlight = { selector: generated.selector, elements: generated.elements, color: HighlightColors.assert };
// forTextExpect can update the target, re-highlight it.
this._recorder.updateHighlight(this._hoverHighlight, true);
@ -722,8 +705,8 @@ class TextAssertionTool implements RecorderTool {
snapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'regex' }),
};
} else {
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
this._hoverHighlight.color = '#8acae480';
const generated = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
this._hoverHighlight = { selector: generated.selector, elements: generated.elements, color: HighlightColors.assert };
// forTextExpect can update the target, re-highlight it.
this._recorder.updateHighlight(this._hoverHighlight, true);
@ -1137,18 +1120,19 @@ export class Recorder {
this._switchCurrentTool();
this.overlay?.setUIState(state);
let highlight: HighlightModel | 'clear' | 'noop' = 'noop';
let highlight: HighlightEntry[] | 'clear' | 'noop' = 'noop';
if (state.actionSelector !== this._lastHighlightedSelector) {
const model = state.actionSelector ? querySelector(this.injectedScript, state.actionSelector, this.document) : null;
highlight = model?.elements.length ? model : 'clear';
this._lastHighlightedSelector = model?.elements.length ? state.actionSelector : undefined;
const entries = state.actionSelector ? entriesForSelectorHighlight(this.injectedScript, state.language, state.actionSelector, this.document) : null;
highlight = entries?.length ? entries : 'clear';
this._lastHighlightedSelector = entries?.length ? state.actionSelector : undefined;
}
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
if (elements.length) {
highlight = { elements };
const color = elements.length > 1 ? HighlightColors.multiple : HighlightColors.single;
highlight = elements.map(element => ({ element, color }));
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
} else {
if (!this._lastHighlightedSelector)
@ -1158,9 +1142,9 @@ export class Recorder {
}
if (highlight === 'clear')
this.clearHighlight();
this.highlight.clearHighlight();
else if (highlight !== 'noop')
this._updateHighlight(highlight, false);
this.highlight.updateHighlight(highlight);
}
clearHighlight() {
@ -1310,9 +1294,12 @@ export class Recorder {
private _updateHighlight(model: HighlightModel | null, userGesture: boolean) {
let tooltipText = model?.tooltipText;
if (tooltipText === undefined && !model?.tooltipList && model?.selector)
if (tooltipText === undefined && model?.selector)
tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector);
this.highlight.updateHighlight(model?.elements || [], { ...model, tooltipText });
if (model)
this.highlight.updateHighlight(model.elements.map(element => ({ element, color: model.color, tooltipText })));
else
this.highlight.clearHighlight();
if (userGesture)
this._delegate.highlightUpdated?.();
}
@ -1471,9 +1458,11 @@ function consumeEvent(e: Event) {
e.stopImmediatePropagation();
}
type HighlightModel = HighlightOptions & {
type HighlightModel = {
selector?: string;
elements: Element[];
color: string;
tooltipText?: string;
};
type HighlightModelWithSelector = HighlightModel & {
@ -1508,18 +1497,18 @@ function removeEventListeners(listeners: (() => void)[]) {
listeners.splice(0, listeners.length);
}
function querySelector(injectedScript: InjectedScript, selector: string, ownerDocument: Document): { selector: string, elements: Element[] } {
function entriesForSelectorHighlight(injectedScript: InjectedScript, language: Language, selector: string, ownerDocument: Document): HighlightEntry[] {
try {
const parsedSelector = injectedScript.parseSelector(selector);
return {
selector,
elements: injectedScript.querySelectorAll(parsedSelector, ownerDocument)
};
const elements = injectedScript.querySelectorAll(parsedSelector, ownerDocument);
const color = elements.length > 1 ? HighlightColors.multiple : HighlightColors.single;
const locator = injectedScript.utils.asLocator(language, selector);
return elements.map((element, index) => {
const suffix = elements.length > 1 ? ` [${index + 1} of ${elements.length}]` : '';
return { element, color, tooltipText: locator + suffix };
});
} catch (e) {
return {
selector,
elements: [],
};
return [];
}
}