From 99b7aaace8c3f5090ddf91dcf14408a169286202 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 15 May 2020 15:21:49 -0700 Subject: [PATCH] chore: refactor injected script harness (#2259) --- src/chromium/crExecutionContext.ts | 6 +- src/chromium/crPage.ts | 6 +- src/dom.ts | 37 +- src/firefox/ffExecutionContext.ts | 6 +- src/firefox/ffPage.ts | 8 +- src/frames.ts | 2 +- .../{injected.ts => injectedScript.ts} | 92 +- ...ig.js => injectedScript.webpack.config.js} | 6 +- src/injected/selectorEvaluator.ts | 97 --- src/injected/zsSelectorEngine.ts | 794 ------------------ .../zsSelectorEngine.webpack.config.js | 45 - src/javascript.ts | 16 +- src/selectors.ts | 116 +-- src/types.ts | 6 + src/webkit/wkExecutionContext.ts | 4 +- src/webkit/wkPage.ts | 6 +- src/webkit/wkWorkers.ts | 2 +- test/queryselector.spec.js | 133 --- utils/runWebpack.js | 3 +- 19 files changed, 186 insertions(+), 1199 deletions(-) rename src/injected/{injected.ts => injectedScript.ts} (79%) rename src/injected/{selectorEvaluator.webpack.config.js => injectedScript.webpack.config.js} (85%) delete mode 100644 src/injected/selectorEvaluator.ts delete mode 100644 src/injected/zsSelectorEngine.ts delete mode 100644 src/injected/zsSelectorEngine.webpack.config.js diff --git a/src/chromium/crExecutionContext.ts b/src/chromium/crExecutionContext.ts index 845eac0063..90cf79b174 100644 --- a/src/chromium/crExecutionContext.ts +++ b/src/chromium/crExecutionContext.ts @@ -49,7 +49,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { }).catch(rewriteError); if (exceptionDetails) throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails)); - return returnByValue ? valueFromRemoteObject(remoteObject) : context._createHandle(remoteObject); + return returnByValue ? valueFromRemoteObject(remoteObject) : context.createHandle(remoteObject); } if (typeof pageFunction !== 'function') @@ -91,7 +91,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { }).catch(rewriteError); if (exceptionDetails) throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails)); - return returnByValue ? valueFromRemoteObject(remoteObject) : context._createHandle(remoteObject); + return returnByValue ? valueFromRemoteObject(remoteObject) : context.createHandle(remoteObject); } finally { dispose(); } @@ -122,7 +122,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { for (const property of response.result) { if (!property.enumerable) continue; - result.set(property.name, handle._context._createHandle(property.value)); + result.set(property.name, handle._context.createHandle(property.value)); } return result; } diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 8042e39e2c..1a7cd8e386 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -577,7 +577,7 @@ class FrameSession { session.send('Runtime.runIfWaitingForDebugger'), ]).catch(logError(this._page)); // This might fail if the target is closed before we initialize. session.on('Runtime.consoleAPICalled', event => { - const args = event.args.map(o => worker._existingExecutionContext!._createHandle(o)); + const args = event.args.map(o => worker._existingExecutionContext!.createHandle(o)); this._page._addConsoleMessage(event.type, args, toConsoleMessageLocation(event.stackTrace)); }); session.on('Runtime.exceptionThrown', exception => this._page.emit(Events.Page.PageError, exceptionToError(exception.exceptionDetails))); @@ -608,7 +608,7 @@ class FrameSession { return; } const context = this._contextIdToContext.get(event.executionContextId)!; - const values = event.args.map(arg => context._createHandle(arg)); + const values = event.args.map(arg => context.createHandle(arg)); this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); } @@ -846,7 +846,7 @@ class FrameSession { }).catch(logError(this._page)); if (!result || result.object.subtype === 'null') throw new Error('Unable to adopt element handle from a different document'); - return to._createHandle(result.object).asElement()!; + return to.createHandle(result.object).asElement()!; } } diff --git a/src/dom.ts b/src/dom.ts index 576ff82848..745c352131 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -20,7 +20,8 @@ import * as path from 'path'; import * as util from 'util'; import * as frames from './frames'; import { assert, helper } from './helper'; -import { Injected, InjectedResult } from './injected/injected'; +import InjectedScript from './injected/injectedScript'; +import * as injectedScriptSource from './generated/injectedScriptSource'; import * as input from './input'; import * as js from './javascript'; import { Page } from './page'; @@ -52,29 +53,35 @@ export class FrameExecutionContext extends js.ExecutionContext { this.frame = frame; } - _adoptIfNeeded(handle: js.JSHandle): Promise | null { + adoptIfNeeded(handle: js.JSHandle): Promise | null { if (handle instanceof ElementHandle && handle._context !== this) return this.frame._page._delegate.adoptElementHandle(handle, this); return null; } - async _doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise { + async doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise { return await this.frame._page._frameManager.waitForSignalsCreatedBy(async () => { return this._delegate.evaluate(this, returnByValue, pageFunction, ...args); }, Number.MAX_SAFE_INTEGER, waitForNavigations ? undefined : { noWaitAfter: true }); } - _createHandle(remoteObject: any): js.JSHandle { + createHandle(remoteObject: any): js.JSHandle { if (this.frame._page._delegate.isElementHandle(remoteObject)) return new ElementHandle(this, remoteObject); - return super._createHandle(remoteObject); + return super.createHandle(remoteObject); } - _injected(): Promise> { + injectedScript(): Promise> { if (!this._injectedPromise) { - this._injectedPromise = selectors._prepareEvaluator(this).then(evaluator => { - return this.evaluateHandleInternal(evaluator => evaluator.injected, evaluator); - }); + const custom: string[] = []; + for (const [name, { source }] of selectors._engines) + custom.push(`{ name: '${name}', engine: (${source}) }`); + const source = ` + new (${injectedScriptSource.source})([ + ${custom.join(',\n')} + ]) + `; + this._injectedPromise = this.doEvaluateInternal(false /* returnByValue */, false /* waitForNavigations */, source); } return this._injectedPromise; } @@ -94,14 +101,14 @@ export class ElementHandle extends js.JSHandle { return this; } - async _evaluateInMain(pageFunction: types.FuncOn<{ injected: Injected, node: T }, Arg, R>, arg: Arg): Promise { + async _evaluateInMain(pageFunction: types.FuncOn<{ injected: InjectedScript, node: T }, Arg, R>, arg: Arg): Promise { const main = await this._context.frame._mainContext(); - return main._doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await main._injected(), node: this }, arg); + return main.doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await main.injectedScript(), node: this }, arg); } - async _evaluateInUtility(pageFunction: types.FuncOn<{ injected: Injected, node: T }, Arg, R>, arg: Arg): Promise { + async _evaluateInUtility(pageFunction: types.FuncOn<{ injected: InjectedScript, node: T }, Arg, R>, arg: Arg): Promise { const utility = await this._context.frame._utilityContext(); - return utility._doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await utility._injected(), node: this }, arg); + return utility.doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await utility.injectedScript(), node: this }, arg); } async ownerFrame(): Promise { @@ -352,7 +359,7 @@ export class ElementHandle extends js.JSHandle { async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options?: types.NavigatingActionWaitOptions) { this._page._log(inputLog, `elementHandle.setInputFiles(...)`); const deadline = this._page._timeoutSettings.computeDeadline(options); - const injectedResult = await this._evaluateInUtility(({ node }): InjectedResult => { + const injectedResult = await this._evaluateInUtility(({ node }): types.InjectedScriptResult => { if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT') return { status: 'error', error: 'Node is not an HTMLInputElement' }; if (!node.isConnected) @@ -500,7 +507,7 @@ export function toFileTransferPayload(files: types.FilePayload[]): types.FileTra })); } -function handleInjectedResult(injectedResult: InjectedResult, timeoutMessage: string): T { +function handleInjectedResult(injectedResult: types.InjectedScriptResult, timeoutMessage: string): T { if (injectedResult.status === 'notconnected') throw new NotConnectedError(); if (injectedResult.status === 'timeout') diff --git a/src/firefox/ffExecutionContext.ts b/src/firefox/ffExecutionContext.ts index 76c8ff25b2..768636b35e 100644 --- a/src/firefox/ffExecutionContext.ts +++ b/src/firefox/ffExecutionContext.ts @@ -39,7 +39,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { checkException(payload.exceptionDetails); if (returnByValue) return deserializeValue(payload.result!); - return context._createHandle(payload.result); + return context.createHandle(payload.result); } if (typeof pageFunction !== 'function') throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); @@ -71,7 +71,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { checkException(payload.exceptionDetails); if (returnByValue) return deserializeValue(payload.result!); - return context._createHandle(payload.result); + return context.createHandle(payload.result); } finally { dispose(); } @@ -97,7 +97,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { }); const result = new Map(); for (const property of response.properties) - result.set(property.name, handle._context._createHandle(property.value)); + result.set(property.name, handle._context.createHandle(property.value)); return result; } diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index a5891c95ad..b1505db73f 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -184,7 +184,7 @@ export class FFPage implements PageDelegate { _onConsole(payload: Protocol.Runtime.consolePayload) { const {type, args, executionContextId, location} = payload; const context = this._contextIdToContext.get(executionContextId)!; - this._page._addConsoleMessage(type, args.map(arg => context._createHandle(arg)), location); + this._page._addConsoleMessage(type, args.map(arg => context.createHandle(arg)), location); } _onDialogOpened(params: Protocol.Page.dialogOpenedPayload) { @@ -205,7 +205,7 @@ export class FFPage implements PageDelegate { async _onFileChooserOpened(payload: Protocol.Page.fileChooserOpenedPayload) { const {executionContextId, element} = payload; const context = this._contextIdToContext.get(executionContextId)!; - const handle = context._createHandle(element).asElement()!; + const handle = context.createHandle(element).asElement()!; this._page._onFileChooserOpened(handle); } @@ -229,7 +229,7 @@ export class FFPage implements PageDelegate { workerSession.on('Runtime.console', event => { const {type, args, location} = event; const context = worker._existingExecutionContext!; - this._page._addConsoleMessage(type, args.map(arg => context._createHandle(arg)), location); + this._page._addConsoleMessage(type, args.map(arg => context.createHandle(arg)), location); }); // Note: we receive worker exceptions directly from the page. } @@ -457,7 +457,7 @@ export class FFPage implements PageDelegate { }); if (!result.remoteObject) throw new Error('Unable to adopt element handle from a different document'); - return to._createHandle(result.remoteObject) as dom.ElementHandle; + return to.createHandle(result.remoteObject) as dom.ElementHandle; } async getAccessibilityTree(needle?: dom.ElementHandle) { diff --git a/src/frames.ts b/src/frames.ts index 0c7d6b8fb1..5bbef9a3d9 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -786,7 +786,7 @@ export class Frame { const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, timeout, arg }) => { const innerPredicate = new Function('arg', predicateBody); return injected.poll(polling, timeout, () => innerPredicate(arg)); - }, { injected: await context._injected(), predicateBody, polling, timeout: helper.timeUntilDeadline(deadline), arg }); + }, { injected: await context.injectedScript(), predicateBody, polling, timeout: helper.timeUntilDeadline(deadline), arg }); return this._scheduleRerunnableTask(task, 'main', deadline) as any as types.SmartHandle; } diff --git a/src/injected/injected.ts b/src/injected/injectedScript.ts similarity index 79% rename from src/injected/injected.ts rename to src/injected/injectedScript.ts index 38a2b1d71e..9096675700 100644 --- a/src/injected/injected.ts +++ b/src/injected/injectedScript.ts @@ -15,15 +15,83 @@ */ import * as types from '../types'; +import { createAttributeEngine } from './attributeSelectorEngine'; +import { createCSSEngine } from './cssSelectorEngine'; +import { SelectorEngine, SelectorRoot } from './selectorEngine'; +import { createTextSelector } from './textSelectorEngine'; +import { XPathEngine } from './xpathSelectorEngine'; type Predicate = () => T; -export type InjectedResult = - (T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) | - { status: 'notconnected' } | - { status: 'timeout' } | - { status: 'error', error: string }; -export class Injected { +export default class InjectedScript { + readonly engines: Map; + + constructor(customEngines: { name: string, engine: SelectorEngine}[]) { + this.engines = new Map(); + // Note: keep predefined names in sync with Selectors class. + this.engines.set('css', createCSSEngine(true)); + this.engines.set('css:light', createCSSEngine(false)); + this.engines.set('xpath', XPathEngine); + this.engines.set('xpath:light', XPathEngine); + this.engines.set('text', createTextSelector(true)); + this.engines.set('text:light', createTextSelector(false)); + this.engines.set('id', createAttributeEngine('id', true)); + this.engines.set('id:light', createAttributeEngine('id', false)); + this.engines.set('data-testid', createAttributeEngine('data-testid', true)); + this.engines.set('data-testid:light', createAttributeEngine('data-testid', false)); + this.engines.set('data-test-id', createAttributeEngine('data-test-id', true)); + this.engines.set('data-test-id:light', createAttributeEngine('data-test-id', false)); + this.engines.set('data-test', createAttributeEngine('data-test', true)); + this.engines.set('data-test:light', createAttributeEngine('data-test', false)); + for (const {name, engine} of customEngines) + this.engines.set(name, engine); + } + + querySelector(selector: types.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 { + const current = selector.parts[index]; + if (index === selector.parts.length - 1) + return this.engines.get(current.name)!.query(root, current.body); + const all = this.engines.get(current.name)!.queryAll(root, current.body); + for (const next of all) { + const result = this._querySelectorRecursively(next, selector, index + 1); + if (result) + return selector.capture === index ? next : result; + } + } + + querySelectorAll(selector: types.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; + // Query all elements up to the capture. + const partsToQuerAll = selector.parts.slice(0, capture + 1); + // Check they have a descendant matching everything after the capture. + const partsToCheckOne = selector.parts.slice(capture + 1); + let set = new Set([ root as SelectorRoot ]); + for (const { name, body } of partsToQuerAll) { + const newSet = new Set(); + for (const prev of set) { + for (const next of this.engines.get(name)!.queryAll(prev, body)) { + if (newSet.has(next)) + continue; + newSet.add(next); + } + } + set = newSet; + } + const candidates = Array.from(set) as Element[]; + if (!partsToCheckOne.length) + return candidates; + const partial = { parts: partsToCheckOne }; + return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0)); + } + isVisible(element: Element): boolean { // Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises. if (!element.ownerDocument || !element.ownerDocument.defaultView) @@ -95,7 +163,7 @@ export class Injected { return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) }; } - selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): InjectedResult { + selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): types.InjectedScriptResult { if (node.nodeName.toLowerCase() !== 'select') return { status: 'error', error: 'Element is not a