From 810382c074fb94ba2ac3f2b399a463d96dc3ef1f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 6 Nov 2023 16:40:33 -0800 Subject: [PATCH] chore(recorder): more UX fixes for text assertions (#27995) --- .../src/server/injected/highlight.ts | 33 ++- .../src/server/injected/recorder.ts | 219 +++++++++++++----- .../playwright-core/src/server/recorder.ts | 19 +- .../src/server/recorder/recorderApp.ts | 9 + packages/recorder/src/main.tsx | 4 +- packages/recorder/src/recorder.css | 4 + packages/recorder/src/recorder.tsx | 5 + packages/recorder/src/recorderTypes.ts | 10 +- packages/trace-viewer/src/ui/snapshotTab.tsx | 2 +- 9 files changed, 222 insertions(+), 83 deletions(-) diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index 75c878cc97..16dd044949 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -30,6 +30,13 @@ type HighlightEntry = { tooltipText?: string, }; +export type HighlightOptions = { + tooltipText?: string; + color?: string; + anchorGetter?: (element: Element) => DOMRect; + decorateTooltip?: (tooltip: Element) => void; +}; + export class Highlight { private _glassPaneElement: HTMLElement; private _glassPaneShadow: ShadowRoot; @@ -112,7 +119,7 @@ export class Highlight { runHighlightOnRaf(selector: ParsedSelector) { if (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)); } @@ -144,17 +151,19 @@ export class Highlight { this._highlightEntries = []; } - updateHighlight(elements: Element[], selector: string, color?: string) { - if (!color) - color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f'; - this._innerUpdateHighlight(elements, { color, tooltipText: selector ? asLocator(this._language, selector) : '' }); + updateHighlight(elements: Element[], options: HighlightOptions) { + this._innerUpdateHighlight(elements, options); } maskElements(elements: Element[], color?: string) { 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 // destroyed layout. @@ -177,6 +186,7 @@ export class Highlight { tooltipElement.style.top = '0'; tooltipElement.style.left = '0'; tooltipElement.style.display = 'flex'; + options.decorateTooltip?.(tooltipElement); } this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement, tooltipText: options.tooltipText }); } @@ -193,14 +203,15 @@ export class Highlight { const totalWidth = this._glassPaneElement.offsetWidth; 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) anchorLeft = totalWidth - tooltipWidth - 5; - let anchorTop = entry.box.bottom + 5; + let anchorTop = anchorBox.bottom + 5; if (anchorTop + tooltipHeight > totalHeight - 5) { // If can't fit below, either position above... - if (entry.box.top > tooltipHeight + 5) { - anchorTop = entry.box.top - tooltipHeight - 5; + if (anchorBox.top > tooltipHeight + 5) { + anchorTop = anchorBox.top - tooltipHeight - 5; } else { // Or on top in case of large element anchorTop = totalHeight - 5 - tooltipHeight; @@ -219,7 +230,7 @@ export class Highlight { entry.tooltipElement.style.left = entry.tooltipLeft + 'px'; } 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.top = box.y + 'px'; entry.highlightElement.style.width = box.width + 'px'; diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 334e8569a5..d78b92d938 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -18,18 +18,19 @@ 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 { Mode, UIState } from '@recorder/recorderTypes'; -import { Highlight } from '../injected/highlight'; +import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes'; +import { Highlight, type HighlightOptions } from '../injected/highlight'; import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils'; import { elementText } from './selectorUtils'; -import { normalizeWhiteSpace } from '@isomorphic/stringUtils'; +import { escapeWithQuotes, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; +import { asLocator } from '../../utils/isomorphic/locatorGenerators'; interface RecorderDelegate { performAction?(action: actions.Action): Promise; recordAction?(action: actions.Action): Promise; setSelector?(selector: string): Promise; setMode?(mode: Mode): Promise; - setOverlayPosition?(position: { x: number, y: number }): Promise; + setOverlayState?(state: OverlayState): Promise; highlightUpdated?(): void; } @@ -436,15 +437,23 @@ class RecordActionTool implements RecorderTool { if (this._hoveredModel && this._hoveredModel.selector === selector) return; 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 { + private _hoverHighlight: HighlightModel | null = null; private _selectionHighlight: HighlightModel | null = null; + private _selectionText: { selectedText: string, fullText: string } | null = null; private _inputHighlight: HighlightModel | null = null; + private _acceptButton: HTMLElement; 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() { @@ -457,13 +466,19 @@ class TextAssertionTool implements RecorderTool { disable() { this._recorder.injectedScript.document.designMode = 'off'; + this._hoverHighlight = null; this._selectionHighlight = null; + this._selectionText = null; this._inputHighlight = null; } 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(); if (event.detail === 1 && selection && !selection.toString() && !this._inputHighlight) { const target = this._recorder.deepEventTarget(event); @@ -477,12 +492,13 @@ class TextAssertionTool implements RecorderTool { if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) { this._recorder.injectedScript.window.getSelection()?.empty(); this._inputHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }); - this._recorder.updateHighlight(this._inputHighlight, true, '#6fdcbd38'); + this._showHighlight(true); consumeEvent(event); return; } this._inputHighlight = null; + this._hoverHighlight = null; this._updateSelectionHighlight(); } @@ -491,7 +507,18 @@ class TextAssertionTool implements RecorderTool { } 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) { @@ -500,48 +527,17 @@ class TextAssertionTool implements RecorderTool { onKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { - this._resetSelectionAndHighlight(); - this._recorder.delegate.setMode?.('recording'); + const selection = this._recorder.document.getSelection(); + if (selection && selection.toString()) + this._resetSelectionAndHighlight(); + else + this._recorder.delegate.setMode?.('recording'); consumeEvent(event); return; } if (event.key === 'Enter') { - const selection = this._recorder.document.getSelection(); - - 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(); - } + this._commitAction(); consumeEvent(event); return; } @@ -558,11 +554,67 @@ class TextAssertionTool implements RecorderTool { } 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() { this._selectionHighlight = null; + this._selectionText = null; this._inputHighlight = null; this._recorder.injectedScript.window.getSelection()?.empty(); this._recorder.updateHighlight(null, false); @@ -572,18 +624,32 @@ class TextAssertionTool implements RecorderTool { if (this._inputHighlight) return; const selection = this._recorder.document.getSelection(); + const selectedText = normalizeWhiteSpace(selection?.toString() || ''); 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); let lcaElement = focusElement ? enclosingElement(selection.anchorNode) : undefined; while (lcaElement && !isInsideScope(lcaElement, focusElement)) lcaElement = parentElementOrShadowHost(lcaElement); 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; 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 { cursor: grab; - height: 2px; - margin: 5px 9px; - border-top: 1px solid rgb(86 86 86 / 90%); - border-bottom: 1px solid rgb(86 86 86 / 90%); + padding: 6px 9px; + } + x-pw-drag-handle > div { + height: 1px; + margin-top: 2px; + background: rgb(148 148 148 / 90%); } x-pw-drag-handle:active { cursor: grabbing; } + x-pw-separator { + height: 1px; + margin: 6px 9px; + background: rgb(148 148 148 / 90%); + } + x-pw-tool-item { cursor: pointer; height: 28px; @@ -680,6 +754,11 @@ class Overlay { -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); } + x-pw-tool-item.close > div { + /* codicon: close */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); + } `; shadow.appendChild(styleElement); @@ -699,6 +778,9 @@ class Overlay { dragHandle.addEventListener('mousedown', event => { 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); this._pickLocatorToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); @@ -727,6 +809,16 @@ class Overlay { }); 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(); } @@ -744,10 +836,11 @@ class Overlay { this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); this._assertToggle.classList.toggle('active', state.mode === 'assertingText'); this._assertToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'inspecting'); - if (this._position.x !== state.overlayPosition.x || this._position.y !== state.overlayPosition.y) { - this._position = state.overlayPosition; + if (this._position.x !== state.overlay.position.x || this._position.y !== state.overlay.position.y) { + this._position = state.overlay.position; this._updateVisualPosition(); } + this._overlayElement.style.display = state.overlay.visible ? 'block' : 'none'; } 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.y = Math.max(0, Math.min(this._recorder.injectedScript.window.innerHeight - this._measure.height, this._position.y)); this._updateVisualPosition(); - this._recorder.delegate.setOverlayPosition?.(this._position); + this._recorder.delegate.setOverlayState?.({ position: this._position, visible: true }); consumeEvent(event); return true; } @@ -794,7 +887,7 @@ export class Recorder { private _highlight: Highlight; private _overlay: Overlay | undefined; 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; delegate: RecorderDelegate = {}; @@ -1000,8 +1093,10 @@ export class Recorder { this._currentTool.onKeyUp?.(event); } - updateHighlight(model: HighlightModel | null, userGesture: boolean, color?: string) { - this._highlight.updateHighlight(model?.elements || [], model?.selector || '', color); + updateHighlight(model: HighlightModel | null, userGesture: boolean, options: HighlightOptions = {}) { + if (options.tooltipText === undefined && model?.selector) + options.tooltipText = asLocator(this.state.language, model.selector); + this._highlight.updateHighlight(model?.elements || [], options); if (userGesture) this.delegate.highlightUpdated?.(); } @@ -1102,7 +1197,7 @@ interface Embedder { __pw_recorderState(): Promise; __pw_recorderSetSelector(selector: string): Promise; __pw_recorderSetMode(mode: Mode): Promise; - __pw_recorderSetOverlayPosition(position: { x: number, y: number }): Promise; + __pw_recorderSetOverlayState(state: OverlayState): Promise; __pw_refreshOverlay(): void; } @@ -1159,8 +1254,8 @@ export class PollingRecorder implements RecorderDelegate { await this._embedder.__pw_recorderSetMode(mode); } - async setOverlayPosition(position: { x: number, y: number }): Promise { - await this._embedder.__pw_recorderSetOverlayPosition(position); + async setOverlayState(state: OverlayState): Promise { + await this._embedder.__pw_recorderSetOverlayState(state); } } diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 8f46e2995b..553d12fcc7 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -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, Source, UIState } from '@recorder/recorderTypes'; +import type { CallLog, CallLogStatus, EventData, Mode, OverlayState, Source, UIState } from '@recorder/recorderTypes'; import { createGuid, isUnderTest, monotonicTime } from '../utils'; import { metadataToCallLog } from './recorder/recorderUtils'; import { Debugger } from './debugger'; @@ -55,7 +55,7 @@ export class Recorder implements InstrumentationListener { private _context: BrowserContext; private _mode: Mode; 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 _currentCallsMetadata = new Map(); private _recorderSources: Source[] = []; @@ -101,7 +101,7 @@ export class Recorder implements InstrumentationListener { if (isUnderTest()) { // 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); return; } + if (data.event === 'setOverlayVisible') { + this._overlayState.visible = data.params.visible; + this._recorderApp?.setOverlayVisible(this._overlayState.visible); + this._refreshOverlay(); + return; + } if (data.event === 'selectorUpdated') { this.setHighlightedSelector(this._currentLanguage, data.params.selector); return; @@ -186,7 +192,7 @@ export class Recorder implements InstrumentationListener { actionSelector, language: this._currentLanguage, testIdAttributeName: this._contextRecorder.testIdAttributeName(), - overlayPosition: this._overlayPosition, + overlay: this._overlayState, }; return uiState; }); @@ -209,10 +215,11 @@ export class Recorder implements InstrumentationListener { 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()) return; - this._overlayPosition = position; + this._overlayState = state; + this._recorderApp?.setOverlayVisible(state.visible); }); await this._context.exposeBinding('__pw_resume', false, () => { diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 3fb72d9f9e..de108c8fc4 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -34,6 +34,7 @@ declare global { playwrightSetMode: (mode: Mode) => void; playwrightSetPaused: (paused: boolean) => void; playwrightSetSources: (sources: Source[]) => void; + playwrightSetOverlayVisible: (visible: boolean) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void; dispatch(data: EventData): Promise; @@ -45,6 +46,7 @@ export interface IRecorderApp extends EventEmitter { close(): Promise; setPaused(paused: boolean): Promise; setMode(mode: Mode): Promise; + setOverlayVisible(visible: boolean): Promise; setFileIfNeeded(file: string): Promise; setSelector(selector: string, userGesture?: boolean): Promise; updateCallLogs(callLogs: CallLog[]): Promise; @@ -55,6 +57,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} async setMode(mode: Mode): Promise {} + async setOverlayVisible(visible: boolean): Promise {} async setFileIfNeeded(file: string): Promise {} async setSelector(selector: string, userGesture?: boolean): Promise {} async updateCallLogs(callLogs: CallLog[]): Promise {} @@ -144,6 +147,12 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }).toString(), { isFunction: true }, mode).catch(() => {}); } + async setOverlayVisible(visible: boolean): Promise { + await this._page.mainFrame().evaluateExpression(((visible: boolean) => { + window.playwrightSetOverlayVisible(visible); + }).toString(), { isFunction: true }, visible).catch(() => {}); + } + async setFileIfNeeded(file: string): Promise { await this._page.mainFrame().evaluateExpression(((file: string) => { window.playwrightSetFileIfNeeded(file); diff --git a/packages/recorder/src/main.tsx b/packages/recorder/src/main.tsx index ddd9d90acd..be5791eb15 100644 --- a/packages/recorder/src/main.tsx +++ b/packages/recorder/src/main.tsx @@ -25,10 +25,12 @@ export const Main: React.FC = ({ const [paused, setPaused] = React.useState(false); const [log, setLog] = React.useState(new Map()); const [mode, setMode] = React.useState('none'); + const [overlayVisible, setOverlayVisible] = React.useState(true); window.playwrightSetMode = setMode; window.playwrightSetSources = setSources; window.playwrightSetPaused = setPaused; + window.playwrightSetOverlayVisible = setOverlayVisible; window.playwrightUpdateLogs = callLogs => { const newLog = new Map(log); for (const callLog of callLogs) { @@ -39,5 +41,5 @@ export const Main: React.FC = ({ }; window.playwrightSourcesEchoForTest = sources; - return ; + return ; }; diff --git a/packages/recorder/src/recorder.css b/packages/recorder/src/recorder.css index 93eae2ba21..8219457295 100644 --- a/packages/recorder/src/recorder.css +++ b/packages/recorder/src/recorder.css @@ -28,6 +28,10 @@ min-width: 100px; } +.recorder .codicon { + font-size: 15px; +} + .recorder .toolbar-button.toggled.circle-large-filled { color: #a1260d; } diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 6a1c6eb325..65d5628b54 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -40,6 +40,7 @@ export interface RecorderProps { paused: boolean, log: Map, mode: Mode, + overlayVisible: boolean, } export const Recorder: React.FC = ({ @@ -47,6 +48,7 @@ export const Recorder: React.FC = ({ paused, log, mode, + overlayVisible, }) => { const [fileId, setFileId] = React.useState(); const [selectedTab, setSelectedTab] = React.useState('log'); @@ -154,6 +156,9 @@ export const Recorder: React.FC = ({ window.dispatch({ event: 'clear' }); }}> toggleTheme()}> + { + window.dispatch({ event: 'setOverlayVisible', params: { visible: !overlayVisible } }); + }}> diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index 5855aa0f63..d8ac13240e 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -21,17 +21,22 @@ export type Point = { x: number, y: number }; export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText' | 'recording-inspecting'; export type EventData = { - event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'setRecordingTool' | 'selectorUpdated' | 'fileChanged'; + event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'fileChanged' | 'setOverlayVisible'; params: any; }; +export type OverlayState = { + position: Point; + visible: boolean; +}; + export type UIState = { mode: Mode; actionPoint?: Point; actionSelector?: string; language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; testIdAttributeName: string; - overlayPosition: Point; + overlay: OverlayState; }; export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused'; @@ -75,6 +80,7 @@ declare global { playwrightSetMode: (mode: Mode) => void; playwrightSetPaused: (paused: boolean) => void; playwrightSetSources: (sources: Source[]) => void; + playwrightSetOverlayVisible: (visible: boolean) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void; playwrightSetFileIfNeeded: (file: string) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void; diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index c29846ebb4..793fb93908 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -242,7 +242,7 @@ export const InspectModeController: React.FunctionComponent<{ actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined, language: sdkLanguage, testIdAttributeName, - overlayPosition: { x: 0, y: 0 }, + overlay: { position: { x: 0, y: 0 }, visible: false }, }, { async setSelector(selector: string) { setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector, false /* isFrameLocator */, true /* playSafe */));