From 0a052cb4d6712584a6d01bf7439450f68b87ff63 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 14 Nov 2023 12:55:34 -0800 Subject: [PATCH] feat(recorder): assert visibility tool (#28142) --- .../src/server/injected/highlight.css | 14 +++-- .../src/server/injected/recorder.ts | 57 ++++++++++++++----- .../playwright-core/src/server/recorder.ts | 10 ++-- .../src/server/recorder/csharp.ts | 2 + .../src/server/recorder/java.ts | 2 + .../src/server/recorder/javascript.ts | 2 + .../src/server/recorder/python.ts | 2 + .../src/server/recorder/recorderActions.ts | 12 +++- packages/recorder/src/recorder.css | 4 ++ packages/recorder/src/recorder.tsx | 14 +++-- packages/recorder/src/recorderTypes.ts | 2 +- 11 files changed, 88 insertions(+), 33 deletions(-) diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 7c7105c131..33ee63720f 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -170,10 +170,16 @@ x-pw-tool-item.pick-locator > x-div { mask-image: url("data:image/svg+xml;utf8,"); } -x-pw-tool-item.assert > x-div { - /* codicon: check-all */ - -webkit-mask-image: url("data:image/svg+xml;utf8,"); - mask-image: url("data:image/svg+xml;utf8,"); +x-pw-tool-item.text > x-div { + /* codicon: whole-word */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); +} + +x-pw-tool-item.visibility > x-div { + /* codicon: eye */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); } x-pw-tool-item.accept > x-div { diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 03ac6d92b5..8785783308 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -83,7 +83,7 @@ class InspectTool implements RecorderTool { private _hoveredModel: HighlightModel | null = null; private _hoveredElement: HTMLElement | null = null; - constructor(private _recorder: Recorder) { + constructor(private _recorder: Recorder, private _assertVisibility: boolean) { } cursor() { @@ -97,7 +97,17 @@ class InspectTool implements RecorderTool { onClick(event: MouseEvent) { consumeEvent(event); - this._recorder.delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : ''); + if (this._assertVisibility) { + if (this._hoveredModel?.selector) { + this._recorder.delegate.recordAction?.({ + name: 'assertVisible', + selector: this._hoveredModel.selector, + signals: [], + }); + } + } else { + this._recorder.delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : ''); + } } onPointerDown(event: PointerEvent) { @@ -144,6 +154,8 @@ class InspectTool implements RecorderTool { onKeyDown(event: KeyboardEvent) { consumeEvent(event); + if (this._assertVisibility && event.key === 'Escape') + this._recorder.delegate.setMode?.('recording'); } onKeyUp(event: KeyboardEvent) { @@ -726,7 +738,8 @@ class Overlay { private _overlayElement: HTMLElement; private _recordToggle: HTMLElement; private _pickLocatorToggle: HTMLElement; - private _assertToggle: HTMLElement; + private _assertVisibilityToggle: HTMLElement; + private _assertTextToggle: HTMLElement; private _offsetX = 0; private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined; private _measure: { width: number, height: number } = { width: 0, height: 0 }; @@ -766,20 +779,31 @@ class Overlay { 'recording': 'recording-inspecting', 'recording-inspecting': 'recording', 'assertingText': 'recording-inspecting', + 'assertingVisibility': 'recording-inspecting', }; this._recorder.delegate.setMode?.(newMode[this._recorder.state.mode]); }); toolsListElement.appendChild(this._pickLocatorToggle); - this._assertToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); - this._assertToggle.title = 'Assert text and values'; - this._assertToggle.classList.add('assert'); - this._assertToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div')); - this._assertToggle.addEventListener('click', () => { - if (!this._assertToggle.classList.contains('disabled')) + this._assertVisibilityToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); + this._assertVisibilityToggle.title = 'Assert visibility'; + this._assertVisibilityToggle.classList.add('visibility'); + this._assertVisibilityToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div')); + this._assertVisibilityToggle.addEventListener('click', () => { + if (!this._assertVisibilityToggle.classList.contains('disabled')) + this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility'); + }); + toolsListElement.appendChild(this._assertVisibilityToggle); + + this._assertTextToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); + this._assertTextToggle.title = 'Assert text and values'; + this._assertTextToggle.classList.add('text'); + this._assertTextToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div')); + this._assertTextToggle.addEventListener('click', () => { + if (!this._assertTextToggle.classList.contains('disabled')) this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingText' ? 'recording' : 'assertingText'); }); - toolsListElement.appendChild(this._assertToggle); + toolsListElement.appendChild(this._assertTextToggle); this._updateVisualPosition(); } @@ -794,10 +818,12 @@ class Overlay { } setUIState(state: UIState) { - this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'recording-inspecting'); + this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'recording-inspecting'); this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); - this._assertToggle.classList.toggle('active', state.mode === 'assertingText'); - this._assertToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === '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'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; this._updateVisualPosition(); @@ -867,10 +893,11 @@ export class Recorder { this._tools = { 'none': new NoneTool(), 'standby': new NoneTool(), - 'inspecting': new InspectTool(this), + 'inspecting': new InspectTool(this, false), 'recording': new RecordActionTool(this), - 'recording-inspecting': new InspectTool(this), + 'recording-inspecting': new InspectTool(this, false), 'assertingText': new TextAssertionTool(this), + 'assertingVisibility': new InspectTool(this, true), }; 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 b2b5e50014..4fc5a63cdf 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._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText'); + this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility'); + this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility'); 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') + if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility') 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') + if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility') 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') + if (this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility') return; const logs: CallLog[] = []; for (const metadata of metadatas) { diff --git a/packages/playwright-core/src/server/recorder/csharp.ts b/packages/playwright-core/src/server/recorder/csharp.ts index 709b9df49d..edd288d6d8 100644 --- a/packages/playwright-core/src/server/recorder/csharp.ts +++ b/packages/playwright-core/src/server/recorder/csharp.ts @@ -158,6 +158,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator { return `await Expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'ToContainTextAsync' : 'ToHaveTextAsync'}(${quote(action.text)});`; case 'assertChecked': return `await Expect(${subject}.${this._asLocator(action.selector)})${action.checked ? '' : '.Not'}.ToBeCheckedAsync();`; + case 'assertVisible': + return `await Expect(${subject}.${this._asLocator(action.selector)}).ToBeVisibleAsync();`; case 'assertValue': { const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmpty()`; return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; diff --git a/packages/playwright-core/src/server/recorder/java.ts b/packages/playwright-core/src/server/recorder/java.ts index c15241a98e..742b4db93a 100644 --- a/packages/playwright-core/src/server/recorder/java.ts +++ b/packages/playwright-core/src/server/recorder/java.ts @@ -126,6 +126,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${action.substring ? 'containsText' : 'hasText'}(${quote(action.text)});`; case 'assertChecked': return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)})${action.checked ? '' : '.not()'}.isChecked();`; + case 'assertVisible': + return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).isVisible();`; case 'assertValue': { const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`; return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`; diff --git a/packages/playwright-core/src/server/recorder/javascript.ts b/packages/playwright-core/src/server/recorder/javascript.ts index a68d3212fa..548e0f6071 100644 --- a/packages/playwright-core/src/server/recorder/javascript.ts +++ b/packages/playwright-core/src/server/recorder/javascript.ts @@ -129,6 +129,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${quote(action.text)});`; case 'assertChecked': return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)})${action.checked ? '' : '.not'}.toBeChecked();`; + case 'assertVisible': + return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).toBeVisible();`; case 'assertValue': { const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`; return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; diff --git a/packages/playwright-core/src/server/recorder/python.ts b/packages/playwright-core/src/server/recorder/python.ts index a0e60e32be..b00e02178c 100644 --- a/packages/playwright-core/src/server/recorder/python.ts +++ b/packages/playwright-core/src/server/recorder/python.ts @@ -138,6 +138,8 @@ export class PythonLanguageGenerator implements LanguageGenerator { return `expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`; case 'assertChecked': return `expect(${subject}.${this._asLocator(action.selector)}).${action.checked ? 'to_be_checked()' : 'not_to_be_checked()'}`; + case 'assertVisible': + return `expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`; case 'assertValue': { const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`; return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; diff --git a/packages/playwright-core/src/server/recorder/recorderActions.ts b/packages/playwright-core/src/server/recorder/recorderActions.ts index 3a4bbab325..3c9720cbc4 100644 --- a/packages/playwright-core/src/server/recorder/recorderActions.ts +++ b/packages/playwright-core/src/server/recorder/recorderActions.ts @@ -29,7 +29,8 @@ export type ActionName = 'setInputFiles' | 'assertText' | 'assertValue' | - 'assertChecked'; + 'assertChecked' | + 'assertVisible'; export type ActionBase = { name: ActionName, @@ -113,8 +114,13 @@ export type AssertCheckedAction = ActionBase & { checked: boolean, }; -export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction; -export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction; +export type AssertVisibleAction = ActionBase & { + name: 'assertVisible', + selector: string, +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction; +export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction; // Signals. diff --git a/packages/recorder/src/recorder.css b/packages/recorder/src/recorder.css index 8219457295..fa7ce623db 100644 --- a/packages/recorder/src/recorder.css +++ b/packages/recorder/src/recorder.css @@ -29,6 +29,10 @@ } .recorder .codicon { + font-size: 16px; +} + +.recorder .codicon.circle-large-filled { font-size: 15px; } diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index a6d1e0571b..4f73faeff5 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -115,8 +115,8 @@ export const Recorder: React.FC = ({ return
- { - window.dispatch({ event: 'setMode', params: { mode: mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'none' } }); + { + window.dispatch({ event: 'setMode', params: { mode: mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby' } }); }}>Record { @@ -127,12 +127,16 @@ export const Recorder: React.FC = ({ 'recording': 'recording-inspecting', 'recording-inspecting': 'recording', 'assertingText': 'recording-inspecting', + 'assertingVisibility': 'recording-inspecting', }[mode]; window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { }); - }}>Pick locator - { + }}> + { + window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility' } }); + }}> + { window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'recording' : 'assertingText' } }); - }}>Assert + }}> { copy(source.text); diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index 144ff5fdbc..e82e608554 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'; +export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText' | 'recording-inspecting' | 'standby' | 'assertingVisibility'; export type EventData = { event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'fileChanged';