From f82e09be044c78c04964162aeda3e03d82c1ed06 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 4 Feb 2022 19:27:45 -0800 Subject: [PATCH] feat(codegen): generate locators and frame locators (#11873) --- .../src/server/injected/highlight.ts | 7 +- .../src/server/injected/injectedScript.ts | 6 +- .../supplements/recorder/codeGenerator.ts | 21 +- .../src/server/supplements/recorder/csharp.ts | 51 ++-- .../src/server/supplements/recorder/java.ts | 51 ++-- .../server/supplements/recorder/javascript.ts | 47 ++-- .../src/server/supplements/recorder/python.ts | 43 +++- .../supplements/recorder/recorderActions.ts | 8 + .../src/server/supplements/recorder/utils.ts | 10 - .../server/supplements/recorderSupplement.ts | 91 +++++-- tests/inspector/cli-codegen-1.spec.ts | 144 +++++------ tests/inspector/cli-codegen-2.spec.ts | 178 ++++--------- tests/inspector/cli-codegen-3.spec.ts | 235 ++++++++++++++++++ tests/inspector/pause.spec.ts | 39 ++- 14 files changed, 611 insertions(+), 320 deletions(-) create mode 100644 tests/inspector/cli-codegen-3.spec.ts diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index 8ee30c5ebe..eaf3bb55fa 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -44,7 +44,7 @@ export class Highlight { this._innerGlassPaneElement.appendChild(this._tooltipElement); // Use a closed shadow root to prevent selectors matching our internal previews. - this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: this._isUnderTest ? 'open' : 'closed' }); + this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: 'closed' }); this._glassPaneShadow.appendChild(this._innerGlassPaneElement); this._glassPaneShadow.appendChild(this._actionPointElement); const styleElement = document.createElement('style'); @@ -105,6 +105,8 @@ export class Highlight { this._actionPointElement.style.top = y + 'px'; this._actionPointElement.style.left = x + 'px'; this._actionPointElement.hidden = false; + if (this._isUnderTest) + console.error('Action point for test: ' + JSON.stringify({ x, y })); // eslint-disable-line no-console } hideActionPoint() { @@ -162,6 +164,9 @@ export class Highlight { highlightElement.style.height = box.height + 'px'; highlightElement.style.display = 'block'; this._highlightElements.push(highlightElement); + + if (this._isUnderTest) + console.error('Highlight box for test: ' + JSON.stringify({ x: box.x, y: box.y, width: box.width, height: box.height })); // eslint-disable-line no-console } for (const highlightElement of pool) { diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 1068977387..715b7d38a2 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -124,6 +124,10 @@ export class InjectedScript { return result; } + generateSelector(targetElement: Element): string { + return generateSelector(this, targetElement, true).selector; + } + querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined { if (!(root as any)['querySelector']) throw this.createStacklessError('Node is not queryable.'); @@ -848,7 +852,7 @@ export class InjectedScript { strictModeViolationError(selector: ParsedSelector, matches: Element[]): Error { const infos = matches.slice(0, 10).map(m => ({ preview: this.previewNode(m), - selector: generateSelector(this, m, true).selector + selector: this.generateSelector(m), })); const lines = infos.map((info, i) => `\n ${i + 1}) ${info.preview} aka playwright.$("${info.selector}")`); if (infos.length < matches.length) diff --git a/packages/playwright-core/src/server/supplements/recorder/codeGenerator.ts b/packages/playwright-core/src/server/supplements/recorder/codeGenerator.ts index 99444c5b98..7a11fa48f2 100644 --- a/packages/playwright-core/src/server/supplements/recorder/codeGenerator.ts +++ b/packages/playwright-core/src/server/supplements/recorder/codeGenerator.ts @@ -18,14 +18,10 @@ import { EventEmitter } from 'events'; import type { BrowserContextOptions, LaunchOptions } from '../../../..'; import { Frame } from '../../frames'; import { LanguageGenerator, LanguageGeneratorOptions } from './language'; -import { Action, Signal } from './recorderActions'; -import { describeFrame } from './utils'; +import { Action, Signal, FrameDescription } from './recorderActions'; export type ActionInContext = { - pageAlias: string; - frameName?: string; - frameUrl: string; - isMainFrame: boolean; + frame: FrameDescription; action: Action; committed?: boolean; }; @@ -82,10 +78,10 @@ export class CodeGenerator extends EventEmitter { didPerformAction(actionInContext: ActionInContext) { if (!this._enabled) return; - const { action, pageAlias } = actionInContext; + const action = actionInContext.action; let eraseLastAction = false; - if (this._lastAction && this._lastAction.pageAlias === pageAlias) { - const { action: lastAction } = this._lastAction; + if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) { + const lastAction = this._lastAction.action; // We augment last action based on the type. if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') { if (action.selector === lastAction.selector) @@ -148,8 +144,11 @@ export class CodeGenerator extends EventEmitter { if (signal.name === 'navigation') { this.addAction({ - pageAlias, - ...describeFrame(frame), + frame: { + pageAlias, + isMainFrame: frame._page.mainFrame() === frame, + url: frame.url(), + }, committed: true, action: { name: 'navigate', diff --git a/packages/playwright-core/src/server/supplements/recorder/csharp.ts b/packages/playwright-core/src/server/supplements/recorder/csharp.ts index 58cbbbd547..4c7f21ff5e 100644 --- a/packages/playwright-core/src/server/supplements/recorder/csharp.ts +++ b/packages/playwright-core/src/server/supplements/recorder/csharp.ts @@ -28,7 +28,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator { highlighter = 'csharp'; generateAction(actionInContext: ActionInContext): string { - const { action, pageAlias } = actionInContext; + const action = actionInContext.action; + const pageAlias = actionInContext.frame.pageAlias; const formatter = new CSharpFormatter(8); formatter.newLine(); formatter.add('// ' + actionTitle(action)); @@ -40,10 +41,17 @@ export class CSharpLanguageGenerator implements LanguageGenerator { return formatter.format(); } - const subject = actionInContext.isMainFrame ? pageAlias : - (actionInContext.frameName ? - `${pageAlias}.Frame(${quote(actionInContext.frameName)})` : - `${pageAlias}.FrameByUrl(${quote(actionInContext.frameUrl)})`); + let subject: string; + if (actionInContext.frame.isMainFrame) { + subject = pageAlias; + } else if (actionInContext.frame.selectorsChain && action.name !== 'navigate') { + const locators = actionInContext.frame.selectorsChain.map(selector => '.' + asLocator(selector, 'FrameLocator')); + subject = `${pageAlias}${locators.join('')}`; + } else if (actionInContext.frame.name) { + subject = `${pageAlias}.Frame(${quote(actionInContext.frame.name)})`; + } else { + subject = `${pageAlias}.FrameByUrl(${quote(actionInContext.frame.url)})`; + } const signals = toSignalMap(action); @@ -58,12 +66,12 @@ export class CSharpLanguageGenerator implements LanguageGenerator { } const lines: string[] = []; - const actionCall = this._generateActionCall(action, actionInContext.isMainFrame); + const actionCall = this._generateActionCall(action, actionInContext.frame.isMainFrame); if (signals.waitForNavigation) { lines.push(`await ${pageAlias}.RunAndWaitForNavigationAsync(async () =>`); lines.push(`{`); lines.push(` await ${subject}.${actionCall};`); - lines.push(`}/*, new ${actionInContext.isMainFrame ? 'Page' : 'Frame'}WaitForNavigationOptions`); + lines.push(`}/*, new ${actionInContext.frame.isMainFrame ? 'Page' : 'Frame'}WaitForNavigationOptions`); lines.push(`{`); lines.push(` UrlString = ${quote(signals.waitForNavigation.url)}`); lines.push(`}*/);`); @@ -110,27 +118,27 @@ export class CSharpLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; if (!Object.entries(options).length) - return `${method}Async(${quote(action.selector)})`; - const optionsString = formatObject(options, ' ', (isPage ? 'Page' : 'Frame') + method + 'Options'); - return `${method}Async(${quote(action.selector)}, ${optionsString})`; + return asLocator(action.selector) + `.${method}Async()`; + const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); + return asLocator(action.selector) + `.${method}Async(${optionsString})`; } case 'check': - return `CheckAsync(${quote(action.selector)})`; + return asLocator(action.selector) + `.CheckAsync()`; case 'uncheck': - return `UncheckAsync(${quote(action.selector)})`; + return asLocator(action.selector) + `.UncheckAsync()`; case 'fill': - return `FillAsync(${quote(action.selector)}, ${quote(action.text)})`; + return asLocator(action.selector) + `.FillAsync(${quote(action.text)})`; case 'setInputFiles': - return `SetInputFilesAsync(${quote(action.selector)}, ${formatObject(action.files)})`; + return asLocator(action.selector) + `.SetInputFilesAsync(${formatObject(action.files)})`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return `PressAsync(${quote(action.selector)}, ${quote(shortcut)})`; + return asLocator(action.selector) + `.PressAsync(${quote(shortcut)})`; } case 'navigate': return `GotoAsync(${quote(action.url)})`; case 'select': - return `SelectOptionAsync(${quote(action.selector)}, ${formatObject(action.options)})`; + return asLocator(action.selector) + `.SelectOptionAsync(${formatObject(action.options)})`; } } @@ -270,4 +278,13 @@ class CSharpFormatter { function quote(text: string) { return escapeWithQuotes(text, '\"'); -} \ No newline at end of file +} + +function asLocator(selector: string, locatorFn = 'Locator') { + const match = selector.match(/(.*)\s+>>\s+nth=(\d+)$/); + if (!match) + return `${locatorFn}(${quote(selector)})`; + if (+match[2] === 0) + return `${locatorFn}(${quote(match[1])}).First`; + return `${locatorFn}(${quote(match[1])}).Nth(${match[2]})`; +} diff --git a/packages/playwright-core/src/server/supplements/recorder/java.ts b/packages/playwright-core/src/server/supplements/recorder/java.ts index 31e545976e..d70efc7f18 100644 --- a/packages/playwright-core/src/server/supplements/recorder/java.ts +++ b/packages/playwright-core/src/server/supplements/recorder/java.ts @@ -29,7 +29,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { highlighter = 'java'; generateAction(actionInContext: ActionInContext): string { - const { action, pageAlias } = actionInContext; + const action = actionInContext.action; + const pageAlias = actionInContext.frame.pageAlias; const formatter = new JavaScriptFormatter(6); formatter.newLine(); formatter.add('// ' + actionTitle(action)); @@ -41,10 +42,17 @@ export class JavaLanguageGenerator implements LanguageGenerator { return formatter.format(); } - const subject = actionInContext.isMainFrame ? pageAlias : - (actionInContext.frameName ? - `${pageAlias}.frame(${quote(actionInContext.frameName)})` : - `${pageAlias}.frameByUrl(${quote(actionInContext.frameUrl)})`); + let subject: string; + if (actionInContext.frame.isMainFrame) { + subject = pageAlias; + } else if (actionInContext.frame.selectorsChain && action.name !== 'navigate') { + const locators = actionInContext.frame.selectorsChain.map(selector => '.' + asLocator(selector, 'frameLocator')); + subject = `${pageAlias}${locators.join('')}`; + } else if (actionInContext.frame.name) { + subject = `${pageAlias}.frame(${quote(actionInContext.frame.name)})`; + } else { + subject = `${pageAlias}.frameByUrl(${quote(actionInContext.frame.url)})`; + } const signals = toSignalMap(action); @@ -55,7 +63,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { });`); } - const actionCall = this._generateActionCall(action, actionInContext.isMainFrame); + const actionCall = this._generateActionCall(action); let code = `${subject}.${actionCall};`; if (signals.popup) { @@ -85,7 +93,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { return formatter.format(); } - private _generateActionCall(action: Action, isPage: boolean): string { + private _generateActionCall(action: Action): string { switch (action.name) { case 'openPage': throw Error('Not reached'); @@ -105,26 +113,26 @@ export class JavaLanguageGenerator implements LanguageGenerator { options.clickCount = action.clickCount; if (action.position) options.position = action.position; - const optionsText = formatClickOptions(options, isPage); - return `${method}(${quote(action.selector)}${optionsText ? ', ' : ''}${optionsText})`; + const optionsText = formatClickOptions(options); + return asLocator(action.selector) + `.${method}(${optionsText})`; } case 'check': - return `check(${quote(action.selector)})`; + return asLocator(action.selector) + `.check()`; case 'uncheck': - return `uncheck(${quote(action.selector)})`; + return asLocator(action.selector) + `.uncheck()`; case 'fill': - return `fill(${quote(action.selector)}, ${quote(action.text)})`; + return asLocator(action.selector) + `.fill(${quote(action.text)})`; case 'setInputFiles': - return `setInputFiles(${quote(action.selector)}, ${formatPath(action.files.length === 1 ? action.files[0] : action.files)})`; + return asLocator(action.selector) + `.setInputFiles(${formatPath(action.files.length === 1 ? action.files[0] : action.files)})`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return `press(${quote(action.selector)}, ${quote(shortcut)})`; + return asLocator(action.selector) + `.press(${quote(shortcut)})`; } case 'navigate': return `navigate(${quote(action.url)})`; case 'select': - return `selectOption(${quote(action.selector)}, ${formatSelectOption(action.options.length > 1 ? action.options : action.options[0])})`; + return asLocator(action.selector) + `.selectOption(${formatSelectOption(action.options.length > 1 ? action.options : action.options[0])})`; } } @@ -217,7 +225,7 @@ function formatContextOptions(contextOptions: BrowserContextOptions, deviceName: return lines.join('\n'); } -function formatClickOptions(options: MouseClickOptions, isPage: boolean) { +function formatClickOptions(options: MouseClickOptions) { const lines = []; if (options.button) lines.push(` .setButton(MouseButton.${options.button.toUpperCase()})`); @@ -229,10 +237,19 @@ function formatClickOptions(options: MouseClickOptions, isPage: boolean) { lines.push(` .setPosition(${options.position.x}, ${options.position.y})`); if (!lines.length) return ''; - lines.unshift(`new ${isPage ? 'Page' : 'Frame'}.ClickOptions()`); + lines.unshift(`new Locator.ClickOptions()`); return lines.join('\n'); } function quote(text: string) { return escapeWithQuotes(text, '\"'); } + +function asLocator(selector: string, locatorFn = 'locator') { + const match = selector.match(/(.*)\s+>>\s+nth=(\d+)$/); + if (!match) + return `${locatorFn}(${quote(selector)})`; + if (+match[2] === 0) + return `${locatorFn}(${quote(match[1])}).first()`; + return `${locatorFn}(${quote(match[1])}).nth(${match[2]})`; +} diff --git a/packages/playwright-core/src/server/supplements/recorder/javascript.ts b/packages/playwright-core/src/server/supplements/recorder/javascript.ts index 4722180eb7..1dbffc3127 100644 --- a/packages/playwright-core/src/server/supplements/recorder/javascript.ts +++ b/packages/playwright-core/src/server/supplements/recorder/javascript.ts @@ -35,7 +35,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { } generateAction(actionInContext: ActionInContext): string { - const { action, pageAlias } = actionInContext; + const action = actionInContext.action; + const pageAlias = actionInContext.frame.pageAlias; const formatter = new JavaScriptFormatter(2); formatter.newLine(); formatter.add('// ' + actionTitle(action)); @@ -49,10 +50,17 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { return formatter.format(); } - const subject = actionInContext.isMainFrame ? pageAlias : - (actionInContext.frameName ? - `${pageAlias}.frame(${formatObject({ name: actionInContext.frameName })})` : - `${pageAlias}.frame(${formatObject({ url: actionInContext.frameUrl })})`); + let subject: string; + if (actionInContext.frame.isMainFrame) { + subject = pageAlias; + } else if (actionInContext.frame.selectorsChain && action.name !== 'navigate') { + const locators = actionInContext.frame.selectorsChain.map(selector => '.' + asLocator(selector, 'frameLocator')); + subject = `${pageAlias}${locators.join('')}`; + } else if (actionInContext.frame.name) { + subject = `${pageAlias}.frame(${formatObject({ name: actionInContext.frame.name })})`; + } else { + subject = `${pageAlias}.frame(${formatObject({ url: actionInContext.frame.url })})`; + } const signals = toSignalMap(action); @@ -123,26 +131,26 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { options.clickCount = action.clickCount; if (action.position) options.position = action.position; - const optionsString = formatOptions(options); - return `${method}(${quote(action.selector)}${optionsString})`; + const optionsString = formatOptions(options, false); + return asLocator(action.selector) + `.${method}(${optionsString})`; } case 'check': - return `check(${quote(action.selector)})`; + return asLocator(action.selector) + `.check()`; case 'uncheck': - return `uncheck(${quote(action.selector)})`; + return asLocator(action.selector) + `.uncheck()`; case 'fill': - return `fill(${quote(action.selector)}, ${quote(action.text)})`; + return asLocator(action.selector) + `.fill(${quote(action.text)})`; case 'setInputFiles': - return `setInputFiles(${quote(action.selector)}, ${formatObject(action.files.length === 1 ? action.files[0] : action.files)})`; + return asLocator(action.selector) + `.setInputFiles(${formatObject(action.files.length === 1 ? action.files[0] : action.files)})`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return `press(${quote(action.selector)}, ${quote(shortcut)})`; + return asLocator(action.selector) + `.press(${quote(shortcut)})`; } case 'navigate': return `goto(${quote(action.url)})`; case 'select': - return `selectOption(${quote(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})`; + return asLocator(action.selector) + `.selectOption(${formatObject(action.options.length > 1 ? action.options : action.options[0])})`; } } @@ -192,11 +200,20 @@ ${useText ? '\ntest.use(' + useText + ');\n' : ''} } } -function formatOptions(value: any): string { +function asLocator(selector: string, locatorFn = 'locator') { + const match = selector.match(/(.*)\s+>>\s+nth=(\d+)$/); + if (!match) + return `${locatorFn}(${quote(selector)})`; + if (+match[2] === 0) + return `${locatorFn}(${quote(match[1])}).first()`; + return `${locatorFn}(${quote(match[1])}).nth(${match[2]})`; +} + +function formatOptions(value: any, hasArguments: boolean): string { const keys = Object.keys(value); if (!keys.length) return ''; - return ', ' + formatObject(value); + return (hasArguments ? ', ' : '') + formatObject(value); } function formatObject(value: any, indent = ' '): string { diff --git a/packages/playwright-core/src/server/supplements/recorder/python.ts b/packages/playwright-core/src/server/supplements/recorder/python.ts index 1247657b85..d0b6bee6eb 100644 --- a/packages/playwright-core/src/server/supplements/recorder/python.ts +++ b/packages/playwright-core/src/server/supplements/recorder/python.ts @@ -40,7 +40,8 @@ export class PythonLanguageGenerator implements LanguageGenerator { } generateAction(actionInContext: ActionInContext): string { - const { action, pageAlias } = actionInContext; + const action = actionInContext.action; + const pageAlias = actionInContext.frame.pageAlias; const formatter = new PythonFormatter(4); formatter.newLine(); formatter.add('# ' + actionTitle(action)); @@ -52,10 +53,17 @@ export class PythonLanguageGenerator implements LanguageGenerator { return formatter.format(); } - const subject = actionInContext.isMainFrame ? pageAlias : - (actionInContext.frameName ? - `${pageAlias}.frame(${formatOptions({ name: actionInContext.frameName }, false)})` : - `${pageAlias}.frame(${formatOptions({ url: actionInContext.frameUrl }, false)})`); + let subject: string; + if (actionInContext.frame.isMainFrame) { + subject = pageAlias; + } else if (actionInContext.frame.selectorsChain && action.name !== 'navigate') { + const locators = actionInContext.frame.selectorsChain.map(selector => '.' + asLocator(selector, 'frame_locator')); + subject = `${pageAlias}${locators.join('')}`; + } else if (actionInContext.frame.name) { + subject = `${pageAlias}.frame(${formatOptions({ name: actionInContext.frame.name }, false)})`; + } else { + subject = `${pageAlias}.frame(${formatOptions({ url: actionInContext.frame.url }, false)})`; + } const signals = toSignalMap(action); @@ -114,26 +122,26 @@ export class PythonLanguageGenerator implements LanguageGenerator { options.clickCount = action.clickCount; if (action.position) options.position = action.position; - const optionsString = formatOptions(options, true); - return `${method}(${quote(action.selector)}${optionsString})`; + const optionsString = formatOptions(options, false); + return asLocator(action.selector) + `.${method}(${optionsString})`; } case 'check': - return `check(${quote(action.selector)})`; + return asLocator(action.selector) + `.check()`; case 'uncheck': - return `uncheck(${quote(action.selector)})`; + return asLocator(action.selector) + `.uncheck()`; case 'fill': - return `fill(${quote(action.selector)}, ${quote(action.text)})`; + return asLocator(action.selector) + `.fill(${quote(action.text)})`; case 'setInputFiles': - return `set_input_files(${quote(action.selector)}, ${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; + return 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 `press(${quote(action.selector)}, ${quote(shortcut)})`; + return asLocator(action.selector) + `.press(${quote(shortcut)})`; } case 'navigate': return `goto(${quote(action.url)})`; case 'select': - return `select_option(${quote(action.selector)}, ${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; + return asLocator(action.selector) + `.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; } } @@ -272,3 +280,12 @@ class PythonFormatter { function quote(text: string) { return escapeWithQuotes(text, '\"'); } + +function asLocator(selector: string, locatorFn = 'locator') { + const match = selector.match(/(.*)\s+>>\s+nth=(\d+)$/); + if (!match) + return `${locatorFn}(${quote(selector)})`; + if (+match[2] === 0) + return `${locatorFn}(${quote(match[1])}).first`; + return `${locatorFn}(${quote(match[1])}).nth(${match[2]})`; +} diff --git a/packages/playwright-core/src/server/supplements/recorder/recorderActions.ts b/packages/playwright-core/src/server/supplements/recorder/recorderActions.ts index 66ddac6c91..b525360aff 100644 --- a/packages/playwright-core/src/server/supplements/recorder/recorderActions.ts +++ b/packages/playwright-core/src/server/supplements/recorder/recorderActions.ts @@ -121,6 +121,14 @@ export type DialogSignal = BaseSignal & { export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal; +export type FrameDescription = { + pageAlias: string; + isMainFrame: boolean; + url: string; + name?: string; + selectorsChain?: string[]; +}; + export function actionTitle(action: Action): string { switch (action.name) { case 'openPage': diff --git a/packages/playwright-core/src/server/supplements/recorder/utils.ts b/packages/playwright-core/src/server/supplements/recorder/utils.ts index 4dea686ee7..8ee92aa453 100644 --- a/packages/playwright-core/src/server/supplements/recorder/utils.ts +++ b/packages/playwright-core/src/server/supplements/recorder/utils.ts @@ -48,13 +48,3 @@ export function toModifiers(modifiers: number): ('Alt' | 'Control' | 'Meta' | 'S result.push('Shift'); return result; } - -export function describeFrame(frame: Frame): { frameName?: string, frameUrl: string, isMainFrame: boolean } { - const page = frame._page; - if (page.mainFrame() === frame) - return { isMainFrame: true, frameUrl: frame.url() }; - const frames = page.frames().filter(f => f.name() === frame.name()); - if (frames.length === 1 && frames[0] === frame) - return { isMainFrame: false, frameUrl: frame.url(), frameName: frame.name() }; - return { isMainFrame: false, frameUrl: frame.url() }; -} diff --git a/packages/playwright-core/src/server/supplements/recorderSupplement.ts b/packages/playwright-core/src/server/supplements/recorderSupplement.ts index 3ea80780af..e250c19bac 100644 --- a/packages/playwright-core/src/server/supplements/recorderSupplement.ts +++ b/packages/playwright-core/src/server/supplements/recorderSupplement.ts @@ -18,7 +18,7 @@ import * as fs from 'fs'; import * as actions from './recorder/recorderActions'; import type * as channels from '../../protocol/channels'; import { CodeGenerator, ActionInContext } from './recorder/codeGenerator'; -import { describeFrame, toClickOptions, toModifiers } from './recorder/utils'; +import { toClickOptions, toModifiers } from './recorder/utils'; import { Page } from '../page'; import { Frame } from '../frames'; import { BrowserContext } from '../browserContext'; @@ -36,6 +36,7 @@ import { createGuid, isUnderTest, monotonicTime } from '../../utils/utils'; import { metadataToCallLog } from './recorder/recorderUtils'; import { Debugger } from './debugger'; import { EventEmitter } from 'events'; +import { raceAgainstTimeout } from '../../utils/async'; type BindingSource = { frame: Frame, page: Page }; @@ -380,16 +381,15 @@ class ContextRecorder extends EventEmitter { // First page is called page, others are called popup1, popup2, etc. const frame = page.mainFrame(); page.on('close', () => { - this._pageAliases.delete(page); this._generator.addAction({ - pageAlias, - ...describeFrame(page.mainFrame()), + frame: this._describeMainFrame(page), committed: true, action: { name: 'closePage', signals: [], } }); + this._pageAliases.delete(page); }); frame.on(Frame.Events.Navigation, () => this._onFrameNavigated(frame, page)); page.on(Page.Events.Download, () => this._onDownload(page)); @@ -402,8 +402,7 @@ class ContextRecorder extends EventEmitter { this._onPopup(page.opener()!, page); } else { this._generator.addAction({ - pageAlias, - ...describeFrame(page.mainFrame()), + frame: this._describeMainFrame(page), committed: true, action: { name: 'openPage', @@ -422,14 +421,69 @@ class ContextRecorder extends EventEmitter { } } + private _describeMainFrame(page: Page): actions.FrameDescription { + return { + pageAlias: this._pageAliases.get(page)!, + isMainFrame: true, + url: page.mainFrame().url(), + }; + } + + private async _describeFrame(frame: Frame): Promise { + const page = frame._page; + const pageAlias = this._pageAliases.get(page)!; + const chain: Frame[] = []; + for (let ancestor: Frame | null = frame; ancestor; ancestor = ancestor.parentFrame()) + chain.push(ancestor); + chain.reverse(); + + if (chain.length === 1) + return this._describeMainFrame(page); + + const hasUniqueName = page.frames().filter(f => f.name() === frame.name()).length === 1; + const fallback: actions.FrameDescription = { + pageAlias, + isMainFrame: false, + url: frame.url(), + name: frame.name() && hasUniqueName ? frame.name() : undefined, + }; + if (chain.length > 3) + return fallback; + + const selectorPromises: Promise[] = []; + for (let i = 0; i < chain.length - 1; i++) + selectorPromises.push(this._findFrameSelector(chain[i + 1], chain[i])); + + const result = await raceAgainstTimeout(() => Promise.all(selectorPromises), 2000); + if (!result.timedOut && result.result.every(selector => !!selector)) { + return { + ...fallback, + selectorsChain: result.result as string[], + }; + } + return fallback; + } + + private async _findFrameSelector(frame: Frame, parent: Frame): Promise { + try { + const frameElement = await frame.frameElement(); + if (!frameElement) + return; + const utility = await parent._utilityContext(); + const injected = await utility.injectedScript(); + const selector = await injected.evaluate((injected, element) => injected.generateSelector(element as Element), frameElement); + return selector; + } catch (e) { + } + } + private async _performAction(frame: Frame, action: actions.Action) { // Commit last action so that no further signals are added to it. this._generator.commitLastAction(); - const page = frame._page; + const frameDescription = await this._describeFrame(frame); const actionInContext: ActionInContext = { - pageAlias: this._pageAliases.get(page)!, - ...describeFrame(frame), + frame: frameDescription, action }; @@ -476,20 +530,20 @@ class ContextRecorder extends EventEmitter { const kActionTimeout = 5000; if (action.name === 'click') { const { options } = toClickOptions(action); - await perform('click', { selector: action.selector }, callMetadata => frame.click(callMetadata, action.selector, { ...options, timeout: kActionTimeout })); + await perform('click', { selector: action.selector }, callMetadata => frame.click(callMetadata, action.selector, { ...options, timeout: kActionTimeout, strict: true })); } if (action.name === 'press') { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - await perform('press', { selector: action.selector, key: shortcut }, callMetadata => frame.press(callMetadata, action.selector, shortcut, { timeout: kActionTimeout })); + await perform('press', { selector: action.selector, key: shortcut }, callMetadata => frame.press(callMetadata, action.selector, shortcut, { timeout: kActionTimeout, strict: true })); } if (action.name === 'check') - await perform('check', { selector: action.selector }, callMetadata => frame.check(callMetadata, action.selector, { timeout: kActionTimeout })); + await perform('check', { selector: action.selector }, callMetadata => frame.check(callMetadata, action.selector, { timeout: kActionTimeout, strict: true })); if (action.name === 'uncheck') - await perform('uncheck', { selector: action.selector }, callMetadata => frame.uncheck(callMetadata, action.selector, { timeout: kActionTimeout })); + await perform('uncheck', { selector: action.selector }, callMetadata => frame.uncheck(callMetadata, action.selector, { timeout: kActionTimeout, strict: true })); if (action.name === 'select') { const values = action.options.map(value => ({ value })); - await perform('selectOption', { selector: action.selector, values }, callMetadata => frame.selectOption(callMetadata, action.selector, [], values, { timeout: kActionTimeout })); + await perform('selectOption', { selector: action.selector, values }, callMetadata => frame.selectOption(callMetadata, action.selector, [], values, { timeout: kActionTimeout, strict: true })); } } @@ -497,11 +551,12 @@ class ContextRecorder extends EventEmitter { // Commit last action so that no further signals are added to it. this._generator.commitLastAction(); - this._generator.addAction({ - pageAlias: this._pageAliases.get(frame._page)!, - ...describeFrame(frame), + const frameDescription = await this._describeFrame(frame); + const actionInContext: ActionInContext = { + frame: frameDescription, action - }); + }; + this._generator.addAction(actionInContext); } private _onFrameNavigated(frame: Frame, page: Page) { diff --git a/tests/inspector/cli-codegen-1.spec.ts b/tests/inspector/cli-codegen-1.spec.ts index ed27f426a5..0dca8e0324 100644 --- a/tests/inspector/cli-codegen-1.spec.ts +++ b/tests/inspector/cli-codegen-1.spec.ts @@ -36,23 +36,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Click text=Submit - await page.click('text=Submit');`); + await page.locator('text=Submit').click();`); expect(sources.get('Python').text).toContain(` # Click text=Submit - page.click("text=Submit")`); + page.locator("text=Submit").click()`); expect(sources.get('Python Async').text).toContain(` # Click text=Submit - await page.click("text=Submit")`); + await page.locator("text=Submit").click()`); expect(sources.get('Java').text).toContain(` // Click text=Submit - page.click("text=Submit");`); + page.locator("text=Submit").click();`); expect(sources.get('C#').text).toContain(` // Click text=Submit - await page.ClickAsync("text=Submit");`); + await page.Locator("text=Submit").ClickAsync();`); expect(message.text()).toBe('click'); }); @@ -84,7 +84,7 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Click text=Submit - await page.click('text=Submit');`); + await page.locator('text=Submit').click();`); expect(message.text()).toBe('click'); }); @@ -115,7 +115,7 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Click canvas - await page.click('canvas', { + await page.locator('canvas').click({ position: { x: 250, y: 250 @@ -124,20 +124,20 @@ test.describe('cli codegen', () => { expect(sources.get('Python').text).toContain(` # Click canvas - page.click("canvas", position={"x":250,"y":250})`); + page.locator("canvas").click(position={"x":250,"y":250})`); expect(sources.get('Python Async').text).toContain(` # Click canvas - await page.click("canvas", position={"x":250,"y":250})`); + await page.locator("canvas").click(position={"x":250,"y":250})`); expect(sources.get('Java').text).toContain(` // Click canvas - page.click("canvas", new Page.ClickOptions() + page.locator("canvas").click(new Locator.ClickOptions() .setPosition(250, 250));`); expect(sources.get('C#').text).toContain(` // Click canvas - await page.ClickAsync("canvas", new PageClickOptions + await page.Locator("canvas").ClickAsync(new LocatorClickOptions { Position = new Position { @@ -170,23 +170,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Click text=Submit - await page.click('text=Submit');`); + await page.locator('text=Submit').click();`); expect(sources.get('Python').text).toContain(` # Click text=Submit - page.click("text=Submit")`); + page.locator("text=Submit").click()`); expect(sources.get('Python Async').text).toContain(` # Click text=Submit - await page.click("text=Submit")`); + await page.locator("text=Submit").click()`); expect(sources.get('Java').text).toContain(` // Click text=Submit - page.click("text=Submit");`); + page.locator("text=Submit").click();`); expect(sources.get('C#').text).toContain(` // Click text=Submit - await page.ClickAsync("text=Submit");`); + await page.Locator("text=Submit").ClickAsync();`); expect(message.text()).toBe('click'); }); @@ -221,7 +221,7 @@ test.describe('cli codegen', () => { ]); expect(sources.get('JavaScript').text).toContain(` // Click text=Some long text here - await page.click('text=Some long text here');`); + await page.locator('text=Some long text here').click();`); expect(message.text()).toBe('click'); }); @@ -240,22 +240,22 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Fill input[name="name"] - await page.fill('input[name="name"]', 'John');`); + await page.locator('input[name="name"]').fill('John');`); expect(sources.get('Java').text).toContain(` // Fill input[name="name"] - page.fill("input[name=\\\"name\\\"]", "John");`); + page.locator("input[name=\\\"name\\\"]").fill("John");`); expect(sources.get('Python').text).toContain(` # Fill input[name="name"] - page.fill(\"input[name=\\\"name\\\"]\", \"John\")`); + page.locator(\"input[name=\\\"name\\\"]\").fill(\"John\")`); expect(sources.get('Python Async').text).toContain(` # Fill input[name="name"] - await page.fill(\"input[name=\\\"name\\\"]\", \"John\")`); + await page.locator(\"input[name=\\\"name\\\"]\").fill(\"John\")`); expect(sources.get('C#').text).toContain(` // Fill input[name="name"] - await page.FillAsync(\"input[name=\\\"name\\\"]\", \"John\");`); + await page.Locator(\"input[name=\\\"name\\\"]\").FillAsync(\"John\");`); expect(message.text()).toBe('John'); }); @@ -274,7 +274,7 @@ test.describe('cli codegen', () => { ]); expect(sources.get('JavaScript').text).toContain(` // Fill textarea[name="name"] - await page.fill('textarea[name="name"]', 'John');`); + await page.locator('textarea[name="name"]').fill('John');`); expect(message.text()).toBe('John'); }); @@ -296,23 +296,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Press Enter with modifiers - await page.press('input[name="name"]', 'Shift+Enter');`); + await page.locator('input[name="name"]').press('Shift+Enter');`); expect(sources.get('Java').text).toContain(` // Press Enter with modifiers - page.press("input[name=\\\"name\\\"]", "Shift+Enter");`); + page.locator("input[name=\\\"name\\\"]").press("Shift+Enter");`); expect(sources.get('Python').text).toContain(` # Press Enter with modifiers - page.press(\"input[name=\\\"name\\\"]\", \"Shift+Enter\")`); + page.locator(\"input[name=\\\"name\\\"]\").press(\"Shift+Enter\")`); expect(sources.get('Python Async').text).toContain(` # Press Enter with modifiers - await page.press(\"input[name=\\\"name\\\"]\", \"Shift+Enter\")`); + await page.locator(\"input[name=\\\"name\\\"]\").press(\"Shift+Enter\")`); expect(sources.get('C#').text).toContain(` // Press Enter with modifiers - await page.PressAsync(\"input[name=\\\"name\\\"]\", \"Shift+Enter\");`); + await page.Locator(\"input[name=\\\"name\\\"]\").PressAsync(\"Shift+Enter\");`); expect(messages[0].text()).toBe('press'); }); @@ -338,15 +338,15 @@ test.describe('cli codegen', () => { const text = recorder.sources().get('JavaScript').text; expect(text).toContain(` // Fill input[name="one"] - await page.fill('input[name="one"]', 'foobar123');`); + await page.locator('input[name="one"]').fill('foobar123');`); expect(text).toContain(` // Press Tab - await page.press('input[name="one"]', 'Tab');`); + await page.locator('input[name="one"]').press('Tab');`); expect(text).toContain(` // Fill input[name="two"] - await page.fill('input[name="two"]', 'barfoo321');`); + await page.locator('input[name="two"]').fill('barfoo321');`); }); test('should record ArrowDown', async ({ page, openRecorder }) => { @@ -368,7 +368,7 @@ test.describe('cli codegen', () => { ]); expect(sources.get('JavaScript').text).toContain(` // Press ArrowDown - await page.press('input[name="name"]', 'ArrowDown');`); + await page.locator('input[name="name"]').press('ArrowDown');`); expect(messages[0].text()).toBe('press:ArrowDown'); }); @@ -392,7 +392,7 @@ test.describe('cli codegen', () => { ]); expect(sources.get('JavaScript').text).toContain(` // Press ArrowDown - await page.press('input[name="name"]', 'ArrowDown');`); + await page.locator('input[name="name"]').press('ArrowDown');`); expect(messages.length).toBe(2); expect(messages[0].text()).toBe('down:ArrowDown'); expect(messages[1].text()).toBe('up:ArrowDown'); @@ -414,23 +414,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Check input[name="accept"] - await page.check('input[name="accept"]');`); + await page.locator('input[name="accept"]').check();`); expect(sources.get('Java').text).toContain(` // Check input[name="accept"] - page.check("input[name=\\\"accept\\\"]");`); + page.locator("input[name=\\\"accept\\\"]").check();`); expect(sources.get('Python').text).toContain(` # Check input[name="accept"] - page.check(\"input[name=\\\"accept\\\"]\")`); + page.locator(\"input[name=\\\"accept\\\"]\").check()`); expect(sources.get('Python Async').text).toContain(` # Check input[name="accept"] - await page.check(\"input[name=\\\"accept\\\"]\")`); + await page.locator(\"input[name=\\\"accept\\\"]\").check()`); expect(sources.get('C#').text).toContain(` // Check input[name="accept"] - await page.CheckAsync(\"input[name=\\\"accept\\\"]\");`); + await page.Locator(\"input[name=\\\"accept\\\"]\").CheckAsync();`); expect(message.text()).toBe('true'); }); @@ -451,7 +451,7 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Check input[name="accept"] - await page.check('input[name="accept"]');`); + await page.locator('input[name="accept"]').check();`); expect(message.text()).toBe('true'); }); @@ -471,23 +471,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Uncheck input[name="accept"] - await page.uncheck('input[name="accept"]');`); + await page.locator('input[name="accept"]').uncheck();`); expect(sources.get('Java').text).toContain(` // Uncheck input[name="accept"] - page.uncheck("input[name=\\\"accept\\\"]");`); + page.locator("input[name=\\\"accept\\\"]").uncheck();`); expect(sources.get('Python').text).toContain(` # Uncheck input[name="accept"] - page.uncheck(\"input[name=\\\"accept\\\"]\")`); + page.locator(\"input[name=\\\"accept\\\"]\").uncheck()`); expect(sources.get('Python Async').text).toContain(` # Uncheck input[name="accept"] - await page.uncheck(\"input[name=\\\"accept\\\"]\")`); + await page.locator(\"input[name=\\\"accept\\\"]\").uncheck()`); expect(sources.get('C#').text).toContain(` // Uncheck input[name="accept"] - await page.UncheckAsync(\"input[name=\\\"accept\\\"]\");`); + await page.Locator(\"input[name=\\\"accept\\\"]\").UncheckAsync();`); expect(message.text()).toBe('false'); }); @@ -508,23 +508,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Select 2 - await page.selectOption('select', '2');`); + await page.locator('select').selectOption('2');`); expect(sources.get('Java').text).toContain(` // Select 2 - page.selectOption("select", "2");`); + page.locator("select").selectOption("2");`); expect(sources.get('Python').text).toContain(` # Select 2 - page.select_option(\"select\", \"2\")`); + page.locator(\"select\").select_option(\"2\")`); expect(sources.get('Python Async').text).toContain(` # Select 2 - await page.select_option(\"select\", \"2\")`); + await page.locator(\"select\").select_option(\"2\")`); expect(sources.get('C#').text).toContain(` // Select 2 - await page.SelectOptionAsync(\"select\", new[] { \"2\" });`); + await page.Locator(\"select\").SelectOptionAsync(new[] { \"2\" });`); expect(message.text()).toBe('2'); }); @@ -548,31 +548,31 @@ test.describe('cli codegen', () => { // Click text=link const [page1] = await Promise.all([ page.waitForEvent('popup'), - page.click('text=link') + page.locator('text=link').click() ]);`); expect(sources.get('Java').text).toContain(` // Click text=link Page page1 = page.waitForPopup(() -> { - page.click("text=link"); + page.locator("text=link").click(); });`); expect(sources.get('Python').text).toContain(` # Click text=link with page.expect_popup() as popup_info: - page.click(\"text=link\") + page.locator(\"text=link\").click() page1 = popup_info.value`); expect(sources.get('Python Async').text).toContain(` # Click text=link async with page.expect_popup() as popup_info: - await page.click(\"text=link\") + await page.locator(\"text=link\").click() page1 = await popup_info.value`); expect(sources.get('C#').text).toContain(` var page1 = await page.RunAndWaitForPopupAsync(async () => { - await page.ClickAsync(\"text=link\"); + await page.Locator(\"text=link\").ClickAsync(); });`); expect(popup.url()).toBe('about:blank'); @@ -593,32 +593,32 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Click text=link - await page.click('text=link'); + await page.locator('text=link').click(); // assert.equal(page.url(), 'about:blank#foo');`); expect(sources.get('Playwright Test').text).toContain(` // Click text=link - await page.click('text=link'); + await page.locator('text=link').click(); await expect(page).toHaveURL('about:blank#foo');`); expect(sources.get('Java').text).toContain(` // Click text=link - page.click("text=link"); + page.locator("text=link").click(); // assert page.url().equals("about:blank#foo");`); expect(sources.get('Python').text).toContain(` # Click text=link - page.click(\"text=link\") + page.locator(\"text=link\").click() # assert page.url == \"about:blank#foo\"`); expect(sources.get('Python Async').text).toContain(` # Click text=link - await page.click(\"text=link\") + await page.locator(\"text=link\").click() # assert page.url == \"about:blank#foo\"`); expect(sources.get('C#').text).toContain(` // Click text=link - await page.ClickAsync(\"text=link\"); + await page.Locator(\"text=link\").ClickAsync(); // Assert.AreEqual(\"about:blank#foo\", page.Url);`); expect(page.url()).toContain('about:blank#foo'); @@ -643,33 +643,33 @@ test.describe('cli codegen', () => { // Click text=link await Promise.all([ page.waitForNavigation(/*{ url: 'about:blank#foo' }*/), - page.click('text=link') + page.locator('text=link').click() ]);`); expect(sources.get('Java').text).toContain(` // Click text=link // page.waitForNavigation(new Page.WaitForNavigationOptions().setUrl("about:blank#foo"), () -> page.waitForNavigation(() -> { - page.click("text=link"); + page.locator("text=link").click(); });`); expect(sources.get('Python').text).toContain(` # Click text=link # with page.expect_navigation(url=\"about:blank#foo\"): with page.expect_navigation(): - page.click(\"text=link\")`); + page.locator(\"text=link\").click()`); expect(sources.get('Python Async').text).toContain(` # Click text=link # async with page.expect_navigation(url=\"about:blank#foo\"): async with page.expect_navigation(): - await page.click(\"text=link\")`); + await page.locator(\"text=link\").click()`); expect(sources.get('C#').text).toContain(` // Click text=link await page.RunAndWaitForNavigationAsync(async () => { - await page.ClickAsync(\"text=link\"); + await page.Locator(\"text=link\").ClickAsync(); }/*, new PageWaitForNavigationOptions { UrlString = \"about:blank#foo\" @@ -688,8 +688,8 @@ test.describe('cli codegen', () => { await recorder.page.keyboard.insertText('@'); await recorder.page.keyboard.type('example.com'); await recorder.waitForOutput('JavaScript', 'example.com'); - expect(recorder.sources().get('JavaScript').text).not.toContain(`await page.press('input', 'AltGraph');`); - expect(recorder.sources().get('JavaScript').text).toContain(`await page.fill('input', 'playwright@example.com');`); + expect(recorder.sources().get('JavaScript').text).not.toContain(`await page.locator('input').press('AltGraph');`); + expect(recorder.sources().get('JavaScript').text).toContain(`await page.locator('input').fill('playwright@example.com');`); }); test('should middle click', async ({ page, openRecorder, server }) => { @@ -703,22 +703,22 @@ test.describe('cli codegen', () => { ]); expect(sources.get('JavaScript').text).toContain(` - await page.click('text=Click me', { + await page.locator('text=Click me').click({ button: 'middle' });`); expect(sources.get('Python').text).toContain(` - page.click("text=Click me", button="middle")`); + page.locator("text=Click me").click(button="middle")`); expect(sources.get('Python Async').text).toContain(` - await page.click("text=Click me", button="middle")`); + await page.locator("text=Click me").click(button="middle")`); expect(sources.get('Java').text).toContain(` - page.click("text=Click me", new Page.ClickOptions() + page.locator("text=Click me").click(new Locator.ClickOptions() .setButton(MouseButton.MIDDLE));`); expect(sources.get('C#').text).toContain(` - await page.ClickAsync("text=Click me", new PageClickOptions + await page.Locator("text=Click me").ClickAsync(new LocatorClickOptions { Button = MouseButton.Middle, });`); diff --git a/tests/inspector/cli-codegen-2.spec.ts b/tests/inspector/cli-codegen-2.spec.ts index c59b2a126f..a3ef05c898 100644 --- a/tests/inspector/cli-codegen-2.spec.ts +++ b/tests/inspector/cli-codegen-2.spec.ts @@ -133,23 +133,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Upload file-to-upload.txt - await page.setInputFiles('input[type="file"]', 'file-to-upload.txt');`); + await page.locator('input[type="file"]').setInputFiles('file-to-upload.txt');`); expect(sources.get('Java').text).toContain(` // Upload file-to-upload.txt - page.setInputFiles("input[type=\\\"file\\\"]", Paths.get("file-to-upload.txt"));`); + page.locator("input[type=\\\"file\\\"]").setInputFiles(Paths.get("file-to-upload.txt"));`); expect(sources.get('Python').text).toContain(` # Upload file-to-upload.txt - page.set_input_files(\"input[type=\\\"file\\\"]\", \"file-to-upload.txt\")`); + page.locator(\"input[type=\\\"file\\\"]\").set_input_files(\"file-to-upload.txt\")`); expect(sources.get('Python Async').text).toContain(` # Upload file-to-upload.txt - await page.set_input_files(\"input[type=\\\"file\\\"]\", \"file-to-upload.txt\")`); + await page.locator(\"input[type=\\\"file\\\"]\").set_input_files(\"file-to-upload.txt\")`); expect(sources.get('C#').text).toContain(` // Upload file-to-upload.txt - await page.SetInputFilesAsync(\"input[type=\\\"file\\\"]\", new[] { \"file-to-upload.txt\" });`); + await page.Locator(\"input[type=\\\"file\\\"]\").SetInputFilesAsync(new[] { \"file-to-upload.txt\" });`); }); test('should upload multiple files', async ({ page, openRecorder, browserName, asset }) => { @@ -170,23 +170,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Upload file-to-upload.txt, file-to-upload-2.txt - await page.setInputFiles('input[type=\"file\"]', ['file-to-upload.txt', 'file-to-upload-2.txt']);`); + await page.locator('input[type=\"file\"]').setInputFiles(['file-to-upload.txt', 'file-to-upload-2.txt']);`); expect(sources.get('Java').text).toContain(` // Upload file-to-upload.txt, file-to-upload-2.txt - page.setInputFiles("input[type=\\\"file\\\"]", new Path[] {Paths.get("file-to-upload.txt"), Paths.get("file-to-upload-2.txt")});`); + page.locator("input[type=\\\"file\\\"]").setInputFiles(new Path[] {Paths.get("file-to-upload.txt"), Paths.get("file-to-upload-2.txt")});`); expect(sources.get('Python').text).toContain(` # Upload file-to-upload.txt, file-to-upload-2.txt - page.set_input_files(\"input[type=\\\"file\\\"]\", [\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`); + page.locator(\"input[type=\\\"file\\\"]\").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`); expect(sources.get('Python Async').text).toContain(` # Upload file-to-upload.txt, file-to-upload-2.txt - await page.set_input_files(\"input[type=\\\"file\\\"]\", [\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`); + await page.locator(\"input[type=\\\"file\\\"]\").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`); expect(sources.get('C#').text).toContain(` // Upload file-to-upload.txt, file-to-upload-2.txt - await page.SetInputFilesAsync(\"input[type=\\\"file\\\"]\", new[] { \"file-to-upload.txt\", \"file-to-upload-2.txt\" });`); + await page.Locator(\"input[type=\\\"file\\\"]\").SetInputFilesAsync(new[] { \"file-to-upload.txt\", \"file-to-upload-2.txt\" });`); }); test('should clear files', async ({ page, openRecorder, browserName, asset }) => { @@ -207,23 +207,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Clear selected files - await page.setInputFiles('input[type=\"file\"]', []);`); + await page.locator('input[type=\"file\"]').setInputFiles([]);`); expect(sources.get('Java').text).toContain(` // Clear selected files - page.setInputFiles("input[type=\\\"file\\\"]", new Path[0]);`); + page.locator("input[type=\\\"file\\\"]").setInputFiles(new Path[0]);`); expect(sources.get('Python').text).toContain(` # Clear selected files - page.set_input_files(\"input[type=\\\"file\\\"]\", []`); + page.locator(\"input[type=\\\"file\\\"]\").set_input_files([])`); expect(sources.get('Python Async').text).toContain(` # Clear selected files - await page.set_input_files(\"input[type=\\\"file\\\"]\", []`); + await page.locator(\"input[type=\\\"file\\\"]\").set_input_files([])`); expect(sources.get('C#').text).toContain(` // Clear selected files - await page.SetInputFilesAsync(\"input[type=\\\"file\\\"]\", new[] { });`); + await page.Locator(\"input[type=\\\"file\\\"]\").SetInputFilesAsync(new[] { });`); }); @@ -257,7 +257,7 @@ test.describe('cli codegen', () => { // Click text=Download const [download] = await Promise.all([ page.waitForEvent('download'), - page.click('text=Download') + page.locator('text=Download').click() ]);`); expect(sources.get('Java').text).toContain(` @@ -265,7 +265,7 @@ test.describe('cli codegen', () => { expect(sources.get('Java').text).toContain(` // Click text=Download Download download = page.waitForDownload(() -> { - page.click("text=Download"); + page.locator("text=Download").click(); });`); expect(sources.get('Python').text).toContain(` @@ -273,7 +273,7 @@ test.describe('cli codegen', () => { expect(sources.get('Python').text).toContain(` # Click text=Download with page.expect_download() as download_info: - page.click(\"text=Download\") + page.locator(\"text=Download\").click() download = download_info.value`); expect(sources.get('Python Async').text).toContain(` @@ -281,7 +281,7 @@ test.describe('cli codegen', () => { expect(sources.get('Python Async').text).toContain(` # Click text=Download async with page.expect_download() as download_info: - await page.click(\"text=Download\") + await page.locator(\"text=Download\").click() download = await download_info.value`); expect(sources.get('C#').text).toContain(` @@ -290,7 +290,7 @@ test.describe('cli codegen', () => { // Click text=Download var download1 = await page.RunAndWaitForDownloadAsync(async () => { - await page.ClickAsync(\"text=Download\"); + await page.Locator(\"text=Download\").ClickAsync(); });`); }); @@ -314,7 +314,7 @@ test.describe('cli codegen', () => { console.log(\`Dialog message: \${dialog.message()}\`); dialog.dismiss().catch(() => {}); }); - await page.click('text=click me');`); + await page.locator('text=click me').click();`); expect(sources.get('Java').text).toContain(` // Click text=click me @@ -322,17 +322,17 @@ test.describe('cli codegen', () => { System.out.println(String.format("Dialog message: %s", dialog.message())); dialog.dismiss(); }); - page.click("text=click me");`); + page.locator("text=click me").click();`); expect(sources.get('Python').text).toContain(` # Click text=click me page.once(\"dialog\", lambda dialog: dialog.dismiss()) - page.click(\"text=click me\")`); + page.locator(\"text=click me\").click()`); expect(sources.get('Python Async').text).toContain(` # Click text=click me page.once(\"dialog\", lambda dialog: dialog.dismiss()) - await page.click(\"text=click me\")`); + await page.locator(\"text=click me\").click()`); expect(sources.get('C#').text).toContain(` // Click text=click me @@ -343,7 +343,7 @@ test.describe('cli codegen', () => { page.Dialog -= page_Dialog1_EventHandler; } page.Dialog += page_Dialog1_EventHandler; - await page.ClickAsync(\"text=click me\");`); + await page.Locator(\"text=click me\").ClickAsync();`); }); @@ -393,7 +393,7 @@ test.describe('cli codegen', () => { // Click text=link const [page1] = await Promise.all([ page.waitForEvent('popup'), - page.click('text=link', { + page.locator('text=link').click({ modifiers: ['${platform === 'darwin' ? 'Meta' : 'Control'}'] }) ]);`); @@ -423,20 +423,20 @@ test.describe('cli codegen', () => { await recorder.waitForOutput('JavaScript', 'TextB'); const sources = recorder.sources(); - expect(sources.get('JavaScript').text).toContain(`await page1.fill('input', 'TextA');`); - expect(sources.get('JavaScript').text).toContain(`await page2.fill('input', 'TextB');`); + expect(sources.get('JavaScript').text).toContain(`await page1.locator('input').fill('TextA');`); + expect(sources.get('JavaScript').text).toContain(`await page2.locator('input').fill('TextB');`); - expect(sources.get('Java').text).toContain(`page1.fill("input", "TextA");`); - expect(sources.get('Java').text).toContain(`page2.fill("input", "TextB");`); + expect(sources.get('Java').text).toContain(`page1.locator("input").fill("TextA");`); + expect(sources.get('Java').text).toContain(`page2.locator("input").fill("TextB");`); - expect(sources.get('Python').text).toContain(`page1.fill(\"input\", \"TextA\")`); - expect(sources.get('Python').text).toContain(`page2.fill(\"input\", \"TextB\")`); + expect(sources.get('Python').text).toContain(`page1.locator(\"input\").fill(\"TextA\")`); + expect(sources.get('Python').text).toContain(`page2.locator(\"input\").fill(\"TextB\")`); - expect(sources.get('Python Async').text).toContain(`await page1.fill(\"input\", \"TextA\")`); - expect(sources.get('Python Async').text).toContain(`await page2.fill(\"input\", \"TextB\")`); + expect(sources.get('Python Async').text).toContain(`await page1.locator(\"input\").fill(\"TextA\")`); + expect(sources.get('Python Async').text).toContain(`await page2.locator(\"input\").fill(\"TextB\")`); - expect(sources.get('C#').text).toContain(`await page1.FillAsync(\"input\", \"TextA\");`); - expect(sources.get('C#').text).toContain(`await page2.FillAsync(\"input\", \"TextB\");`); + expect(sources.get('C#').text).toContain(`await page1.Locator(\"input\").FillAsync(\"TextA\");`); + expect(sources.get('C#').text).toContain(`await page2.Locator(\"input\").FillAsync(\"TextB\");`); }); test('click should emit events in order', async ({ page, openRecorder }) => { @@ -458,7 +458,7 @@ test.describe('cli codegen', () => { }); await Promise.all([ page.click('button'), - recorder.waitForOutput('JavaScript', 'page.click') + recorder.waitForOutput('JavaScript', '.click(') ]); expect(messages).toEqual(['mousedown', 'mouseup', 'click']); }); @@ -496,99 +496,6 @@ test.describe('cli codegen', () => { ]); }); - test('should prefer frame name', async ({ page, openRecorder, server }) => { - const recorder = await openRecorder(); - await recorder.setContentAndWait(` - - - - `, server.EMPTY_PAGE, 4); - const frameOne = page.frame({ name: 'one' }); - const frameTwo = page.frame({ name: 'two' }); - const otherFrame = page.frames().find(f => f !== page.mainFrame() && !f.name()); - - let [sources] = await Promise.all([ - recorder.waitForOutput('JavaScript', 'one'), - frameOne.click('div'), - ]); - - expect(sources.get('JavaScript').text).toContain(` - // Click text=Hi, I'm frame - await page.frame({ - name: 'one' - }).click('text=Hi, I\\'m frame');`); - - expect(sources.get('Java').text).toContain(` - // Click text=Hi, I'm frame - page.frame("one").click("text=Hi, I'm frame");`); - - expect(sources.get('Python').text).toContain(` - # Click text=Hi, I'm frame - page.frame(name=\"one\").click(\"text=Hi, I'm frame\")`); - - expect(sources.get('Python Async').text).toContain(` - # Click text=Hi, I'm frame - await page.frame(name=\"one\").click(\"text=Hi, I'm frame\")`); - - expect(sources.get('C#').text).toContain(` - // Click text=Hi, I'm frame - await page.Frame(\"one\").ClickAsync(\"text=Hi, I'm frame\");`); - - [sources] = await Promise.all([ - recorder.waitForOutput('JavaScript', 'two'), - frameTwo.click('div'), - ]); - - expect(sources.get('JavaScript').text).toContain(` - // Click text=Hi, I'm frame - await page.frame({ - name: 'two' - }).click('text=Hi, I\\'m frame');`); - - expect(sources.get('Java').text).toContain(` - // Click text=Hi, I'm frame - page.frame("two").click("text=Hi, I'm frame");`); - - expect(sources.get('Python').text).toContain(` - # Click text=Hi, I'm frame - page.frame(name=\"two\").click(\"text=Hi, I'm frame\")`); - - expect(sources.get('Python Async').text).toContain(` - # Click text=Hi, I'm frame - await page.frame(name=\"two\").click(\"text=Hi, I'm frame\")`); - - expect(sources.get('C#').text).toContain(` - // Click text=Hi, I'm frame - await page.Frame(\"two\").ClickAsync(\"text=Hi, I'm frame\");`); - - [sources] = await Promise.all([ - recorder.waitForOutput('JavaScript', 'url: \''), - otherFrame.click('div'), - ]); - - expect(sources.get('JavaScript').text).toContain(` - // Click text=Hi, I'm frame - await page.frame({ - url: 'http://localhost:${server.PORT}/frames/frame.html' - }).click('text=Hi, I\\'m frame');`); - - expect(sources.get('Java').text).toContain(` - // Click text=Hi, I'm frame - page.frameByUrl("http://localhost:${server.PORT}/frames/frame.html").click("text=Hi, I'm frame");`); - - expect(sources.get('Python').text).toContain(` - # Click text=Hi, I'm frame - page.frame(url=\"http://localhost:${server.PORT}/frames/frame.html\").click(\"text=Hi, I'm frame\")`); - - expect(sources.get('Python Async').text).toContain(` - # Click text=Hi, I'm frame - await page.frame(url=\"http://localhost:${server.PORT}/frames/frame.html\").click(\"text=Hi, I'm frame\")`); - - expect(sources.get('C#').text).toContain(` - // Click text=Hi, I'm frame - await page.FrameByUrl(\"http://localhost:${server.PORT}/frames/frame.html\").ClickAsync(\"text=Hi, I'm frame\");`); - }); - test('should record navigations after identical pushState', async ({ page, openRecorder, server }) => { const recorder = await openRecorder(); server.setRoute('/page2.html', (req, res) => { @@ -655,22 +562,23 @@ test.describe('cli codegen', () => { expect(sources.get('JavaScript').text).toContain(` // Fill textarea[name="name"] - await page.fill('textarea[name="name"]', 'Hello\\'"\`\\nWorld');`); + await page.locator('textarea[name="name"]').fill('Hello\\'"\`\\nWorld');`); + expect(sources.get('Java').text).toContain(` // Fill textarea[name="name"] - page.fill("textarea[name=\\\"name\\\"]", "Hello'\\"\`\\nWorld");`); + page.locator("textarea[name=\\\"name\\\"]").fill("Hello'\\"\`\\nWorld");`); expect(sources.get('Python').text).toContain(` # Fill textarea[name="name"] - page.fill(\"textarea[name=\\\"name\\\"]\", \"Hello'\\"\`\\nWorld\")`); + page.locator(\"textarea[name=\\\"name\\\"]\").fill(\"Hello'\\"\`\\nWorld\")`); expect(sources.get('Python Async').text).toContain(` # Fill textarea[name="name"] - await page.fill(\"textarea[name=\\\"name\\\"]\", \"Hello'\\"\`\\nWorld\")`); + await page.locator(\"textarea[name=\\\"name\\\"]\").fill(\"Hello'\\"\`\\nWorld\")`); expect(sources.get('C#').text).toContain(` // Fill textarea[name="name"] - await page.FillAsync(\"textarea[name=\\\"name\\\"]\", \"Hello'\\"\`\\nWorld\");`); + await page.Locator(\"textarea[name=\\\"name\\\"]\").FillAsync(\"Hello'\\"\`\\nWorld\");`); expect(message.text()).toBe('Hello\'\"\`\nWorld'); }); diff --git a/tests/inspector/cli-codegen-3.spec.ts b/tests/inspector/cli-codegen-3.spec.ts new file mode 100644 index 0000000000..8997ee26dd --- /dev/null +++ b/tests/inspector/cli-codegen-3.spec.ts @@ -0,0 +1,235 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './inspectorTest'; + +test.describe('cli codegen', () => { + test.skip(({ mode }) => mode !== 'default'); + + test('should click locator.first', async ({ page, openRecorder }) => { + const recorder = await openRecorder(); + + await recorder.setContentAndWait(` + + + `); + + const selector = await recorder.hoverOverElement('button'); + expect(selector).toBe('text=Submit >> nth=0'); + + const [message, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error'), + recorder.waitForOutput('JavaScript', 'click'), + page.dispatchEvent('button', 'click', { detail: 1 }) + ]); + + expect(sources.get('JavaScript').text).toContain(` + // Click text=Submit >> nth=0 + await page.locator('text=Submit').first().click();`); + + expect(sources.get('Python').text).toContain(` + # Click text=Submit >> nth=0 + page.locator("text=Submit").first.click()`); + + expect(sources.get('Python Async').text).toContain(` + # Click text=Submit >> nth=0 + await page.locator("text=Submit").first.click()`); + + expect(sources.get('Java').text).toContain(` + // Click text=Submit >> nth=0 + page.locator("text=Submit").first().click();`); + + expect(sources.get('C#').text).toContain(` + // Click text=Submit >> nth=0 + await page.Locator("text=Submit").First.ClickAsync();`); + + expect(message.text()).toBe('click1'); + }); + + test('should click locator.nth', async ({ page, openRecorder }) => { + const recorder = await openRecorder(); + + await recorder.setContentAndWait(` + + + `); + + const selector = await recorder.hoverOverElement('button >> nth=1'); + expect(selector).toBe('text=Submit >> nth=1'); + + const [message, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error'), + recorder.waitForOutput('JavaScript', 'click'), + page.dispatchEvent('button', 'click', { detail: 1 }) + ]); + + expect(sources.get('JavaScript').text).toContain(` + // Click text=Submit >> nth=1 + await page.locator('text=Submit').nth(1).click();`); + + expect(sources.get('Python').text).toContain(` + # Click text=Submit >> nth=1 + page.locator("text=Submit").nth(1).click()`); + + expect(sources.get('Python Async').text).toContain(` + # Click text=Submit >> nth=1 + await page.locator("text=Submit").nth(1).click()`); + + expect(sources.get('Java').text).toContain(` + // Click text=Submit >> nth=1 + page.locator("text=Submit").nth(1).click();`); + + expect(sources.get('C#').text).toContain(` + // Click text=Submit >> nth=1 + await page.Locator("text=Submit").Nth(1).ClickAsync();`); + + expect(message.text()).toBe('click2'); + }); + + test('should generate frame locators', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + /* + iframe + div Hello1 + iframe + div Hello2 + iframe[name=one] + div HelloNameOne + iframe[name=two] + dev HelloNameTwo + iframe + dev HelloAnonymous + */ + await recorder.setContentAndWait(` +