parent
513899a3b9
commit
4c8ba3ed67
5
package-lock.json
generated
5
package-lock.json
generated
|
|
@ -3314,11 +3314,6 @@
|
||||||
"delayed-stream": "~1.0.0"
|
"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": {
|
"commondir": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.15.0"
|
"node": ">=10.15.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
|
||||||
"playwright": "lib/cli/index.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ctest": "cross-env BROWSER=chromium node --unhandled-rejections=strict test/test.js",
|
"ctest": "cross-env BROWSER=chromium node --unhandled-rejections=strict test/test.js",
|
||||||
"ftest": "cross-env BROWSER=firefox 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",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^5.1.0",
|
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
"extract-zip": "^2.0.0",
|
"extract-zip": "^2.0.0",
|
||||||
"https-proxy-agent": "^5.0.0",
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Writable } from 'stream';
|
|
||||||
import { isUnderTest, helper, deprecate} from './helper';
|
import { isUnderTest, helper, deprecate} from './helper';
|
||||||
import * as network from './network';
|
import * as network from './network';
|
||||||
import { Page, PageBinding } from './page';
|
import { Page, PageBinding } from './page';
|
||||||
|
|
@ -69,7 +68,6 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
|
||||||
readonly _downloads = new Set<Download>();
|
readonly _downloads = new Set<Download>();
|
||||||
readonly _browserBase: BrowserBase;
|
readonly _browserBase: BrowserBase;
|
||||||
readonly _apiLogger: Logger;
|
readonly _apiLogger: Logger;
|
||||||
private _debugController: DebugController | undefined;
|
|
||||||
|
|
||||||
constructor(browserBase: BrowserBase, options: BrowserContextOptions, isPersistentContext: boolean) {
|
constructor(browserBase: BrowserBase, options: BrowserContextOptions, isPersistentContext: boolean) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -82,16 +80,8 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initialize() {
|
async _initialize() {
|
||||||
if (helper.isDebugMode() || helper.isRecordMode()) {
|
if (helper.isDebugMode())
|
||||||
this._debugController = new DebugController(this, {
|
new DebugController(this);
|
||||||
recorderOutput: helper.isRecordMode() ? process.stdout : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_initDebugModeForTest(options: { recorderOutput: Writable }): DebugController {
|
|
||||||
this._debugController = new DebugController(this, options);
|
|
||||||
return this._debugController;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> {
|
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> {
|
||||||
|
|
|
||||||
120
src/cli/index.ts
120
src/cli/index.ts
|
|
@ -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 <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium')
|
|
||||||
.option('--headless', 'run in headless mode', false)
|
|
||||||
.option('--device <deviceName>', '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();
|
|
||||||
}
|
|
||||||
|
|
@ -14,24 +14,15 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Writable } from 'stream';
|
|
||||||
import { BrowserContextBase } from '../browserContext';
|
import { BrowserContextBase } from '../browserContext';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
import * as frames from '../frames';
|
import * as frames from '../frames';
|
||||||
import * as js from '../javascript';
|
import * as js from '../javascript';
|
||||||
import { Page } from '../page';
|
import { Page } from '../page';
|
||||||
import { RecorderController } from './recorderController';
|
|
||||||
import DebugScript from './injected/debugScript';
|
import DebugScript from './injected/debugScript';
|
||||||
|
|
||||||
export class DebugController {
|
export class DebugController {
|
||||||
private _options: { recorderOutput?: Writable | undefined };
|
constructor(context: BrowserContextBase) {
|
||||||
|
|
||||||
constructor(context: BrowserContextBase, options: { recorderOutput?: Writable | undefined }) {
|
|
||||||
this._options = options;
|
|
||||||
|
|
||||||
if (options.recorderOutput)
|
|
||||||
new RecorderController(context, options.recorderOutput);
|
|
||||||
|
|
||||||
context.on(Events.BrowserContext.Page, (page: Page) => {
|
context.on(Events.BrowserContext.Page, (page: Page) => {
|
||||||
for (const frame of page.frames())
|
for (const frame of page.frames())
|
||||||
this.ensureInstalledInFrame(frame);
|
this.ensureInstalledInFrame(frame);
|
||||||
|
|
@ -42,13 +33,8 @@ export class DebugController {
|
||||||
private async ensureInstalledInFrame(frame: frames.Frame): Promise<js.JSHandle<DebugScript> | undefined> {
|
private async ensureInstalledInFrame(frame: frames.Frame): Promise<js.JSHandle<DebugScript> | undefined> {
|
||||||
try {
|
try {
|
||||||
const mainContext = await frame._mainContext();
|
const mainContext = await frame._mainContext();
|
||||||
return await mainContext.createDebugScript({ console: true, record: !!this._options.recorderOutput });
|
return await mainContext.createDebugScript({ console: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureInstalledInFrameForTest(frame: frames.Frame): Promise<void> {
|
|
||||||
const handle = await this.ensureInstalledInFrame(frame);
|
|
||||||
await handle!.evaluate(debugScript => debugScript.recorder!.refreshListeners());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ConsoleAPI } from './consoleApi';
|
import { ConsoleAPI } from './consoleApi';
|
||||||
import { Recorder } from './recorder';
|
|
||||||
import InjectedScript from '../../injected/injectedScript';
|
import InjectedScript from '../../injected/injectedScript';
|
||||||
|
|
||||||
export default class DebugScript {
|
export default class DebugScript {
|
||||||
consoleAPI: ConsoleAPI | undefined;
|
consoleAPI: ConsoleAPI | undefined;
|
||||||
recorder: Recorder | undefined;
|
|
||||||
|
|
||||||
initialize(injectedScript: InjectedScript, options: { console?: boolean, record?: boolean }) {
|
initialize(injectedScript: InjectedScript, options: { console?: boolean }) {
|
||||||
if (options.console)
|
if (options.console)
|
||||||
this.consoleAPI = new ConsoleAPI(injectedScript);
|
this.consoleAPI = new ConsoleAPI(injectedScript);
|
||||||
if (options.record)
|
|
||||||
this.recorder = new Recorder(injectedScript);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<void>;
|
|
||||||
recordPlaywrightAction: (action: actions.Action) => Promise<void>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 '<selector>';
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Page, string>();
|
|
||||||
private _lastPopupOrdinal = 0;
|
|
||||||
private _timers = new Set<NodeJS.Timeout>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
return this._injectedScriptPromise;
|
return this._injectedScriptPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
createDebugScript(options: { record?: boolean, console?: boolean }): Promise<js.JSHandle<DebugScript> | undefined> {
|
createDebugScript(options: { console?: boolean }): Promise<js.JSHandle<DebugScript> | undefined> {
|
||||||
if (!this._debugScriptPromise) {
|
if (!this._debugScriptPromise) {
|
||||||
const source = `new (${debugScriptSource.source})()`;
|
const source = `new (${debugScriptSource.source})()`;
|
||||||
this._debugScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId)).then(async debugScript => {
|
this._debugScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId)).then(async debugScript => {
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,7 @@ export type RegisteredListener = {
|
||||||
|
|
||||||
export type Listener = (...args: any[]) => void;
|
export type Listener = (...args: any[]) => void;
|
||||||
|
|
||||||
let isInDebugMode = !!getFromENV('PWDEBUG');
|
const isInDebugMode = !!getFromENV('PWDEBUG');
|
||||||
let isInRecordMode = false;
|
|
||||||
|
|
||||||
const deprecatedHits = new Set();
|
const deprecatedHits = new Set();
|
||||||
export function deprecate(methodName: string, message: string) {
|
export function deprecate(methodName: string, message: string) {
|
||||||
|
|
@ -320,18 +319,6 @@ class Helper {
|
||||||
static isDebugMode(): boolean {
|
static isDebugMode(): boolean {
|
||||||
return isInDebugMode;
|
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 {
|
export function assert(value: any, message?: string): asserts value {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
|
|
@ -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(`<button onclick="console.log('click')">Submit</button>`);
|
|
||||||
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(`<button onclick="console.log('click')">Submit</button>`);
|
|
||||||
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(`<input id="input" name="name" oninput="console.log(input.value)"></input>`);
|
|
||||||
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(`<input name="name" onkeypress="console.log('press')"></input>`);
|
|
||||||
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(`<input id="checkbox" type="checkbox" name="accept" onchange="console.log(checkbox.checked)"></input>`);
|
|
||||||
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(`<input id="checkbox" type="checkbox" checked name="accept" onchange="console.log(checkbox.checked)"></input>`);
|
|
||||||
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('<select id="age" onchange="console.log(age.selectedOptions[0].value)"><option value="1"><option value="2"></select>');
|
|
||||||
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('<a target=_blank rel=noopener href="/popup/popup.html">link</a>');
|
|
||||||
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(`<a onclick="setTimeout(() => window.location.href='${server.PREFIX}/popup/popup.html', 1000)">link</a>`);
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -120,7 +120,6 @@ module.exports = {
|
||||||
'./channels.spec.js',
|
'./channels.spec.js',
|
||||||
'./ignorehttpserrors.spec.js',
|
'./ignorehttpserrors.spec.js',
|
||||||
'./popup.spec.js',
|
'./popup.spec.js',
|
||||||
'./recorder.spec.js',
|
|
||||||
],
|
],
|
||||||
environments: [customEnvironment, 'browser'],
|
environments: [customEnvironment, 'browser'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue