chore(recorder): more UX fixes for text assertions (#27995)

This commit is contained in:
Dmitry Gozman 2023-11-06 16:40:33 -08:00 committed by GitHub
parent 87787dcc7d
commit 810382c074
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 222 additions and 83 deletions

View file

@ -30,6 +30,13 @@ type HighlightEntry = {
tooltipText?: string, tooltipText?: string,
}; };
export type HighlightOptions = {
tooltipText?: string;
color?: string;
anchorGetter?: (element: Element) => DOMRect;
decorateTooltip?: (tooltip: Element) => void;
};
export class Highlight { export class Highlight {
private _glassPaneElement: HTMLElement; private _glassPaneElement: HTMLElement;
private _glassPaneShadow: ShadowRoot; private _glassPaneShadow: ShadowRoot;
@ -112,7 +119,7 @@ export class Highlight {
runHighlightOnRaf(selector: ParsedSelector) { runHighlightOnRaf(selector: ParsedSelector) {
if (this._rafRequest) if (this._rafRequest)
cancelAnimationFrame(this._rafRequest); cancelAnimationFrame(this._rafRequest);
this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), stringifySelector(selector)); this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), { tooltipText: asLocator(this._language, stringifySelector(selector)) });
this._rafRequest = requestAnimationFrame(() => this.runHighlightOnRaf(selector)); this._rafRequest = requestAnimationFrame(() => this.runHighlightOnRaf(selector));
} }
@ -144,17 +151,19 @@ export class Highlight {
this._highlightEntries = []; this._highlightEntries = [];
} }
updateHighlight(elements: Element[], selector: string, color?: string) { updateHighlight(elements: Element[], options: HighlightOptions) {
if (!color) this._innerUpdateHighlight(elements, options);
color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
this._innerUpdateHighlight(elements, { color, tooltipText: selector ? asLocator(this._language, selector) : '' });
} }
maskElements(elements: Element[], color?: string) { maskElements(elements: Element[], color?: string) {
this._innerUpdateHighlight(elements, { color: color ? color : '#F0F' }); this._innerUpdateHighlight(elements, { color: color ? color : '#F0F' });
} }
private _innerUpdateHighlight(elements: Element[], options: { color: string, tooltipText?: string }) { private _innerUpdateHighlight(elements: Element[], options: HighlightOptions) {
let color = options.color;
if (!color)
color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
// Code below should trigger one layout and leave with the // Code below should trigger one layout and leave with the
// destroyed layout. // destroyed layout.
@ -177,6 +186,7 @@ export class Highlight {
tooltipElement.style.top = '0'; tooltipElement.style.top = '0';
tooltipElement.style.left = '0'; tooltipElement.style.left = '0';
tooltipElement.style.display = 'flex'; tooltipElement.style.display = 'flex';
options.decorateTooltip?.(tooltipElement);
} }
this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement, tooltipText: options.tooltipText }); this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement, tooltipText: options.tooltipText });
} }
@ -193,14 +203,15 @@ export class Highlight {
const totalWidth = this._glassPaneElement.offsetWidth; const totalWidth = this._glassPaneElement.offsetWidth;
const totalHeight = this._glassPaneElement.offsetHeight; const totalHeight = this._glassPaneElement.offsetHeight;
let anchorLeft = entry.box.left; const anchorBox = options.anchorGetter ? options.anchorGetter(entry.targetElement) : entry.box;
let anchorLeft = anchorBox.left;
if (anchorLeft + tooltipWidth > totalWidth - 5) if (anchorLeft + tooltipWidth > totalWidth - 5)
anchorLeft = totalWidth - tooltipWidth - 5; anchorLeft = totalWidth - tooltipWidth - 5;
let anchorTop = entry.box.bottom + 5; let anchorTop = anchorBox.bottom + 5;
if (anchorTop + tooltipHeight > totalHeight - 5) { if (anchorTop + tooltipHeight > totalHeight - 5) {
// If can't fit below, either position above... // If can't fit below, either position above...
if (entry.box.top > tooltipHeight + 5) { if (anchorBox.top > tooltipHeight + 5) {
anchorTop = entry.box.top - tooltipHeight - 5; anchorTop = anchorBox.top - tooltipHeight - 5;
} else { } else {
// Or on top in case of large element // Or on top in case of large element
anchorTop = totalHeight - 5 - tooltipHeight; anchorTop = totalHeight - 5 - tooltipHeight;
@ -219,7 +230,7 @@ export class Highlight {
entry.tooltipElement.style.left = entry.tooltipLeft + 'px'; entry.tooltipElement.style.left = entry.tooltipLeft + 'px';
} }
const box = entry.box!; const box = entry.box!;
entry.highlightElement.style.backgroundColor = options.color; entry.highlightElement.style.backgroundColor = color;
entry.highlightElement.style.left = box.x + 'px'; entry.highlightElement.style.left = box.x + 'px';
entry.highlightElement.style.top = box.y + 'px'; entry.highlightElement.style.top = box.y + 'px';
entry.highlightElement.style.width = box.width + 'px'; entry.highlightElement.style.width = box.width + 'px';

View file

@ -18,18 +18,19 @@ import type * as actions from '../recorder/recorderActions';
import type { InjectedScript } from '../injected/injectedScript'; import type { InjectedScript } from '../injected/injectedScript';
import { generateSelector } from '../injected/selectorGenerator'; import { generateSelector } from '../injected/selectorGenerator';
import type { Point } from '../../common/types'; import type { Point } from '../../common/types';
import type { Mode, UIState } from '@recorder/recorderTypes'; import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes';
import { Highlight } from '../injected/highlight'; import { Highlight, type HighlightOptions } from '../injected/highlight';
import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils'; import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils';
import { elementText } from './selectorUtils'; import { elementText } from './selectorUtils';
import { normalizeWhiteSpace } from '@isomorphic/stringUtils'; import { escapeWithQuotes, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
interface RecorderDelegate { interface RecorderDelegate {
performAction?(action: actions.Action): Promise<void>; performAction?(action: actions.Action): Promise<void>;
recordAction?(action: actions.Action): Promise<void>; recordAction?(action: actions.Action): Promise<void>;
setSelector?(selector: string): Promise<void>; setSelector?(selector: string): Promise<void>;
setMode?(mode: Mode): Promise<void>; setMode?(mode: Mode): Promise<void>;
setOverlayPosition?(position: { x: number, y: number }): Promise<void>; setOverlayState?(state: OverlayState): Promise<void>;
highlightUpdated?(): void; highlightUpdated?(): void;
} }
@ -436,15 +437,23 @@ class RecordActionTool implements RecorderTool {
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 } : null;
this._recorder.updateHighlight(this._hoveredModel, true, '#dc6f6f7f'); this._recorder.updateHighlight(this._hoveredModel, true, { color: '#dc6f6f7f' });
} }
} }
class TextAssertionTool implements RecorderTool { class TextAssertionTool implements RecorderTool {
private _hoverHighlight: HighlightModel | null = null;
private _selectionHighlight: HighlightModel | null = null; private _selectionHighlight: HighlightModel | null = null;
private _selectionText: { selectedText: string, fullText: string } | null = null;
private _inputHighlight: HighlightModel | null = null; private _inputHighlight: HighlightModel | null = null;
private _acceptButton: HTMLElement;
constructor(private _recorder: Recorder) { constructor(private _recorder: Recorder) {
this._acceptButton = this._recorder.document.createElement('button');
this._acceptButton.textContent = 'Accept';
this._acceptButton.style.cursor = 'pointer';
this._acceptButton.style.pointerEvents = 'auto';
this._acceptButton.addEventListener('click', () => this._commitAction());
} }
cursor() { cursor() {
@ -457,13 +466,19 @@ class TextAssertionTool implements RecorderTool {
disable() { disable() {
this._recorder.injectedScript.document.designMode = 'off'; this._recorder.injectedScript.document.designMode = 'off';
this._hoverHighlight = null;
this._selectionHighlight = null; this._selectionHighlight = null;
this._selectionText = null;
this._inputHighlight = null; this._inputHighlight = null;
} }
onClick(event: MouseEvent) { onClick(event: MouseEvent) {
consumeEvent(event); // Hack: work around highlight's glass pane having a closed shadow root.
const box = this._acceptButton.getBoundingClientRect();
if (box.left <= event.clientX && event.clientX <= box.right && box.top <= event.clientY && event.clientY <= box.bottom)
return;
consumeEvent(event);
const selection = this._recorder.document.getSelection(); const selection = this._recorder.document.getSelection();
if (event.detail === 1 && selection && !selection.toString() && !this._inputHighlight) { if (event.detail === 1 && selection && !selection.toString() && !this._inputHighlight) {
const target = this._recorder.deepEventTarget(event); const target = this._recorder.deepEventTarget(event);
@ -477,12 +492,13 @@ class TextAssertionTool implements RecorderTool {
if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) { if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
this._recorder.injectedScript.window.getSelection()?.empty(); this._recorder.injectedScript.window.getSelection()?.empty();
this._inputHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }); this._inputHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
this._recorder.updateHighlight(this._inputHighlight, true, '#6fdcbd38'); this._showHighlight(true);
consumeEvent(event); consumeEvent(event);
return; return;
} }
this._inputHighlight = null; this._inputHighlight = null;
this._hoverHighlight = null;
this._updateSelectionHighlight(); this._updateSelectionHighlight();
} }
@ -491,7 +507,18 @@ class TextAssertionTool implements RecorderTool {
} }
onMouseMove(event: MouseEvent) { onMouseMove(event: MouseEvent) {
this._updateSelectionHighlight(); const selection = this._recorder.document.getSelection();
if (selection && selection.toString()) {
this._updateSelectionHighlight();
return;
}
if (this._inputHighlight || event.buttons)
return;
const target = this._recorder.deepEventTarget(event);
if (this._hoverHighlight?.elements[0] === target)
return;
this._hoverHighlight = elementText(new Map(), target).full ? { elements: [target], selector: '' } : null;
this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' });
} }
onDragStart(event: DragEvent) { onDragStart(event: DragEvent) {
@ -500,48 +527,17 @@ class TextAssertionTool implements RecorderTool {
onKeyDown(event: KeyboardEvent) { onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
this._resetSelectionAndHighlight(); const selection = this._recorder.document.getSelection();
this._recorder.delegate.setMode?.('recording'); if (selection && selection.toString())
this._resetSelectionAndHighlight();
else
this._recorder.delegate.setMode?.('recording');
consumeEvent(event); consumeEvent(event);
return; return;
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
const selection = this._recorder.document.getSelection(); this._commitAction();
if (this._inputHighlight) {
const target = this._inputHighlight.elements[0] as HTMLInputElement;
if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes(target.type.toLowerCase())) {
this._recorder.delegate.recordAction?.({
name: 'assertChecked',
selector: this._inputHighlight.selector,
signals: [],
// Interestingly, inputElement.checked is reversed inside this event handler.
checked: !(target as HTMLInputElement).checked,
});
this._recorder.delegate.setMode?.('recording');
} else {
this._recorder.delegate.recordAction?.({
name: 'assertValue',
selector: this._inputHighlight.selector,
signals: [],
value: target.value,
});
this._recorder.delegate.setMode?.('recording');
}
} else if (selection && this._selectionHighlight) {
const selectedText = normalizeWhiteSpace(selection.toString());
const fullText = normalizeWhiteSpace(elementText(new Map(), this._selectionHighlight.elements[0]).full);
this._recorder.delegate.recordAction?.({
name: 'assertText',
selector: this._selectionHighlight.selector,
signals: [],
text: selectedText,
substring: fullText !== selectedText,
});
this._recorder.delegate.setMode?.('recording');
this._resetSelectionAndHighlight();
}
consumeEvent(event); consumeEvent(event);
return; return;
} }
@ -558,11 +554,67 @@ class TextAssertionTool implements RecorderTool {
} }
onScroll(event: Event) { onScroll(event: Event) {
this._recorder.updateHighlight(this._selectionHighlight, false, '#6fdcbd38'); this._hoverHighlight = null;
this._showHighlight(false);
}
private _generateAction(): actions.Action | null {
if (this._inputHighlight) {
const target = this._inputHighlight.elements[0] as HTMLInputElement;
if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes(target.type.toLowerCase())) {
return {
name: 'assertChecked',
selector: this._inputHighlight.selector,
signals: [],
// Interestingly, inputElement.checked is reversed inside this event handler.
checked: !(target as HTMLInputElement).checked,
};
} else {
return {
name: 'assertValue',
selector: this._inputHighlight.selector,
signals: [],
value: target.value,
};
}
} else if (this._selectionText && this._selectionHighlight) {
return {
name: 'assertText',
selector: this._selectionHighlight.selector,
signals: [],
text: this._selectionText.selectedText,
substring: this._selectionText.fullText !== this._selectionText.selectedText,
};
}
return null;
}
private _generateActionPreview() {
const action = this._generateAction();
// TODO: support other languages, maybe unify with code generator?
if (action?.name === 'assertText')
return `expect(${asLocator(this._recorder.state.language, action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${escapeWithQuotes(action.text)})`;
if (action?.name === 'assertChecked')
return `expect(${asLocator(this._recorder.state.language, action.selector)})${action.checked ? '' : '.not'}.toBeChecked()`;
if (action?.name === 'assertValue') {
const assertion = action.value ? `toHaveValue(${escapeWithQuotes(action.value)})` : `toBeEmpty()`;
return `expect(${asLocator(this._recorder.state.language, action.selector)}).${assertion}`;
}
return '';
}
private _commitAction() {
const action = this._generateAction();
if (action) {
this._resetSelectionAndHighlight();
this._recorder.delegate.recordAction?.(action);
this._recorder.delegate.setMode?.('recording');
}
} }
private _resetSelectionAndHighlight() { private _resetSelectionAndHighlight() {
this._selectionHighlight = null; this._selectionHighlight = null;
this._selectionText = null;
this._inputHighlight = null; this._inputHighlight = null;
this._recorder.injectedScript.window.getSelection()?.empty(); this._recorder.injectedScript.window.getSelection()?.empty();
this._recorder.updateHighlight(null, false); this._recorder.updateHighlight(null, false);
@ -572,18 +624,32 @@ class TextAssertionTool implements RecorderTool {
if (this._inputHighlight) if (this._inputHighlight)
return; return;
const selection = this._recorder.document.getSelection(); const selection = this._recorder.document.getSelection();
const selectedText = normalizeWhiteSpace(selection?.toString() || '');
let highlight: HighlightModel | null = null; let highlight: HighlightModel | null = null;
if (selection && selection.focusNode && selection.anchorNode && selection.toString()) { if (selection && selection.focusNode && selection.anchorNode && selectedText) {
const focusElement = enclosingElement(selection.focusNode); const focusElement = enclosingElement(selection.focusNode);
let lcaElement = focusElement ? enclosingElement(selection.anchorNode) : undefined; let lcaElement = focusElement ? enclosingElement(selection.anchorNode) : undefined;
while (lcaElement && !isInsideScope(lcaElement, focusElement)) while (lcaElement && !isInsideScope(lcaElement, focusElement))
lcaElement = parentElementOrShadowHost(lcaElement); lcaElement = parentElementOrShadowHost(lcaElement);
highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }) : null; highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }) : null;
} }
if (highlight?.selector === this._selectionHighlight?.selector) const fullText = highlight ? normalizeWhiteSpace(elementText(new Map(), highlight.elements[0]).full) : '';
const selectionText = highlight ? { selectedText, fullText } : null;
if (highlight?.selector === this._selectionHighlight?.selector && this._selectionText?.fullText === selectionText?.fullText && this._selectionText?.selectedText === selectionText?.selectedText)
return; return;
this._selectionHighlight = highlight; this._selectionHighlight = highlight;
this._recorder.updateHighlight(highlight, true, '#6fdcbd38'); this._selectionText = selectionText;
this._showHighlight(true);
}
private _showHighlight(userGesture: boolean) {
const options: HighlightOptions = { color: '#6fdcbd38', tooltipText: this._generateActionPreview(), decorateTooltip: tooltip => tooltip.appendChild(this._acceptButton) };
if (this._inputHighlight) {
this._recorder.updateHighlight(this._inputHighlight, userGesture, options);
} else {
options.anchorGetter = (e: Element) => this._recorder.document.getSelection()?.getRangeAt(0)?.getBoundingClientRect() || e.getBoundingClientRect();
this._recorder.updateHighlight(this._selectionHighlight, userGesture, options);
}
} }
} }
@ -624,15 +690,23 @@ class Overlay {
x-pw-drag-handle { x-pw-drag-handle {
cursor: grab; cursor: grab;
height: 2px; padding: 6px 9px;
margin: 5px 9px; }
border-top: 1px solid rgb(86 86 86 / 90%); x-pw-drag-handle > div {
border-bottom: 1px solid rgb(86 86 86 / 90%); height: 1px;
margin-top: 2px;
background: rgb(148 148 148 / 90%);
} }
x-pw-drag-handle:active { x-pw-drag-handle:active {
cursor: grabbing; cursor: grabbing;
} }
x-pw-separator {
height: 1px;
margin: 6px 9px;
background: rgb(148 148 148 / 90%);
}
x-pw-tool-item { x-pw-tool-item {
cursor: pointer; cursor: pointer;
height: 28px; height: 28px;
@ -680,6 +754,11 @@ class Overlay {
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M15.62 3.596L7.815 12.81l-.728-.033L4 8.382l.754-.53 2.744 3.907L14.917 3l.703.596z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M7.234 8.774l4.386-5.178L10.917 3l-4.23 4.994.547.78zm-1.55.403l.548.78-.547-.78zm-1.617 1.91l.547.78-.799.943-.728-.033L0 8.382l.754-.53 2.744 3.907.57-.672z'/></svg>"); -webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M15.62 3.596L7.815 12.81l-.728-.033L4 8.382l.754-.53 2.744 3.907L14.917 3l.703.596z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M7.234 8.774l4.386-5.178L10.917 3l-4.23 4.994.547.78zm-1.55.403l.548.78-.547-.78zm-1.617 1.91l.547.78-.799.943-.728-.033L0 8.382l.754-.53 2.744 3.907.57-.672z'/></svg>");
mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M15.62 3.596L7.815 12.81l-.728-.033L4 8.382l.754-.53 2.744 3.907L14.917 3l.703.596z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M7.234 8.774l4.386-5.178L10.917 3l-4.23 4.994.547.78zm-1.55.403l.548.78-.547-.78zm-1.617 1.91l.547.78-.799.943-.728-.033L0 8.382l.754-.53 2.744 3.907.57-.672z'/></svg>"); mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M15.62 3.596L7.815 12.81l-.728-.033L4 8.382l.754-.53 2.744 3.907L14.917 3l.703.596z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M7.234 8.774l4.386-5.178L10.917 3l-4.23 4.994.547.78zm-1.55.403l.548.78-.547-.78zm-1.617 1.91l.547.78-.799.943-.728-.033L0 8.382l.754-.53 2.744 3.907.57-.672z'/></svg>");
} }
x-pw-tool-item.close > div {
/* codicon: close */
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z'/></svg>");
mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z'/></svg>");
}
`; `;
shadow.appendChild(styleElement); shadow.appendChild(styleElement);
@ -699,6 +778,9 @@ class Overlay {
dragHandle.addEventListener('mousedown', event => { dragHandle.addEventListener('mousedown', event => {
this._dragState = { position: this._position, dragStart: { x: event.clientX, y: event.clientY } }; this._dragState = { position: this._position, dragStart: { x: event.clientX, y: event.clientY } };
}); });
dragHandle.append(document.createElement('div'));
dragHandle.append(document.createElement('div'));
dragHandle.append(document.createElement('div'));
toolsListElement.appendChild(dragHandle); toolsListElement.appendChild(dragHandle);
this._pickLocatorToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); this._pickLocatorToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
@ -727,6 +809,16 @@ class Overlay {
}); });
toolsListElement.appendChild(this._assertToggle); toolsListElement.appendChild(this._assertToggle);
const closeButton = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
closeButton.title = 'Hide this overlay';
closeButton.classList.add('close');
closeButton.appendChild(this._recorder.injectedScript.document.createElement('div'));
closeButton.addEventListener('click', () => {
this._overlayElement.style.display = 'none';
this._recorder.delegate.setOverlayState?.({ position: this._position, visible: false });
});
toolsListElement.appendChild(closeButton);
this._updateVisualPosition(); this._updateVisualPosition();
} }
@ -744,10 +836,11 @@ class Overlay {
this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting');
this._assertToggle.classList.toggle('active', state.mode === 'assertingText'); this._assertToggle.classList.toggle('active', state.mode === 'assertingText');
this._assertToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'inspecting'); this._assertToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'inspecting');
if (this._position.x !== state.overlayPosition.x || this._position.y !== state.overlayPosition.y) { if (this._position.x !== state.overlay.position.x || this._position.y !== state.overlay.position.y) {
this._position = state.overlayPosition; this._position = state.overlay.position;
this._updateVisualPosition(); this._updateVisualPosition();
} }
this._overlayElement.style.display = state.overlay.visible ? 'block' : 'none';
} }
private _updateVisualPosition() { private _updateVisualPosition() {
@ -768,7 +861,7 @@ class Overlay {
this._position.x = Math.max(0, Math.min(this._recorder.injectedScript.window.innerWidth - this._measure.width, this._position.x)); this._position.x = Math.max(0, Math.min(this._recorder.injectedScript.window.innerWidth - this._measure.width, this._position.x));
this._position.y = Math.max(0, Math.min(this._recorder.injectedScript.window.innerHeight - this._measure.height, this._position.y)); this._position.y = Math.max(0, Math.min(this._recorder.injectedScript.window.innerHeight - this._measure.height, this._position.y));
this._updateVisualPosition(); this._updateVisualPosition();
this._recorder.delegate.setOverlayPosition?.(this._position); this._recorder.delegate.setOverlayState?.({ position: this._position, visible: true });
consumeEvent(event); consumeEvent(event);
return true; return true;
} }
@ -794,7 +887,7 @@ export class Recorder {
private _highlight: Highlight; private _highlight: Highlight;
private _overlay: Overlay | undefined; private _overlay: Overlay | undefined;
private _styleElement: HTMLStyleElement; private _styleElement: HTMLStyleElement;
state: UIState = { mode: 'none', testIdAttributeName: 'data-testid', language: 'javascript', overlayPosition: { x: 0, y: 0 } }; state: UIState = { mode: 'none', testIdAttributeName: 'data-testid', language: 'javascript', overlay: { position: { x: 0, y: 0 }, visible: true } };
readonly document: Document; readonly document: Document;
delegate: RecorderDelegate = {}; delegate: RecorderDelegate = {};
@ -1000,8 +1093,10 @@ export class Recorder {
this._currentTool.onKeyUp?.(event); this._currentTool.onKeyUp?.(event);
} }
updateHighlight(model: HighlightModel | null, userGesture: boolean, color?: string) { updateHighlight(model: HighlightModel | null, userGesture: boolean, options: HighlightOptions = {}) {
this._highlight.updateHighlight(model?.elements || [], model?.selector || '', color); if (options.tooltipText === undefined && model?.selector)
options.tooltipText = asLocator(this.state.language, model.selector);
this._highlight.updateHighlight(model?.elements || [], options);
if (userGesture) if (userGesture)
this.delegate.highlightUpdated?.(); this.delegate.highlightUpdated?.();
} }
@ -1102,7 +1197,7 @@ interface Embedder {
__pw_recorderState(): Promise<UIState>; __pw_recorderState(): Promise<UIState>;
__pw_recorderSetSelector(selector: string): Promise<void>; __pw_recorderSetSelector(selector: string): Promise<void>;
__pw_recorderSetMode(mode: Mode): Promise<void>; __pw_recorderSetMode(mode: Mode): Promise<void>;
__pw_recorderSetOverlayPosition(position: { x: number, y: number }): Promise<void>; __pw_recorderSetOverlayState(state: OverlayState): Promise<void>;
__pw_refreshOverlay(): void; __pw_refreshOverlay(): void;
} }
@ -1159,8 +1254,8 @@ export class PollingRecorder implements RecorderDelegate {
await this._embedder.__pw_recorderSetMode(mode); await this._embedder.__pw_recorderSetMode(mode);
} }
async setOverlayPosition(position: { x: number, y: number }): Promise<void> { async setOverlayState(state: OverlayState): Promise<void> {
await this._embedder.__pw_recorderSetOverlayPosition(position); await this._embedder.__pw_recorderSetOverlayState(state);
} }
} }

View file

@ -35,7 +35,7 @@ import type { IRecorderApp } from './recorder/recorderApp';
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
import type { Point } from '../common/types'; import type { Point } from '../common/types';
import type { CallLog, CallLogStatus, EventData, Mode, Source, UIState } from '@recorder/recorderTypes'; import type { CallLog, CallLogStatus, EventData, Mode, OverlayState, Source, UIState } from '@recorder/recorderTypes';
import { createGuid, isUnderTest, monotonicTime } from '../utils'; import { createGuid, isUnderTest, monotonicTime } from '../utils';
import { metadataToCallLog } from './recorder/recorderUtils'; import { metadataToCallLog } from './recorder/recorderUtils';
import { Debugger } from './debugger'; import { Debugger } from './debugger';
@ -55,7 +55,7 @@ export class Recorder implements InstrumentationListener {
private _context: BrowserContext; private _context: BrowserContext;
private _mode: Mode; private _mode: Mode;
private _highlightedSelector = ''; private _highlightedSelector = '';
private _overlayPosition: Point = { x: 0, y: 0 }; private _overlayState: OverlayState = { position: { x: 0, y: 0 }, visible: true };
private _recorderApp: IRecorderApp | null = null; private _recorderApp: IRecorderApp | null = null;
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>(); private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
private _recorderSources: Source[] = []; private _recorderSources: Source[] = [];
@ -101,7 +101,7 @@ export class Recorder implements InstrumentationListener {
if (isUnderTest()) { if (isUnderTest()) {
// Most of our tests put elements at the top left, so get out of the way. // Most of our tests put elements at the top left, so get out of the way.
this._overlayPosition = { x: 350, y: 350 }; this._overlayState.position = { x: 350, y: 350 };
} }
} }
@ -123,6 +123,12 @@ export class Recorder implements InstrumentationListener {
this.setMode(data.params.mode); this.setMode(data.params.mode);
return; return;
} }
if (data.event === 'setOverlayVisible') {
this._overlayState.visible = data.params.visible;
this._recorderApp?.setOverlayVisible(this._overlayState.visible);
this._refreshOverlay();
return;
}
if (data.event === 'selectorUpdated') { if (data.event === 'selectorUpdated') {
this.setHighlightedSelector(this._currentLanguage, data.params.selector); this.setHighlightedSelector(this._currentLanguage, data.params.selector);
return; return;
@ -186,7 +192,7 @@ export class Recorder implements InstrumentationListener {
actionSelector, actionSelector,
language: this._currentLanguage, language: this._currentLanguage,
testIdAttributeName: this._contextRecorder.testIdAttributeName(), testIdAttributeName: this._contextRecorder.testIdAttributeName(),
overlayPosition: this._overlayPosition, overlay: this._overlayState,
}; };
return uiState; return uiState;
}); });
@ -209,10 +215,11 @@ export class Recorder implements InstrumentationListener {
this.setMode(mode); this.setMode(mode);
}); });
await this._context.exposeBinding('__pw_recorderSetOverlayPosition', false, async ({ frame }, position: Point) => { await this._context.exposeBinding('__pw_recorderSetOverlayState', false, async ({ frame }, state: OverlayState) => {
if (frame.parentFrame()) if (frame.parentFrame())
return; return;
this._overlayPosition = position; this._overlayState = state;
this._recorderApp?.setOverlayVisible(state.visible);
}); });
await this._context.exposeBinding('__pw_resume', false, () => { await this._context.exposeBinding('__pw_resume', false, () => {

View file

@ -34,6 +34,7 @@ declare global {
playwrightSetMode: (mode: Mode) => void; playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (paused: boolean) => void; playwrightSetPaused: (paused: boolean) => void;
playwrightSetSources: (sources: Source[]) => void; playwrightSetSources: (sources: Source[]) => void;
playwrightSetOverlayVisible: (visible: boolean) => void;
playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void;
dispatch(data: EventData): Promise<void>; dispatch(data: EventData): Promise<void>;
@ -45,6 +46,7 @@ export interface IRecorderApp extends EventEmitter {
close(): Promise<void>; close(): Promise<void>;
setPaused(paused: boolean): Promise<void>; setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>; setMode(mode: Mode): Promise<void>;
setOverlayVisible(visible: boolean): Promise<void>;
setFileIfNeeded(file: string): Promise<void>; setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>; setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>; updateCallLogs(callLogs: CallLog[]): Promise<void>;
@ -55,6 +57,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {} async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {} async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: Mode): Promise<void> {} async setMode(mode: Mode): Promise<void> {}
async setOverlayVisible(visible: boolean): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {} async setFileIfNeeded(file: string): Promise<void> {}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {} async setSelector(selector: string, userGesture?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {} async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
@ -144,6 +147,12 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}).toString(), { isFunction: true }, mode).catch(() => {}); }).toString(), { isFunction: true }, mode).catch(() => {});
} }
async setOverlayVisible(visible: boolean): Promise<void> {
await this._page.mainFrame().evaluateExpression(((visible: boolean) => {
window.playwrightSetOverlayVisible(visible);
}).toString(), { isFunction: true }, visible).catch(() => {});
}
async setFileIfNeeded(file: string): Promise<void> { async setFileIfNeeded(file: string): Promise<void> {
await this._page.mainFrame().evaluateExpression(((file: string) => { await this._page.mainFrame().evaluateExpression(((file: string) => {
window.playwrightSetFileIfNeeded(file); window.playwrightSetFileIfNeeded(file);

View file

@ -25,10 +25,12 @@ export const Main: React.FC = ({
const [paused, setPaused] = React.useState(false); const [paused, setPaused] = React.useState(false);
const [log, setLog] = React.useState(new Map<string, CallLog>()); const [log, setLog] = React.useState(new Map<string, CallLog>());
const [mode, setMode] = React.useState<Mode>('none'); const [mode, setMode] = React.useState<Mode>('none');
const [overlayVisible, setOverlayVisible] = React.useState<boolean>(true);
window.playwrightSetMode = setMode; window.playwrightSetMode = setMode;
window.playwrightSetSources = setSources; window.playwrightSetSources = setSources;
window.playwrightSetPaused = setPaused; window.playwrightSetPaused = setPaused;
window.playwrightSetOverlayVisible = setOverlayVisible;
window.playwrightUpdateLogs = callLogs => { window.playwrightUpdateLogs = callLogs => {
const newLog = new Map<string, CallLog>(log); const newLog = new Map<string, CallLog>(log);
for (const callLog of callLogs) { for (const callLog of callLogs) {
@ -39,5 +41,5 @@ export const Main: React.FC = ({
}; };
window.playwrightSourcesEchoForTest = sources; window.playwrightSourcesEchoForTest = sources;
return <Recorder sources={sources} paused={paused} log={log} mode={mode}/>; return <Recorder sources={sources} paused={paused} log={log} mode={mode} overlayVisible={overlayVisible}/>;
}; };

View file

@ -28,6 +28,10 @@
min-width: 100px; min-width: 100px;
} }
.recorder .codicon {
font-size: 15px;
}
.recorder .toolbar-button.toggled.circle-large-filled { .recorder .toolbar-button.toggled.circle-large-filled {
color: #a1260d; color: #a1260d;
} }

View file

@ -40,6 +40,7 @@ export interface RecorderProps {
paused: boolean, paused: boolean,
log: Map<string, CallLog>, log: Map<string, CallLog>,
mode: Mode, mode: Mode,
overlayVisible: boolean,
} }
export const Recorder: React.FC<RecorderProps> = ({ export const Recorder: React.FC<RecorderProps> = ({
@ -47,6 +48,7 @@ export const Recorder: React.FC<RecorderProps> = ({
paused, paused,
log, log,
mode, mode,
overlayVisible,
}) => { }) => {
const [fileId, setFileId] = React.useState<string | undefined>(); const [fileId, setFileId] = React.useState<string | undefined>();
const [selectedTab, setSelectedTab] = React.useState<string>('log'); const [selectedTab, setSelectedTab] = React.useState<string>('log');
@ -154,6 +156,9 @@ export const Recorder: React.FC<RecorderProps> = ({
window.dispatch({ event: 'clear' }); window.dispatch({ event: 'clear' });
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton> <ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
<ToolbarButton icon='editor-layout' title='Toggle overlay' toggled={overlayVisible} onClick={() => {
window.dispatch({ event: 'setOverlayVisible', params: { visible: !overlayVisible } });
}}></ToolbarButton>
</Toolbar> </Toolbar>
<SplitView sidebarSize={200} sidebarHidden={mode === 'recording'}> <SplitView sidebarSize={200} sidebarHidden={mode === 'recording'}>
<CodeMirrorWrapper text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine} readOnly={true} lineNumbers={true}/> <CodeMirrorWrapper text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine} readOnly={true} lineNumbers={true}/>

View file

@ -21,17 +21,22 @@ export type Point = { x: number, y: number };
export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText' | 'recording-inspecting'; export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText' | 'recording-inspecting';
export type EventData = { export type EventData = {
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'setRecordingTool' | 'selectorUpdated' | 'fileChanged'; event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'fileChanged' | 'setOverlayVisible';
params: any; params: any;
}; };
export type OverlayState = {
position: Point;
visible: boolean;
};
export type UIState = { export type UIState = {
mode: Mode; mode: Mode;
actionPoint?: Point; actionPoint?: Point;
actionSelector?: string; actionSelector?: string;
language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
testIdAttributeName: string; testIdAttributeName: string;
overlayPosition: Point; overlay: OverlayState;
}; };
export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused'; export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused';
@ -75,6 +80,7 @@ declare global {
playwrightSetMode: (mode: Mode) => void; playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (paused: boolean) => void; playwrightSetPaused: (paused: boolean) => void;
playwrightSetSources: (sources: Source[]) => void; playwrightSetSources: (sources: Source[]) => void;
playwrightSetOverlayVisible: (visible: boolean) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void;
playwrightSetFileIfNeeded: (file: string) => void; playwrightSetFileIfNeeded: (file: string) => void;
playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void;

View file

@ -242,7 +242,7 @@ export const InspectModeController: React.FunctionComponent<{
actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined, actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined,
language: sdkLanguage, language: sdkLanguage,
testIdAttributeName, testIdAttributeName,
overlayPosition: { x: 0, y: 0 }, overlay: { position: { x: 0, y: 0 }, visible: false },
}, { }, {
async setSelector(selector: string) { async setSelector(selector: string) {
setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector, false /* isFrameLocator */, true /* playSafe */)); setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector, false /* isFrameLocator */, true /* playSafe */));