chore: migrate to the sync engine query

This commit is contained in:
Pavel Feldman
2020-09-06 18:21:40 -07:00
parent b274301b10
commit d6a8703a34
22 changed files with 306 additions and 196 deletions
+49 -1
View File
@@ -2,7 +2,7 @@
[![npm version](https://img.shields.io/npm/v/playwright-cli.svg?style=flat)](https://www.npmjs.com/package/playwright) [![Join Slack](https://img.shields.io/badge/join-slack-infomational)](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.
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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> {
+81
View File
@@ -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();
+29
View File
@@ -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);
}
}
@@ -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))
-99
View File
@@ -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)!;
+54
View File
@@ -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) {
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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) {