diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 33ee63720f..b0275a303c 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -55,6 +55,13 @@ x-pw-dialog-body { flex: auto; } +x-pw-dialog-body label { + margin: 5px 8px; + display: flex; + flex-direction: row; + align-items: center; +} + x-pw-highlight { position: absolute; top: 0; @@ -129,6 +136,14 @@ x-pw-tool-item:not(.disabled):hover { background-color: hsl(0, 0%, 86%); } +x-pw-tool-item.active { + background-color: rgba(138, 202, 228, 0.5); +} + +x-pw-tool-item.active:not(.disabled):hover { + background-color: #8acae4c4; +} + x-pw-tool-item > x-div { width: 100%; height: 100%; @@ -146,8 +161,12 @@ x-pw-tool-item.disabled > x-div { cursor: default; } -x-pw-tool-item.active > x-div { - background-color: #006ab1; +x-pw-tool-item.record.active { + background-color: transparent; +} + +x-pw-tool-item.record.active:hover { + background-color: hsl(0, 0%, 86%); } x-pw-tool-item.record.active > x-div { @@ -182,6 +201,12 @@ x-pw-tool-item.visibility > x-div { mask-image: url("data:image/svg+xml;utf8,"); } +x-pw-tool-item.value > x-div { + /* codicon: symbol-constant */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); +} + x-pw-tool-item.accept > x-div { -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 8785783308..6ecd28fe03 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -80,10 +80,14 @@ class NoneTool implements RecorderTool { } class InspectTool implements RecorderTool { + private _recorder: Recorder; private _hoveredModel: HighlightModel | null = null; private _hoveredElement: HTMLElement | null = null; + private _assertVisibility: boolean; - constructor(private _recorder: Recorder, private _assertVisibility: boolean) { + constructor(recorder: Recorder, assertVisibility: boolean) { + this._recorder = recorder; + this._assertVisibility = assertVisibility; } cursor() { @@ -104,6 +108,7 @@ class InspectTool implements RecorderTool { selector: this._hoveredModel.selector, signals: [], }); + this._recorder.delegate.setMode?.('recording'); } } else { this._recorder.delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : ''); @@ -138,7 +143,7 @@ class InspectTool implements RecorderTool { if (this._hoveredModel?.selector === model?.selector) return; this._hoveredModel = model; - this._recorder.updateHighlight(model, true); + this._recorder.updateHighlight(model, true, { color: this._assertVisibility ? '#8acae480' : undefined }); } onMouseLeave(event: MouseEvent) { @@ -170,13 +175,15 @@ class InspectTool implements RecorderTool { } class RecordActionTool implements RecorderTool { + private _recorder: Recorder; private _performingAction = false; private _hoveredModel: HighlightModel | null = null; private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; - constructor(private _recorder: Recorder) { + constructor(recorder: Recorder) { + this._recorder = recorder; } cursor() { @@ -474,6 +481,7 @@ class RecordActionTool implements RecorderTool { } class TextAssertionTool implements RecorderTool { + private _recorder: Recorder; private _hoverHighlight: HighlightModel | null = null; private _action: actions.AssertAction | null = null; private _dialogElement: HTMLElement | null = null; @@ -481,8 +489,12 @@ class TextAssertionTool implements RecorderTool { private _cancelButton: HTMLElement; private _keyboardListener: ((event: KeyboardEvent) => void) | undefined; private _textCache = new Map(); + private _kind: 'text' | 'value'; + + constructor(recorder: Recorder, kind: 'text' | 'value') { + this._recorder = recorder; + this._kind = kind; - constructor(private _recorder: Recorder) { this._acceptButton = this._recorder.document.createElement('x-pw-tool-item'); this._acceptButton.title = 'Accept'; this._acceptButton.classList.add('accept'); @@ -523,7 +535,10 @@ class TextAssertionTool implements RecorderTool { const target = this._recorder.deepEventTarget(event); if (this._hoverHighlight?.elements[0] === target) return; - this._hoverHighlight = target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA' || elementText(new Map(), target).full ? { elements: [target], selector: '' } : null; + if (this._kind === 'text') + this._hoverHighlight = elementText(this._textCache, target).full ? { elements: [target], selector: '' } : null; + else + this._hoverHighlight = this._elementHasValue(target) ? generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null; this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' }); } @@ -533,12 +548,22 @@ class TextAssertionTool implements RecorderTool { consumeEvent(event); } + onScroll(event: Event) { + this._recorder.updateHighlight(this._hoverHighlight, false, { color: '#8acae480' }); + } + + private _elementHasValue(element: Element) { + return element.nodeName === 'TEXTAREA' || element.nodeName === 'SELECT' || (element.nodeName === 'INPUT' && !['button', 'image', 'reset', 'submit'].includes((element as HTMLInputElement).type)); + } + private _generateAction(): actions.AssertAction | null { this._textCache.clear(); const target = this._hoverHighlight?.elements[0]; if (!target) return null; - if (target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA' || target.nodeName === 'SELECT') { + if (this._kind === 'value') { + if (!this._elementHasValue(target)) + return null; const { selector } = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }); if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes((target as HTMLInputElement).type.toLowerCase())) { return { @@ -553,7 +578,7 @@ class TextAssertionTool implements RecorderTool { name: 'assertValue', selector, signals: [], - value: (target as (HTMLInputElement | HTMLSelectElement)).value, + value: (target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).value, }; } } else { @@ -637,7 +662,11 @@ class TextAssertionTool implements RecorderTool { cm.on('change', () => { if (this._action) { const selector = locatorOrSelectorAsSelector(this._recorder.state.language, cm.getValue(), this._recorder.state.testIdAttributeName); - const elements = this._recorder.injectedScript.querySelectorAll(parseSelector(selector), this._recorder.document); + let elements: Element[] = []; + try { + elements = this._recorder.injectedScript.querySelectorAll(parseSelector(selector), this._recorder.document); + } catch { + } cmElement.classList.toggle('does-not-match', !elements.length); this._hoverHighlight = elements.length ? { selector, @@ -735,16 +764,19 @@ class TextAssertionTool implements RecorderTool { } class Overlay { + private _recorder: Recorder; private _overlayElement: HTMLElement; private _recordToggle: HTMLElement; private _pickLocatorToggle: HTMLElement; private _assertVisibilityToggle: HTMLElement; private _assertTextToggle: HTMLElement; + private _assertValuesToggle: HTMLElement; private _offsetX = 0; private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined; private _measure: { width: number, height: number } = { width: 0, height: 0 }; - constructor(private _recorder: Recorder) { + constructor(recorder: Recorder) { + this._recorder = recorder; const document = this._recorder.injectedScript.document; this._overlayElement = document.createElement('x-pw-overlay'); @@ -780,6 +812,7 @@ class Overlay { 'recording-inspecting': 'recording', 'assertingText': 'recording-inspecting', 'assertingVisibility': 'recording-inspecting', + 'assertingValue': 'recording-inspecting', }; this._recorder.delegate.setMode?.(newMode[this._recorder.state.mode]); }); @@ -805,6 +838,16 @@ class Overlay { }); toolsListElement.appendChild(this._assertTextToggle); + this._assertValuesToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); + this._assertValuesToggle.title = 'Assert value'; + this._assertValuesToggle.classList.add('value'); + this._assertValuesToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div')); + this._assertValuesToggle.addEventListener('click', () => { + if (!this._assertValuesToggle.classList.contains('disabled')) + this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); + }); + toolsListElement.appendChild(this._assertValuesToggle); + this._updateVisualPosition(); } @@ -818,12 +861,14 @@ class Overlay { } setUIState(state: UIState) { - this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'recording-inspecting'); + this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'recording-inspecting'); this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); this._assertVisibilityToggle.classList.toggle('active', state.mode === 'assertingVisibility'); this._assertVisibilityToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertTextToggle.classList.toggle('active', state.mode === 'assertingText'); this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); + this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue'); + this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; this._updateVisualPosition(); @@ -896,8 +941,9 @@ export class Recorder { 'inspecting': new InspectTool(this, false), 'recording': new RecordActionTool(this), 'recording-inspecting': new InspectTool(this, false), - 'assertingText': new TextAssertionTool(this), + 'assertingText': new TextAssertionTool(this, 'text'), 'assertingVisibility': new InspectTool(this, true), + 'assertingValue': new TextAssertionTool(this, 'value'), }; this._currentTool = this._tools.none; if (injectedScript.window.top === injectedScript.window) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 4fc5a63cdf..bedcb7a691 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -246,8 +246,8 @@ export class Recorder implements InstrumentationListener { this._highlightedSelector = ''; this._mode = mode; this._recorderApp?.setMode(this._mode); - this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility'); - this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility'); + this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); + this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1) this._context.pages()[0].bringToFront().catch(() => {}); this._refreshOverlay(); @@ -281,7 +281,7 @@ export class Recorder implements InstrumentationListener { } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { - if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility') + if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue') return; this._currentCallsMetadata.set(metadata, sdkObject); this._updateUserSources(); @@ -295,7 +295,7 @@ export class Recorder implements InstrumentationListener { } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { - if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility') + if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue') return; if (!metadata.error) this._currentCallsMetadata.delete(metadata); @@ -345,7 +345,7 @@ export class Recorder implements InstrumentationListener { } updateCallLog(metadatas: CallMetadata[]) { - if (this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility') + if (this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue') return; const logs: CallLog[] = []; for (const metadata of metadatas) { diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 4f73faeff5..69f8983c3f 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -128,15 +128,19 @@ export const Recorder: React.FC = ({ 'recording-inspecting': 'recording', 'assertingText': 'recording-inspecting', 'assertingVisibility': 'recording-inspecting', + 'assertingValue': 'recording-inspecting', }[mode]; window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { }); }}> { window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility' } }); }}> - { + { window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'recording' : 'assertingText' } }); }}> + { + window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingValue' ? 'recording' : 'assertingValue' } }); + }}> { copy(source.text); diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index e82e608554..c2b51549c4 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -18,7 +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' | 'assertingText' | 'recording-inspecting' | 'standby' | 'assertingVisibility'; +export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText' | 'recording-inspecting' | 'standby' | 'assertingVisibility' | 'assertingValue'; export type EventData = { event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'fileChanged';