From 1bb463163b4efef507a4b8d8feeb48e49e4a756e Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 19 Mar 2024 14:01:04 -0700 Subject: [PATCH] feat(codegen): add button to generate toHaveScreenshot statement (#29996) Fixes #29250. --- .../src/server/injected/highlight.css | 6 ++++ .../src/server/injected/recorder/recorder.ts | 25 ++++++++++++++-- .../src/server/recorder/csharp.ts | 2 ++ .../src/server/recorder/java.ts | 2 ++ .../src/server/recorder/javascript.ts | 2 ++ .../src/server/recorder/python.ts | 30 ++++++++++--------- .../src/server/recorder/recorderActions.ts | 7 ++++- tests/library/inspector/cli-codegen-3.spec.ts | 14 +++++++++ 8 files changed, 71 insertions(+), 17 deletions(-) diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 9ee0370720..4efa63de0a 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -231,6 +231,12 @@ x-pw-tool-item.value > x-div { mask-image: url("data:image/svg+xml;utf8,"); } +x-pw-tool-item.screenshot > x-div { + /* codicon: device-camera */ + -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/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 2074ffa67f..e85f91c44c 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -761,6 +761,7 @@ class Overlay { private _assertVisibilityToggle: HTMLElement; private _assertTextToggle: HTMLElement; private _assertValuesToggle: HTMLElement; + private _assertScreenshotButton: HTMLElement; private _offsetX = 0; private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined; private _measure: { width: number, height: number } = { width: 0, height: 0 }; @@ -807,6 +808,12 @@ class Overlay { this._assertValuesToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div')); toolsListElement.appendChild(this._assertValuesToggle); + this._assertScreenshotButton = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); + this._assertScreenshotButton.title = 'Assert screenshot'; + this._assertScreenshotButton.classList.add('screenshot'); + this._assertScreenshotButton.appendChild(this._recorder.injectedScript.document.createElement('x-div')); + toolsListElement.appendChild(this._assertScreenshotButton); + this._updateVisualPosition(); this._refreshListeners(); } @@ -845,6 +852,15 @@ class Overlay { if (!this._assertValuesToggle.classList.contains('disabled')) this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); }), + addEventListener(this._assertScreenshotButton, 'click', () => { + if (!this._assertScreenshotButton.classList.contains('disabled')) { + this._recorder.delegate.recordAction?.({ + name: 'assertScreenshot', + signals: [], + }); + this.flashToolSucceeded('assertScreenshot'); + } + }), ]; } @@ -867,6 +883,7 @@ class Overlay { 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'); + this._assertScreenshotButton.classList.toggle('disabled', state.mode !== 'recording'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; this._updateVisualPosition(); @@ -877,8 +894,12 @@ class Overlay { this._showOverlay(); } - flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') { - const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle; + flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue' | 'assertScreenshot') { + const element = { + 'assertingVisibility': this._assertVisibilityToggle, + 'assertingValue': this._assertValuesToggle, + 'assertScreenshot': this._assertScreenshotButton, + }[tool]; element.classList.add('succeeded'); setTimeout(() => element.classList.remove('succeeded'), 2000); } diff --git a/packages/playwright-core/src/server/recorder/csharp.ts b/packages/playwright-core/src/server/recorder/csharp.ts index 46fadc244a..2b42ae4e2c 100644 --- a/packages/playwright-core/src/server/recorder/csharp.ts +++ b/packages/playwright-core/src/server/recorder/csharp.ts @@ -164,6 +164,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmptyAsync()`; return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertScreenshot': + return `// AssertScreenshot(await ${subject}.ScreenshotAsync());`; } } diff --git a/packages/playwright-core/src/server/recorder/java.ts b/packages/playwright-core/src/server/recorder/java.ts index d4ebfdeea4..384584d272 100644 --- a/packages/playwright-core/src/server/recorder/java.ts +++ b/packages/playwright-core/src/server/recorder/java.ts @@ -152,6 +152,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`; return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`; } + case 'assertScreenshot': + return `// assertScreenshot(${subject}.screenshot());`; } } diff --git a/packages/playwright-core/src/server/recorder/javascript.ts b/packages/playwright-core/src/server/recorder/javascript.ts index 548e0f6071..e30c6c6e59 100644 --- a/packages/playwright-core/src/server/recorder/javascript.ts +++ b/packages/playwright-core/src/server/recorder/javascript.ts @@ -135,6 +135,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`; return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertScreenshot': + return `${this._isTest ? '' : '// '}await expect(${subject}).toHaveScreenshot();`; } } diff --git a/packages/playwright-core/src/server/recorder/python.ts b/packages/playwright-core/src/server/recorder/python.ts index b00e02178c..caf74dd57d 100644 --- a/packages/playwright-core/src/server/recorder/python.ts +++ b/packages/playwright-core/src/server/recorder/python.ts @@ -73,7 +73,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (signals.dialog) formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`); - let code = `${this._awaitPrefix}${this._generateActionCall(subject, action)}`; + let code = this._generateActionCall(subject, action); if (signals.popup) { code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as ${signals.popup.popupAlias}_info { @@ -99,7 +99,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { case 'openPage': throw Error('Not reached'); case 'closePage': - return `${subject}.close()`; + return `${this._awaitPrefix}${subject}.close()`; case 'click': { let method = 'click'; if (action.clickCount === 2) @@ -115,35 +115,37 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; const optionsString = formatOptions(options, false); - return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; } case 'check': - return `${subject}.${this._asLocator(action.selector)}.check()`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.check()`; case 'uncheck': - return `${subject}.${this._asLocator(action.selector)}.uncheck()`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.uncheck()`; case 'fill': - return `${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`; case 'setInputFiles': - return `${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return `${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`; } case 'navigate': - return `${subject}.goto(${quote(action.url)})`; + return `${this._awaitPrefix}${subject}.goto(${quote(action.url)})`; case 'select': - return `${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; case 'assertText': - return `expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`; + return `${this._awaitPrefix}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()'}`; + return `${this._awaitPrefix}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()`; + return `${this._awaitPrefix}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};`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${assertion}`; } + case 'assertScreenshot': + return `# assert_screenshot(${this._awaitPrefix}${subject}.screenshot())`; } } diff --git a/packages/playwright-core/src/server/recorder/recorderActions.ts b/packages/playwright-core/src/server/recorder/recorderActions.ts index 3c9720cbc4..ff8c168dbc 100644 --- a/packages/playwright-core/src/server/recorder/recorderActions.ts +++ b/packages/playwright-core/src/server/recorder/recorderActions.ts @@ -30,6 +30,7 @@ export type ActionName = 'assertText' | 'assertValue' | 'assertChecked' | + 'assertScreenshot' | 'assertVisible'; export type ActionBase = { @@ -119,7 +120,11 @@ export type AssertVisibleAction = ActionBase & { selector: string, }; -export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction; +export type AssertScreenshotAction = ActionBase & { + name: 'assertScreenshot', +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertScreenshotAction; export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction; // Signals. diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index 96a6295f13..4afa8dd51d 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -648,4 +648,18 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`); expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`); expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`); }); + + test('should assert screenshot', async ({ openRecorder }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(`
Hello, world
`); + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'toHaveScreenshot'), + recorder.page.click('x-pw-tool-item.screenshot'), + ]); + expect.soft(sources.get('JavaScript')!.text).toContain(`await expect(page).toHaveScreenshot()`); + expect.soft(sources.get('Python')!.text).toContain(`# assert_screenshot(page.screenshot())`); + expect.soft(sources.get('Python Async')!.text).toContain(`# assert_screenshot(await page.screenshot())`); + expect.soft(sources.get('Java')!.text).toContain(`// assertScreenshot(page.screenshot());`); + expect.soft(sources.get('C#')!.text).toContain(`// AssertScreenshot(await page.ScreenshotAsync())`); + }); });