diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 533d6eaf94..fb0bc2053c 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -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); } diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index fdd5e9f1f8..75c878cc97 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -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'; diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 664694e281..ad51729374 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -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; recordAction?(action: actions.Action): Promise; setSelector?(selector: string): Promise; + setMode?(mode: Mode): Promise; 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; + 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,"); + mask-image: url("data:image/svg+xml;utf8,"); + } + x-pw-tool-item.inspecting > div { + /* codicon: target */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); + } + x-pw-tool-item.recording > div { + /* codicon: record */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); + } + x-pw-tool-item.assertingText > div { + /* codicon: text-size */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); + } + `; + 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; 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; __pw_recorderState(): Promise; __pw_recorderSetSelector(selector: string): Promise; + __pw_recorderSetMode(mode: Mode): Promise; __pw_refreshOverlay(): void; } @@ -853,6 +1065,10 @@ export class PollingRecorder implements RecorderDelegate { async setSelector(selector: string): Promise { await this._embedder.__pw_recorderSetSelector(selector); } + + async setMode(mode: Mode): Promise { + await this._embedder.__pw_recorderSetMode(mode); + } } export default PollingRecorder; diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 3dc1482879..f90645e778 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, 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(); @@ -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) { diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 97bca8e9c6..aef4b999ee 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -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; setPaused(paused: boolean): Promise; setMode(mode: Mode): Promise; - setRecordingTool(tool: RecordingTool): Promise; setFileIfNeeded(file: string): Promise; setSelector(selector: string, focus?: boolean): Promise; updateCallLogs(callLogs: CallLog[]): Promise; @@ -56,7 +55,6 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} async setMode(mode: Mode): Promise {} - async setRecordingTool(tool: RecordingTool): Promise {} async setFileIfNeeded(file: string): Promise {} async setSelector(selector: string, focus?: boolean): Promise {} async updateCallLogs(callLogs: CallLog[]): Promise {} @@ -146,12 +144,6 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }).toString(), { isFunction: true }, mode).catch(() => {}); } - async setRecordingTool(tool: RecordingTool): Promise { - await this._page.mainFrame().evaluateExpression(((tool: RecordingTool) => { - window.playwrightSetRecordingTool(tool); - }).toString(), { isFunction: true }, tool).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 8be0d0d2d2..ddd9d90acd 100644 --- a/packages/recorder/src/main.tsx +++ b/packages/recorder/src/main.tsx @@ -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()); const [mode, setMode] = React.useState('none'); - const [tool, setTool] = React.useState('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 ; + return ; }; diff --git a/packages/recorder/src/recorder.css b/packages/recorder/src/recorder.css index fc824f98fe..b91eba9ff1 100644 --- a/packages/recorder/src/recorder.css +++ b/packages/recorder/src/recorder.css @@ -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; } diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 8b132f2071..fd795fc517 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -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, mode: Mode, - tool: RecordingTool, } export const Recorder: React.FC = ({ @@ -48,7 +47,6 @@ export const Recorder: React.FC = ({ paused, log, mode, - tool, }) => { const [fileId, setFileId] = React.useState(); const [selectedTab, setSelectedTab] = React.useState('log'); @@ -115,11 +113,11 @@ export const Recorder: React.FC = ({ return
- { + { window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' } }); }}>Record - { - window.dispatch({ event: 'setRecordingTool', params: { tool: tool === 'assert' ? 'action' : 'assert' } }); + { + window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'none' : 'assertingText' } }); }}>Assert { copy(source.text); diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index 62f174729d..fc1c3b9777 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -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; diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index da0de98eb4..f07035c041 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -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'; diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index bddc0f2a16..b128827b9b 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -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,