chore: form the debug script for authoring hints / helpers (#2551)
This commit is contained in:
parent
48088222ed
commit
894826dec0
|
|
@ -19,13 +19,12 @@ import { helper } from './helper';
|
||||||
import * as network from './network';
|
import * as network from './network';
|
||||||
import { Page, PageBinding } from './page';
|
import { Page, PageBinding } from './page';
|
||||||
import { TimeoutSettings } from './timeoutSettings';
|
import { TimeoutSettings } from './timeoutSettings';
|
||||||
|
import * as frames from './frames';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import { Events } from './events';
|
import { Events } from './events';
|
||||||
import { Download } from './download';
|
import { Download } from './download';
|
||||||
import { BrowserBase } from './browser';
|
import { BrowserBase } from './browser';
|
||||||
import { InnerLogger, Logger } from './logger';
|
import { InnerLogger, Logger } from './logger';
|
||||||
import { FunctionWithSource } from './frames';
|
|
||||||
import * as debugSupport from './debug/debugSupport';
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { ProgressController } from './progress';
|
import { ProgressController } from './progress';
|
||||||
|
|
||||||
|
|
@ -69,7 +68,7 @@ export interface BrowserContext {
|
||||||
setOffline(offline: boolean): Promise<void>;
|
setOffline(offline: boolean): Promise<void>;
|
||||||
setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>;
|
setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>;
|
||||||
addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): 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>;
|
exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
|
||||||
route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
|
route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
|
||||||
unroute(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() {
|
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> {
|
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));
|
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()) {
|
for (const page of this.pages()) {
|
||||||
if (page._pageBindings.has(name))
|
if (page._pageBindings.has(name))
|
||||||
throw new Error(`Function "${name}" has been already registered in one of the pages`);
|
throw new Error(`Function "${name}" has been already registered in one of the pages`);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { readProtocolStream } from './crProtocolHelper';
|
||||||
import { Events } from './events';
|
import { Events } from './events';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import { CRExecutionContext } from './crExecutionContext';
|
import { CRExecutionContext } from './crExecutionContext';
|
||||||
import { CRDevTools } from '../debug/crDevTools';
|
import { CRDevTools } from './crDevTools';
|
||||||
|
|
||||||
export class CRBrowser extends BrowserBase {
|
export class CRBrowser extends BrowserBase {
|
||||||
readonly _connection: CRConnection;
|
readonly _connection: CRConnection;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } f
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { InnerLogger, errorLog } from '../logger';
|
import { InnerLogger, errorLog } from '../logger';
|
||||||
import { rewriteErrorMessage } from '../debug/stackTrace';
|
import { rewriteErrorMessage } from '../utils/stackTrace';
|
||||||
|
|
||||||
export const ConnectionEvents = {
|
export const ConnectionEvents = {
|
||||||
Disconnected: Symbol('ConnectionEvents.Disconnected')
|
Disconnected: Symbol('ConnectionEvents.Disconnected')
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { CRSession } from './crConnection';
|
||||||
import { assert, helper, RegisteredListener } from '../helper';
|
import { assert, helper, RegisteredListener } from '../helper';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import * as types from '../types';
|
import * as types from '../types';
|
||||||
import * as debugSupport from '../debug/debugSupport';
|
import * as sourceMap from '../utils/sourceMap';
|
||||||
|
|
||||||
type JSRange = {
|
type JSRange = {
|
||||||
startOffset: number,
|
startOffset: number,
|
||||||
|
|
@ -122,7 +122,7 @@ class JSCoverage {
|
||||||
|
|
||||||
async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) {
|
async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) {
|
||||||
// Ignore playwright-injected scripts
|
// Ignore playwright-injected scripts
|
||||||
if (debugSupport.isPlaywrightSourceUrl(event.url))
|
if (sourceMap.isPlaywrightSourceUrl(event.url))
|
||||||
return;
|
return;
|
||||||
this._scriptIds.add(event.scriptId);
|
this._scriptIds.add(event.scriptId);
|
||||||
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true.
|
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import { CRSession } from '../chromium/crConnection';
|
import { CRSession } from './crConnection';
|
||||||
|
|
||||||
const kBindingName = '__pw_devtools__';
|
const kBindingName = '__pw_devtools__';
|
||||||
|
|
||||||
|
|
@ -19,9 +19,9 @@ import { CRSession } from './crConnection';
|
||||||
import { getExceptionMessage, releaseObject } from './crProtocolHelper';
|
import { getExceptionMessage, releaseObject } from './crProtocolHelper';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import * as js from '../javascript';
|
import * as js from '../javascript';
|
||||||
import * as debugSupport from '../debug/debugSupport';
|
import * as sourceMap from '../utils/sourceMap';
|
||||||
import { rewriteErrorMessage } from '../debug/stackTrace';
|
import { rewriteErrorMessage } from '../utils/stackTrace';
|
||||||
import { parseEvaluationResultValue } from '../utilityScriptSerializers';
|
import { parseEvaluationResultValue } from '../common/utilityScriptSerializers';
|
||||||
|
|
||||||
export class CRExecutionContext implements js.ExecutionContextDelegate {
|
export class CRExecutionContext implements js.ExecutionContextDelegate {
|
||||||
_client: CRSession;
|
_client: CRSession;
|
||||||
|
|
@ -34,7 +34,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
|
||||||
|
|
||||||
async rawEvaluate(expression: string): Promise<string> {
|
async rawEvaluate(expression: string): Promise<string> {
|
||||||
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', {
|
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', {
|
||||||
expression: debugSupport.ensureSourceUrl(expression),
|
expression: sourceMap.ensureSourceUrl(expression),
|
||||||
contextId: this._contextId,
|
contextId: this._contextId,
|
||||||
}).catch(rewriteError);
|
}).catch(rewriteError);
|
||||||
if (exceptionDetails)
|
if (exceptionDetails)
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ import { CRBrowserContext } from './crBrowser';
|
||||||
import * as types from '../types';
|
import * as types from '../types';
|
||||||
import { ConsoleMessage } from '../console';
|
import { ConsoleMessage } from '../console';
|
||||||
import { NotConnectedError } from '../errors';
|
import { NotConnectedError } from '../errors';
|
||||||
import * as debugSupport from '../debug/debugSupport';
|
import * as sourceMap from '../utils/sourceMap';
|
||||||
import { rewriteErrorMessage } from '../debug/stackTrace';
|
import { rewriteErrorMessage } from '../utils/stackTrace';
|
||||||
|
|
||||||
|
|
||||||
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
||||||
|
|
@ -408,7 +408,7 @@ class FrameSession {
|
||||||
lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
|
lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
|
||||||
this._client.send('Runtime.enable', {}),
|
this._client.send('Runtime.enable', {}),
|
||||||
this._client.send('Page.addScriptToEvaluateOnNewDocument', {
|
this._client.send('Page.addScriptToEvaluateOnNewDocument', {
|
||||||
source: debugSupport.generateSourceUrl(),
|
source: sourceMap.generateSourceUrl(),
|
||||||
worldName: UTILITY_WORLD_NAME,
|
worldName: UTILITY_WORLD_NAME,
|
||||||
}),
|
}),
|
||||||
this._networkManager.initialize(),
|
this._networkManager.initialize(),
|
||||||
|
|
|
||||||
1
src/common/README.md
Normal file
1
src/common/README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Files in this folder are used both in Node and injected environments, they can't have dependencies.
|
||||||
88
src/common/selectorParser.ts
Normal file
88
src/common/selectorParser.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* 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 {
|
export function parseEvaluationResultValue(value: any, handles: any[] = []): any {
|
||||||
// { type: 'undefined' } does not even have value.
|
// { type: 'undefined' } does not even have value.
|
||||||
if (value === 'undefined')
|
if (value === 'undefined')
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
98
src/debug/injected/consoleApi.ts
Normal file
98
src/debug/injected/consoleApi.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/debug/injected/debugScript.ts
Normal file
32
src/debug/injected/debugScript.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/debug/injected/debugScript.webpack.config.js
Normal file
46
src/debug/injected/debugScript.webpack.config.js
Normal 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
196
src/debug/injected/html.ts
Normal 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;
|
||||||
|
}
|
||||||
124
src/debug/injected/recorder.ts
Normal file
124
src/debug/injected/recorder.ts
Normal 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';
|
||||||
|
}
|
||||||
|
|
@ -20,50 +20,49 @@ export type ActionName =
|
||||||
'press' |
|
'press' |
|
||||||
'select';
|
'select';
|
||||||
|
|
||||||
export type ClickAction = {
|
export type ActionBase = {
|
||||||
|
signals: Signal[],
|
||||||
|
frameUrl?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClickAction = ActionBase & {
|
||||||
name: 'click',
|
name: 'click',
|
||||||
signals?: Signal[],
|
|
||||||
selector: string,
|
selector: string,
|
||||||
button: 'left' | 'middle' | 'right',
|
button: 'left' | 'middle' | 'right',
|
||||||
modifiers: number,
|
modifiers: number,
|
||||||
clickCount: number,
|
clickCount: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CheckAction = {
|
export type CheckAction = ActionBase & {
|
||||||
name: 'check',
|
name: 'check',
|
||||||
signals?: Signal[],
|
|
||||||
selector: string,
|
selector: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UncheckAction = {
|
export type UncheckAction = ActionBase & {
|
||||||
name: 'uncheck',
|
name: 'uncheck',
|
||||||
signals?: Signal[],
|
|
||||||
selector: string,
|
selector: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FillAction = {
|
export type FillAction = ActionBase & {
|
||||||
name: 'fill',
|
name: 'fill',
|
||||||
signals?: Signal[],
|
|
||||||
selector: string,
|
selector: string,
|
||||||
text: string
|
text: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavigateAction = {
|
export type NavigateAction = ActionBase & {
|
||||||
name: 'navigate',
|
name: 'navigate',
|
||||||
signals?: Signal[],
|
url: string,
|
||||||
url: string
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PressAction = {
|
export type PressAction = ActionBase & {
|
||||||
name: 'press',
|
name: 'press',
|
||||||
signals?: Signal[],
|
|
||||||
selector: string,
|
selector: string,
|
||||||
key: string
|
key: string,
|
||||||
|
modifiers: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SelectAction = {
|
export type SelectAction = ActionBase & {
|
||||||
name: 'select',
|
name: 'select',
|
||||||
signals?: Signal[],
|
|
||||||
selector: string,
|
selector: string,
|
||||||
options: string[],
|
options: string[],
|
||||||
};
|
};
|
||||||
|
|
@ -97,7 +96,7 @@ export function actionTitle(action: Action): string {
|
||||||
case 'fill':
|
case 'fill':
|
||||||
return 'Fill';
|
return 'Fill';
|
||||||
case 'navigate':
|
case 'navigate':
|
||||||
return 'Navigate';
|
return 'Go to';
|
||||||
case 'press':
|
case 'press':
|
||||||
return 'Press';
|
return 'Press';
|
||||||
case 'select':
|
case 'select':
|
||||||
59
src/debug/recorderController.ts
Normal file
59
src/debug/recorderController.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as dom from '../dom';
|
import * as dom from '../dom';
|
||||||
import { Formatter, formatColors } from './formatter';
|
import { Formatter, formatColors } from '../utils/formatter';
|
||||||
import { Action, NavigationSignal, actionTitle } from './actions';
|
import { Action, NavigationSignal, actionTitle } from './recorderActions';
|
||||||
|
|
||||||
export class Script {
|
export class Script {
|
||||||
private _actions: Action[] = [];
|
private _actions: Action[] = [];
|
||||||
|
|
@ -56,6 +56,7 @@ export class Script {
|
||||||
generate(browserType: string) {
|
generate(browserType: string) {
|
||||||
const formatter = new Formatter();
|
const formatter = new Formatter();
|
||||||
const { cst, cmt, fnc, kwd, prp, str } = formatColors;
|
const { cst, cmt, fnc, kwd, prp, str } = formatColors;
|
||||||
|
|
||||||
formatter.add(`
|
formatter.add(`
|
||||||
${kwd('const')} { ${cst('chromium')}. ${cst('firefox')}, ${cst('webkit')} } = ${fnc('require')}(${str('playwright')});
|
${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('browser')} = ${kwd('await')} ${cst(`${browserType}`)}.${fnc('launch')}();
|
||||||
${kwd('const')} ${cst('page')} = ${kwd('await')} ${cst('browser')}.${fnc('newPage')}();
|
${kwd('const')} ${cst('page')} = ${kwd('await')} ${cst('browser')}.${fnc('newPage')}();
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const action of this._compact()) {
|
for (const action of this._compact()) {
|
||||||
formatter.newLine();
|
formatter.newLine();
|
||||||
formatter.add(cmt(actionTitle(action)));
|
formatter.add(cmt(actionTitle(action)));
|
||||||
let navigationSignal: NavigationSignal | undefined;
|
let navigationSignal: NavigationSignal | undefined;
|
||||||
if (action.name !== 'navigate' && action.signals && action.signals.length)
|
if (action.name !== 'navigate' && action.signals && action.signals.length)
|
||||||
navigationSignal = action.signals[action.signals.length - 1];
|
navigationSignal = action.signals[action.signals.length - 1];
|
||||||
|
|
||||||
if (navigationSignal) {
|
if (navigationSignal) {
|
||||||
formatter.add(`${kwd('await')} ${cst('Promise')}.${fnc('all')}([
|
formatter.add(`${kwd('await')} ${cst('Promise')}.${fnc('all')}([
|
||||||
${cst('page')}.${fnc('waitForNavigation')}({ ${prp('url')}: ${str(navigationSignal.url)} }),`);
|
${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 prefix = navigationSignal ? '' : kwd('await') + ' ';
|
||||||
const suffix = navigationSignal ? '' : ';';
|
const suffix = navigationSignal ? '' : ';';
|
||||||
if (action.name === 'click') {
|
switch (action.name) {
|
||||||
let method = 'click';
|
case 'click': {
|
||||||
if (action.clickCount === 2)
|
let method = 'click';
|
||||||
method = 'dblclick';
|
if (action.clickCount === 2)
|
||||||
const modifiers = toModifiers(action.modifiers);
|
method = 'dblclick';
|
||||||
const options: dom.ClickOptions = {};
|
const modifiers = toModifiers(action.modifiers);
|
||||||
if (action.button !== 'left')
|
const options: dom.ClickOptions = {};
|
||||||
options.button = action.button;
|
if (action.button !== 'left')
|
||||||
if (modifiers.length)
|
options.button = action.button;
|
||||||
options.modifiers = modifiers;
|
if (modifiers.length)
|
||||||
if (action.clickCount > 2)
|
options.modifiers = modifiers;
|
||||||
options.clickCount = action.clickCount;
|
if (action.clickCount > 2)
|
||||||
const optionsString = formatOptions(options);
|
options.clickCount = action.clickCount;
|
||||||
formatter.add(`${prefix}${cst('page')}.${fnc(method)}(${str(action.selector)}${optionsString})${suffix}`);
|
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)
|
if (navigationSignal)
|
||||||
formatter.add(`]);`);
|
formatter.add(`]);`);
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +151,7 @@ function formatObject(value: any): string {
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
for (const key of keys)
|
for (const key of keys)
|
||||||
tokens.push(`${prp(key)}: ${formatObject(value[key])}`);
|
tokens.push(`${prp(key)}: ${formatObject(value[key])}`);
|
||||||
return `{ ${tokens.join(', ')} }`;
|
return `{${tokens.join(', ')}}`;
|
||||||
}
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
26
src/dom.ts
26
src/dom.ts
|
|
@ -22,6 +22,7 @@ import * as frames from './frames';
|
||||||
import { assert, helper } from './helper';
|
import { assert, helper } from './helper';
|
||||||
import InjectedScript from './injected/injectedScript';
|
import InjectedScript from './injected/injectedScript';
|
||||||
import * as injectedScriptSource from './generated/injectedScriptSource';
|
import * as injectedScriptSource from './generated/injectedScriptSource';
|
||||||
|
import * as debugScriptSource from './generated/debugScriptSource';
|
||||||
import * as input from './input';
|
import * as input from './input';
|
||||||
import * as js from './javascript';
|
import * as js from './javascript';
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
|
|
@ -30,6 +31,7 @@ import * as types from './types';
|
||||||
import { NotConnectedError } from './errors';
|
import { NotConnectedError } from './errors';
|
||||||
import { apiLog } from './logger';
|
import { apiLog } from './logger';
|
||||||
import { Progress, runAbortableTask } from './progress';
|
import { Progress, runAbortableTask } from './progress';
|
||||||
|
import DebugScript from './debug/injected/debugScript';
|
||||||
|
|
||||||
export type PointerActionOptions = {
|
export type PointerActionOptions = {
|
||||||
modifiers?: input.Modifier[];
|
modifiers?: input.Modifier[];
|
||||||
|
|
@ -42,7 +44,8 @@ export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOpti
|
||||||
|
|
||||||
export class FrameExecutionContext extends js.ExecutionContext {
|
export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
readonly frame: frames.Frame;
|
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) {
|
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) {
|
||||||
super(delegate);
|
super(delegate);
|
||||||
|
|
@ -78,7 +81,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
injectedScript(): Promise<js.JSHandle<InjectedScript>> {
|
injectedScript(): Promise<js.JSHandle<InjectedScript>> {
|
||||||
if (!this._injectedPromise) {
|
if (!this._injectedScriptPromise) {
|
||||||
const custom: string[] = [];
|
const custom: string[] = [];
|
||||||
for (const [name, { source }] of selectors._engines)
|
for (const [name, { source }] of selectors._engines)
|
||||||
custom.push(`{ name: '${name}', engine: (${source}) }`);
|
custom.push(`{ name: '${name}', engine: (${source}) }`);
|
||||||
|
|
@ -87,9 +90,24 @@ export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
${custom.join(',\n')}
|
${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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { assert } from '../helper';
|
||||||
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } from '../transport';
|
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } from '../transport';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import { InnerLogger, errorLog } from '../logger';
|
import { InnerLogger, errorLog } from '../logger';
|
||||||
import { rewriteErrorMessage } from '../debug/stackTrace';
|
import { rewriteErrorMessage } from '../utils/stackTrace';
|
||||||
|
|
||||||
export const ConnectionEvents = {
|
export const ConnectionEvents = {
|
||||||
Disconnected: Symbol('Disconnected'),
|
Disconnected: Symbol('Disconnected'),
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@
|
||||||
import * as js from '../javascript';
|
import * as js from '../javascript';
|
||||||
import { FFSession } from './ffConnection';
|
import { FFSession } from './ffConnection';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import * as debugSupport from '../debug/debugSupport';
|
import * as sourceMap from '../utils/sourceMap';
|
||||||
import { rewriteErrorMessage } from '../debug/stackTrace';
|
import { rewriteErrorMessage } from '../utils/stackTrace';
|
||||||
import { parseEvaluationResultValue } from '../utilityScriptSerializers';
|
import { parseEvaluationResultValue } from '../common/utilityScriptSerializers';
|
||||||
|
|
||||||
export class FFExecutionContext implements js.ExecutionContextDelegate {
|
export class FFExecutionContext implements js.ExecutionContextDelegate {
|
||||||
_session: FFSession;
|
_session: FFSession;
|
||||||
|
|
@ -33,7 +33,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
|
||||||
|
|
||||||
async rawEvaluate(expression: string): Promise<string> {
|
async rawEvaluate(expression: string): Promise<string> {
|
||||||
const payload = await this._session.send('Runtime.evaluate', {
|
const payload = await this._session.send('Runtime.evaluate', {
|
||||||
expression: debugSupport.ensureSourceUrl(expression),
|
expression: sourceMap.ensureSourceUrl(expression),
|
||||||
returnByValue: false,
|
returnByValue: false,
|
||||||
executionContextId: this._executionContextId,
|
executionContextId: this._executionContextId,
|
||||||
}).catch(rewriteError);
|
}).catch(rewriteError);
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { FFNetworkManager, headersArray } from './ffNetworkManager';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import { selectors } from '../selectors';
|
import { selectors } from '../selectors';
|
||||||
import { NotConnectedError } from '../errors';
|
import { NotConnectedError } from '../errors';
|
||||||
import { rewriteErrorMessage } from '../debug/stackTrace';
|
import { rewriteErrorMessage } from '../utils/stackTrace';
|
||||||
|
|
||||||
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ export type RegisteredListener = {
|
||||||
|
|
||||||
export type Listener = (...args: any[]) => void;
|
export type Listener = (...args: any[]) => void;
|
||||||
|
|
||||||
|
const isDebugModeEnv = !!getFromENV('PWDEBUG');
|
||||||
|
|
||||||
class Helper {
|
class Helper {
|
||||||
static evaluationString(fun: Function | string, ...args: any[]): string {
|
static evaluationString(fun: Function | string, ...args: any[]): string {
|
||||||
if (Helper.isString(fun)) {
|
if (Helper.isString(fun)) {
|
||||||
|
|
@ -299,6 +301,10 @@ class Helper {
|
||||||
helper.removeEventListeners(listeners);
|
helper.removeEventListeners(listeners);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isDebugMode(): boolean {
|
||||||
|
return isDebugModeEnv;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assert(value: any, message?: string): asserts value {
|
export function assert(value: any, message?: string): asserts value {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { createCSSEngine } from './cssSelectorEngine';
|
||||||
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||||
import { createTextSelector } from './textSelectorEngine';
|
import { createTextSelector } from './textSelectorEngine';
|
||||||
import { XPathEngine } from './xpathSelectorEngine';
|
import { XPathEngine } from './xpathSelectorEngine';
|
||||||
|
import { ParsedSelector } from '../common/selectorParser';
|
||||||
|
|
||||||
type Falsy = false | 0 | '' | undefined | null;
|
type Falsy = false | 0 | '' | undefined | null;
|
||||||
type Predicate<T> = (progress: types.InjectedScriptProgress) => T | Falsy;
|
type Predicate<T> = (progress: types.InjectedScriptProgress) => T | Falsy;
|
||||||
|
|
@ -48,13 +49,13 @@ export default class InjectedScript {
|
||||||
this.engines.set(name, engine);
|
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'])
|
if (!(root as any)['querySelector'])
|
||||||
throw new Error('Node is not queryable.');
|
throw new Error('Node is not queryable.');
|
||||||
return this._querySelectorRecursively(root as SelectorRoot, selector, 0);
|
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];
|
const current = selector.parts[index];
|
||||||
if (index === selector.parts.length - 1)
|
if (index === selector.parts.length - 1)
|
||||||
return this.engines.get(current.name)!.query(root, current.body);
|
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'])
|
if (!(root as any)['querySelectorAll'])
|
||||||
throw new Error('Node is not queryable.');
|
throw new Error('Node is not queryable.');
|
||||||
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
|
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { serializeAsCallArgument, parseEvaluationResultValue } from '../utilityScriptSerializers';
|
import { serializeAsCallArgument, parseEvaluationResultValue } from '../common/utilityScriptSerializers';
|
||||||
|
|
||||||
export default class UtilityScript {
|
export default class UtilityScript {
|
||||||
evaluate(returnByValue: boolean, expression: string) {
|
evaluate(returnByValue: boolean, expression: string) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import * as dom from './dom';
|
import * as dom from './dom';
|
||||||
import * as utilityScriptSource from './generated/utilityScriptSource';
|
import * as utilityScriptSource from './generated/utilityScriptSource';
|
||||||
import * as debugSupport from './debug/debugSupport';
|
import * as sourceMap from './utils/sourceMap';
|
||||||
import { serializeAsCallArgument } from './utilityScriptSerializers';
|
import { serializeAsCallArgument } from './common/utilityScriptSerializers';
|
||||||
import { helper } from './helper';
|
import { helper } from './helper';
|
||||||
|
|
||||||
type ObjectId = string;
|
type ObjectId = string;
|
||||||
|
|
@ -106,7 +106,7 @@ export class JSHandle<T = any> {
|
||||||
if (!this._objectId)
|
if (!this._objectId)
|
||||||
return this._value;
|
return this._value;
|
||||||
const utilityScript = await this._context.utilityScript();
|
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]);
|
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> {
|
export async function evaluate(context: ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||||
const utilityScript = await context.utilityScript();
|
const utilityScript = await context.utilityScript();
|
||||||
if (helper.isString(pageFunction)) {
|
if (helper.isString(pageFunction)) {
|
||||||
const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)` + debugSupport.generateSourceUrl();
|
const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)` + sourceMap.generateSourceUrl();
|
||||||
return context._delegate.evaluateWithArguments(script, returnByValue, utilityScript, [returnByValue, debugSupport.ensureSourceUrl(pageFunction)], []);
|
return context._delegate.evaluateWithArguments(script, returnByValue, utilityScript, [returnByValue, sourceMap.ensureSourceUrl(pageFunction)], []);
|
||||||
}
|
}
|
||||||
if (typeof pageFunction !== 'function')
|
if (typeof pageFunction !== 'function')
|
||||||
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`);
|
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!);
|
utilityScriptObjectIds.push(handle._objectId!);
|
||||||
}
|
}
|
||||||
|
|
||||||
functionText += await debugSupport.generateSourceMapUrl(originalText, functionText);
|
functionText += await sourceMap.generateSourceMapUrl(originalText, functionText);
|
||||||
// See UtilityScript for arguments.
|
// See UtilityScript for arguments.
|
||||||
const utilityScriptValues = [returnByValue, functionText, args.length, ...args];
|
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 {
|
try {
|
||||||
return context._delegate.evaluateWithArguments(script, returnByValue, utilityScript, utilityScriptValues, utilityScriptObjectIds);
|
return context._delegate.evaluateWithArguments(script, returnByValue, utilityScript, utilityScriptValues, utilityScriptObjectIds);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { EventEmitter } from 'events';
|
||||||
import { FileChooser } from './fileChooser';
|
import { FileChooser } from './fileChooser';
|
||||||
import { logError, InnerLogger } from './logger';
|
import { logError, InnerLogger } from './logger';
|
||||||
import { ProgressController } from './progress';
|
import { ProgressController } from './progress';
|
||||||
import { Recorder } from './recorder/recorder';
|
import { RecorderController } from './debug/recorderController';
|
||||||
|
|
||||||
export interface PageDelegate {
|
export interface PageDelegate {
|
||||||
readonly rawMouse: input.RawMouse;
|
readonly rawMouse: input.RawMouse;
|
||||||
|
|
@ -505,7 +505,9 @@ export class Page extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startRecordingUser() {
|
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) {
|
async waitForTimeout(timeout: number) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import { InnerLogger, Log, apiLog } from './logger';
|
import { InnerLogger, Log, apiLog } from './logger';
|
||||||
import { TimeoutError } from './errors';
|
import { TimeoutError } from './errors';
|
||||||
import { assert } from './helper';
|
import { assert } from './helper';
|
||||||
import { getCurrentApiCall, rewriteErrorMessage } from './debug/stackTrace';
|
import { getCurrentApiCall, rewriteErrorMessage } from './utils/stackTrace';
|
||||||
|
|
||||||
export interface Progress {
|
export interface Progress {
|
||||||
readonly apiName: string;
|
readonly apiName: string;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,7 @@ import * as dom from './dom';
|
||||||
import { assert, helper } from './helper';
|
import { assert, helper } from './helper';
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import { rewriteErrorMessage } from './debug/stackTrace';
|
import { rewriteErrorMessage } from './utils/stackTrace';
|
||||||
|
|
||||||
export class Screenshotter {
|
export class Screenshotter {
|
||||||
private _queue = new TaskQueue();
|
private _queue = new TaskQueue();
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import * as frames from './frames';
|
||||||
import { helper, assert } from './helper';
|
import { helper, assert } from './helper';
|
||||||
import * as js from './javascript';
|
import * as js from './javascript';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
|
import { ParsedSelector, parseSelector } from './common/selectorParser';
|
||||||
|
|
||||||
export class Selectors {
|
export class Selectors {
|
||||||
readonly _builtinEngines: Set<string>;
|
readonly _builtinEngines: Set<string>;
|
||||||
|
|
@ -53,7 +54,7 @@ export class Selectors {
|
||||||
++this._generation;
|
++this._generation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _needsMainContext(parsed: types.ParsedSelector): boolean {
|
private _needsMainContext(parsed: ParsedSelector): boolean {
|
||||||
return parsed.parts.some(({name}) => {
|
return parsed.parts.some(({name}) => {
|
||||||
const custom = this._engines.get(name);
|
const custom = this._engines.get(name);
|
||||||
return custom ? !custom.contentScript : false;
|
return custom ? !custom.contentScript : false;
|
||||||
|
|
@ -170,7 +171,7 @@ export class Selectors {
|
||||||
}, { target: handle, name });
|
}, { target: handle, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
private _parseSelector(selector: string): types.ParsedSelector {
|
private _parseSelector(selector: string): ParsedSelector {
|
||||||
assert(helper.isString(selector), `selector must be a string`);
|
assert(helper.isString(selector), `selector must be a string`);
|
||||||
const parsed = parseSelector(selector);
|
const parsed = parseSelector(selector);
|
||||||
for (const {name} of parsed.parts) {
|
for (const {name} of parsed.parts) {
|
||||||
|
|
@ -182,66 +183,3 @@ export class Selectors {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectors = new 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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import * as browserPaths from '../install/browserPaths';
|
||||||
import { Logger, InnerLogger } from '../logger';
|
import { Logger, InnerLogger } from '../logger';
|
||||||
import { ConnectionTransport, WebSocketTransport } from '../transport';
|
import { ConnectionTransport, WebSocketTransport } from '../transport';
|
||||||
import { BrowserBase, BrowserOptions, Browser } from '../browser';
|
import { BrowserBase, BrowserOptions, Browser } from '../browser';
|
||||||
import { assert } from '../helper';
|
import { assert, helper } from '../helper';
|
||||||
import { launchProcess, Env, waitForLine } from './processLauncher';
|
import { launchProcess, Env, waitForLine } from './processLauncher';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
import { PipeTransport } from './pipeTransport';
|
import { PipeTransport } from './pipeTransport';
|
||||||
|
|
@ -260,6 +260,6 @@ function copyTestHooks(from: object, to: object) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLaunchOptions<Options extends LaunchOptionsBase>(options: Options): Options {
|
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 };
|
return { ...options, devtools, headless };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { assert, getFromENV, logPolitely } from '../helper';
|
import { assert, getFromENV, logPolitely, helper } from '../helper';
|
||||||
import { CRBrowser } from '../chromium/crBrowser';
|
import { CRBrowser } from '../chromium/crBrowser';
|
||||||
import * as ws from 'ws';
|
import * as ws from 'ws';
|
||||||
import { Env } from './processLauncher';
|
import { Env } from './processLauncher';
|
||||||
|
|
@ -25,8 +25,7 @@ import { LaunchOptionsBase, BrowserTypeBase } from './browserType';
|
||||||
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
|
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
|
||||||
import { InnerLogger } from '../logger';
|
import { InnerLogger } from '../logger';
|
||||||
import { BrowserDescriptor } from '../install/browserPaths';
|
import { BrowserDescriptor } from '../install/browserPaths';
|
||||||
import { CRDevTools } from '../debug/crDevTools';
|
import { CRDevTools } from '../chromium/crDevTools';
|
||||||
import * as debugSupport from '../debug/debugSupport';
|
|
||||||
import { BrowserOptions } from '../browser';
|
import { BrowserOptions } from '../browser';
|
||||||
import { WebSocketServer } from './webSocketServer';
|
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);
|
super(packagePath, browser, debugPort ? { webSocketRegex: /^DevTools listening on (ws:\/\/.*)$/, stream: 'stderr' } : null);
|
||||||
this._debugPort = debugPort;
|
this._debugPort = debugPort;
|
||||||
if (debugSupport.isDebugMode())
|
if (helper.isDebugMode())
|
||||||
this._devtools = this._createDevTools();
|
this._devtools = this._createDevTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TimeoutOptions } from './types';
|
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 {
|
export class TimeoutSettings {
|
||||||
private _parent: TimeoutSettings | undefined;
|
private _parent: TimeoutSettings | undefined;
|
||||||
|
|
|
||||||
|
|
@ -154,14 +154,6 @@ export type JSCoverageOptions = {
|
||||||
reportAnonymousScripts?: boolean,
|
reportAnonymousScripts?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedSelector = {
|
|
||||||
parts: {
|
|
||||||
name: string,
|
|
||||||
body: string,
|
|
||||||
}[],
|
|
||||||
capture?: number,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InjectedScriptResult<T = undefined> =
|
export type InjectedScriptResult<T = undefined> =
|
||||||
(T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) |
|
(T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) |
|
||||||
{ status: 'notconnected' } |
|
{ status: 'notconnected' } |
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class Formatter {
|
||||||
private _lines: string[] = [];
|
private _lines: string[] = [];
|
||||||
|
|
||||||
constructor(indent: number = 2) {
|
constructor(indent: number = 2) {
|
||||||
this._baseIndent = [...Array(indent + 1)].join(' ');
|
this._baseIndent = ' '.repeat(indent);
|
||||||
}
|
}
|
||||||
|
|
||||||
prepend(text: string) {
|
prepend(text: string) {
|
||||||
|
|
@ -17,13 +17,37 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import { getCallerFilePath } from './stackTrace';
|
import { getCallerFilePath } from './stackTrace';
|
||||||
|
import { helper } from '../helper';
|
||||||
|
|
||||||
type Position = {
|
type Position = {
|
||||||
line: number;
|
line: number;
|
||||||
column: 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();
|
const filePath = getCallerFilePath();
|
||||||
if (!filePath)
|
if (!filePath)
|
||||||
return;
|
return;
|
||||||
|
|
@ -20,7 +20,7 @@ import { assert } from '../helper';
|
||||||
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } from '../transport';
|
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, protocolLog } from '../transport';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import { InnerLogger, errorLog } from '../logger';
|
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
|
// WKPlaywright uses this special id to issue Browser.close command which we
|
||||||
// should ignore.
|
// should ignore.
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
import { WKSession, isSwappedOutError } from './wkConnection';
|
import { WKSession, isSwappedOutError } from './wkConnection';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
import * as js from '../javascript';
|
import * as js from '../javascript';
|
||||||
import * as debugSupport from '../debug/debugSupport';
|
import { parseEvaluationResultValue } from '../common/utilityScriptSerializers';
|
||||||
import { parseEvaluationResultValue } from '../utilityScriptSerializers';
|
import * as sourceMap from '../utils/sourceMap';
|
||||||
|
|
||||||
export class WKExecutionContext implements js.ExecutionContextDelegate {
|
export class WKExecutionContext implements js.ExecutionContextDelegate {
|
||||||
private readonly _session: WKSession;
|
private readonly _session: WKSession;
|
||||||
|
|
@ -42,7 +42,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
|
||||||
async rawEvaluate(expression: string): Promise<string> {
|
async rawEvaluate(expression: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await this._session.send('Runtime.evaluate', {
|
const response = await this._session.send('Runtime.evaluate', {
|
||||||
expression: debugSupport.ensureSourceUrl(expression),
|
expression: sourceMap.ensureSourceUrl(expression),
|
||||||
contextId: this._contextId,
|
contextId: this._contextId,
|
||||||
returnByValue: false
|
returnByValue: false
|
||||||
});
|
});
|
||||||
|
|
@ -93,7 +93,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
|
||||||
try {
|
try {
|
||||||
const utilityScript = await context.utilityScript();
|
const utilityScript = await context.utilityScript();
|
||||||
const serializeResponse = await this._session.send('Runtime.callFunctionOn', {
|
const serializeResponse = await this._session.send('Runtime.callFunctionOn', {
|
||||||
functionDeclaration: 'object => object' + debugSupport.generateSourceUrl(),
|
functionDeclaration: 'object => object' + sourceMap.generateSourceUrl(),
|
||||||
objectId: utilityScript._objectId!,
|
objectId: utilityScript._objectId!,
|
||||||
arguments: [ { objectId } ],
|
arguments: [ { objectId } ],
|
||||||
returnByValue: true
|
returnByValue: true
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ describe('Fixtures', function() {
|
||||||
|
|
||||||
describe('StackTrace', () => {
|
describe('StackTrace', () => {
|
||||||
it('caller file path', async state => {
|
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 callme = require('./fixtures/callback');
|
||||||
const filePath = callme(() => {
|
const filePath = callme(() => {
|
||||||
return stackTrace.getCallerFilePath(path.join(__dirname, 'fixtures') + path.sep);
|
return stackTrace.getCallerFilePath(path.join(__dirname, 'fixtures') + path.sep);
|
||||||
|
|
@ -188,7 +188,7 @@ describe('StackTrace', () => {
|
||||||
expect(filePath).toBe(__filename);
|
expect(filePath).toBe(__filename);
|
||||||
});
|
});
|
||||||
it('api call', async state => {
|
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 callme = require('./fixtures/callback');
|
||||||
const apiCall = callme(stackTrace.getCurrentApiCall.bind(stackTrace, path.join(__dirname, 'fixtures') + path.sep));
|
const apiCall = callme(stackTrace.getCurrentApiCall.bind(stackTrace, path.join(__dirname, 'fixtures') + path.sep));
|
||||||
expect(apiCall).toBe('callme');
|
expect(apiCall).toBe('callme');
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const path = require('path');
|
||||||
const files = [
|
const files = [
|
||||||
path.join('src', 'injected', 'injectedScript.webpack.config.js'),
|
path.join('src', 'injected', 'injectedScript.webpack.config.js'),
|
||||||
path.join('src', 'injected', 'utilityScript.webpack.config.js'),
|
path.join('src', 'injected', 'utilityScript.webpack.config.js'),
|
||||||
|
path.join('src', 'debug', 'injected', 'debugScript.webpack.config.js'),
|
||||||
];
|
];
|
||||||
|
|
||||||
function runOne(runner, file) {
|
function runOne(runner, file) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue