chore: form the debug script for authoring hints / helpers (#2551)

This commit is contained in:
Pavel Feldman 2020-06-11 18:18:33 -07:00 committed by GitHub
parent 48088222ed
commit 894826dec0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 835 additions and 477 deletions

View File

@ -19,13 +19,12 @@ import { helper } from './helper';
import * as network from './network';
import { Page, PageBinding } from './page';
import { TimeoutSettings } from './timeoutSettings';
import * as frames from './frames';
import * as types from './types';
import { Events } from './events';
import { Download } from './download';
import { BrowserBase } from './browser';
import { InnerLogger, Logger } from './logger';
import { FunctionWithSource } from './frames';
import * as debugSupport from './debug/debugSupport';
import { EventEmitter } from 'events';
import { ProgressController } from './progress';
@ -69,7 +68,7 @@ export interface BrowserContext {
setOffline(offline: boolean): Promise<void>;
setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>;
addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise<void>;
exposeBinding(name: string, playwrightBinding: FunctionWithSource): Promise<void>;
exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource): Promise<void>;
exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>;
@ -99,7 +98,21 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
}
async _initialize() {
await debugSupport.installConsoleHelpers(this);
if (!helper.isDebugMode())
return;
const installInFrame = async (frame: frames.Frame) => {
try {
const mainContext = await frame._mainContext();
await mainContext.debugScript();
} catch (e) {
}
};
this.on(Events.BrowserContext.Page, (page: Page) => {
for (const frame of page.frames())
installInFrame(frame);
page.on(Events.Page.FrameNavigated, installInFrame);
});
}
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> {
@ -147,7 +160,7 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
await this.exposeBinding(name, (options, ...args: any) => playwrightFunction(...args));
}
async exposeBinding(name: string, playwrightBinding: FunctionWithSource): Promise<void> {
async exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource): Promise<void> {
for (const page of this.pages()) {
if (page._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered in one of the pages`);

View File

@ -29,7 +29,7 @@ import { readProtocolStream } from './crProtocolHelper';
import { Events } from './events';
import { Protocol } from './protocol';
import { CRExecutionContext } from './crExecutionContext';
import { CRDevTools } from '../debug/crDevTools';
import { CRDevTools } from './crDevTools';
export class CRBrowser extends BrowserBase {
readonly _connection: CRConnection;

View File

@ -20,7 +20,7 @@ import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } f
import { Protocol } from './protocol';
import { EventEmitter } from 'events';
import { InnerLogger, errorLog } from '../logger';
import { rewriteErrorMessage } from '../debug/stackTrace';
import { rewriteErrorMessage } from '../utils/stackTrace';
export const ConnectionEvents = {
Disconnected: Symbol('ConnectionEvents.Disconnected')

View File

@ -19,7 +19,7 @@ import { CRSession } from './crConnection';
import { assert, helper, RegisteredListener } from '../helper';
import { Protocol } from './protocol';
import * as types from '../types';
import * as debugSupport from '../debug/debugSupport';
import * as sourceMap from '../utils/sourceMap';
type JSRange = {
startOffset: number,
@ -122,7 +122,7 @@ class JSCoverage {
async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) {
// Ignore playwright-injected scripts
if (debugSupport.isPlaywrightSourceUrl(event.url))
if (sourceMap.isPlaywrightSourceUrl(event.url))
return;
this._scriptIds.add(event.scriptId);
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true.

View File

@ -16,7 +16,7 @@
import * as fs from 'fs';
import * as util from 'util';
import { CRSession } from '../chromium/crConnection';
import { CRSession } from './crConnection';
const kBindingName = '__pw_devtools__';

View File

@ -19,9 +19,9 @@ import { CRSession } from './crConnection';
import { getExceptionMessage, releaseObject } from './crProtocolHelper';
import { Protocol } from './protocol';
import * as js from '../javascript';
import * as debugSupport from '../debug/debugSupport';
import { rewriteErrorMessage } from '../debug/stackTrace';
import { parseEvaluationResultValue } from '../utilityScriptSerializers';
import * as sourceMap from '../utils/sourceMap';
import { rewriteErrorMessage } from '../utils/stackTrace';
import { parseEvaluationResultValue } from '../common/utilityScriptSerializers';
export class CRExecutionContext implements js.ExecutionContextDelegate {
_client: CRSession;
@ -34,7 +34,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
async rawEvaluate(expression: string): Promise<string> {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', {
expression: debugSupport.ensureSourceUrl(expression),
expression: sourceMap.ensureSourceUrl(expression),
contextId: this._contextId,
}).catch(rewriteError);
if (exceptionDetails)

View File

@ -36,8 +36,8 @@ import { CRBrowserContext } from './crBrowser';
import * as types from '../types';
import { ConsoleMessage } from '../console';
import { NotConnectedError } from '../errors';
import * as debugSupport from '../debug/debugSupport';
import { rewriteErrorMessage } from '../debug/stackTrace';
import * as sourceMap from '../utils/sourceMap';
import { rewriteErrorMessage } from '../utils/stackTrace';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
@ -408,7 +408,7 @@ class FrameSession {
lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
this._client.send('Runtime.enable', {}),
this._client.send('Page.addScriptToEvaluateOnNewDocument', {
source: debugSupport.generateSourceUrl(),
source: sourceMap.generateSourceUrl(),
worldName: UTILITY_WORLD_NAME,
}),
this._networkManager.initialize(),

1
src/common/README.md Normal file
View File

@ -0,0 +1 @@
Files in this folder are used both in Node and injected environments, they can't have dependencies.

View File

@ -0,0 +1,88 @@
/**
* 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.
*/
// This file can't have dependencies, it is a part of the utility script.
export type ParsedSelector = {
parts: {
name: string,
body: string,
}[],
capture?: number,
};
export function parseSelector(selector: string): ParsedSelector {
let index = 0;
let quote: string | undefined;
let start = 0;
const result: ParsedSelector = { parts: [] };
const append = () => {
const part = selector.substring(start, index).trim();
const eqIndex = part.indexOf('=');
let name: string;
let body: string;
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) {
name = part.substring(0, eqIndex).trim();
body = part.substring(eqIndex + 1);
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
name = 'text';
body = part;
} else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") {
name = 'text';
body = part;
} else if (/^\(*\/\//.test(part)) {
// If selector starts with '//' or '//' prefixed with multiple opening
// parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817
name = 'xpath';
body = part;
} else {
name = 'css';
body = part;
}
name = name.toLowerCase();
let capture = false;
if (name[0] === '*') {
capture = true;
name = name.substring(1);
}
result.parts.push({ name, body });
if (capture) {
if (result.capture !== undefined)
throw new Error(`Only one of the selectors can capture using * modifier`);
result.capture = result.parts.length - 1;
}
};
while (index < selector.length) {
const c = selector[index];
if (c === '\\' && index + 1 < selector.length) {
index += 2;
} else if (c === quote) {
quote = undefined;
index++;
} else if (!quote && (c === '"' || c === '\'' || c === '`')) {
quote = c;
index++;
} else if (!quote && c === '>' && selector[index + 1] === '>') {
append();
index += 2;
start = index;
} else {
index++;
}
}
append();
return result;
}

