feat(recorder): in-page overlay (#27904)

This commit is contained in:
Dmitry Gozman 2023-11-01 15:56:49 -07:00 committed by GitHub
parent d983941447
commit 3dedbced13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 314 additions and 121 deletions

View File

@ -122,7 +122,7 @@ export class DebugController extends SdkObject {
// Toggle the mode.
for (const recorder of await this._allRecorders()) {
recorder.hideHighlightedSelector();
if (params.mode === 'recording')
if (params.mode !== 'inspecting')
recorder.setOutput(this._codegenId, params.file);
recorder.setMode(params.mode);
}

View File

@ -50,7 +50,7 @@ export class Highlight {
this._glassPaneElement.style.right = '0';
this._glassPaneElement.style.bottom = '0';
this._glassPaneElement.style.left = '0';
this._glassPaneElement.style.zIndex = '2147483647';
this._glassPaneElement.style.zIndex = '2147483646';
this._glassPaneElement.style.pointerEvents = 'none';
this._glassPaneElement.style.display = 'flex';
this._glassPaneElement.style.backgroundColor = 'transparent';

View File

@ -18,7 +18,7 @@ import type * as actions from '../recorder/recorderActions';
import type { InjectedScript } from '../injected/injectedScript';
import { generateSelector } from '../injected/selectorGenerator';
import type { Point } from '../../common/types';
import type { UIState, Mode, RecordingTool } from '@recorder/recorderTypes';
import type { Mode, UIState } from '@recorder/recorderTypes';
import { Highlight } from '../injected/highlight';
import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils';
import { elementText } from './selectorUtils';
@ -28,10 +28,12 @@ interface RecorderDelegate {
performAction?(action: actions.Action): Promise<void>;
recordAction?(action: actions.Action): Promise<void>;
setSelector?(selector: string): Promise<void>;
setMode?(mode: Mode): Promise<void>;
highlightUpdated?(): void;
}
interface RecorderTool {
cursor(): string;
disable?(): void;
onClick?(event: MouseEvent): void;
onInput?(event: Event): void;
@ -46,6 +48,9 @@ interface RecorderTool {
}
class NoneTool implements RecorderTool {
cursor() {
return 'default';
}
}
class InspectTool implements RecorderTool {
@ -55,6 +60,10 @@ class InspectTool implements RecorderTool {
constructor(private _recorder: Recorder) {
}
cursor() {
return 'pointer';
}
disable() {
this._hoveredModel = null;
this._hoveredElement = null;
@ -75,13 +84,13 @@ class InspectTool implements RecorderTool {
onMouseMove(event: MouseEvent) {
consumeEvent(event);
let target: HTMLElement | null = deepEventTarget(event);
let target: HTMLElement | null = this._recorder.deepEventTarget(event);
if (!target.isConnected)
target = null;
if (this._hoveredElement === target)
return;
this._hoveredElement = target;
const model = this._hoveredElement ? generateSelector(this._recorder.injectedScript, this._hoveredElement, { testIdAttributeName: this._recorder.testIdAttributeName }) : null;
const model = this._hoveredElement ? generateSelector(this._recorder.injectedScript, this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null;
if (this._hoveredModel?.selector === model?.selector)
return;
this._hoveredModel = model;
@ -92,7 +101,7 @@ class InspectTool implements RecorderTool {
consumeEvent(event);
const window = this._recorder.injectedScript.window;
// Leaving iframe.
if (window.top !== window && deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
if (window.top !== window && this._recorder.deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
this._hoveredElement = null;
this._hoveredModel = null;
this._recorder.updateHighlight(null, true);
@ -124,6 +133,10 @@ class RecordActionTool implements RecorderTool {
constructor(private _recorder: Recorder) {
}
cursor() {
return 'pointer';
}
disable() {
this._hoveredModel = null;
this._hoveredElement = null;
@ -139,7 +152,7 @@ class RecordActionTool implements RecorderTool {
if (this._consumedDueToNoModel(event, this._hoveredModel))
return;
const checkbox = asCheckbox(deepEventTarget(event));
const checkbox = asCheckbox(this._recorder.deepEventTarget(event));
if (checkbox) {
// Interestingly, inputElement.checked is reversed inside this event handler.
this._performAction({
@ -177,7 +190,7 @@ class RecordActionTool implements RecorderTool {
}
onMouseMove(event: MouseEvent) {
const target = deepEventTarget(event);
const target = this._recorder.deepEventTarget(event);
if (this._hoveredElement === target)
return;
this._hoveredElement = target;
@ -187,7 +200,7 @@ class RecordActionTool implements RecorderTool {
onMouseLeave(event: MouseEvent) {
const window = this._recorder.injectedScript.window;
// Leaving iframe.
if (window.top !== window && deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
if (window.top !== window && this._recorder.deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
this._hoveredElement = null;
this._updateModelForHoveredElement();
}
@ -198,7 +211,7 @@ class RecordActionTool implements RecorderTool {
}
onInput(event: Event) {
const target = deepEventTarget(event);
const target = this._recorder.deepEventTarget(event);
if (target.nodeName === 'INPUT' && (target as HTMLInputElement).type.toLowerCase() === 'file') {
this._recorder.delegate.recordAction?.({
@ -251,7 +264,7 @@ class RecordActionTool implements RecorderTool {
return;
// Similarly to click, trigger checkbox on key event, not input.
if (event.key === ' ') {
const checkbox = asCheckbox(deepEventTarget(event));
const checkbox = asCheckbox(this._recorder.deepEventTarget(event));
if (checkbox) {
this._performAction({
name: checkbox.checked ? 'uncheck' : 'check',
@ -295,7 +308,7 @@ class RecordActionTool implements RecorderTool {
// We'd like to ignore this stray event.
if (userGesture && activeElement === this._recorder.document.body)
return;
const result = activeElement ? generateSelector(this._recorder.injectedScript, activeElement, { testIdAttributeName: this._recorder.testIdAttributeName }) : null;
const result = activeElement ? generateSelector(this._recorder.injectedScript, activeElement, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null;
this._activeModel = result && result.selector ? result : null;
if (userGesture)
this._hoveredElement = activeElement as HTMLElement | null;
@ -303,7 +316,7 @@ class RecordActionTool implements RecorderTool {
}
private _shouldIgnoreMouseEvent(event: MouseEvent): boolean {
const target = deepEventTarget(event);
const target = this._recorder.deepEventTarget(event);
const nodeName = target.nodeName;
if (nodeName === 'SELECT' || nodeName === 'OPTION')
return true;
@ -329,7 +342,7 @@ class RecordActionTool implements RecorderTool {
}
private _consumedDueWrongTarget(event: Event): boolean {
if (this._activeModel && this._activeModel.elements[0] === deepEventTarget(event))
if (this._activeModel && this._activeModel.elements[0] === this._recorder.deepEventTarget(event))
return false;
consumeEvent(event);
return true;
@ -359,7 +372,7 @@ class RecordActionTool implements RecorderTool {
private _shouldGenerateKeyPressFor(event: KeyboardEvent): boolean {
// Enter aka. new line is handled in input event.
if (event.key === 'Enter' && (deepEventTarget(event).nodeName === 'TEXTAREA' || deepEventTarget(event).isContentEditable))
if (event.key === 'Enter' && (this._recorder.deepEventTarget(event).nodeName === 'TEXTAREA' || this._recorder.deepEventTarget(event).isContentEditable))
return false;
// Backspace, Delete, AltGraph are changing input, will handle it there.
if (['Backspace', 'Delete', 'AltGraph'].includes(event.key))
@ -381,7 +394,7 @@ class RecordActionTool implements RecorderTool {
return false;
const hasModifier = event.ctrlKey || event.altKey || event.metaKey;
if (event.key.length === 1 && !hasModifier)
return !!asCheckbox(deepEventTarget(event));
return !!asCheckbox(this._recorder.deepEventTarget(event));
return true;
}
@ -392,7 +405,7 @@ class RecordActionTool implements RecorderTool {
this._recorder.updateHighlight(null, true);
return;
}
const { selector, elements } = generateSelector(this._recorder.injectedScript, this._hoveredElement, { testIdAttributeName: this._recorder.testIdAttributeName });
const { selector, elements } = generateSelector(this._recorder.injectedScript, this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName });
if (this._hoveredModel && this._hoveredModel.selector === selector)
return;
this._hoveredModel = selector ? { selector, elements } : null;
@ -406,6 +419,10 @@ class TextAssertionTool implements RecorderTool {
constructor(private _recorder: Recorder) {
}
cursor() {
return 'text';
}
disable() {
this._selectionModel = null;
this._syncDocumentSelection();
@ -415,7 +432,7 @@ class TextAssertionTool implements RecorderTool {
consumeEvent(event);
if (event.detail !== 1 || this._getSelectionText())
return;
const target = deepEventTarget(event);
const target = this._recorder.deepEventTarget(event);
const text = target ? elementText(new Map(), target).full : '';
if (text) {
this._selectionModel = { anchor: { node: target, offset: 0 }, focus: { node: target, offset: target.childNodes.length }, highlight: null };
@ -517,7 +534,7 @@ class TextAssertionTool implements RecorderTool {
let lcaElement = focusElement ? enclosingElement(this._selectionModel.anchor.node) : undefined;
while (lcaElement && !isInsideScope(lcaElement, focusElement))
lcaElement = parentElementOrShadowHost(lcaElement);
const highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.testIdAttributeName, forTextExpect: true }) : null;
const highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }) : null;
if (highlight?.selector === this._selectionModel.highlight?.selector)
return;
this._selectionModel.highlight = highlight;
@ -525,21 +542,190 @@ class TextAssertionTool implements RecorderTool {
}
}
class Overlay {
private _overlayElement: HTMLElement;
private _tools: Record<Mode, HTMLElement>;
private _position: { x: number, y: number } = { x: 0, y: 0 };
private _dragState: { position: { x: number, y: number }, dragStart: { x: number, y: number } } | undefined;
private _measure: { width: number, height: number } = { width: 0, height: 0 };
constructor(private _recorder: Recorder) {
const document = this._recorder.injectedScript.document;
this._overlayElement = document.createElement('x-pw-overlay');
const shadow = this._overlayElement.attachShadow({ mode: 'closed' });
const styleElement = document.createElement('style');
styleElement.textContent = `
:host {
position: fixed;
max-width: min-content;
z-index: 2147483647;
background: transparent;
cursor: grab;
}
x-pw-tools-list {
box-shadow: rgba(0, 0, 0, 0.1) 0px 0.25em 0.5em;
backdrop-filter: blur(5px);
background-color: hsla(0 0% 100% / .9);
font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono',
'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace;
display: flex;
flex-direction: column;
margin: 1em;
padding: 0px;
border-radius: 2em;
}
x-pw-tool-item {
cursor: pointer;
height: 2.25em;
width: 2.25em;
margin: 0.05em 0.25em;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: 50%;
}
x-pw-tool-item:first-child {
margin-top: 0.25em;
}
x-pw-tool-item:last-child {
margin-bottom: 0.25em;
}
x-pw-tool-item:hover {
background-color: hsl(0, 0%, 95%);
}
x-pw-tool-item.active {
background-color: hsl(0, 0%, 100%);
}
x-pw-tool-item > div {
width: 100%;
height: 100%;
background-color: black;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: 20px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: 20px;
}
x-pw-tool-item.active > div {
background-color: #ff4ca5;
}
x-pw-tool-item.none > 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>");
}
x-pw-tool-item.inspecting > div {
/* codicon: target */
-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 d='M8 9C8.55228 9 9 8.55228 9 8C9 7.44772 8.55228 7 8 7C7.44772 7 7 7.44772 7 8C7 8.55228 7.44772 9 8 9Z'/><path d='M12 8C12 10.2091 10.2091 12 8 12C5.79086 12 4 10.2091 4 8C4 5.79086 5.79086 4 8 4C10.2091 4 12 5.79086 12 8ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z'/><path d='M15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8ZM8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z'/></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 d='M8 9C8.55228 9 9 8.55228 9 8C9 7.44772 8.55228 7 8 7C7.44772 7 7 7.44772 7 8C7 8.55228 7.44772 9 8 9Z'/><path d='M12 8C12 10.2091 10.2091 12 8 12C5.79086 12 4 10.2091 4 8C4 5.79086 5.79086 4 8 4C10.2091 4 12 5.79086 12 8ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z'/><path d='M15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8ZM8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z'/></svg>");
}
x-pw-tool-item.recording > div {
/* codicon: record */
-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 d='M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z'/></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 d='M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z'/></svg>");
}
x-pw-tool-item.assertingText > div {
/* codicon: text-size */
-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 d='M3.36 7L1 13h1.34l.51-1.47h2.26L5.64 13H7L4.65 7H3.36zm-.15 3.53l.78-2.14.78 2.14H3.21zM11.82 4h-1.6L7 13h1.56l.75-2.29h3.36l.77 2.29H15l-3.18-9zM9.67 9.5l1.18-3.59c.059-.185.1-.376.12-.57.027.192.064.382.11.57l1.25 3.59H9.67z'/></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 d='M3.36 7L1 13h1.34l.51-1.47h2.26L5.64 13H7L4.65 7H3.36zm-.15 3.53l.78-2.14.78 2.14H3.21zM11.82 4h-1.6L7 13h1.56l.75-2.29h3.36l.77 2.29H15l-3.18-9zM9.67 9.5l1.18-3.59c.059-.185.1-.376.12-.57.027.192.064.382.11.57l1.25 3.59H9.67z'/></svg>");
}
`;
shadow.appendChild(styleElement);
const toolsListElement = document.createElement('x-pw-tools-list');
shadow.appendChild(toolsListElement);
this._tools = {
none: this._createToolElement(toolsListElement, 'none', 'Disable'),
inspecting: this._createToolElement(toolsListElement, 'inspecting', 'Pick locator'),
recording: this._createToolElement(toolsListElement, 'recording', 'Record actions'),
assertingText: this._createToolElement(toolsListElement, 'assertingText', 'Assert text'),
};
this._overlayElement.addEventListener('mousedown', event => {
this._dragState = { position: this._position, dragStart: { x: event.clientX, y: event.clientY } };
});
if (this._recorder.injectedScript.isUnderTest) {
// Most of our tests put elements at the top left, so get out of the way.
this._position = { x: 350, y: 350 };
}
this._updateVisualPosition();
}
private _createToolElement(parent: Element, mode: Mode, title: string) {
const element = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
element.title = title;
element.classList.add(mode);
element.appendChild(this._recorder.injectedScript.document.createElement('div'));
element.addEventListener('click', () => this._recorder.delegate.setMode?.(mode));
parent.appendChild(element);
return element;
}
install() {
this._recorder.injectedScript.document.documentElement.appendChild(this._overlayElement);
this._measure = this._overlayElement.getBoundingClientRect();
}
contains(element: Element) {
return isInsideScope(this._overlayElement, element);
}
setUIState(state: UIState) {
for (const [mode, tool] of Object.entries(this._tools))
tool.classList.toggle('active', state.mode === mode);
}
private _updateVisualPosition() {
this._overlayElement.style.left = this._position.x + 'px';
this._overlayElement.style.top = this._position.y + 'px';
}
onMouseMove(event: MouseEvent) {
if (!event.buttons) {
this._dragState = undefined;
return false;
}
if (this._dragState) {
this._position = {
x: this._dragState.position.x + event.clientX - this._dragState.dragStart.x,
y: this._dragState.position.y + event.clientY - this._dragState.dragStart.y,
};
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._updateVisualPosition();
consumeEvent(event);
return true;
}
return false;
}
onMouseUp(event: MouseEvent) {
if (this._dragState) {
this._dragState = undefined;
consumeEvent(event);
return true;
}
return false;
}
}
export class Recorder {
readonly injectedScript: InjectedScript;
private _listeners: (() => void)[] = [];
private _mode: Mode = 'none';
private _tool: RecordingTool = 'action';
private _currentTool: RecorderTool;
private _noneTool: NoneTool;
private _inspectTool: InspectTool;
private _recordActionTool: RecordActionTool;
private _textAssertionTool: TextAssertionTool;
private _actionPoint: Point | undefined;
private _tools: Record<Mode, RecorderTool>;
private _actionSelectorModel: HighlightModel | null = null;
private _highlightModel: HighlightModel | null = null;
private _highlight: Highlight;
testIdAttributeName: string = 'data-testid';
private _overlay: Overlay | undefined;
private _styleElement: HTMLStyleElement;
state: UIState = { mode: 'none', testIdAttributeName: 'data-testid', language: 'javascript' };
readonly document: Document;
delegate: RecorderDelegate = {};
@ -547,11 +733,23 @@ export class Recorder {
this.document = injectedScript.document;
this.injectedScript = injectedScript;
this._highlight = new Highlight(injectedScript);
this._noneTool = new NoneTool();
this._inspectTool = new InspectTool(this);
this._recordActionTool = new RecordActionTool(this);
this._textAssertionTool = new TextAssertionTool(this);
this._currentTool = this._noneTool;
this._tools = {
none: new NoneTool(),
inspecting: new InspectTool(this),
recording: new RecordActionTool(this),
assertingText: new TextAssertionTool(this),
};
this._currentTool = this._tools.none;
if (injectedScript.window.top === injectedScript.window) {
this._overlay = new Overlay(this);
this._overlay.setUIState(this.state);
}
this._styleElement = this.document.createElement('style');
this._styleElement.textContent = `
body[data-pw-cursor=pointer] *, body[data-pw-cursor=pointer] *::after { cursor: pointer !important; }
body[data-pw-cursor=text] *, body[data-pw-cursor=text] *::after { cursor: text !important; }
`;
this.installListeners();
if (injectedScript.isUnderTest)
console.error('Recorder script ready for test'); // eslint-disable-line no-console
@ -576,61 +774,45 @@ export class Recorder {
addEventListener(this.document, 'scroll', event => this._onScroll(event), true),
];
this._highlight.install();
}
uninstallListeners() {
removeEventListeners(this._listeners);
this._highlight.uninstall();
this._overlay?.install();
this.injectedScript.document.head.appendChild(this._styleElement);
}
private _switchCurrentTool() {
const newTool = this._tools[this.state.mode];
if (newTool === this._currentTool)
return;
this._currentTool.disable?.();
this.clearHighlight();
if (this._mode === 'none')
this._currentTool = this._noneTool;
else if (this._mode === 'inspecting')
this._currentTool = this._inspectTool;
else if (this._tool === 'action')
this._currentTool = this._recordActionTool;
else
this._currentTool = this._textAssertionTool;
this._currentTool = newTool;
this.injectedScript.document.body.setAttribute('data-pw-cursor', newTool.cursor());
}
setUIState(state: UIState, delegate: RecorderDelegate) {
this.delegate = delegate;
if (state.mode !== 'none' || state.actionSelector)
this.installListeners();
else
this.uninstallListeners();
const { mode, tool, actionPoint, actionSelector, language, testIdAttributeName } = state;
this.testIdAttributeName = testIdAttributeName;
this._highlight.setLanguage(language);
if (mode !== this._mode || this._tool !== tool) {
this._mode = mode;
this._tool = tool;
this._switchCurrentTool();
}
if (actionPoint && this._actionPoint && actionPoint.x === this._actionPoint.x && actionPoint.y === this._actionPoint.y) {
if (state.actionPoint && this.state.actionPoint && state.actionPoint.x === this.state.actionPoint.x && state.actionPoint.y === this.state.actionPoint.y) {
// All good.
} else if (!actionPoint && !this._actionPoint) {
} else if (!state.actionPoint && !this.state.actionPoint) {
// All good.
} else {
if (actionPoint)
this._highlight.showActionPoint(actionPoint.x, actionPoint.y);
if (state.actionPoint)
this._highlight.showActionPoint(state.actionPoint.x, state.actionPoint.y);
else
this._highlight.hideActionPoint();
this._actionPoint = actionPoint;
}
this.state = state;
this._highlight.setLanguage(state.language);
this._switchCurrentTool();
this._overlay?.setUIState(state);
// Race or scroll.
if (this._actionSelectorModel?.selector && !this._actionSelectorModel?.elements.length)
this._actionSelectorModel = null;
if (actionSelector !== this._actionSelectorModel?.selector)
this._actionSelectorModel = actionSelector ? querySelector(this.injectedScript, actionSelector, this.document) : null;
if (this._mode === 'none')
if (state.actionSelector !== this._actionSelectorModel?.selector)
this._actionSelectorModel = state.actionSelector ? querySelector(this.injectedScript, state.actionSelector, this.document) : null;
if (this.state.mode === 'none')
this.updateHighlight(this._actionSelectorModel, false);
}
@ -642,36 +824,52 @@ export class Recorder {
private _onClick(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onClick?.(event);
}
private _onMouseDown(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onMouseDown?.(event);
}
private _onMouseUp(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._overlay?.onMouseUp(event))
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onMouseUp?.(event);
}
private _onMouseMove(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._overlay?.onMouseMove(event))
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onMouseMove?.(event);
}
private _onMouseLeave(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onMouseLeave?.(event);
}
private _onFocus(event: Event) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onFocus?.(event);
}
@ -683,27 +881,44 @@ export class Recorder {
}
private _onInput(event: Event) {
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onInput?.(event);
}
private _onKeyDown(event: KeyboardEvent) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onKeyDown?.(event);
}
private _onKeyUp(event: KeyboardEvent) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onKeyUp?.(event);
}
updateHighlight(model: HighlightModel | null, userGesture: boolean, color?: string) {
this._highlightModel = model;
this._highlight.updateHighlight(model?.elements || [], model?.selector || '', color);
if (userGesture)
this.delegate.highlightUpdated?.();
}
private _ignoreOverlayEvent(event: Event) {
return this._overlay?.contains(event.composedPath()[0] as Element);
}
deepEventTarget(event: Event): HTMLElement {
for (const element of event.composedPath()) {
if (!this._overlay?.contains(element as Element))
return element as HTMLElement;
}
return event.composedPath()[0] as HTMLElement;
}
}
function deepActiveElement(document: Document): Element | null {
@ -713,10 +928,6 @@ function deepActiveElement(document: Document): Element | null {
return activeElement;
}
function deepEventTarget(event: Event): HTMLElement {
return event.composedPath()[0] as HTMLElement;
}
function modifiersForEvent(event: MouseEvent | KeyboardEvent): number {
return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
}
@ -798,6 +1009,7 @@ interface Embedder {
__pw_recorderRecordAction(action: actions.Action): Promise<void>;
__pw_recorderState(): Promise<UIState>;
__pw_recorderSetSelector(selector: string): Promise<void>;
__pw_recorderSetMode(mode: Mode): Promise<void>;
__pw_refreshOverlay(): void;
}
@ -853,6 +1065,10 @@ export class PollingRecorder implements RecorderDelegate {
async setSelector(selector: string): Promise<void> {
await this._embedder.__pw_recorderSetSelector(selector);
}
async setMode(mode: Mode): Promise<void> {
await this._embedder.__pw_recorderSetMode(mode);
}
}
export default PollingRecorder;

View File

@ -35,7 +35,7 @@ import type { IRecorderApp } from './recorder/recorderApp';
import { RecorderApp } from './recorder/recorderApp';
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
import type { Point } from '../common/types';
import type { CallLog, CallLogStatus, EventData, Mode, RecordingTool, Source, UIState } from '@recorder/recorderTypes';
import type { CallLog, CallLogStatus, EventData, Mode, Source, UIState } from '@recorder/recorderTypes';
import { createGuid, isUnderTest, monotonicTime } from '../utils';
import { metadataToCallLog } from './recorder/recorderUtils';
import { Debugger } from './debugger';
@ -54,7 +54,6 @@ const recorderSymbol = Symbol('recorderSymbol');
export class Recorder implements InstrumentationListener {
private _context: BrowserContext;
private _mode: Mode;
private _tool: RecordingTool = 'action';
private _highlightedSelector = '';
private _recorderApp: IRecorderApp | null = null;
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
@ -118,10 +117,6 @@ export class Recorder implements InstrumentationListener {
this.setMode(data.params.mode);
return;
}
if (data.event === 'setRecordingTool') {
this.setRecordingTool(data.params.tool);
return;
}
if (data.event === 'selectorUpdated') {
this.setHighlightedSelector(this._currentLanguage, data.params.selector);
return;
@ -181,7 +176,6 @@ export class Recorder implements InstrumentationListener {
}
const uiState: UIState = {
mode: this._mode,
tool: this._tool,
actionPoint,
actionSelector,
language: this._currentLanguage,
@ -202,6 +196,12 @@ export class Recorder implements InstrumentationListener {
await this._recorderApp?.setSelector(fullSelector.join(' >> internal:control=enter-frame >> '), true);
});
await this._context.exposeBinding('__pw_recorderSetMode', false, async ({ frame }, mode: Mode) => {
if (frame.parentFrame())
return;
this.setMode(mode);
});
await this._context.exposeBinding('__pw_resume', false, () => {
this._debugger.resume(false);
});
@ -233,21 +233,13 @@ export class Recorder implements InstrumentationListener {
this._highlightedSelector = '';
this._mode = mode;
this._recorderApp?.setMode(this._mode);
this._contextRecorder.setEnabled(this._mode === 'recording');
this._debugger.setMuted(this._mode === 'recording');
this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText');
this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText');
if (this._mode !== 'none' && this._context.pages().length === 1)
this._context.pages()[0].bringToFront().catch(() => {});
this._refreshOverlay();
}
setRecordingTool(tool: RecordingTool) {
if (this._tool === tool)
return;
this._tool = tool;
this._recorderApp?.setRecordingTool(this._tool);
this._refreshOverlay();
}
resume() {
this._debugger.resume(false);
}
@ -272,7 +264,7 @@ export class Recorder implements InstrumentationListener {
}
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
if (this._omitCallTracking || this._mode === 'recording')
if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText')
return;
this._currentCallsMetadata.set(metadata, sdkObject);
this._updateUserSources();
@ -286,7 +278,7 @@ export class Recorder implements InstrumentationListener {
}
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) {
if (this._omitCallTracking || this._mode === 'recording')
if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText')
return;
if (!metadata.error)
this._currentCallsMetadata.delete(metadata);
@ -336,7 +328,7 @@ export class Recorder implements InstrumentationListener {
}
updateCallLog(metadatas: CallMetadata[]) {
if (this._mode === 'recording')
if (this._mode === 'recording' || this._mode === 'assertingText')
return;
const logs: CallLog[] = [];
for (const metadata of metadatas) {

View File

@ -20,7 +20,7 @@ import type { Page } from '../page';
import { ProgressController } from '../progress';
import { EventEmitter } from 'events';
import { serverSideCallMetadata } from '../instrumentation';
import type { CallLog, EventData, Mode, RecordingTool, Source } from '@recorder/recorderTypes';
import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes';
import { isUnderTest } from '../../utils';
import { mime } from '../../utilsBundle';
import { syncLocalStorageWithSettings } from '../launchApp';
@ -45,7 +45,6 @@ export interface IRecorderApp extends EventEmitter {
close(): Promise<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setRecordingTool(tool: RecordingTool): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, focus?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
@ -56,7 +55,6 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: Mode): Promise<void> {}
async setRecordingTool(tool: RecordingTool): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {}
async setSelector(selector: string, focus?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
@ -146,12 +144,6 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}).toString(), { isFunction: true }, mode).catch(() => {});
}
async setRecordingTool(tool: RecordingTool): Promise<void> {
await this._page.mainFrame().evaluateExpression(((tool: RecordingTool) => {
window.playwrightSetRecordingTool(tool);
}).toString(), { isFunction: true }, tool).catch(() => {});
}
async setFileIfNeeded(file: string): Promise<void> {
await this._page.mainFrame().evaluateExpression(((file: string) => {
window.playwrightSetFileIfNeeded(file);

View File

@ -14,7 +14,7 @@
limitations under the License.
*/
import type { CallLog, Mode, RecordingTool, Source } from './recorderTypes';
import type { CallLog, Mode, Source } from './recorderTypes';
import * as React from 'react';
import { Recorder } from './recorder';
import './recorder.css';
@ -25,10 +25,8 @@ export const Main: React.FC = ({
const [paused, setPaused] = React.useState(false);
const [log, setLog] = React.useState(new Map<string, CallLog>());
const [mode, setMode] = React.useState<Mode>('none');
const [tool, setTool] = React.useState<RecordingTool>('action');
window.playwrightSetMode = setMode;
window.playwrightSetRecordingTool = setTool;
window.playwrightSetSources = setSources;
window.playwrightSetPaused = setPaused;
window.playwrightUpdateLogs = callLogs => {
@ -41,5 +39,5 @@ export const Main: React.FC = ({
};
window.playwrightSourcesEchoForTest = sources;
return <Recorder sources={sources} paused={paused} log={log} mode={mode} tool={tool}/>;
return <Recorder sources={sources} paused={paused} log={log} mode={mode}/>;
};

View File

@ -28,11 +28,13 @@
min-width: 100px;
}
.recorder .toolbar-button.toggled.record {
.recorder .toolbar-button.toggled.record,
.recorder .toolbar-button.toggled.text-size {
color: #a1260d;
}
body.dark-mode .recorder .toolbar-button.toggled.record {
body.dark-mode .recorder .toolbar-button.toggled.record,
body.dark-mode .recorder .toolbar-button.toggled.text-size {
color: #f48771;
}

View File

@ -14,7 +14,7 @@
limitations under the License.
*/
import type { CallLog, Mode, RecordingTool, Source } from './recorderTypes';
import type { CallLog, Mode, Source } from './recorderTypes';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { SplitView } from '@web/components/splitView';
import { TabbedPane } from '@web/components/tabbedPane';
@ -40,7 +40,6 @@ export interface RecorderProps {
paused: boolean,
log: Map<string, CallLog>,
mode: Mode,
tool: RecordingTool,
}
export const Recorder: React.FC<RecorderProps> = ({
@ -48,7 +47,6 @@ export const Recorder: React.FC<RecorderProps> = ({
paused,
log,
mode,
tool,
}) => {
const [fileId, setFileId] = React.useState<string | undefined>();
const [selectedTab, setSelectedTab] = React.useState<string>('log');
@ -115,11 +113,11 @@ export const Recorder: React.FC<RecorderProps> = ({
return <div className='recorder'>
<Toolbar>
<ToolbarButton icon='record' title='Record' toggled={mode === 'recording'} onClick={() => {
<ToolbarButton icon='record' title='Record actions' toggled={mode === 'recording'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' } });
}}>Record</ToolbarButton>
<ToolbarButton icon='check-all' title={tool === 'action' ? 'Recording actions' : 'Recording assertions'} toggled={tool === 'assert'} disabled={mode !== 'recording'} onClick={() => {
window.dispatch({ event: 'setRecordingTool', params: { tool: tool === 'assert' ? 'action' : 'assert' } });
<ToolbarButton icon='text-size' title='Assert text' toggled={mode === 'assertingText'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'none' : 'assertingText' } });
}}>Assert</ToolbarButton>
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
copy(source.text);

View File

@ -18,9 +18,7 @@ import type { Language } from '../../playwright-core/src/utils/isomorphic/locato
export type Point = { x: number, y: number };
export type Mode = 'inspecting' | 'recording' | 'none';
export type RecordingTool = 'action' | 'assert';
export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText';
export type EventData = {
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'setRecordingTool' | 'selectorUpdated' | 'fileChanged';
@ -29,7 +27,6 @@ export type EventData = {
export type UIState = {
mode: Mode;
tool: RecordingTool;
actionPoint?: Point;
actionSelector?: string;
language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
@ -75,7 +72,6 @@ export type Source = {
declare global {
interface Window {
playwrightSetMode: (mode: Mode) => void;
playwrightSetRecordingTool: (tool: RecordingTool) => void;
playwrightSetPaused: (paused: boolean) => void;
playwrightSetSources: (sources: Source[]) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void;

View File

@ -312,7 +312,7 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
pointElement.style.height = '20px';
pointElement.style.borderRadius = '10px';
pointElement.style.margin = '-10px 0 0 -10px';
pointElement.style.zIndex = '2147483647';
pointElement.style.zIndex = '2147483646';
const box = target.getBoundingClientRect();
pointElement.style.left = (box.left + box.width / 2) + 'px';
pointElement.style.top = (box.top + box.height / 2) + 'px';

View File

@ -239,7 +239,6 @@ export const InspectModeController: React.FunctionComponent<{
const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, highlightedLocator, testIdAttributeName);
recorder.setUIState({
mode: isInspecting ? 'inspecting' : 'none',
tool: 'action',
actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined,
language: sdkLanguage,
testIdAttributeName,