diff --git a/package-lock.json b/package-lock.json index 9192792945..43361550a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3314,11 +3314,6 @@ "delayed-stream": "~1.0.0" } }, - "commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" - }, "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", diff --git a/package.json b/package.json index 5d8a7a4149..f8beaec180 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,6 @@ "engines": { "node": ">=10.15.0" }, - "bin": { - "playwright": "lib/cli/index.js" - }, "scripts": { "ctest": "cross-env BROWSER=chromium node --unhandled-rejections=strict test/test.js", "ftest": "cross-env BROWSER=firefox node --unhandled-rejections=strict test/test.js", @@ -42,7 +39,6 @@ }, "license": "Apache-2.0", "dependencies": { - "commander": "^5.1.0", "debug": "^4.1.1", "extract-zip": "^2.0.0", "https-proxy-agent": "^5.0.0", diff --git a/src/browserContext.ts b/src/browserContext.ts index 36ea0c987e..7a0ffec22f 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { Writable } from 'stream'; import { isUnderTest, helper, deprecate} from './helper'; import * as network from './network'; import { Page, PageBinding } from './page'; @@ -69,7 +68,6 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser readonly _downloads = new Set(); readonly _browserBase: BrowserBase; readonly _apiLogger: Logger; - private _debugController: DebugController | undefined; constructor(browserBase: BrowserBase, options: BrowserContextOptions, isPersistentContext: boolean) { super(); @@ -82,16 +80,8 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser } async _initialize() { - if (helper.isDebugMode() || helper.isRecordMode()) { - this._debugController = new DebugController(this, { - recorderOutput: helper.isRecordMode() ? process.stdout : undefined - }); - } - } - - _initDebugModeForTest(options: { recorderOutput: Writable }): DebugController { - this._debugController = new DebugController(this, options); - return this._debugController; + if (helper.isDebugMode()) + new DebugController(this); } async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise { diff --git a/src/cli/index.ts b/src/cli/index.ts deleted file mode 100755 index 73b4b7bb67..0000000000 --- a/src/cli/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node - -/** - * 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. - */ - -/* eslint-disable no-console */ - -import * as program from 'commander'; -import { Playwright } from '../server/playwright'; -import { BrowserType } from '../server/browserType'; -import { DeviceDescriptors } from '../deviceDescriptors'; -import { helper } from '../helper'; -import { LaunchOptions, BrowserContextOptions } from '../types'; - -const playwright = new Playwright(__dirname, require('../../browsers.json')['browsers']); - -program - .version('Version ' + require('../../package.json').version) - .option('-b, --browser ', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium') - .option('--headless', 'run in headless mode', false) - .option('--device ', 'emulate device, for example "iPhone 11"'); - -program - .command('open [url]') - .description('open page in browser specified via -b, --browser') - .action(function(url, command) { - open(command.parent, url); - }).on('--help', function() { - console.log(''); - console.log('Examples:'); - console.log(''); - console.log(' $ open'); - console.log(' $ -b webkit open https://example.com'); - }); - -program - .command('record [url]') - .description('open page in browser specified via -b, --browser and start recording') - .action(function(url, command) { - record(command.parent, url); - }).on('--help', function() { - console.log(''); - console.log('Examples:'); - console.log(''); - console.log(' $ record'); - console.log(' $ -b webkit record https://example.com'); - }); - -const browsers = [ - { initial: 'cr', name: 'Chromium', type: 'chromium' }, - { initial: 'ff', name: 'Firefox', type: 'firefox' }, - { initial: 'wk', name: 'WebKit', type: 'webkit' }, -]; - -for (const {initial, name, type} of browsers) { - program - .command(`${initial} [url]`) - .description(`open page in ${name} browser`) - .action(function(url, command) { - open({ ...command.parent, browser: type }, url); - }).on('--help', function() { - console.log(''); - console.log('Examples:'); - console.log(''); - console.log(` $ ${initial} https://example.com`); - }); -} - -program.parse(process.argv); - -type Options = { - browser: string, - device: string | undefined, - verbose: boolean, - headless: boolean, -}; - -async function open(options: Options, url: string | undefined) { - const browserType = lookupBrowserType(options.browser); - const launchOptions: LaunchOptions = { headless: options.headless }; - const browser = await browserType.launch(launchOptions); - const contextOptions: BrowserContextOptions = options.device ? DeviceDescriptors[options.device] || {} : {}; - const page = await browser.newPage(contextOptions); - if (url) { - if (!url.startsWith('http')) - url = 'http://' + url; - await page.goto(url); - } - return { browser, page }; -} - -async function record(options: Options, url: string | undefined) { - helper.setRecordMode(true); - return await open(options, url); -} - -function lookupBrowserType(name: string): BrowserType { - switch (name) { - case 'chromium': return playwright.chromium!; - case 'webkit': return playwright.webkit!; - case 'firefox': return playwright.firefox!; - case 'cr': return playwright.chromium!; - case 'wk': return playwright.webkit!; - case 'ff': return playwright.firefox!; - } - program.help(); -} diff --git a/src/debug/debugController.ts b/src/debug/debugController.ts index e0b0d97195..d3b8317e02 100644 --- a/src/debug/debugController.ts +++ b/src/debug/debugController.ts @@ -14,24 +14,15 @@ * limitations under the License. */ -import { Writable } from 'stream'; import { BrowserContextBase } from '../browserContext'; import { Events } from '../events'; import * as frames from '../frames'; import * as js from '../javascript'; import { Page } from '../page'; -import { RecorderController } from './recorderController'; import DebugScript from './injected/debugScript'; export class DebugController { - private _options: { recorderOutput?: Writable | undefined }; - - constructor(context: BrowserContextBase, options: { recorderOutput?: Writable | undefined }) { - this._options = options; - - if (options.recorderOutput) - new RecorderController(context, options.recorderOutput); - + constructor(context: BrowserContextBase) { context.on(Events.BrowserContext.Page, (page: Page) => { for (const frame of page.frames()) this.ensureInstalledInFrame(frame); @@ -42,13 +33,8 @@ export class DebugController { private async ensureInstalledInFrame(frame: frames.Frame): Promise | undefined> { try { const mainContext = await frame._mainContext(); - return await mainContext.createDebugScript({ console: true, record: !!this._options.recorderOutput }); + return await mainContext.createDebugScript({ console: true }); } catch (e) { } } - - async ensureInstalledInFrameForTest(frame: frames.Frame): Promise { - const handle = await this.ensureInstalledInFrame(frame); - await handle!.evaluate(debugScript => debugScript.recorder!.refreshListeners()); - } } diff --git a/src/debug/injected/debugScript.ts b/src/debug/injected/debugScript.ts index 3d8da14a9e..cbc9699cee 100644 --- a/src/debug/injected/debugScript.ts +++ b/src/debug/injected/debugScript.ts @@ -15,17 +15,13 @@ */ import { ConsoleAPI } from './consoleApi'; -import { Recorder } from './recorder'; import InjectedScript from '../../injected/injectedScript'; export default class DebugScript { consoleAPI: ConsoleAPI | undefined; - recorder: Recorder | undefined; - initialize(injectedScript: InjectedScript, options: { console?: boolean, record?: boolean }) { + initialize(injectedScript: InjectedScript, options: { console?: boolean }) { if (options.console) this.consoleAPI = new ConsoleAPI(injectedScript); - if (options.record) - this.recorder = new Recorder(injectedScript); } } diff --git a/src/debug/injected/recorder.ts b/src/debug/injected/recorder.ts deleted file mode 100644 index 4b4bedd8d1..0000000000 --- a/src/debug/injected/recorder.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * 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 type * as actions from '../recorderActions'; -import InjectedScript from '../../injected/injectedScript'; -import { parseSelector } from '../../common/selectorParser'; - -declare global { - interface Window { - performPlaywrightAction: (action: actions.Action) => Promise; - recordPlaywrightAction: (action: actions.Action) => Promise; - } -} - -export class Recorder { - private _injectedScript: InjectedScript; - private _performingAction = false; - readonly refreshListeners: () => void; - - constructor(injectedScript: InjectedScript) { - this._injectedScript = injectedScript; - - const onClick = this._onClick.bind(this); - const onInput = this._onInput.bind(this); - const onKeyDown = this._onKeyDown.bind(this); - this.refreshListeners = () => { - document.removeEventListener('click', onClick, true); - document.removeEventListener('input', onInput, true); - document.removeEventListener('keydown', onKeyDown, true); - document.addEventListener('click', onClick, true); - document.addEventListener('input', onInput, true); - document.addEventListener('keydown', onKeyDown, true); - }; - this.refreshListeners(); - // Document listeners are cleared upon document.open, - // so we refresh them periodically in a best-effort manner. - // Note: keep in sync with the same constant in the test. - setInterval(this.refreshListeners, 1000); - } - - private async _onClick(event: MouseEvent) { - if ((event.target as Element).nodeName === 'SELECT') - return; - if ((event.target as Element).nodeName === 'INPUT') { - // Check/uncheck are handled in input. - if (((event.target as HTMLInputElement).type || '').toLowerCase() === 'checkbox') - return; - } - - // Perform action consumes this event and asks Playwright to perform it. - this._performAction(event, { - name: 'click', - selector: this._buildSelector(event.target as Element), - signals: [], - button: buttonForEvent(event), - modifiers: modifiersForEvent(event), - clickCount: event.detail - }); - } - - private async _onInput(event: Event) { - const selector = this._buildSelector(event.target as Element); - if ((event.target as Element).nodeName === 'INPUT') { - const inputElement = event.target as HTMLInputElement; - if ((inputElement.type || '').toLowerCase() === 'checkbox') { - // Perform action consumes this event and asks Playwright to perform it. - this._performAction(event, { - name: inputElement.checked ? 'check' : 'uncheck', - selector, - signals: [], - }); - return; - } else { - // Non-navigating actions are simply recorded by Playwright. - window.recordPlaywrightAction({ - name: 'fill', - selector, - signals: [], - text: (event.target! as HTMLInputElement).value, - }); - } - } - if ((event.target as Element).nodeName === 'SELECT') { - const selectElement = event.target as HTMLSelectElement; - this._performAction(event, { - name: 'select', - selector, - options: [...selectElement.selectedOptions].map(option => option.value), - signals: [] - }); - } - } - - private async _onKeyDown(event: KeyboardEvent) { - if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape') - return; - this._performAction(event, { - name: 'press', - selector: this._buildSelector(event.target as Element), - signals: [], - key: event.key, - modifiers: modifiersForEvent(event), - }); - } - - private async _performAction(event: Event, action: actions.Action) { - // If Playwright is performing action for us, bail. - if (this._performingAction) - return; - // Consume as the first thing. - consumeEvent(event); - this._performingAction = true; - await window.performPlaywrightAction(action); - this._performingAction = false; - } - - private _buildSelector(targetElement: Element): string { - const path: string[] = []; - const root = document.documentElement; - for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) { - const selector = this._buildSelectorCandidate(element); - if (selector) - path.unshift(selector.selector); - - const fullSelector = path.join(' '); - if (selector && selector.final) - return fullSelector; - if (targetElement === this._injectedScript.querySelector(parseSelector(fullSelector), root)) - return fullSelector; - } - return ''; - } - - private _buildSelectorCandidate(element: Element): { final: boolean, selector: string } | null { - for (const attribute of ['data-testid', 'data-test-id', 'data-test']) { - if (element.hasAttribute(attribute)) - return { final: true, selector: `${element.nodeName.toLocaleLowerCase()}[${attribute}=${element.getAttribute(attribute)}]` }; - } - for (const attribute of ['aria-label']) { - if (element.hasAttribute(attribute)) - return { final: false, selector: `${element.nodeName.toLocaleLowerCase()}[${attribute}=${element.getAttribute(attribute)}]` }; - } - if (element.nodeName === 'INPUT') { - if (element.hasAttribute('name')) - return { final: false, selector: `input[name=${element.getAttribute('name')}]` }; - if (element.hasAttribute('type')) - return { final: false, selector: `input[type=${element.getAttribute('type')}]` }; - } else if (element.nodeName === 'IMG') { - if (element.hasAttribute('alt')) - return { final: false, selector: `img[alt="${element.getAttribute('alt')}"]` }; - } - const textSelector = textSelectorForElement(element); - if (textSelector) - return { final: false, selector: textSelector }; - - // Depreoritize id, but still use it as a last resort. - if (element.hasAttribute('id')) - return { final: true, selector: `${element.nodeName.toLocaleLowerCase()}[id=${element.getAttribute('id')}]` }; - - return null; - } -} - -function textSelectorForElement(node: Node): string | null { - let needsTrim = false; - let onlyText: string | null = null; - for (const child of node.childNodes) { - if (child.nodeType !== Node.TEXT_NODE) - continue; - if (child.textContent && child.textContent.trim()) { - if (onlyText) - return null; - onlyText = child.textContent.trim(); - needsTrim = child.textContent !== child.textContent.trim(); - } else { - needsTrim = true; - } - } - if (!onlyText) - return null; - return needsTrim ? `text=/\\s*${escapeForRegex(onlyText)}\\s*/` : `text="${onlyText}"`; -} - -function modifiersForEvent(event: MouseEvent | KeyboardEvent): number { - return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0); -} - -function buttonForEvent(event: MouseEvent): 'left' | 'middle' | 'right' { - switch (event.which) { - case 1: return 'left'; - case 2: return 'middle'; - case 3: return 'right'; - } - return 'left'; -} - -function escapeForRegex(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function consumeEvent(e: Event) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); -} diff --git a/src/debug/recorderActions.ts b/src/debug/recorderActions.ts deleted file mode 100644 index 6a1d72f3fa..0000000000 --- a/src/debug/recorderActions.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 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. - */ - -export type ActionName = - 'goto' | - 'fill' | - 'press' | - 'select'; - -export type ActionBase = { - signals: Signal[], - committed?: boolean, -} - -export type ClickAction = ActionBase & { - name: 'click', - selector: string, - button: 'left' | 'middle' | 'right', - modifiers: number, - clickCount: number, -}; - -export type CheckAction = ActionBase & { - name: 'check', - selector: string, -}; - -export type UncheckAction = ActionBase & { - name: 'uncheck', - selector: string, -}; - -export type FillAction = ActionBase & { - name: 'fill', - selector: string, - text: string, -}; - -export type NavigateAction = ActionBase & { - name: 'navigate', - url: string, -}; - -export type PressAction = ActionBase & { - name: 'press', - selector: string, - key: string, - modifiers: number, -}; - -export type SelectAction = ActionBase & { - name: 'select', - selector: string, - options: string[], -}; - -export type Action = ClickAction | CheckAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction; - -// Signals. - -export type NavigationSignal = { - name: 'navigation', - url: string, - type: 'assert' | 'await', -}; - -export type PopupSignal = { - name: 'popup', - popupAlias: string, -}; - -export type Signal = NavigationSignal | PopupSignal; - -export function actionTitle(action: Action): string { - switch (action.name) { - case 'check': - return `Check ${action.selector}`; - case 'uncheck': - return `Uncheck ${action.selector}`; - case 'click': { - if (action.clickCount === 1) - return `Click ${action.selector}`; - if (action.clickCount === 2) - return `Double click ${action.selector}`; - if (action.clickCount === 3) - return `Triple click ${action.selector}`; - return `${action.clickCount}× click`; - } - case 'fill': - return `Fill ${action.selector}`; - case 'navigate': - return `Go to ${action.url}`; - case 'press': - return `Press ${action.key}` + (action.modifiers ? ' with modifiers' : ''); - case 'select': - return `Select ${action.selector}`; - } -} diff --git a/src/debug/recorderController.ts b/src/debug/recorderController.ts deleted file mode 100644 index 2d7add4344..0000000000 --- a/src/debug/recorderController.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 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 { Writable } from 'stream'; -import { BrowserContextBase } from '../browserContext'; -import * as types from '../types'; -import { Events } from '../events'; -import * as frames from '../frames'; -import { Page } from '../page'; -import * as actions from './recorderActions'; -import { TerminalOutput } from './terminalOutput'; - -export class RecorderController { - private _output: TerminalOutput; - private _performingAction = false; - private _pageAliases = new Map(); - private _lastPopupOrdinal = 0; - private _timers = new Set(); - - constructor(context: BrowserContextBase, output: Writable) { - this._output = new TerminalOutput(output || process.stdout); - context.on(Events.BrowserContext.Page, (page: Page) => { - // First page is called page, others are called popup1, popup2, etc. - const pageName = this._pageAliases.size ? 'popup' + ++this._lastPopupOrdinal : 'page'; - this._pageAliases.set(page, pageName); - page.on(Events.Page.Close, () => this._pageAliases.delete(page)); - - // Input actions that potentially lead to navigation are intercepted on the page and are - // performed by the Playwright. - page.exposeBinding('performPlaywrightAction', - (source, action: actions.Action) => this._performAction(source.frame, action)).catch(e => {}); - - // Other non-essential actions are simply being recorded. - page.exposeBinding('recordPlaywrightAction', - (source, action: actions.Action) => this._recordAction(source.frame, action)).catch(e => {}); - - page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => this._onFrameNavigated(frame)); - page.on(Events.Page.Popup, (popup: Page) => this._onPopup(page, popup)); - }); - - context.once(Events.BrowserContext.Close, () => { - for (const timer of this._timers) - clearTimeout(timer); - this._timers.clear(); - }); - } - - private async _performAction(frame: frames.Frame, action: actions.Action) { - this._performingAction = true; - this._recordAction(frame, action); - if (action.name === 'click') { - const { options } = toClickOptions(action); - await frame.click(action.selector, options); - } - if (action.name === 'press') { - const modifiers = toModifiers(action.modifiers); - const shortcut = [...modifiers, action.key].join('+'); - await frame.press(action.selector, shortcut); - } - if (action.name === 'check') - await frame.check(action.selector); - if (action.name === 'uncheck') - await frame.uncheck(action.selector); - if (action.name === 'select') - await frame.selectOption(action.selector, action.options); - this._performingAction = false; - const timer = setTimeout(() => { - action.committed = true; - this._timers.delete(timer); - }, 5000); - this._timers.add(timer); - } - - private async _recordAction(frame: frames.Frame, action: actions.Action) { - this._output.addAction(this._pageAliases.get(frame._page)!, frame, action); - } - - private _onFrameNavigated(frame: frames.Frame) { - if (frame.parentFrame()) - return; - const pageAlias = this._pageAliases.get(frame._page); - const action = this._output.lastAction(); - // We only augment actions that have not been committed. - if (action && !action.committed && action.name !== 'navigate') { - // If we hit a navigation while action is executed, we assert it. Otherwise, we await it. - this._output.signal(pageAlias!, frame, { name: 'navigation', url: frame.url(), type: this._performingAction ? 'assert' : 'await' }); - } else if (!action || action.committed) { - // If navigation happens out of the blue, we just log it. - this._output.addAction( - pageAlias!, frame, { - name: 'navigate', - url: frame.url(), - signals: [], - }); - } - } - - private _onPopup(page: Page, popup: Page) { - const pageAlias = this._pageAliases.get(page)!; - const popupAlias = this._pageAliases.get(popup)!; - const action = this._output.lastAction(); - // We only augment actions that have not been committed. - if (action && !action.committed) { - // If we hit a navigation while action is executed, we assert it. Otherwise, we await it. - this._output.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias }); - } - } -} - -export function toClickOptions(action: actions.ClickAction): { method: 'click' | 'dblclick', options: types.MouseClickOptions } { - let method: 'click' | 'dblclick' = 'click'; - if (action.clickCount === 2) - method = 'dblclick'; - const modifiers = toModifiers(action.modifiers); - const options: types.MouseClickOptions = {}; - if (action.button !== 'left') - options.button = action.button; - if (modifiers.length) - options.modifiers = modifiers; - if (action.clickCount > 2) - options.clickCount = action.clickCount; - return { method, options }; -} - -export function toModifiers(modifiers: number): ('Alt' | 'Control' | 'Meta' | 'Shift')[] { - const result: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = []; - if (modifiers & 1) - result.push('Alt'); - if (modifiers & 2) - result.push('Control'); - if (modifiers & 4) - result.push('Meta'); - if (modifiers & 8) - result.push('Shift'); - return result; -} diff --git a/src/debug/terminalOutput.ts b/src/debug/terminalOutput.ts deleted file mode 100644 index deab213ee3..0000000000 --- a/src/debug/terminalOutput.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * 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 { Writable } from 'stream'; -import * as types from '../types'; -import { Frame } from '../frames'; -import { formatColors, Formatter } from '../utils/formatter'; -import { Action, actionTitle, NavigationSignal, PopupSignal, Signal } from './recorderActions'; -import { toModifiers } from './recorderController'; - -const { cst, cmt, fnc, kwd, prp, str } = formatColors; - -export class TerminalOutput { - private _lastAction: Action | undefined; - private _lastActionText: string | undefined; - private _out: Writable; - - constructor(out: Writable) { - this._out = out; - const formatter = new Formatter(); - - formatter.add(` - ${kwd('const')} ${cst('assert')} = ${fnc('require')}(${str('assert')}); - ${kwd('const')} { ${cst('chromium')}, ${cst('firefox')}, ${cst('webkit')} } = ${fnc('require')}(${str('playwright')}); - - (${kwd('async')}() => { - ${kwd('const')} ${cst('browser')} = ${kwd('await')} ${cst(`chromium`)}.${fnc('launch')}(); - ${kwd('const')} ${cst('page')} = ${kwd('await')} ${cst('browser')}.${fnc('newPage')}(); - `); - this._out.write(formatter.format() + '\n`})();`\n'); - } - - addAction(pageAlias: string, frame: Frame, action: Action) { - // We augment last action based on the type. - let eraseLastAction = false; - if (this._lastAction && action.name === 'fill' && this._lastAction.name === 'fill') { - if (action.selector === this._lastAction.selector) - eraseLastAction = true; - } - if (this._lastAction && action.name === 'click' && this._lastAction.name === 'click') { - if (action.selector === this._lastAction.selector && action.clickCount > this._lastAction.clickCount) - eraseLastAction = true; - } - for (const name of ['check', 'uncheck']) { - if (this._lastAction && action.name === name && this._lastAction.name === 'click') { - if ((action as any).selector === (this._lastAction as any).selector) - eraseLastAction = true; - } - } - this._printAction(pageAlias, frame, action, eraseLastAction); - } - - _printAction(pageAlias: string, frame: Frame, action: Action, eraseLastAction: boolean) { - // We erase terminating `})();` at all times. - let eraseLines = 1; - if (eraseLastAction && this._lastActionText) - eraseLines += this._lastActionText.split('\n').length; - // And we erase the last action too if augmenting. - for (let i = 0; i < eraseLines; ++i) - this._out.write('\u001B[1A\u001B[2K'); - - this._lastAction = action; - this._lastActionText = this._generateAction(pageAlias, frame, action); - this._out.write(this._lastActionText + '\n})();\n'); - } - - lastAction(): Action | undefined { - return this._lastAction; - } - - signal(pageAlias: string, frame: Frame, signal: Signal) { - if (this._lastAction) { - this._lastAction.signals.push(signal); - this._printAction(pageAlias, frame, this._lastAction, true); - } - } - - private _generateAction(pageAlias: string, frame: Frame, action: Action): string { - const formatter = new Formatter(2); - formatter.newLine(); - formatter.add(cmt(actionTitle(action))); - - const subject = frame === frame._page.mainFrame() ? cst(pageAlias) : - `${cst(pageAlias)}.${fnc('frame')}(${formatObject({ url: frame.url() })})`; - - let navigationSignal: NavigationSignal | undefined; - let popupSignal: PopupSignal | undefined; - for (const signal of action.signals) { - if (signal.name === 'navigation') - navigationSignal = signal; - if (signal.name === 'popup') - popupSignal = signal; - } - - const waitForNavigation = navigationSignal && navigationSignal.type === 'await'; - const assertNavigation = navigationSignal && navigationSignal.type === 'assert'; - - const emitPromiseAll = waitForNavigation || popupSignal; - if (emitPromiseAll) { - // Generate either await Promise.all([]) or - // const [popup1] = await Promise.all([]). - let leftHandSide = ''; - if (popupSignal) - leftHandSide = `${kwd('const')} [${cst(popupSignal.popupAlias)}] = `; - formatter.add(`${leftHandSide}${kwd('await')} ${cst('Promise')}.${fnc('all')}([`); - } - - // Popup signals. - if (popupSignal) - formatter.add(`${cst(pageAlias)}.${fnc('waitForEvent')}(${str('popup')}),`); - - // Navigation signal. - if (waitForNavigation) - formatter.add(`${cst(pageAlias)}.${fnc('waitForNavigation')}({ ${prp('url')}: ${str(navigationSignal!.url)} }),`); - - const prefix = waitForNavigation ? '' : kwd('await') + ' '; - const actionCall = this._generateActionCall(action); - const suffix = waitForNavigation ? '' : ';'; - formatter.add(`${prefix}${subject}.${actionCall}${suffix}`); - - if (emitPromiseAll) - formatter.add(`]);`); - else if (assertNavigation) - formatter.add(` ${cst('assert')}.${fnc('equal')}(${cst(pageAlias)}.${fnc('url')}(), ${str(navigationSignal!.url)});`); - return formatter.format(); - } - - private _generateActionCall(action: Action): string { - switch (action.name) { - case 'click': { - let method = 'click'; - if (action.clickCount === 2) - method = 'dblclick'; - const modifiers = toModifiers(action.modifiers); - const options: types.MouseClickOptions = {}; - if (action.button !== 'left') - options.button = action.button; - if (modifiers.length) - options.modifiers = modifiers; - if (action.clickCount > 2) - options.clickCount = action.clickCount; - const optionsString = formatOptions(options); - return `${fnc(method)}(${str(action.selector)}${optionsString})`; - } - case 'check': - return `${fnc('check')}(${str(action.selector)})`; - case 'uncheck': - return `${fnc('uncheck')}(${str(action.selector)})`; - case 'fill': - return `${fnc('fill')}(${str(action.selector)}, ${str(action.text)})`; - case 'press': { - const modifiers = toModifiers(action.modifiers); - const shortcut = [...modifiers, action.key].join('+'); - return `${fnc('press')}(${str(action.selector)}, ${str(shortcut)})`; - } - case 'navigate': - return `${fnc('goto')}(${str(action.url)})`; - case 'select': - return `${fnc('selectOption')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})`; - } - } -} - -function formatOptions(value: any): string { - const keys = Object.keys(value); - if (!keys.length) - return ''; - return ', ' + formatObject(value); -} - -function formatObject(value: any): string { - if (typeof value === 'string') - return str(value); - if (Array.isArray(value)) - return `[${value.map(o => formatObject(o)).join(', ')}]`; - if (typeof value === 'object') { - const keys = Object.keys(value); - if (!keys.length) - return '{}'; - const tokens: string[] = []; - for (const key of keys) - tokens.push(`${prp(key)}: ${formatObject(value[key])}`); - return `{${tokens.join(', ')}}`; - } - return String(value); -} - diff --git a/src/dom.ts b/src/dom.ts index 129cdff29e..01c9997925 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -93,7 +93,7 @@ export class FrameExecutionContext extends js.ExecutionContext { return this._injectedScriptPromise; } - createDebugScript(options: { record?: boolean, console?: boolean }): Promise | undefined> { + createDebugScript(options: { console?: boolean }): Promise | undefined> { if (!this._debugScriptPromise) { const source = `new (${debugScriptSource.source})()`; this._debugScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId)).then(async debugScript => { diff --git a/src/helper.ts b/src/helper.ts index 9342342304..1bcb8a6011 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -33,8 +33,7 @@ export type RegisteredListener = { export type Listener = (...args: any[]) => void; -let isInDebugMode = !!getFromENV('PWDEBUG'); -let isInRecordMode = false; +const isInDebugMode = !!getFromENV('PWDEBUG'); const deprecatedHits = new Set(); export function deprecate(methodName: string, message: string) { @@ -320,18 +319,6 @@ class Helper { static isDebugMode(): boolean { return isInDebugMode; } - - static setDebugMode(enabled: boolean) { - isInDebugMode = enabled; - } - - static isRecordMode(): boolean { - return isInRecordMode; - } - - static setRecordMode(enabled: boolean) { - isInRecordMode = enabled; - } } export function assert(value: any, message?: string): asserts value { diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts deleted file mode 100644 index bacdb388a6..0000000000 --- a/src/utils/formatter.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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. - */ - -export class Formatter { - private _baseIndent: string; - private _baseOffset: string; - private _lines: string[] = []; - - constructor(offset = 0) { - this._baseIndent = ' '.repeat(2); - this._baseOffset = ' '.repeat(offset); - } - - prepend(text: string) { - this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines); - } - - add(text: string) { - this._lines.push(...text.trim().split('\n').map(line => line.trim())); - } - - newLine() { - this._lines.push(''); - } - - format(): string { - let spaces = ''; - let previousLine = ''; - return this._lines.map((line: string) => { - if (line === '') - return line; - if (line.startsWith('}') || line.startsWith(']')) - spaces = spaces.substring(this._baseIndent.length); - - const extraSpaces = /^(for|while|if).*\(.*\)$/.test(previousLine) ? this._baseIndent : ''; - previousLine = line; - - line = spaces + extraSpaces + line; - if (line.endsWith('{') || line.endsWith('[')) - spaces += this._baseIndent; - return this._baseOffset + line; - }).join('\n'); - } -} - -type StringFormatter = (s: string) => string; - -export const formatColors: { cst: StringFormatter; kwd: StringFormatter; fnc: StringFormatter; prp: StringFormatter, str: StringFormatter; cmt: StringFormatter } = { - cst: text => `\u001b[38;5;72m${text}\x1b[0m`, - kwd: text => `\u001b[38;5;39m${text}\x1b[0m`, - fnc: text => `\u001b[38;5;223m${text}\x1b[0m`, - prp: text => `\u001b[38;5;159m${text}\x1b[0m`, - str: text => `\u001b[38;5;130m${quote(text)}\x1b[0m`, - cmt: text => `\u001b[38;5;23m// ${text}\x1b[0m` -}; - -function quote(text: string, char: string = '\'') { - if (char === '\'') - return char + text.replace(/[']/g, '\\\'').replace(/\\/g, '\\\\') + char; - if (char === '"') - return char + text.replace(/["]/g, '\\"').replace(/\\/g, '\\\\') + char; - if (char === '`') - return char + text.replace(/[`]/g, '\\`').replace(/\\/g, '\\\\') + char; - throw new Error('Invalid escape char'); -} diff --git a/test/recorder.spec.js b/test/recorder.spec.js deleted file mode 100644 index e9b389ad6e..0000000000 --- a/test/recorder.spec.js +++ /dev/null @@ -1,209 +0,0 @@ -/** - * 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. - */ - -const {FFOX, CHROMIUM, WEBKIT, USES_HOOKS} = require('./utils').testOptions(browserType); - -class WritableBuffer { - constructor() { - this.lines = []; - } - - write(chunk) { - if (chunk === '\u001B[F\u001B[2K') { - this.lines.pop(); - return; - } - this.lines.push(...chunk.split('\n')); - if (this._callback && chunk.includes(this._text)) - this._callback(); - } - - waitFor(text) { - if (this.lines.join('\n').includes(text)) - return Promise.resolve(); - this._text = text; - return new Promise(f => this._callback = f); - } - - data() { - return this.lines.join('\n'); - } - - text() { - const pattern = [ - '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', - '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))' - ].join('|'); - return this.data().replace(new RegExp(pattern, 'g'), ''); - } -} - -describe.skip(USES_HOOKS)('Recorder', function() { - beforeEach(async state => { - state.context = await state.browser.newContext(); - state.output = new WritableBuffer(); - const debugController = state.toImpl(state.context)._initDebugModeForTest({ recorderOutput: state.output }); - state.page = await state.context.newPage(); - state.setContent = async (content) => { - await state.page.setContent(content); - await debugController.ensureInstalledInFrameForTest(state.toImpl(state.page.mainFrame())); - }; - }); - - afterEach(async state => { - await state.context.close(); - }); - - it('should click', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(``); - const [message] = await Promise.all([ - page.waitForEvent('console'), - output.waitFor('click'), - page.dispatchEvent('button', 'click', { detail: 1 }) - ]); - expect(output.text()).toContain(` - // Click text="Submit" - await page.click('text="Submit"');`); - expect(message.text()).toBe('click'); - }); - - it('should click after document.open', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(``); - await page.evaluate(() => { - document.open(); - document.write(``); - document.close(); - // Give it time to refresh. See Recorder for details. - return new Promise(f => setTimeout(f, 1000)); - }); - const [message] = await Promise.all([ - page.waitForEvent('console'), - output.waitFor('click'), - page.dispatchEvent('button', 'click', { detail: 1 }) - ]); - expect(output.text()).toContain(` - // Click text="Submit" - await page.click('text="Submit"');`); - expect(message.text()).toBe('click'); - }); - - it('should fill', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(``); - const [message] = await Promise.all([ - page.waitForEvent('console'), - output.waitFor('fill'), - page.fill('input', 'John') - ]); - expect(output.text()).toContain(` - // Fill input[name=name] - await page.fill('input[name=name]', 'John');`); - expect(message.text()).toBe('John'); - }); - - it('should press', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(``); - const [message] = await Promise.all([ - page.waitForEvent('console'), - output.waitFor('press'), - page.press('input', 'Shift+Enter') - ]); - expect(output.text()).toContain(` - // Press Enter with modifiers - await page.press('input[name=name]', 'Shift+Enter');`); - expect(message.text()).toBe('press'); - }); - - it('should check', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(``); - const [message] = await Promise.all([ - page.waitForEvent('console'), - output.waitFor('check'), - page.dispatchEvent('input', 'click', { detail: 1 }) - ]); - await output.waitFor('check'); - expect(output.text()).toContain(` - // Check input[name=accept] - await page.check('input[name=accept]');`); - expect(message.text()).toBe("true"); - }); - - it('should uncheck', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(``); - const [message] = await Promise.all([ - page.waitForEvent('console'), - output.waitFor('uncheck'), - page.dispatchEvent('input', 'click', { detail: 1 }) - ]); - expect(output.text()).toContain(` - // Uncheck input[name=accept] - await page.uncheck('input[name=accept]');`); - expect(message.text()).toBe("false"); - }); - - it('should select', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(''); - const [message] = await Promise.all([ - page.waitForEvent('console'), - output.waitFor('select'), - page.selectOption('select', '2') - ]); - expect(output.text()).toContain(` - // Select select[id=age] - await page.selectOption('select[id=age]', '2');`); - expect(message.text()).toBe("2"); - }); - - it('should await popup', async function({context, page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent('link'); - const [popup] = await Promise.all([ - context.waitForEvent('page'), - output.waitFor('waitForEvent'), - page.dispatchEvent('a', 'click', { detail: 1 }) - ]); - expect(output.text()).toContain(` - // Click text="link" - const [popup1] = await Promise.all([ - page.waitForEvent('popup'), - await page.click('text="link"'); - ]);`); - expect(popup.url()).toBe(`${server.PREFIX}/popup/popup.html`); - }); - - it('should await navigation', async function({page, output, setContent, server}) { - await page.goto(server.EMPTY_PAGE); - await setContent(`link`); - await Promise.all([ - page.waitForNavigation(), - output.waitFor('waitForNavigation'), - page.dispatchEvent('a', 'click', { detail: 1 }) - ]); - expect(output.text()).toContain(` - // Click text="link" - await Promise.all([ - page.waitForNavigation({ url: '${server.PREFIX}/popup/popup.html' }), - page.click('text="link"') - ]);`); - expect(page.url()).toContain('/popup/popup.html'); - }); -}); diff --git a/test/test.config.js b/test/test.config.js index d7d3562275..73bcee3c25 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -120,7 +120,6 @@ module.exports = { './channels.spec.js', './ignorehttpserrors.spec.js', './popup.spec.js', - './recorder.spec.js', ], environments: [customEnvironment, 'browser'], },