mirror of
https://github.com/microsoft/playwright-cli.git
synced 2026-09-14 19:59:39 +08:00
chore: migrate to the sync engine query
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) [](https://join.slack.com/t/playwright/shared_invite/enQtOTEyMTUxMzgxMjIwLThjMDUxZmIyNTRiMTJjNjIyMzdmZDA3MTQxZWUwZTFjZjQwNGYxZGM5MzRmNzZlMWI5ZWUyOTkzMjE5Njg1NDg)
|
||||
|
||||
## [Usage](#usage) | [Examples](#examples)
|
||||
## [Usage](#usage) | [Examples](#examples) | [DevTools API](#devtools-api)
|
||||
|
||||
Playwright CLI is a CLI wrapper around the [Playwright](https://github.com/Microsoft/playwright) library.
|
||||
|
||||
@@ -124,9 +124,57 @@ $ npx playwright-cli pdf https://en.wikipedia.org/wiki/PDF wiki.pdf
|
||||
|
||||
### Generate Playwright code
|
||||
|
||||
Run `codegen` and perform actions to one or multiple pages. Playwright will generate simple script that will capture the right pages, frames, popups, downloads, etc. It'll attempt to generate a resilient text-based selectors for user actions. This script is available right in the terminal.
|
||||
|
||||
```sh
|
||||
# Run the generator
|
||||
$ npx playwright-cli codegen wikipedia.org
|
||||
```
|
||||
|
||||
<img width="600px" src="https://user-images.githubusercontent.com/883973/92158503-dd54c980-ede0-11ea-95f0-0d8550818871.png">
|
||||
|
||||
## DevTools API
|
||||
|
||||
You can use following API inside the Dev Tools console of the respective browser:
|
||||
|
||||
### playwright.$(selector)
|
||||
|
||||
Query Playwright selector, using the actual Playwright query engine, for example:
|
||||
|
||||
```js
|
||||
> playwright.$('.auth-form >> text=Log in');
|
||||
|
||||
<button>Log in</button>
|
||||
```
|
||||
|
||||
### playwright.$$(selector)
|
||||
|
||||
Same as `playwright.$`, but returns all matching elements.
|
||||
|
||||
```js
|
||||
> playwright.$$('li >> text=John')
|
||||
|
||||
> [<li>, <li>, <li>, <li>]
|
||||
```
|
||||
|
||||
### playwright.inspect(selector)
|
||||
|
||||
Reveal element in the Elements panel (if DevTools of the respective browser support that).
|
||||
|
||||
```js
|
||||
> playwright.inspect('text=Log in')
|
||||
|
||||
<button>Log in</button>
|
||||
```
|
||||
|
||||
### playwright.selector(element)
|
||||
|
||||
Generates selector for the given element:
|
||||
|
||||
```js
|
||||
> playwright.selector($0)
|
||||
|
||||
"div[id="glow-ingress-block"] >> text=/.*Hello.*/"
|
||||
```
|
||||
|
||||
> Note that opening WebKit Web Inspector disconnects Playwright from the browser, so once it is open, code generation is no longer happening.
|
||||
Generated
+3
-3
@@ -4234,9 +4234,9 @@
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"version": "1.3.0-next.1599240263382",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.3.0-next.1599240263382.tgz",
|
||||
"integrity": "sha512-8fuybPv16Z7cwqaVsNlkQaXdeHuR/2nFbVLQ8DR5b3RtvfFWsjUlMobvEWK8BH9x6Ylaz+jgAksXV7UdVICZ+Q==",
|
||||
"version": "1.3.0-next.1599453530390",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.3.0-next.1599453530390.tgz",
|
||||
"integrity": "sha512-nBk4zJnq2xCjyvZILiOpXgUD05N8OE5tD57rqGOZ5pBKl/BkXneEAWU85CdOritILMUGSLosmqU6SKAnzV+q/A==",
|
||||
"requires": {
|
||||
"debug": "^4.1.1",
|
||||
"extract-zip": "^2.0.1",
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"dependencies": {
|
||||
"commander": "^6.1.0",
|
||||
"highlight.js": "^10.1.2",
|
||||
"playwright": "1.3.0-next.1599240263382"
|
||||
"playwright": "1.3.0-next.1599453530390"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test-runner": "^0.3.2",
|
||||
|
||||
+10
-10
@@ -17,10 +17,11 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import * as program from 'commander';
|
||||
import * as playwright from 'playwright';
|
||||
import * as os from 'os';
|
||||
import { RecorderController } from './recorder/recorderController';
|
||||
import { BrowserContext, Page, Browser } from 'playwright';
|
||||
import * as playwright from 'playwright';
|
||||
import { Browser, BrowserContext, Page } from 'playwright';
|
||||
import { RecorderController } from './recorderController';
|
||||
import { ScriptController } from './scriptController';
|
||||
|
||||
program
|
||||
.version('Version ' + require('../package.json').version)
|
||||
@@ -39,7 +40,7 @@ program
|
||||
.command('open [url]')
|
||||
.description('open page in browser specified via -b, --browser')
|
||||
.action(function(url, command) {
|
||||
open(command.parent, url);
|
||||
open(command.parent, url, false);
|
||||
}).on('--help', function() {
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
@@ -59,7 +60,7 @@ for (const {alias, name, type} of browsers) {
|
||||
.command(`${alias} [url]`)
|
||||
.description(`open page in ${name}`)
|
||||
.action(function(url, command) {
|
||||
open({ ...command.parent, browser: type }, url);
|
||||
open({ ...command.parent, browser: type }, url, false);
|
||||
}).on('--help', function() {
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
@@ -239,8 +240,9 @@ async function openPage(context: playwright.BrowserContext, url: string | undefi
|
||||
return page;
|
||||
}
|
||||
|
||||
async function open(options: Options, url: string | undefined) {
|
||||
const { context } = await launchContext(options, false);
|
||||
async function open(options: Options, url: string | undefined, enableRecorder: boolean) {
|
||||
const { context, browserName, launchOptions, contextOptions } = await launchContext(options, false);
|
||||
new ScriptController(browserName, launchOptions, contextOptions, context, process.stdout, enableRecorder);
|
||||
await openPage(context, url);
|
||||
}
|
||||
|
||||
@@ -280,9 +282,7 @@ async function pdf(options: Options, captureOptions: CaptureOptions, url: string
|
||||
}
|
||||
|
||||
async function codegen(options: Options, url: string | undefined) {
|
||||
const { context, browserName, launchOptions, contextOptions } = await launchContext(options, false);
|
||||
new RecorderController(browserName, launchOptions, contextOptions, context, process.stdout);
|
||||
await openPage(context, url);
|
||||
return open(options, url, true);
|
||||
}
|
||||
|
||||
function lookupBrowserType(options: Options): playwright.BrowserType<playwright.WebKitBrowser | playwright.ChromiumBrowser | playwright.FirefoxBrowser> {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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 { buildSelector } from "./selectorGenerator";
|
||||
|
||||
export type ParsedSelector = {
|
||||
parts: {
|
||||
name: string,
|
||||
body: string,
|
||||
}[],
|
||||
capture?: number,
|
||||
};
|
||||
|
||||
export interface InjectedScript {
|
||||
parseSelector(selector: string): ParsedSelector;
|
||||
engines: Set<string>;
|
||||
querySelectorAll(selector: ParsedSelector, document: Document): Element[];
|
||||
};
|
||||
|
||||
export class ConsoleAPI {
|
||||
private _injectedScript: InjectedScript;
|
||||
|
||||
constructor(injectedScript: InjectedScript) {
|
||||
this._injectedScript = injectedScript;
|
||||
(window as any).playwright = {
|
||||
$: (selector: string) => this.querySelector(selector),
|
||||
$$: (selector: string) => this.querySelectorAll(selector),
|
||||
inspect: (selector: string) => this.inspect(selector),
|
||||
selector: (element: Element) => this.buildSelector(element).selector,
|
||||
};
|
||||
}
|
||||
|
||||
private _checkSelector(parsed: ParsedSelector) {
|
||||
for (const {name} of parsed.parts) {
|
||||
if (!this._injectedScript.engines.has(name))
|
||||
throw new Error(`Unknown engine "${name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
querySelector(selector: string): (Element | undefined) {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.$('Playwright >> selector').`);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
this._checkSelector(parsed);
|
||||
const elements = this._injectedScript.querySelectorAll(parsed, document);
|
||||
return elements[0];
|
||||
}
|
||||
|
||||
querySelectorAll(selector: string): Element[] {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
this._checkSelector(parsed);
|
||||
return this._injectedScript.querySelectorAll(parsed, document);
|
||||
}
|
||||
|
||||
inspect(selector: string) {
|
||||
if (typeof (window as any).inspect !== 'function')
|
||||
return;
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.inspect('Playwright >> selector').`);
|
||||
(window as any).inspect(this.querySelector(selector));
|
||||
}
|
||||
|
||||
buildSelector(element: Element): { selector: string, elements: Element[] } {
|
||||
return buildSelector(this._injectedScript, element);
|
||||
}
|
||||
}
|
||||
@@ -15,24 +15,21 @@
|
||||
*/
|
||||
|
||||
import type * as actions from '../recorderActions';
|
||||
import { ConsoleAPI, InjectedScript } from './consoleApi';
|
||||
import { html } from './html';
|
||||
import { RegisteredListener, addEventListener, removeEventListeners } from './util';
|
||||
import { Throttler } from './throttler';
|
||||
import { buildSelector } from './selectorGenerator';
|
||||
import { addEventListener, RegisteredListener, removeEventListeners } from './util';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
performPlaywrightAction: (action: actions.Action) => Promise<void>;
|
||||
recordPlaywrightAction: (action: actions.Action) => Promise<void>;
|
||||
commitLastAction: () => Promise<void>;
|
||||
queryPlaywrightSelector: (selector: string) => Promise<Element[]>;
|
||||
playwrightRecorderScript: RecorderScript;
|
||||
}
|
||||
}
|
||||
|
||||
const recorderSymbol = Symbol('recorderSymbol');
|
||||
const scriptSymbol = Symbol('scriptSymbol');
|
||||
|
||||
export default class RecorderScript {
|
||||
export class Recorder {
|
||||
private _performingAction = false;
|
||||
private _outerGlassPaneElement: HTMLElement;
|
||||
private _glassPaneShadow: ShadowRoot;
|
||||
@@ -42,11 +39,11 @@ export default class RecorderScript {
|
||||
private _listeners: RegisteredListener[] = [];
|
||||
private _hoveredModel: HighlightModel | null = null;
|
||||
private _hoveredElement: HTMLElement | null = null;
|
||||
private _throttler = new Throttler(50);
|
||||
private _activeModel: HighlightModel | null = null;
|
||||
private _consoleAPI: ConsoleAPI;
|
||||
|
||||
constructor() {
|
||||
window.playwrightRecorderScript = this;
|
||||
constructor(injectedScript: InjectedScript, consoleAPI: ConsoleAPI) {
|
||||
this._consoleAPI = consoleAPI;
|
||||
|
||||
this._outerGlassPaneElement = html`
|
||||
<x-pw-glass style="
|
||||
@@ -101,12 +98,13 @@ export default class RecorderScript {
|
||||
setInterval(() => {
|
||||
this._refreshListenersIfNeeded();
|
||||
}, 100);
|
||||
this._consoleAPI = new ConsoleAPI(injectedScript);
|
||||
}
|
||||
|
||||
private _refreshListenersIfNeeded() {
|
||||
if ((document.documentElement as any)[recorderSymbol])
|
||||
if ((document.documentElement as any)[scriptSymbol])
|
||||
return;
|
||||
(document.documentElement as any)[recorderSymbol] = true;
|
||||
(document.documentElement as any)[scriptSymbol] = true;
|
||||
removeEventListeners(this._listeners);
|
||||
this._listeners = [
|
||||
addEventListener(document, 'click', event => this._onClick(event as MouseEvent), true),
|
||||
@@ -182,31 +180,32 @@ export default class RecorderScript {
|
||||
return;
|
||||
this._hoveredElement = event.target as HTMLElement | null;
|
||||
// Mouse moved -> mark last action as committed via committing a commit action.
|
||||
this._throttler.schedule(() => this._commitActionAndUpdateModelForHoveredElement());
|
||||
this._commitActionAndUpdateModelForHoveredElement();
|
||||
}
|
||||
|
||||
private _onMouseLeave(event: MouseEvent) {
|
||||
// Leaving iframe.
|
||||
if ((event.target as Node).nodeType === Node.DOCUMENT_NODE) {
|
||||
this._hoveredElement = null;
|
||||
this._throttler.schedule(() => this._commitActionAndUpdateModelForHoveredElement(), true);
|
||||
this._commitActionAndUpdateModelForHoveredElement();
|
||||
}
|
||||
}
|
||||
|
||||
private async _onFocus(event: FocusEvent) {
|
||||
const result = document.activeElement ? await buildSelector(document.activeElement) : null;
|
||||
private _onFocus(event: FocusEvent) {
|
||||
const result = document.activeElement ? this._consoleAPI.buildSelector(document.activeElement) : null;
|
||||
this._activeModel = result && result.selector ? result : null;
|
||||
if ((window as any)._highlightUpdatedForTest)
|
||||
(window as any)._highlightUpdatedForTest(result ? result.selector : null);
|
||||
}
|
||||
|
||||
private async _commitActionAndUpdateModelForHoveredElement() {
|
||||
private _commitActionAndUpdateModelForHoveredElement() {
|
||||
if (!this._hoveredElement) {
|
||||
this._hoveredModel = null;
|
||||
this._updateHighlight();
|
||||
return;
|
||||
}
|
||||
const hoveredElement = this._hoveredElement;
|
||||
const { selector, elements } = await buildSelector(hoveredElement);
|
||||
const { selector, elements } = this._consoleAPI.buildSelector(hoveredElement);
|
||||
if ((this._hoveredModel && this._hoveredModel.selector === selector) || this._hoveredElement !== hoveredElement)
|
||||
return;
|
||||
window.commitLastAction();
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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 { ConsoleAPI, InjectedScript } from './consoleAPI';
|
||||
import { Recorder } from './recorder';
|
||||
|
||||
export default class Script {
|
||||
private _consoleAPI: ConsoleAPI;
|
||||
private _recorder: Recorder | undefined;
|
||||
|
||||
constructor(injectedScript: InjectedScript, options: { enableRecorder: boolean }) {
|
||||
this._consoleAPI = new ConsoleAPI(injectedScript);
|
||||
if (options.enableRecorder)
|
||||
this._recorder = new Recorder(injectedScript, this._consoleAPI);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const InlineSource = require('../../../utils/webpack-inline-source-plugin');
|
||||
const InlineSource = require('../../utils/webpack-inline-source-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: path.join(__dirname, 'recorderScript.ts'),
|
||||
entry: path.join(__dirname, 'script.ts'),
|
||||
devtool: 'source-map',
|
||||
module: {
|
||||
rules: [
|
||||
@@ -37,10 +37,10 @@ module.exports = {
|
||||
},
|
||||
output: {
|
||||
libraryTarget: 'var',
|
||||
filename: 'recorderScriptSource.js',
|
||||
path: path.resolve(__dirname, '../../../lib/packed')
|
||||
filename: 'scriptSource.js',
|
||||
path: path.resolve(__dirname, '../../lib/packed')
|
||||
},
|
||||
plugins: [
|
||||
new InlineSource(path.join(__dirname, '..', '..', 'generated', 'recorderScriptSource.ts')),
|
||||
new InlineSource(path.join(__dirname, '..', 'generated', 'scriptSource.ts')),
|
||||
]
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ConsoleAPI, InjectedScript } from './consoleApi';
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
@@ -16,15 +17,16 @@
|
||||
|
||||
import { XPathEngine } from './xpathSelectorEngine';
|
||||
|
||||
export async function buildSelector(targetElement: Element): Promise<{ selector: string, elements: Element[] }> {
|
||||
export function buildSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } {
|
||||
const path: SelectorToken[] = [];
|
||||
let numberOfMatchingElements = Number.MAX_SAFE_INTEGER;
|
||||
for (let element: Element | null = targetElement; element && element !== document.documentElement; element = element.parentElement) {
|
||||
const selector = buildSelectorCandidate(element);
|
||||
const selector = buildSelectorCandidate(injectedScript, element);
|
||||
if (!selector)
|
||||
continue;
|
||||
const fullSelector = joinSelector([selector, ...path]);
|
||||
const selectorTargets = await window.queryPlaywrightSelector(fullSelector);
|
||||
const parsedSelector = injectedScript.parseSelector(fullSelector);
|
||||
const selectorTargets = injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument);
|
||||
if (!selectorTargets.length)
|
||||
break;
|
||||
if (selectorTargets[0] === targetElement)
|
||||
@@ -35,13 +37,14 @@ export async function buildSelector(targetElement: Element): Promise<{ selector:
|
||||
}
|
||||
}
|
||||
const xpathSelector = XPathEngine.create(document.documentElement, targetElement, 'default')!;
|
||||
const parsedSelector = injectedScript.parseSelector(xpathSelector);
|
||||
return {
|
||||
selector: xpathSelector,
|
||||
elements: await window.queryPlaywrightSelector(xpathSelector)
|
||||
elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument)
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectorCandidate(element: Element): SelectorToken | null {
|
||||
function buildSelectorCandidate(injectedScript: InjectedScript, element: Element): SelectorToken | null {
|
||||
const nodeName = element.nodeName.toLowerCase();
|
||||
for (const attribute of ['data-testid', 'data-test-id', 'data-test']) {
|
||||
if (element.hasAttribute(attribute))
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications 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 Throttler {
|
||||
private _timeout: number;
|
||||
private _isRunningProcess = false;
|
||||
private _asSoonAsPossible = false;
|
||||
private _process: (() => Promise<void>) | null = null;
|
||||
private _lastCompleteTime = 0;
|
||||
private _schedulePromise: Promise<void>;
|
||||
private _scheduleResolve: (() => void) | undefined;
|
||||
private _processTimeout: any;
|
||||
|
||||
constructor(timeout: number) {
|
||||
this._timeout = timeout;
|
||||
this._schedulePromise = new Promise(fulfill => {
|
||||
this._scheduleResolve = fulfill;
|
||||
});
|
||||
}
|
||||
|
||||
private _processCompleted() {
|
||||
this._lastCompleteTime = this._getTime();
|
||||
this._isRunningProcess = false;
|
||||
if (this._process)
|
||||
this._innerSchedule(false);
|
||||
}
|
||||
|
||||
private _onTimeout() {
|
||||
delete this._processTimeout;
|
||||
this._asSoonAsPossible = false;
|
||||
this._isRunningProcess = true;
|
||||
|
||||
Promise.resolve()
|
||||
.then(this._process)
|
||||
.catch(console.error.bind(console))
|
||||
.then(this._processCompleted.bind(this))
|
||||
.then(this._scheduleResolve);
|
||||
this._schedulePromise = new Promise(fulfill => {
|
||||
this._scheduleResolve = fulfill;
|
||||
});
|
||||
this._process = null;
|
||||
}
|
||||
|
||||
schedule(process: () => Promise<any>, asSoonAsPossible?: boolean): Promise<void> {
|
||||
// Deliberately skip previous process.
|
||||
this._process = process;
|
||||
|
||||
// Run the first scheduled task instantly.
|
||||
const hasScheduledTasks = !!this._processTimeout || this._isRunningProcess;
|
||||
const okToFire = this._getTime() - this._lastCompleteTime > this._timeout;
|
||||
asSoonAsPossible = !!asSoonAsPossible || (!hasScheduledTasks && okToFire);
|
||||
const forceTimerUpdate = asSoonAsPossible && !this._asSoonAsPossible;
|
||||
this._asSoonAsPossible = this._asSoonAsPossible || asSoonAsPossible;
|
||||
this._innerSchedule(forceTimerUpdate);
|
||||
|
||||
return this._schedulePromise;
|
||||
}
|
||||
|
||||
private _innerSchedule(forceTimerUpdate: boolean) {
|
||||
if (this._isRunningProcess) {
|
||||
return;
|
||||
}
|
||||
if (this._processTimeout && !forceTimerUpdate) {
|
||||
return;
|
||||
}
|
||||
if (this._processTimeout) {
|
||||
this._clearTimeout(this._processTimeout);
|
||||
}
|
||||
|
||||
const timeout = this._asSoonAsPossible ? 0 : this._timeout;
|
||||
this._processTimeout = this._setTimeout(this._onTimeout.bind(this), timeout);
|
||||
}
|
||||
|
||||
private _clearTimeout(timeoutId: number) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
private _setTimeout(operation: () => void, timeout: number): number {
|
||||
return window.setTimeout(operation, timeout);
|
||||
}
|
||||
|
||||
private _getTime(): number {
|
||||
return window.performance.now();
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,6 @@ export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageActi
|
||||
export type NavigationSignal = {
|
||||
name: 'navigation',
|
||||
url: string,
|
||||
type: 'assert' | 'await',
|
||||
};
|
||||
|
||||
export type PopupSignal = {
|
||||
@@ -19,11 +19,9 @@ import { Writable } from 'stream';
|
||||
import * as actions from './recorderActions';
|
||||
import { TerminalOutput } from './terminalOutput';
|
||||
import { BindingSource, toClickOptions, toModifiers } from './utils';
|
||||
import * as recorderScriptSource from '../generated/recorderScriptSource';
|
||||
|
||||
export class RecorderController {
|
||||
private _output: TerminalOutput;
|
||||
private _performingAction = false;
|
||||
private _pageAliases = new Map<playwright.Page, string>();
|
||||
private _lastPopupOrdinal = 0;
|
||||
private _timers = new Set<NodeJS.Timeout>();
|
||||
@@ -42,14 +40,7 @@ export class RecorderController {
|
||||
|
||||
// Commits last action so that no furhter signals are added to it.
|
||||
context.exposeBinding('commitLastAction',
|
||||
(source: BindingSource, action: actions.Action) => this._commitLastAction()).catch(e => {});
|
||||
|
||||
// Other non-essential actions are simply being recorded.
|
||||
context.exposeBinding('queryPlaywrightSelector', async (source: BindingSource, selector: string) => {
|
||||
return await source.frame.$$(selector).catch(e => []);
|
||||
});
|
||||
|
||||
context.addInitScript(`new (${recorderScriptSource.source})()`);
|
||||
(source: BindingSource, action: actions.Action) => this._output.commitLastAction()).catch(e => {});
|
||||
|
||||
context.on('page', page => this._onPage(page));
|
||||
for (const page of context.pages())
|
||||
@@ -95,14 +86,7 @@ export class RecorderController {
|
||||
}
|
||||
}
|
||||
|
||||
private _commitLastAction() {
|
||||
const action = this._output.lastAction();
|
||||
if (action)
|
||||
action.committed = true;
|
||||
}
|
||||
|
||||
private async _performAction(frame: playwright.Frame, page: playwright.Page, action: actions.Action) {
|
||||
this._performingAction = true;
|
||||
this._output.willPerformAction(this._pageAliases.get(page)!, frame, action);
|
||||
if (action.name === 'click') {
|
||||
const { options } = toClickOptions(action);
|
||||
@@ -119,7 +103,6 @@ export class RecorderController {
|
||||
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);
|
||||
@@ -137,32 +120,13 @@ export class RecorderController {
|
||||
if (frame.parentFrame())
|
||||
return;
|
||||
const pageAlias = this._pageAliases.get(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',
|
||||
committed: true,
|
||||
url: frame.url(),
|
||||
signals: [],
|
||||
});
|
||||
}
|
||||
this._output.signal(pageAlias!, frame, { name: 'navigation', url: frame.url() });
|
||||
}
|
||||
|
||||
private _onPopup(page: playwright.Page, popup: playwright.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 });
|
||||
}
|
||||
this._output.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias });
|
||||
}
|
||||
private _onDownload(page: playwright.Page, download: playwright.Download) {
|
||||
const pageAlias = this._pageAliases.get(page)!;
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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 * as playwright from 'playwright';
|
||||
import * as injectedScriptSource from './generated/scriptSource';
|
||||
import { Writable } from 'stream';
|
||||
import { RecorderController } from './recorderController';
|
||||
|
||||
const toImpl = (playwright as any)._toImpl;
|
||||
|
||||
const scriptSymbol = Symbol('script');
|
||||
|
||||
export class ScriptController {
|
||||
private _recorder: RecorderController | undefined;
|
||||
|
||||
constructor(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, context: playwright.BrowserContext, output: Writable, enableRecorder: boolean) {
|
||||
if (enableRecorder)
|
||||
this._recorder = new RecorderController(browserName, launchOptions, contextOptions, context, output);
|
||||
context.on('page', page => this._onPage(page));
|
||||
for (const page of context.pages())
|
||||
this._onPage(page);
|
||||
}
|
||||
|
||||
private async _onPage(page: playwright.Page) {
|
||||
// Install in all frames.
|
||||
for (const frame of page.frames())
|
||||
this._ensureInstalledInFrame(frame);
|
||||
page.on('framenavigated', frame => this._ensureInstalledInFrame(frame));
|
||||
}
|
||||
|
||||
private async _ensureInstalledInFrame(frame: playwright.Frame) {
|
||||
try {
|
||||
const mainContext = await toImpl(frame)._mainContext();
|
||||
if (mainContext[scriptSymbol])
|
||||
return;
|
||||
mainContext[scriptSymbol] = true;
|
||||
await mainContext.extendInjectedScript(injectedScriptSource.source, { enableRecorder: !!this._recorder });
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,12 @@ export class TerminalOutput {
|
||||
this._printAction(pageAlias, frame, action, eraseLastAction);
|
||||
}
|
||||
|
||||
commitLastAction() {
|
||||
const action = this._lastAction;
|
||||
if (action)
|
||||
action.committed = true;
|
||||
}
|
||||
|
||||
_printAction(pageAlias: string, frame: playwright.Frame, action: Action, eraseLastAction: boolean) {
|
||||
// We erase terminating `})();` at all times.
|
||||
let eraseLines = 1;
|
||||
@@ -105,29 +111,37 @@ export class TerminalOutput {
|
||||
for (let i = 0; i < eraseLines; ++i)
|
||||
this._out.write('\u001B[1A\u001B[2K');
|
||||
|
||||
const performingAction = !!this._currentAction;
|
||||
this._currentAction = undefined;
|
||||
this._lastAction = action;
|
||||
this._lastActionText = this._generateAction(pageAlias, frame, action);
|
||||
this._lastActionText = this._generateAction(pageAlias, frame, action, performingAction);
|
||||
this._out.write(this._lastActionText + '\n})();\n');
|
||||
}
|
||||
|
||||
lastAction(): Action | undefined {
|
||||
return this._lastAction;
|
||||
}
|
||||
|
||||
signal(pageAlias: string, frame: playwright.Frame, signal: Signal) {
|
||||
// Signal either arrives while action is being performed or shortly after.
|
||||
if (this._currentAction) {
|
||||
this._currentAction.signals.push(signal);
|
||||
return;
|
||||
}
|
||||
if (this._lastAction) {
|
||||
if (this._lastAction && !this._lastAction.committed) {
|
||||
this._lastAction.signals.push(signal);
|
||||
this._printAction(pageAlias, frame, this._lastAction, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.name === 'navigation') {
|
||||
this.addAction(
|
||||
pageAlias!, frame, {
|
||||
name: 'navigate',
|
||||
committed: true,
|
||||
url: frame.url(),
|
||||
signals: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _generateAction(pageAlias: string, frame: playwright.Frame, action: Action): string {
|
||||
private _generateAction(pageAlias: string, frame: playwright.Frame, action: Action, performingAction: boolean): string {
|
||||
const formatter = new Formatter(2);
|
||||
formatter.newLine();
|
||||
formatter.add('// ' + actionTitle(action));
|
||||
@@ -162,8 +176,8 @@ export class TerminalOutput {
|
||||
});`)
|
||||
}
|
||||
|
||||
const waitForNavigation = navigationSignal && navigationSignal.type === 'await';
|
||||
const assertNavigation = navigationSignal && navigationSignal.type === 'assert';
|
||||
const waitForNavigation = navigationSignal && !performingAction;
|
||||
const assertNavigation = navigationSignal && performingAction;
|
||||
|
||||
const emitPromiseAll = waitForNavigation || popupSignal || downloadSignal;
|
||||
if (emitPromiseAll) {
|
||||
@@ -17,7 +17,8 @@
|
||||
import * as http from 'http'
|
||||
import * as playwright from 'playwright';
|
||||
import { parameters, fixtures as baseFixtures} from '@playwright/test-runner';
|
||||
import { RecorderController } from '../lib/recorder/recorderController';
|
||||
import { ScriptController } from '../lib/scriptController';
|
||||
import { RecorderController } from '../lib/recorderController';
|
||||
import { Page } from 'playwright';
|
||||
|
||||
type WorkerFixtures = {
|
||||
@@ -82,7 +83,7 @@ fixtures.registerWorkerFixture('httpServer', async ({parallelIndex}, runTest) =>
|
||||
fixtures.registerFixture('contextWrapper', async ({ browser }, runTest, info) => {
|
||||
const context = await browser.newContext();
|
||||
const output = new WritableBuffer();
|
||||
new RecorderController('chromium', {}, {}, context, output);
|
||||
new ScriptController('chromium', {}, {}, context, output, true);
|
||||
await runTest({ context, output });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
+19
-2
@@ -110,8 +110,6 @@ it('should update selected element after pressing Tab', async ({ page, recorder
|
||||
<input name="two"></input>
|
||||
`);
|
||||
|
||||
const selector = await recorder.hoverOverElement('input');
|
||||
expect(selector).toBe('input[name="one"]');
|
||||
await page.click('input[name="one"]');
|
||||
await recorder.waitForOutput('click');
|
||||
await page.keyboard.type('foobar123');
|
||||
@@ -225,6 +223,25 @@ it('should await popup', async ({ page, recorder }) => {
|
||||
expect(popup.url()).toBe('about:blank');
|
||||
});
|
||||
|
||||
it('should assert navigation', async ({ page, recorder }) => {
|
||||
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
|
||||
|
||||
const selector = await recorder.hoverOverElement('a');
|
||||
expect(selector).toBe('text="link"');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForNavigation(),
|
||||
recorder.waitForOutput('assert'),
|
||||
page.dispatchEvent('a', 'click', { detail: 1 })
|
||||
]);
|
||||
expect(recorder.output()).toContain(`
|
||||
// Click text="link"
|
||||
await page.click('text="link"');
|
||||
// assert.equal(page.url(), 'about:blank#foo');`);
|
||||
expect(page.url()).toContain('about:blank#foo');
|
||||
});
|
||||
|
||||
|
||||
it('should await navigation', async ({ page, recorder }) => {
|
||||
await recorder.setContentAndWait(`<a onclick="setTimeout(() => window.location.href='about:blank#foo', 1000)">link</a>`);
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ const child_process = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const files = [
|
||||
path.join('src', 'recorder', 'injected', 'recorderScript.webpack.config.js'),
|
||||
path.join('src', 'injected', 'script.webpack.config.js'),
|
||||
];
|
||||
|
||||
function runOne(runner, file) {
|
||||
|
||||
Reference in New Issue
Block a user