diff --git a/src/browserContext.ts b/src/browserContext.ts index d9eec0aa0e..88325f18d9 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -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; setHTTPCredentials(httpCredentials: types.Credentials | null): Promise; addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise; - exposeBinding(name: string, playwrightBinding: FunctionWithSource): Promise; + exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource): Promise; exposeFunction(name: string, playwrightFunction: Function): Promise; route(url: types.URLMatch, handler: network.RouteHandler): Promise; unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise; @@ -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 { @@ -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 { + async exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource): Promise { 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`); diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index 595dde1108..cec2a2f139 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -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; diff --git a/src/chromium/crConnection.ts b/src/chromium/crConnection.ts index dd33f32ec0..bf641674a4 100644 --- a/src/chromium/crConnection.ts +++ b/src/chromium/crConnection.ts @@ -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') diff --git a/src/chromium/crCoverage.ts b/src/chromium/crCoverage.ts index cd3110ac87..ba0ecf61a1 100644 --- a/src/chromium/crCoverage.ts +++ b/src/chromium/crCoverage.ts @@ -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. diff --git a/src/debug/crDevTools.ts b/src/chromium/crDevTools.ts similarity index 98% rename from src/debug/crDevTools.ts rename to src/chromium/crDevTools.ts index 082f35117b..6e9445cd88 100644 --- a/src/debug/crDevTools.ts +++ b/src/chromium/crDevTools.ts @@ -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__'; diff --git a/src/chromium/crExecutionContext.ts b/src/chromium/crExecutionContext.ts index be26b5b627..f3d9fc1ad9 100644 --- a/src/chromium/crExecutionContext.ts +++ b/src/chromium/crExecutionContext.ts @@ -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 { 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) diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index cf73e4edfd..88de64f6ad 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -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(), diff --git a/src/common/README.md b/src/common/README.md new file mode 100644 index 0000000000..4a5e48f390 --- /dev/null +++ b/src/common/README.md @@ -0,0 +1 @@ +Files in this folder are used both in Node and injected environments, they can't have dependencies. \ No newline at end of file diff --git a/src/common/selectorParser.ts b/src/common/selectorParser.ts new file mode 100644 index 0000000000..917626d836 --- /dev/null +++ b/src/common/selectorParser.ts @@ -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; +} diff --git a/src/utilityScriptSerializers.ts b/src/common/utilityScriptSerializers.ts similarity index 98% rename from src/utilityScriptSerializers.ts rename to src/common/utilityScriptSerializers.ts index 4d6885d30a..8eec2fc7d6 100644 --- a/src/utilityScriptSerializers.ts +++ b/src/common/utilityScriptSerializers.ts @@ -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') diff --git a/src/debug/debugSupport.ts b/src/debug/debugSupport.ts deleted file mode 100644 index af7456866c..0000000000 --- a/src/debug/debugSupport.ts +++ /dev/null @@ -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 { - 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 }; -} diff --git a/src/debug/injected/consoleApi.ts b/src/debug/injected/consoleApi.ts new file mode 100644 index 0000000000..6290eb0876 --- /dev/null +++ b/src/debug/injected/consoleApi.ts @@ -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`
`; + (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`
`; + 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(); + } +} diff --git a/src/debug/injected/debugScript.ts b/src/debug/injected/debugScript.ts new file mode 100644 index 0000000000..3a0979db08 --- /dev/null +++ b/src/debug/injected/debugScript.ts @@ -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(); + } +} diff --git a/src/debug/injected/debugScript.webpack.config.js b/src/debug/injected/debugScript.webpack.config.js new file mode 100644 index 0000000000..690b64a857 --- /dev/null +++ b/src/debug/injected/debugScript.webpack.config.js @@ -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')), + ] +}; diff --git a/src/debug/injected/html.ts b/src/debug/injected/html.ts new file mode 100644 index 0000000000..054a9afc52 --- /dev/null +++ b/src/debug/injected/html.ts @@ -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 +} + +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; +} diff --git a/src/debug/injected/recorder.ts b/src/debug/injected/recorder.ts new file mode 100644 index 0000000000..09b97398aa --- /dev/null +++ b/src/debug/injected/recorder.ts @@ -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 ''; + } +} + +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'; +} diff --git a/src/recorder/actions.ts b/src/debug/recorderActions.ts similarity index 80% rename from src/recorder/actions.ts rename to src/debug/recorderActions.ts index 33fb33a3f7..47f065285d 100644 --- a/src/recorder/actions.ts +++ b/src/debug/recorderActions.ts @@ -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': diff --git a/src/debug/recorderController.ts b/src/debug/recorderController.ts new file mode 100644 index 0000000000..24eaf8ad14 --- /dev/null +++ b/src/debug/recorderController.ts @@ -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 + } +} diff --git a/src/recorder/script.ts b/src/debug/recorderScript.ts similarity index 66% rename from src/recorder/script.ts rename to src/debug/recorderScript.ts index 32a9a0b158..1f8be9a061 100644 --- a/src/recorder/script.ts +++ b/src/debug/recorderScript.ts @@ -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); } diff --git a/src/dom.ts b/src/dom.ts index 430acbc1dd..411b63bb6a 100644 --- a/src/dom.ts +++ b/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; + private _injectedScriptPromise?: Promise; + private _debugScriptPromise?: Promise; constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) { super(delegate); @@ -78,7 +81,7 @@ export class FrameExecutionContext extends js.ExecutionContext { } injectedScript(): Promise> { - 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 | 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; } } diff --git a/src/firefox/ffConnection.ts b/src/firefox/ffConnection.ts index c9229c35a2..15a80c338b 100644 --- a/src/firefox/ffConnection.ts +++ b/src/firefox/ffConnection.ts @@ -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'), diff --git a/src/firefox/ffExecutionContext.ts b/src/firefox/ffExecutionContext.ts index a271ce580a..f23fc4ce59 100644 --- a/src/firefox/ffExecutionContext.ts +++ b/src/firefox/ffExecutionContext.ts @@ -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 { const payload = await this._session.send('Runtime.evaluate', { - expression: debugSupport.ensureSourceUrl(expression), + expression: sourceMap.ensureSourceUrl(expression), returnByValue: false, executionContextId: this._executionContextId, }).catch(rewriteError); diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index 76e6e43317..153285eb2c 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -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__'; diff --git a/src/helper.ts b/src/helper.ts index e71dbe4652..44b17c91de 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -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 { diff --git a/src/injected/injectedScript.ts b/src/injected/injectedScript.ts index a6b7f86537..38f449abb7 100644 --- a/src/injected/injectedScript.ts +++ b/src/injected/injectedScript.ts @@ -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 = (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; diff --git a/src/injected/utilityScript.ts b/src/injected/utilityScript.ts index 8c53a761d4..d48c6f1610 100644 --- a/src/injected/utilityScript.ts +++ b/src/injected/utilityScript.ts @@ -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) { diff --git a/src/javascript.ts b/src/javascript.ts index 405af1c783..6faa030064 100644 --- a/src/javascript.ts +++ b/src/javascript.ts @@ -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 { 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 { export async function evaluate(context: ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise { 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 { diff --git a/src/page.ts b/src/page.ts index eed866973a..1d8dcdad94 100644 --- a/src/page.ts +++ b/src/page.ts @@ -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) { diff --git a/src/progress.ts b/src/progress.ts index c9155b7191..bef850c887 100644 --- a/src/progress.ts +++ b/src/progress.ts @@ -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; diff --git a/src/recorder/recorder.ts b/src/recorder/recorder.ts deleted file mode 100644 index 73a5f9b4e6..0000000000 --- a/src/recorder/recorder.ts +++ /dev/null @@ -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 - } -} diff --git a/src/screenshotter.ts b/src/screenshotter.ts index 0a6aee30e0..b83bfcb642 100644 --- a/src/screenshotter.ts +++ b/src/screenshotter.ts @@ -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(); diff --git a/src/selectors.ts b/src/selectors.ts index 158bf52378..bcd8433e05 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -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; @@ -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; -} diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 872bf96126..5950dc3dda 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -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: Options): Options { - const { devtools = false, headless = !devtools } = options; + const { devtools = false, headless = !helper.isDebugMode() && !devtools } = options; return { ...options, devtools, headless }; } diff --git a/src/server/chromium.ts b/src/server/chromium.ts index 0e075e44b7..603f08acff 100644 --- a/src/server/chromium.ts +++ b/src/server/chromium.ts @@ -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(); } diff --git a/src/timeoutSettings.ts b/src/timeoutSettings.ts index d1b09e6856..ce318f61df 100644 --- a/src/timeoutSettings.ts +++ b/src/timeoutSettings.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index 5f9f05fad5..92a508b6d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,14 +154,6 @@ export type JSCoverageOptions = { reportAnonymousScripts?: boolean, }; -export type ParsedSelector = { - parts: { - name: string, - body: string, - }[], - capture?: number, -}; - export type InjectedScriptResult = (T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) | { status: 'notconnected' } | diff --git a/src/recorder/formatter.ts b/src/utils/formatter.ts similarity index 97% rename from src/recorder/formatter.ts rename to src/utils/formatter.ts index 38003e38b6..99314d3fce 100644 --- a/src/recorder/formatter.ts +++ b/src/utils/formatter.ts @@ -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) { diff --git a/src/debug/sourceMap.ts b/src/utils/sourceMap.ts similarity index 80% rename from src/debug/sourceMap.ts rename to src/utils/sourceMap.ts index ac6cebcb45..163ed8a67f 100644 --- a/src/debug/sourceMap.ts +++ b/src/utils/sourceMap.ts @@ -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 { +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 { + 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 { const filePath = getCallerFilePath(); if (!filePath) return; diff --git a/src/debug/stackTrace.ts b/src/utils/stackTrace.ts similarity index 100% rename from src/debug/stackTrace.ts rename to src/utils/stackTrace.ts diff --git a/src/webkit/wkConnection.ts b/src/webkit/wkConnection.ts index bf495e437e..7a548a2d38 100644 --- a/src/webkit/wkConnection.ts +++ b/src/webkit/wkConnection.ts @@ -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. diff --git a/src/webkit/wkExecutionContext.ts b/src/webkit/wkExecutionContext.ts index 623cc44168..1fa038abb3 100644 --- a/src/webkit/wkExecutionContext.ts +++ b/src/webkit/wkExecutionContext.ts @@ -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 { 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 diff --git a/test/fixtures.spec.js b/test/fixtures.spec.js index a356f7f237..edcf19cfe2 100644 --- a/test/fixtures.spec.js +++ b/test/fixtures.spec.js @@ -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'); diff --git a/utils/runWebpack.js b/utils/runWebpack.js index 9018b2f585..cd3b7ac056 100644 --- a/utils/runWebpack.js +++ b/utils/runWebpack.js @@ -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) {