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