View File

@ -14,8 +14,6 @@
* limitations under the License.
*/
// This file can't have dependencies, it is a part of the utility script.
export function parseEvaluationResultValue(value: any, handles: any[] = []): any {
// { type: 'undefined' } does not even have value.
if (value === 'undefined')

View File

@ -1,147 +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 * as sourceMap from './sourceMap';
import { getFromENV } from '../helper';
import { BrowserContextBase } from '../browserContext';
import { Frame } from '../frames';
import { Events } from '../events';
import { Page } from '../page';
import { parseSelector } from '../selectors';
import * as types from '../types';
import InjectedScript from '../injected/injectedScript';
let debugMode: boolean | undefined;
export function isDebugMode(): boolean {
if (debugMode === undefined)
debugMode = !!getFromENV('PLAYWRIGHT_DEBUG_UI');
return debugMode;
}
let sourceUrlCounter = 0;
const playwrightSourceUrlPrefix = '__playwright_evaluation_script__';
const sourceUrlRegex = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
export function generateSourceUrl(): string {
return `\n//# sourceURL=${playwrightSourceUrlPrefix}${sourceUrlCounter++}\n`;
}
export function isPlaywrightSourceUrl(s: string): boolean {
return s.startsWith(playwrightSourceUrlPrefix);
}
export function ensureSourceUrl(expression: string): string {
return sourceUrlRegex.test(expression) ? expression : expression + generateSourceUrl();
}
export async function generateSourceMapUrl(functionText: string, generatedText: string): Promise<string> {
if (!isDebugMode())
return generateSourceUrl();
const sourceMapUrl = await sourceMap.generateSourceMapUrl(functionText, generatedText);
return sourceMapUrl || generateSourceUrl();
}
export async function installConsoleHelpers(context: BrowserContextBase) {
if (!isDebugMode())
return;
const installInFrame = async (frame: Frame) => {
try {
const mainContext = await frame._mainContext();
const injectedScript = await mainContext.injectedScript();
await injectedScript.evaluate(installPlaywrightObjectOnWindow, parseSelector.toString());
} catch (e) {
}
};
context.on(Events.BrowserContext.Page, (page: Page) => {
installInFrame(page.mainFrame());
page.on(Events.Page.FrameNavigated, installInFrame);
});
}
function installPlaywrightObjectOnWindow(injectedScript: InjectedScript, parseSelectorFunctionString: string) {
const parseSelector: (selector: string) => types.ParsedSelector =
new Function('...args', 'return (' + parseSelectorFunctionString + ')(...args)') as any;
const highlightContainer = document.createElement('div');
highlightContainer.style.cssText = 'position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 10000;';
function checkSelector(parsed: types.ParsedSelector) {
for (const {name} of parsed.parts) {
if (!injectedScript.engines.has(name))
throw new Error(`Unknown engine "${name}"`);
}
}
function highlightElements(elements: Element[] = [], target?: Element) {
const scrollLeft = document.scrollingElement ? document.scrollingElement.scrollLeft : 0;
const scrollTop = document.scrollingElement ? document.scrollingElement.scrollTop : 0;
highlightContainer.textContent = '';
for (const element of elements) {
const rect = element.getBoundingClientRect();
const highlight = document.createElement('div');
highlight.style.position = 'absolute';
highlight.style.left = (rect.left + scrollLeft) + 'px';
highlight.style.top = (rect.top + scrollTop) + 'px';
highlight.style.height = rect.height + 'px';
highlight.style.width = rect.width + 'px';
highlight.style.pointerEvents = 'none';
if (element === target) {
highlight.style.background = 'hsla(30, 97%, 37%, 0.3)';
highlight.style.border = '3px solid hsla(30, 97%, 37%, 0.6)';
} else {
highlight.style.background = 'hsla(120, 100%, 37%, 0.3)';
highlight.style.border = '3px solid hsla(120, 100%, 37%, 0.8)';
}
highlight.style.borderRadius = '3px';
highlightContainer.appendChild(highlight);
}
document.body.appendChild(highlightContainer);
}
function $(selector: string): (Element | undefined) {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
const parsed = parseSelector(selector);
checkSelector(parsed);
const elements = injectedScript.querySelectorAll(parsed, document);
highlightElements(elements, elements[0]);
return elements[0];
}
function $$(selector: string): Element[] {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
const parsed = parseSelector(selector);
checkSelector(parsed);
const elements = injectedScript.querySelectorAll(parsed, document);
highlightElements(elements);
return elements;
}
function inspect(selector: string) {
if (typeof (window as any).inspect !== 'function')
return;
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.inspect('Playwright >> selector').`);
highlightElements();
(window as any).inspect($(selector));
}
function clear() {
highlightContainer.remove();
}
(window as any).playwright = { $, $$, inspect, clear };
}

View File

@ -0,0 +1,98 @@
/**
* 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 { ParsedSelector, parseSelector } from '../../common/selectorParser';
import type InjectedScript from '../../injected/injectedScript';
import { html } from './html';
export class ConsoleAPI {
private _injectedScript: InjectedScript;
private _highlightContainer: Element;
constructor(injectedScript: InjectedScript) {
this._injectedScript = injectedScript;
this._highlightContainer = html`<div style="position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 10000;"></div>`;
(window as any).playwright = {
$: (selector: string) => this._querySelector(selector),
$$: (selector: string) => this._querySelectorAll(selector),
inspect: (selector: string) => this._inspect(selector),
clear: () => this._clearHighlight()
};
}
private _checkSelector(parsed: ParsedSelector) {
for (const {name} of parsed.parts) {
if (!this._injectedScript.engines.has(name))
throw new Error(`Unknown engine "${name}"`);
}
}
private _highlightElements(elements: Element[] = [], target?: Element) {
const scrollLeft = document.scrollingElement ? document.scrollingElement.scrollLeft : 0;
const scrollTop = document.scrollingElement ? document.scrollingElement.scrollTop : 0;
this._highlightContainer.textContent = '';
for (const element of elements) {
const rect = element.getBoundingClientRect();
const highlight = html`<div style="position: absolute; pointer-events: none; border-radius: 3px"></div>`;
highlight.style.left = (rect.left + scrollLeft) + 'px';
highlight.style.top = (rect.top + scrollTop) + 'px';
highlight.style.height = rect.height + 'px';
highlight.style.width = rect.width + 'px';
if (element === target) {
highlight.style.background = 'hsla(30, 97%, 37%, 0.3)';
highlight.style.border = '3px solid hsla(30, 97%, 37%, 0.6)';
} else {
highlight.style.background = 'hsla(120, 100%, 37%, 0.3)';
highlight.style.border = '3px solid hsla(120, 100%, 37%, 0.8)';
}
this._highlightContainer.appendChild(highlight);
}
document.body.appendChild(this._highlightContainer);
}
_querySelector(selector: string): (Element | undefined) {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
const parsed = parseSelector(selector);
this._checkSelector(parsed);
const elements = this._injectedScript.querySelectorAll(parsed, document);
this._highlightElements(elements, elements[0]);
return elements[0];
}
_querySelectorAll(selector: string): Element[] {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
const parsed = parseSelector(selector);
this._checkSelector(parsed);
const elements = this._injectedScript.querySelectorAll(parsed, document);
this._highlightElements(elements);
return elements;
}
_inspect(selector: string) {
if (typeof (window as any).inspect !== 'function')
return;
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.inspect('Playwright >> selector').`);
this._highlightElements();
(window as any).inspect(this._querySelector(selector));
}
_clearHighlight() {
this._highlightContainer.remove();
}
}

View File

@ -0,0 +1,32 @@
/**
* 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 } from './consoleApi';
import { Recorder } from './recorder';
import InjectedScript from '../../injected/injectedScript';
export default class DebugScript {
consoleAPI: ConsoleAPI | undefined;
recorder: Recorder | undefined;
constructor() {
}
initialize(injectedScript: InjectedScript) {
this.consoleAPI = new ConsoleAPI(injectedScript);
this.recorder = new Recorder();
}
}

View File

@ -0,0 +1,46 @@
/**
* 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 path = require('path');
const InlineSource = require('../../injected/webpack-inline-source-plugin');
module.exports = {
entry: path.join(__dirname, 'debugScript.ts'),
devtool: 'source-map',
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
transpileOnly: true
},
exclude: /node_modules/
}
]
},
resolve: {
extensions: [ '.tsx', '.ts', '.js' ]
},
output: {
libraryTarget: 'var',
filename: 'debugScriptSource.js',
path: path.resolve(__dirname, '../../../lib/injected/packed')
},
plugins: [
new InlineSource(path.join(__dirname, '..', '..', 'generated', 'debugScriptSource.ts')),
]
};

196
src/debug/injected/html.ts Normal file
View File

@ -0,0 +1,196 @@
/**
* 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 templateCache = new Map();
export interface Element$ extends HTMLElement {
$(id: string): HTMLElement;
$$(id: string): Iterable<HTMLElement>
}
const BOOLEAN_ATTRS = new Set([
'async', 'autofocus', 'autoplay', 'checked', 'contenteditable', 'controls',
'default', 'defer', 'disabled', 'expanded', 'formNoValidate', 'frameborder', 'hidden',
'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate',
'open', 'readonly', 'required', 'reversed', 'scoped', 'selected', 'typemustmatch',
]);
type Sub = {
node: Element,
type?: string,
nameParts?: string[],
valueParts?: string[],
isSimpleValue?: boolean,
attr?: string,
nodeIndex?: number
};
export function onDOMEvent(target: EventTarget, name: string, listener: (e: any) => void, capturing = false): () => void {
target.addEventListener(name, listener, capturing);
return () => {
target.removeEventListener(name, listener, capturing);
};
}
export function onDOMResize(target: HTMLElement, callback: () => void) {
const resizeObserver = new (window as any).ResizeObserver(callback);
resizeObserver.observe(target);
return () => resizeObserver.disconnect();
}
export function html(strings: TemplateStringsArray, ...values: any): Element$ {
let cache = templateCache.get(strings);
if (!cache) {
cache = prepareTemplate(strings);
templateCache.set(strings, cache);
}
const node = renderTemplate(cache.template, cache.subs, values) as any;
if (node.querySelector) {
node.$ = node.querySelector.bind(node);
node.$$ = node.querySelectorAll.bind(node);
}
return node;
}
const SPACE_REGEX = /^\s*\n\s*$/;
const MARKER_REGEX = /---dom-template-\d+---/;
function prepareTemplate(strings: TemplateStringsArray) {
const template = document.createElement('template');
let html = '';
for (let i = 0; i < strings.length - 1; ++i) {
html += strings[i];
html += `---dom-template-${i}---`;
}
html += strings[strings.length - 1];
template.innerHTML = html;
const walker = template.ownerDocument!.createTreeWalker(
template.content, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null, false);
const emptyTextNodes: Node[] = [];
const subs: Sub[] = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (node.nodeType === Node.ELEMENT_NODE && MARKER_REGEX.test((node as Element).tagName))
throw new Error('Should not use a parameter as an html tag');
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).hasAttributes()) {
const element = node as Element;
for (let i = 0; i < element.attributes.length; i++) {
const name = element.attributes[i].name;
const nameParts = name.split(MARKER_REGEX);
const valueParts = element.attributes[i].value.split(MARKER_REGEX);
const isSimpleValue = valueParts.length === 2 && valueParts[0] === '' && valueParts[1] === '';
if (nameParts.length > 1 || valueParts.length > 1)
subs.push({ node: element, nameParts, valueParts, isSimpleValue, attr: name});
}
} else if (node.nodeType === Node.TEXT_NODE && MARKER_REGEX.test((node as Text).data)) {
const text = node as Text;
const texts = text.data.split(MARKER_REGEX);
text.data = texts[0];
const anchor = node.nextSibling;
for (let i = 1; i < texts.length; ++i) {
const span = document.createElement('span');
node.parentNode!.insertBefore(span, anchor);
node.parentNode!.insertBefore(document.createTextNode(texts[i]), anchor);
subs.push({
node: span,
type: 'replace-node',
});
}
if (shouldRemoveTextNode(text))
emptyTextNodes.push(text);
} else if (node.nodeType === Node.TEXT_NODE && shouldRemoveTextNode((node as Text))) {
emptyTextNodes.push(node);
}
}
for (const emptyTextNode of emptyTextNodes)
(emptyTextNode as any).remove();
const markedNodes = new Map();
for (const sub of subs) {
let index = markedNodes.get(sub.node);
if (index === undefined) {
index = markedNodes.size;
sub.node.setAttribute('dom-template-marked', 'true');
markedNodes.set(sub.node, index);
}
sub.nodeIndex = index;
}
return {template, subs};
}
function shouldRemoveTextNode(node: Text) {
if (!node.previousSibling && !node.nextSibling)
return !node.data.length;
return (!node.previousSibling || node.previousSibling.nodeType === Node.ELEMENT_NODE) &&
(!node.nextSibling || node.nextSibling.nodeType === Node.ELEMENT_NODE) &&
(!node.data.length || SPACE_REGEX.test(node.data));
}
function renderTemplate(template: HTMLTemplateElement, subs: Sub[], values: (string | Node)[]): DocumentFragment | ChildNode {
const content = template.ownerDocument!.importNode(template.content, true)!;
const boundElements = Array.from(content.querySelectorAll('[dom-template-marked]'));
for (const node of boundElements)
node.removeAttribute('dom-template-marked');
let valueIndex = 0;
const interpolateText = (texts: string[]) => {
let newText = texts[0];
for (let i = 1; i < texts.length; ++i) {
newText += values[valueIndex++];
newText += texts[i];
}
return newText;
};
for (const sub of subs) {
const n = boundElements[sub.nodeIndex!];
if (sub.attr) {
n.removeAttribute(sub.attr);
const name = interpolateText(sub.nameParts!);
const value = sub.isSimpleValue ? values[valueIndex++] : interpolateText(sub.valueParts!);
if (BOOLEAN_ATTRS.has(name))
n.toggleAttribute(name, !!value);
else
n.setAttribute(name, String(value));
} else if (sub.type === 'replace-node') {
const replacement = values[valueIndex++];
if (Array.isArray(replacement)) {
const fragment = document.createDocumentFragment();
for (const node of replacement)
fragment.appendChild(node);
n.replaceWith(fragment);
} else if (replacement instanceof Node) {
n.replaceWith(replacement);
} else {
n.replaceWith(document.createTextNode(replacement || ''));
}
}
}
return content.firstChild && content.firstChild === content.lastChild ? content.firstChild : content;
}
export function deepActiveElement() {
let activeElement = document.activeElement;
while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement)
activeElement = activeElement.shadowRoot.activeElement;
return activeElement;
}

View File

@ -0,0 +1,124 @@
/**
* 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';
declare global {
interface Window {
recordPlaywrightAction?: (action: actions.Action) => void;
}
}
export class Recorder {
constructor() {
document.addEventListener('click', event => this._onClick(event), true);
document.addEventListener('input', event => this._onInput(event), true);
document.addEventListener('keydown', event => this._onKeyDown(event), true);
}
private _onClick(event: MouseEvent) {
if (!window.recordPlaywrightAction)
return;
const selector = this._buildSelector(event.target as Node);
if ((event.target as Element).nodeName === 'SELECT')
return;
window.recordPlaywrightAction({
name: 'click',
selector,
signals: [],
button: buttonForEvent(event),
modifiers: modifiersForEvent(event),
clickCount: event.detail
});
}
private _onInput(event: Event) {
if (!window.recordPlaywrightAction)
return;
const selector = this._buildSelector(event.target as Node);
if ((event.target as Element).nodeName === 'INPUT') {
const inputElement = event.target as HTMLInputElement;
if ((inputElement.type || '').toLowerCase() === 'checkbox') {
window.recordPlaywrightAction({
name: inputElement.checked ? 'check' : 'uncheck',
selector,
signals: [],
});
} else {
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;
window.recordPlaywrightAction({
name: 'select',
selector,
options: [...selectElement.selectedOptions].map(option => option.value),
signals: []
});
}
}
private _onKeyDown(event: KeyboardEvent) {
if (!window.recordPlaywrightAction)
return;
if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape')
return;
const selector = this._buildSelector(event.target as Node);
window.recordPlaywrightAction({
name: 'press',
selector,
signals: [],
key: event.key,
modifiers: modifiersForEvent(event),
});
}
private _buildSelector(node: Node): string {
const element = node as Element;
for (const attribute of ['data-testid', 'aria-label', 'id', 'data-test-id', 'data-test']) {
if (element.hasAttribute(attribute))
return `[${attribute}=${element.getAttribute(attribute)}]`;
}
if (element.nodeName === 'INPUT') {
if (element.hasAttribute('name'))
return `[input name=${element.getAttribute('name')}]`;
if (element.hasAttribute('type'))
return `[input type=${element.getAttribute('type')}]`;
}
if (element.firstChild && element.firstChild === element.lastChild && element.firstChild.nodeType === Node.TEXT_NODE)
return `text="${element.textContent}"`;
return '<selector>';
}
}
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';
}

View File

@ -20,50 +20,49 @@ export type ActionName =
'press' |
'select';
export type ClickAction = {
export type ActionBase = {
signals: Signal[],
frameUrl?: string,
}
export type ClickAction = ActionBase & {
name: 'click',
signals?: Signal[],
selector: string,
button: 'left' | 'middle' | 'right',
modifiers: number,
clickCount: number,
};
export type CheckAction = {
export type CheckAction = ActionBase & {
name: 'check',
signals?: Signal[],
selector: string,
};
export type UncheckAction = {
export type UncheckAction = ActionBase & {
name: 'uncheck',
signals?: Signal[],
selector: string,
};
export type FillAction = {
export type FillAction = ActionBase & {
name: 'fill',
signals?: Signal[],
selector: string,
text: string
text: string,
};
export type NavigateAction = {
export type NavigateAction = ActionBase & {
name: 'navigate',
signals?: Signal[],
url: string
url: string,
};
export type PressAction = {
export type PressAction = ActionBase & {
name: 'press',
signals?: Signal[],
selector: string,
key: string
key: string,
modifiers: number,
};
export type SelectAction = {
export type SelectAction = ActionBase & {
name: 'select',
signals?: Signal[],
selector: string,
options: string[],
};
@ -97,7 +96,7 @@ export function actionTitle(action: Action): string {
case 'fill':
return 'Fill';
case 'navigate':
return 'Navigate';
return 'Go to';
case 'press':
return 'Press';
case 'select':

View File

@ -0,0 +1,59 @@
/**
* 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 actions from './recorderActions';
import * as frames from '../frames';
import { Page } from '../page';
import { Events } from '../events';
import { Script } from './recorderScript';
export class RecorderController {
private _page: Page;
private _script = new Script();
constructor(page: Page) {
this._page = page;
}
start() {
this._script.addAction({
name: 'navigate',
url: this._page.url(),
signals: [],
});
this._printScript();
this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => {
action.frameUrl = source.frame.url();
this._script.addAction(action);
this._printScript();
});
this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => {
if (frame.parentFrame())
return;
const action = this._script.lastAction();
if (action)
action.signals.push({ name: 'navigation', url: frame.url() });
this._printScript();
});
}
_printScript() {
console.log('\x1Bc'); // eslint-disable-line no-console
console.log(this._script.generate('chromium')); // eslint-disable-line no-console
}
}

View File

@ -15,8 +15,8 @@
*/
import * as dom from '../dom';
import { Formatter, formatColors } from './formatter';
import { Action, NavigationSignal, actionTitle } from './actions';
import { Formatter, formatColors } from '../utils/formatter';
import { Action, NavigationSignal, actionTitle } from './recorderActions';
export class Script {
private _actions: Action[] = [];
@ -56,6 +56,7 @@ export class Script {
generate(browserType: string) {
const formatter = new Formatter();
const { cst, cmt, fnc, kwd, prp, str } = formatColors;
formatter.add(`
${kwd('const')} { ${cst('chromium')}. ${cst('firefox')}, ${cst('webkit')} } = ${fnc('require')}(${str('playwright')});
@ -63,45 +64,63 @@ export class Script {
${kwd('const')} ${cst('browser')} = ${kwd('await')} ${cst(`${browserType}`)}.${fnc('launch')}();
${kwd('const')} ${cst('page')} = ${kwd('await')} ${cst('browser')}.${fnc('newPage')}();
`);
for (const action of this._compact()) {
formatter.newLine();
formatter.add(cmt(actionTitle(action)));
let navigationSignal: NavigationSignal | undefined;
if (action.name !== 'navigate' && action.signals && action.signals.length)
navigationSignal = action.signals[action.signals.length - 1];
if (navigationSignal) {
formatter.add(`${kwd('await')} ${cst('Promise')}.${fnc('all')}([
${cst('page')}.${fnc('waitForNavigation')}({ ${prp('url')}: ${str(navigationSignal.url)} }),`);
}
const subject = action.frameUrl ?
`${cst('page')}.${fnc('frame')}(${formatObject({ url: action.frameUrl })})` : cst('page');
const prefix = navigationSignal ? '' : kwd('await') + ' ';
const suffix = navigationSignal ? '' : ';';
if (action.name === 'click') {
let method = 'click';
if (action.clickCount === 2)
method = 'dblclick';
const modifiers = toModifiers(action.modifiers);
const options: dom.ClickOptions = {};
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);
formatter.add(`${prefix}${cst('page')}.${fnc(method)}(${str(action.selector)}${optionsString})${suffix}`);
switch (action.name) {
case 'click': {
let method = 'click';
if (action.clickCount === 2)
method = 'dblclick';
const modifiers = toModifiers(action.modifiers);
const options: dom.ClickOptions = {};
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);
formatter.add(`${prefix}${subject}.${fnc(method)}(${str(action.selector)}${optionsString})${suffix}`);
break;
}
case 'check':
formatter.add(`${prefix}${subject}.${fnc('check')}(${str(action.selector)})${suffix}`);
break;
case 'uncheck':
formatter.add(`${prefix}${subject}.${fnc('uncheck')}(${str(action.selector)})${suffix}`);
break;
case 'fill':
formatter.add(`${prefix}${subject}.${fnc('fill')}(${str(action.selector)}, ${str(action.text)})${suffix}`);
break;
case 'press': {
const modifiers = toModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
formatter.add(`${prefix}${subject}.${fnc('press')}(${str(action.selector)}, ${str(shortcut)})${suffix}`);
break;
}
case 'navigate':
formatter.add(`${prefix}${subject}.${fnc('goto')}(${str(action.url)})${suffix}`);
break;
case 'select':
formatter.add(`${prefix}${subject}.${fnc('select')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})${suffix}`);
break;
}
if (action.name === 'check')
formatter.add(`${prefix}${cst('page')}.${fnc('check')}(${str(action.selector)})${suffix}`);
if (action.name === 'uncheck')
formatter.add(`${prefix}${cst('page')}.${fnc('uncheck')}(${str(action.selector)})${suffix}`);
if (action.name === 'fill')
formatter.add(`${prefix}${cst('page')}.${fnc('fill')}(${str(action.selector)}, ${str(action.text)})${suffix}`);
if (action.name === 'press')
formatter.add(`${prefix}${cst('page')}.${fnc('press')}(${str(action.selector)}, ${str(action.key)})${suffix}`);
if (action.name === 'navigate')
formatter.add(`${prefix}${cst('page')}.${fnc('goto')}(${str(action.url)})${suffix}`);
if (action.name === 'select')
formatter.add(`${prefix}${cst('page')}.${fnc('select')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})${suffix}`);
if (navigationSignal)
formatter.add(`]);`);
}
@ -132,7 +151,7 @@ function formatObject(value: any): string {
const tokens: string[] = [];
for (const key of keys)
tokens.push(`${prp(key)}: ${formatObject(value[key])}`);
return `{ ${tokens.join(', ')} }`;
return `{${tokens.join(', ')}}`;
}
return String(value);
}

View File

@ -22,6 +22,7 @@ import * as frames from './frames';
import { assert, helper } from './helper';
import InjectedScript from './injected/injectedScript';
import * as injectedScriptSource from './generated/injectedScriptSource';
import * as debugScriptSource from './generated/debugScriptSource';
import * as input from './input';
import * as js from './javascript';
import { Page } from './page';
@ -30,6 +31,7 @@ import * as types from './types';
import { NotConnectedError } from './errors';
import { apiLog } from './logger';
import { Progress, runAbortableTask } from './progress';
import DebugScript from './debug/injected/debugScript';
export type PointerActionOptions = {
modifiers?: input.Modifier[];
@ -42,7 +44,8 @@ export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOpti
export class FrameExecutionContext extends js.ExecutionContext {
readonly frame: frames.Frame;
private _injectedPromise?: Promise<js.JSHandle>;
private _injectedScriptPromise?: Promise<js.JSHandle>;
private _debugScriptPromise?: Promise<js.JSHandle | undefined>;
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) {
super(delegate);
@ -78,7 +81,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
}
injectedScript(): Promise<js.JSHandle<InjectedScript>> {
if (!this._injectedPromise) {
if (!this._injectedScriptPromise) {
const custom: string[] = [];
for (const [name, { source }] of selectors._engines)
custom.push(`{ name: '${name}', engine: (${source}) }`);
@ -87,9 +90,24 @@ export class FrameExecutionContext extends js.ExecutionContext {
${custom.join(',\n')}
])
`;
this._injectedPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId));
this._injectedScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId));
}
return this._injectedPromise;
return this._injectedScriptPromise;
}
debugScript(): Promise<js.JSHandle<DebugScript> | undefined> {
if (!helper.isDebugMode())
return Promise.resolve(undefined);
if (!this._debugScriptPromise) {
const source = `new (${debugScriptSource.source})()`;
this._debugScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId)).then(async debugScript => {
const injectedScript = await this.injectedScript();
await debugScript.evaluate((debugScript: DebugScript, injectedScript) => debugScript.initialize(injectedScript), injectedScript);
return debugScript;
}).catch(e => undefined);
}
return this._debugScriptPromise;
}
}

View File

@ -20,7 +20,7 @@ import { assert } from '../helper';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } from '../transport';
import { Protocol } from './protocol';
import { InnerLogger, errorLog } from '../logger';
import { rewriteErrorMessage } from '../debug/stackTrace';
import { rewriteErrorMessage } from '../utils/stackTrace';
export const ConnectionEvents = {
Disconnected: Symbol('Disconnected'),

View File

@ -18,9 +18,9 @@
import * as js from '../javascript';
import { FFSession } from './ffConnection';
import { Protocol } from './protocol';
import * as debugSupport from '../debug/debugSupport';
import { rewriteErrorMessage } from '../debug/stackTrace';
import { parseEvaluationResultValue } from '../utilityScriptSerializers';
import * as sourceMap from '../utils/sourceMap';
import { rewriteErrorMessage } from '../utils/stackTrace';
import { parseEvaluationResultValue } from '../common/utilityScriptSerializers';
export class FFExecutionContext implements js.ExecutionContextDelegate {
_session: FFSession;
@ -33,7 +33,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
async rawEvaluate(expression: string): Promise<string> {
const payload = await this._session.send('Runtime.evaluate', {
expression: debugSupport.ensureSourceUrl(expression),
expression: sourceMap.ensureSourceUrl(expression),
returnByValue: false,
executionContextId: this._executionContextId,
}).catch(rewriteError);

View File

@ -32,7 +32,7 @@ import { FFNetworkManager, headersArray } from './ffNetworkManager';
import { Protocol } from './protocol';
import { selectors } from '../selectors';
import { NotConnectedError } from '../errors';
import { rewriteErrorMessage } from '../debug/stackTrace';
import { rewriteErrorMessage } from '../utils/stackTrace';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';

View File

@ -33,6 +33,8 @@ export type RegisteredListener = {
export type Listener = (...args: any[]) => void;
const isDebugModeEnv = !!getFromENV('PWDEBUG');
class Helper {
static evaluationString(fun: Function | string, ...args: any[]): string {
if (Helper.isString(fun)) {
@ -299,6 +301,10 @@ class Helper {
helper.removeEventListeners(listeners);
return result;
}
static isDebugMode(): boolean {
return isDebugModeEnv;
}
}
export function assert(value: any, message?: string): asserts value {

View File

@ -20,6 +20,7 @@ import { createCSSEngine } from './cssSelectorEngine';
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { createTextSelector } from './textSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { ParsedSelector } from '../common/selectorParser';
type Falsy = false | 0 | '' | undefined | null;
type Predicate<T> = (progress: types.InjectedScriptProgress) => T | Falsy;
@ -48,13 +49,13 @@ export default class InjectedScript {
this.engines.set(name, engine);
}
querySelector(selector: types.ParsedSelector, root: Node): Element | undefined {
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
if (!(root as any)['querySelector'])
throw new Error('Node is not queryable.');
return this._querySelectorRecursively(root as SelectorRoot, selector, 0);
}
private _querySelectorRecursively(root: SelectorRoot, selector: types.ParsedSelector, index: number): Element | undefined {
private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined {
const current = selector.parts[index];
if (index === selector.parts.length - 1)
return this.engines.get(current.name)!.query(root, current.body);
@ -66,7 +67,7 @@ export default class InjectedScript {
}
}
querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] {
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
if (!(root as any)['querySelectorAll'])
throw new Error('Node is not queryable.');
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { serializeAsCallArgument, parseEvaluationResultValue } from '../utilityScriptSerializers';
import { serializeAsCallArgument, parseEvaluationResultValue } from '../common/utilityScriptSerializers';
export default class UtilityScript {
evaluate(returnByValue: boolean, expression: string) {

View File

@ -17,8 +17,8 @@
import * as types from './types';
import * as dom from './dom';
import * as utilityScriptSource from './generated/utilityScriptSource';
import * as debugSupport from './debug/debugSupport';
import { serializeAsCallArgument } from './utilityScriptSerializers';
import * as sourceMap from './utils/sourceMap';
import { serializeAsCallArgument } from './common/utilityScriptSerializers';
import { helper } from './helper';
type ObjectId = string;
@ -106,7 +106,7 @@ export class JSHandle<T = any> {
if (!this._objectId)
return this._value;
const utilityScript = await this._context.utilityScript();
const script = `(utilityScript, ...args) => utilityScript.jsonValue(...args)` + debugSupport.generateSourceUrl();
const script = `(utilityScript, ...args) => utilityScript.jsonValue(...args)` + sourceMap.generateSourceUrl();
return this._context._delegate.evaluateWithArguments(script, true, utilityScript, [true], [this._objectId]);
}
@ -135,8 +135,8 @@ export class JSHandle<T = any> {
export async function evaluate(context: ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
const utilityScript = await context.utilityScript();
if (helper.isString(pageFunction)) {
const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)` + debugSupport.generateSourceUrl();
return context._delegate.evaluateWithArguments(script, returnByValue, utilityScript, [returnByValue, debugSupport.ensureSourceUrl(pageFunction)], []);
const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)` + sourceMap.generateSourceUrl();
return context._delegate.evaluateWithArguments(script, returnByValue, utilityScript, [returnByValue, sourceMap.ensureSourceUrl(pageFunction)], []);
}
if (typeof pageFunction !== 'function')
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`);
@ -189,11 +189,11 @@ export async function evaluate(context: ExecutionContext, returnByValue: boolean
utilityScriptObjectIds.push(handle._objectId!);
}
functionText += await debugSupport.generateSourceMapUrl(originalText, functionText);
functionText += await sourceMap.generateSourceMapUrl(originalText, functionText);
// See UtilityScript for arguments.
const utilityScriptValues = [returnByValue, functionText, args.length, ...args];
const script = `(utilityScript, ...args) => utilityScript.callFunction(...args)` + debugSupport.generateSourceUrl();
const script = `(utilityScript, ...args) => utilityScript.callFunction(...args)` + sourceMap.generateSourceUrl();
try {
return context._delegate.evaluateWithArguments(script, returnByValue, utilityScript, utilityScriptValues, utilityScriptObjectIds);
} finally {

View File

@ -32,7 +32,7 @@ import { EventEmitter } from 'events';
import { FileChooser } from './fileChooser';
import { logError, InnerLogger } from './logger';
import { ProgressController } from './progress';
import { Recorder } from './recorder/recorder';
import { RecorderController } from './debug/recorderController';
export interface PageDelegate {
readonly rawMouse: input.RawMouse;
@ -505,7 +505,9 @@ export class Page extends EventEmitter {
}
async _startRecordingUser() {
new Recorder(this).start();
if (!helper.isDebugMode())
throw new Error('page._startRecordingUser is only available with PWDEBUG=1 environment variable');
new RecorderController(this).start();
}
async waitForTimeout(timeout: number) {

View File

@ -17,7 +17,7 @@
import { InnerLogger, Log, apiLog } from './logger';
import { TimeoutError } from './errors';
import { assert } from './helper';
import { getCurrentApiCall, rewriteErrorMessage } from './debug/stackTrace';
import { getCurrentApiCall, rewriteErrorMessage } from './utils/stackTrace';
export interface Progress {
readonly apiName: string;

View File

@ -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 * as frames from '../frames';
import { Page } from '../page';
import { Script } from './script';
import { Events } from '../events';
import * as actions from './actions';
declare global {
interface Window {
recordPlaywrightAction: (action: actions.Action) => void;
}
}
export class Recorder {
private _page: Page;
private _script = new Script();
constructor(page: Page) {
this._page = page;
}
start() {
this._script.addAction({
name: 'navigate',
url: this._page.url()
});
this._printScript();
this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => {
this._script.addAction(action);
this._printScript();
});
this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => {
if (frame.parentFrame())
return;
const action = this._script.lastAction();
if (action) {
action.signals = action.signals || [];
action.signals.push({ name: 'navigation', url: frame.url() });
}
this._printScript();
});
const injectedScript = () => {
if (document.readyState === 'complete')
addListeners();
else
document.addEventListener('load', addListeners);
function addListeners() {
document.addEventListener('click', (event: MouseEvent) => {
const selector = buildSelector(event.target as Node);
if ((event.target as Element).nodeName === 'SELECT')
return;
window.recordPlaywrightAction({
name: 'click',
selector,
button: buttonForEvent(event),
modifiers: modifiersForEvent(event),
clickCount: event.detail
});
}, true);
document.addEventListener('input', (event: Event) => {
const selector = buildSelector(event.target as Node);
if ((event.target as Element).nodeName === 'INPUT') {
const inputElement = event.target as HTMLInputElement;
if ((inputElement.type || '').toLowerCase() === 'checkbox') {
window.recordPlaywrightAction({
name: inputElement.checked ? 'check' : 'uncheck',
selector,
});
} else {
window.recordPlaywrightAction({
name: 'fill',
selector,
text: (event.target! as HTMLInputElement).value,
});
}
}
if ((event.target as Element).nodeName === 'SELECT') {
const selectElement = event.target as HTMLSelectElement;
window.recordPlaywrightAction({
name: 'select',
selector,
options: [...selectElement.selectedOptions].map(option => option.value),
});
}
}, true);
document.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape')
return;
const selector = buildSelector(event.target as Node);
window.recordPlaywrightAction({
name: 'press',
selector,
key: event.key,
});
}, true);
}
function buildSelector(node: Node): string {
const element = node as Element;
for (const attribute of ['data-testid', 'aria-label', 'id', 'data-test-id', 'data-test']) {
if (element.hasAttribute(attribute))
return `[${attribute}=${element.getAttribute(attribute)}]`;
}
if (element.nodeName === 'INPUT')
return `[input name=${element.getAttribute('name')}]`;
return `text="${element.textContent}"`;
}
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';
}
};
this._page.addInitScript(injectedScript);
this._page.evaluate(injectedScript);
}
_printScript() {
console.log('\x1Bc'); // eslint-disable-line no-console
console.log(this._script.generate('chromium')); // eslint-disable-line no-console
}
}

View File

@ -22,7 +22,7 @@ import * as dom from './dom';
import { assert, helper } from './helper';
import { Page } from './page';
import * as types from './types';
import { rewriteErrorMessage } from './debug/stackTrace';
import { rewriteErrorMessage } from './utils/stackTrace';
export class Screenshotter {
private _queue = new TaskQueue();

View File

@ -19,6 +19,7 @@ import * as frames from './frames';
import { helper, assert } from './helper';
import * as js from './javascript';
import * as types from './types';
import { ParsedSelector, parseSelector } from './common/selectorParser';
export class Selectors {
readonly _builtinEngines: Set<string>;
@ -53,7 +54,7 @@ export class Selectors {
++this._generation;
}
private _needsMainContext(parsed: types.ParsedSelector): boolean {
private _needsMainContext(parsed: ParsedSelector): boolean {
return parsed.parts.some(({name}) => {
const custom = this._engines.get(name);
return custom ? !custom.contentScript : false;
@ -170,7 +171,7 @@ export class Selectors {
}, { target: handle, name });
}
private _parseSelector(selector: string): types.ParsedSelector {
private _parseSelector(selector: string): ParsedSelector {
assert(helper.isString(selector), `selector must be a string`);
const parsed = parseSelector(selector);
for (const {name} of parsed.parts) {
@ -182,66 +183,3 @@ export class Selectors {
}
export const selectors = new Selectors();
export function parseSelector(selector: string): types.ParsedSelector {
let index = 0;
let quote: string | undefined;
let start = 0;
const result: types.ParsedSelector = { parts: [] };
const append = () => {
const part = selector.substring(start, index).trim();
const eqIndex = part.indexOf('=');
let name: string;
let body: string;
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) {
name = part.substring(0, eqIndex).trim();
body = part.substring(eqIndex + 1);
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
name = 'text';
body = part;
} else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") {
name = 'text';
body = part;
} else if (/^\(*\/\//.test(part)) {
// If selector starts with '//' or '//' prefixed with multiple opening
// parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817
name = 'xpath';
body = part;
} else {
name = 'css';
body = part;
}
name = name.toLowerCase();
let capture = false;
if (name[0] === '*') {
capture = true;
name = name.substring(1);
}
result.parts.push({ name, body });
if (capture) {
if (result.capture !== undefined)
throw new Error(`Only one of the selectors can capture using * modifier`);
result.capture = result.parts.length - 1;
}
};
while (index < selector.length) {
const c = selector[index];
if (c === '\\' && index + 1 < selector.length) {
index += 2;
} else if (c === quote) {
quote = undefined;
index++;
} else if (!quote && (c === '"' || c === '\'' || c === '`')) {
quote = c;
index++;
} else if (!quote && c === '>' && selector[index + 1] === '>') {
append();
index += 2;
start = index;
} else {
index++;
}
}
append();
return result;
}

View File

@ -24,7 +24,7 @@ import * as browserPaths from '../install/browserPaths';
import { Logger, InnerLogger } from '../logger';
import { ConnectionTransport, WebSocketTransport } from '../transport';
import { BrowserBase, BrowserOptions, Browser } from '../browser';
import { assert } from '../helper';
import { assert, helper } from '../helper';
import { launchProcess, Env, waitForLine } from './processLauncher';
import { Events } from '../events';
import { PipeTransport } from './pipeTransport';
@ -260,6 +260,6 @@ function copyTestHooks(from: object, to: object) {
}
function validateLaunchOptions<Options extends LaunchOptionsBase>(options: Options): Options {
const { devtools = false, headless = !devtools } = options;
const { devtools = false, headless = !helper.isDebugMode() && !devtools } = options;
return { ...options, devtools, headless };
}

View File

@ -16,7 +16,7 @@
*/
import * as path from 'path';
import { assert, getFromENV, logPolitely } from '../helper';
import { assert, getFromENV, logPolitely, helper } from '../helper';
import { CRBrowser } from '../chromium/crBrowser';
import * as ws from 'ws';
import { Env } from './processLauncher';
@ -25,8 +25,7 @@ import { LaunchOptionsBase, BrowserTypeBase } from './browserType';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
import { InnerLogger } from '../logger';
import { BrowserDescriptor } from '../install/browserPaths';
import { CRDevTools } from '../debug/crDevTools';
import * as debugSupport from '../debug/debugSupport';
import { CRDevTools } from '../chromium/crDevTools';
import { BrowserOptions } from '../browser';
import { WebSocketServer } from './webSocketServer';
@ -45,7 +44,7 @@ export class Chromium extends BrowserTypeBase {
super(packagePath, browser, debugPort ? { webSocketRegex: /^DevTools listening on (ws:\/\/.*)$/, stream: 'stderr' } : null);
this._debugPort = debugPort;
if (debugSupport.isDebugMode())
if (helper.isDebugMode())
this._devtools = this._createDevTools();
}

View File

@ -16,9 +16,9 @@
*/
import { TimeoutOptions } from './types';
import * as debugSupport from './debug/debugSupport';
import { helper } from './helper';
const DEFAULT_TIMEOUT = debugSupport.isDebugMode() ? 0 : 30000;
const DEFAULT_TIMEOUT = helper.isDebugMode() ? 0 : 30000;
export class TimeoutSettings {
private _parent: TimeoutSettings | undefined;

View File

@ -154,14 +154,6 @@ export type JSCoverageOptions = {
reportAnonymousScripts?: boolean,
};
export type ParsedSelector = {
parts: {
name: string,
body: string,
}[],
capture?: number,
};
export type InjectedScriptResult<T = undefined> =
(T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) |
{ status: 'notconnected' } |

View File

@ -19,7 +19,7 @@ export class Formatter {
private _lines: string[] = [];
constructor(indent: number = 2) {
this._baseIndent = [...Array(indent + 1)].join(' ');
this._baseIndent = ' '.repeat(indent);
}
prepend(text: string) {

View File

@ -17,13 +17,37 @@
import * as fs from 'fs';
import * as util from 'util';
import { getCallerFilePath } from './stackTrace';
import { helper } from '../helper';
type Position = {
line: number;
column: number;
};
export async function generateSourceMapUrl(functionText: string, generatedText: string): Promise<string | undefined> {
let sourceUrlCounter = 0;
const playwrightSourceUrlPrefix = '__playwright_evaluation_script__';
const sourceUrlRegex = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
export function isPlaywrightSourceUrl(s: string): boolean {
return s.startsWith(playwrightSourceUrlPrefix);
}
export function ensureSourceUrl(expression: string): string {
return sourceUrlRegex.test(expression) ? expression : expression + generateSourceUrl();
}
export async function generateSourceMapUrl(functionText: string, generatedText: string): Promise<string> {
if (!helper.isDebugMode())
return generateSourceUrl();
const sourceMapUrl = await innerGenerateSourceMapUrl(functionText, generatedText);
return sourceMapUrl || generateSourceUrl();
}
export function generateSourceUrl(): string {
return `\n//# sourceURL=${playwrightSourceUrlPrefix}${sourceUrlCounter++}\n`;
}
async function innerGenerateSourceMapUrl(functionText: string, generatedText: string): Promise<string | undefined> {
const filePath = getCallerFilePath();
if (!filePath)
return;

View File

@ -20,7 +20,7 @@ import { assert } from '../helper';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } from '../transport';
import { Protocol } from './protocol';
import { InnerLogger, errorLog } from '../logger';
import { rewriteErrorMessage } from '../debug/stackTrace';
import { rewriteErrorMessage } from '../utils/stackTrace';
// WKPlaywright uses this special id to issue Browser.close command which we
// should ignore.

View File

@ -18,8 +18,8 @@
import { WKSession, isSwappedOutError } from './wkConnection';
import { Protocol } from './protocol';
import * as js from '../javascript';
import * as debugSupport from '../debug/debugSupport';
import { parseEvaluationResultValue } from '../utilityScriptSerializers';
import { parseEvaluationResultValue } from '../common/utilityScriptSerializers';
import * as sourceMap from '../utils/sourceMap';
export class WKExecutionContext implements js.ExecutionContextDelegate {
private readonly _session: WKSession;
@ -42,7 +42,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
async rawEvaluate(expression: string): Promise<string> {
try {
const response = await this._session.send('Runtime.evaluate', {
expression: debugSupport.ensureSourceUrl(expression),
expression: sourceMap.ensureSourceUrl(expression),
contextId: this._contextId,
returnByValue: false
});
@ -93,7 +93,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
try {
const utilityScript = await context.utilityScript();
const serializeResponse = await this._session.send('Runtime.callFunctionOn', {
functionDeclaration: 'object => object' + debugSupport.generateSourceUrl(),
functionDeclaration: 'object => object' + sourceMap.generateSourceUrl(),
objectId: utilityScript._objectId!,
arguments: [ { objectId } ],
returnByValue: true

View File

@ -180,7 +180,7 @@ describe('Fixtures', function() {
describe('StackTrace', () => {
it('caller file path', async state => {
const stackTrace = require(path.join(state.playwrightPath, 'lib', 'debug', 'stackTrace'));
const stackTrace = require(path.join(state.playwrightPath, 'lib', 'utils', 'stackTrace'));
const callme = require('./fixtures/callback');
const filePath = callme(() => {
return stackTrace.getCallerFilePath(path.join(__dirname, 'fixtures') + path.sep);
@ -188,7 +188,7 @@ describe('StackTrace', () => {
expect(filePath).toBe(__filename);
});
it('api call', async state => {
const stackTrace = require(path.join(state.playwrightPath, 'lib', 'debug', 'stackTrace'));
const stackTrace = require(path.join(state.playwrightPath, 'lib', 'utils', 'stackTrace'));
const callme = require('./fixtures/callback');
const apiCall = callme(stackTrace.getCurrentApiCall.bind(stackTrace, path.join(__dirname, 'fixtures') + path.sep));
expect(apiCall).toBe('callme');

View File

@ -20,6 +20,7 @@ const path = require('path');
const files = [
path.join('src', 'injected', 'injectedScript.webpack.config.js'),
path.join('src', 'injected', 'utilityScript.webpack.config.js'),
path.join('src', 'debug', 'injected', 'debugScript.webpack.config.js'),
];
function runOne(runner, file) {