chore: refactor injected script harness (#2259)

This commit is contained in:
Pavel Feldman 2020-05-15 15:21:49 -07:00 committed by GitHub
parent 9c7e43a83b
commit 99b7aaace8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 186 additions and 1199 deletions

View File

@ -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;
}

View File

@ -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()!;
}
}

View File

@ -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<js.JSHandle> | null {
adoptIfNeeded(handle: js.JSHandle): Promise<js.JSHandle> | 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<any> {
async doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise<any> {
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<js.JSHandle<Injected>> {
injectedScript(): Promise<js.JSHandle<InjectedScript>> {
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<T extends Node = Node> extends js.JSHandle<T> {
return this;
}
async _evaluateInMain<R, Arg>(pageFunction: types.FuncOn<{ injected: Injected, node: T }, Arg, R>, arg: Arg): Promise<R> {
async _evaluateInMain<R, Arg>(pageFunction: types.FuncOn<{ injected: InjectedScript, node: T }, Arg, R>, arg: Arg): Promise<R> {
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<R, Arg>(pageFunction: types.FuncOn<{ injected: Injected, node: T }, Arg, R>, arg: Arg): Promise<R> {
async _evaluateInUtility<R, Arg>(pageFunction: types.FuncOn<{ injected: InjectedScript, node: T }, Arg, R>, arg: Arg): Promise<R> {
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<frames.Frame | null> {
@ -352,7 +359,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
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<boolean> => {
const injectedResult = await this._evaluateInUtility(({ node }): types.InjectedScriptResult<boolean> => {
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<T = undefined>(injectedResult: InjectedResult<T>, timeoutMessage: string): T {
function handleInjectedResult<T = undefined>(injectedResult: types.InjectedScriptResult<T>, timeoutMessage: string): T {
if (injectedResult.status === 'notconnected')
throw new NotConnectedError();
if (injectedResult.status === 'timeout')

View File

@ -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;
}

View File

@ -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<T>;
return to.createHandle(result.remoteObject) as dom.ElementHandle<T>;
}
async getAccessibilityTree(needle?: dom.ElementHandle) {

View File

@ -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<R>;
}

View File

@ -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> = () => T;
export type InjectedResult<T = undefined> =
(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<string, SelectorEngine>;
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<SelectorRoot>([ root as SelectorRoot ]);
for (const { name, body } of partsToQuerAll) {
const newSet = new Set<Element>();
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<string[]> {
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): types.InjectedScriptResult<string[]> {
if (node.nodeName.toLowerCase() !== 'select')
return { status: 'error', error: 'Element is not a <select> element.' };
if (!node.isConnected)
@ -126,7 +194,7 @@ export class Injected {
return { status: 'success', value: options.filter(option => option.selected).map(option => option.value) };
}
fill(node: Node, value: string): InjectedResult<boolean> {
fill(node: Node, value: string): types.InjectedScriptResult<boolean> {
if (node.nodeType !== Node.ELEMENT_NODE)
return { status: 'error', error: 'Node is not of type HTMLElement' };
const element = node as HTMLElement;
@ -175,7 +243,7 @@ export class Injected {
return result;
}
selectText(node: Node): InjectedResult {
selectText(node: Node): types.InjectedScriptResult {
if (node.nodeType !== Node.ELEMENT_NODE)
return { status: 'error', error: 'Node is not of type HTMLElement' };
if (!node.isConnected)
@ -207,7 +275,7 @@ export class Injected {
return { status: 'success' };
}
focusNode(node: Node): InjectedResult {
focusNode(node: Node): types.InjectedScriptResult {
if (!node.isConnected)
return { status: 'notconnected' };
if (!(node as any)['focus'])
@ -262,7 +330,7 @@ export class Injected {
input.dispatchEvent(new Event('change', { 'bubbles': true }));
}
async waitForDisplayedAtStablePosition(node: Node, rafCount: number, timeout: number): Promise<InjectedResult> {
async waitForDisplayedAtStablePosition(node: Node, rafCount: number, timeout: number): Promise<types.InjectedScriptResult> {
if (!node.isConnected)
return { status: 'notconnected' };
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
@ -305,7 +373,7 @@ export class Injected {
return { status: result === 'notconnected' ? 'notconnected' : (result ? 'success' : 'timeout') };
}
checkHitTargetAt(node: Node, point: types.Point): InjectedResult<boolean> {
checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult<boolean> {
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
while (element && window.getComputedStyle(element).pointerEvents === 'none')
element = element.parentElement;

View File

@ -18,7 +18,7 @@ const path = require('path');
const InlineSource = require('./webpack-inline-source-plugin.js');
module.exports = {
entry: path.join(__dirname, 'selectorEvaluator.ts'),
entry: path.join(__dirname, 'injectedScript.ts'),
devtool: 'source-map',
module: {
rules: [
@ -36,10 +36,10 @@ module.exports = {
extensions: [ '.tsx', '.ts', '.js' ]
},
output: {
filename: 'selectorEvaluatorSource.js',
filename: 'injectedScriptSource.js',
path: path.resolve(__dirname, '../../lib/injected/packed')
},
plugins: [
new InlineSource(path.join(__dirname, '..', 'generated', 'selectorEvaluatorSource.ts')),
new InlineSource(path.join(__dirname, '..', 'generated', 'injectedScriptSource.ts')),
]
};

View File

@ -1,97 +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 types from '../types';
import { createAttributeEngine } from './attributeSelectorEngine';
import { createCSSEngine } from './cssSelectorEngine';
import { Injected } from './injected';
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { createTextSelector } from './textSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
class SelectorEvaluator {
readonly engines: Map<string, SelectorEngine>;
readonly injected: Injected;
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
this.injected = new Injected();
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<SelectorRoot>([ root as SelectorRoot ]);
for (const { name, body } of partsToQuerAll) {
const newSet = new Set<Element>();
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));
}
}
export default SelectorEvaluator;

View File

@ -1,794 +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 { SelectorEngine, SelectorType, SelectorRoot } from './selectorEngine';
type Token = {
combinator: '' | '>' | '~' | '^',
index?: number,
text?: string,
css?: string,
};
function tokenize(selector: string): Token[] | number {
const tokens: Token[] = [];
let pos = 0;
const skipWhitespace = () => {
while (pos < selector.length && selector[pos] === ' ')
pos++;
};
while (pos < selector.length) {
skipWhitespace();
if (pos === selector.length)
break;
if (!tokens.length && '^>~'.includes(selector[pos]))
return pos;
const token: Token = { combinator: '' };
if (selector[pos] === '^') {
token.combinator = '^';
tokens.push(token);
pos++;
continue;
}
if (selector[pos] === '>') {
token.combinator = '>';
pos++;
skipWhitespace();
if (pos === selector.length)
return pos;
} else if (selector[pos] === '~') {
token.combinator = '~';
pos++;
skipWhitespace();
if (pos === selector.length)
return pos;
}
let text = '';
let end = pos;
let stringQuote: string | undefined;
const isText = '`"\''.includes(selector[pos]);
while (end < selector.length) {
if (stringQuote) {
if (selector[end] === '\\' && end + 1 < selector.length) {
if (!isText)
text += selector[end];
text += selector[end + 1];
end += 2;
} else if (selector[end] === stringQuote) {
text += selector[end++];
stringQuote = undefined;
if (isText)
break;
} else {
text += selector[end++];
}
} else if (' >~^#'.includes(selector[end])) {
break;
} else if ('`"\''.includes(selector[end])) {
stringQuote = selector[end];
text += selector[end++];
} else {
text += selector[end++];
}
}
if (stringQuote)
return end;
if (isText)
token.text = JSON.stringify(text.substring(1, text.length - 1));
else
token.css = text;
pos = end;
if (pos < selector.length && selector[pos] === '#') {
pos++;
let end = pos;
while (end < selector.length && selector[end] >= '0' && selector[end] <= '9')
end++;
if (end === pos)
return pos;
const num = Number(selector.substring(pos, end));
if (isNaN(num))
return pos;
token.index = num;
pos = end;
}
tokens.push(token);
}
return tokens;
}
function pathFromRoot(root: SelectorRoot, targetElement: Element): (Element | SelectorRoot)[] {
let target: Element | SelectorRoot = targetElement;
const path: (Element | SelectorRoot)[] = [target];
while (target !== root) {
if (!target.parentNode || target.parentNode.nodeType !== 1 /* Node.ELEMENT_NODE */ && target.parentNode.nodeType !== 11 /* Node.DOCUMENT_FRAGMENT_NODE */)
throw new Error('Target does not belong to the root subtree');
target = target.parentNode as (Element | SelectorRoot);
path.push(target);
}
path.reverse();
return path;
}
// This is a map from a list element (parent) to a number of contained lists (immediate children).
//
// Example:
// <div>
// <span class=a><img/><img/></span>
// <span class=a/>
// <span class=a/>
// <br>
// <div class=b/>
// <div class=b/>
// <div class=b/>
// </div>
//
// Here we might have the following:
// div -> [[span, span, span], [div, div, div]]
// span -> [[img, img]]
type ListsMap = Map<Element | SelectorRoot, Element[][]>;
function detectLists(root: SelectorRoot, shouldConsider: (e: Element | SelectorRoot) => boolean, getBox: (e: Element) => ClientRect): ListsMap {
const lists: ListsMap = new Map();
const add = (map: Map<string, Element[]>, element: Element, key: string): void => {
let list = map.get(key);
if (!list) {
list = [];
map.set(key, list);
}
list.push(element);
};
const mark = (parent: Element | SelectorRoot, map: Map<string, Element[]>, used: Set<Element>): void => {
for (let list of map.values()) {
list = list.filter(item => !used.has(item));
if (list.length < 2)
continue;
let collection = lists.get(parent);
if (!collection) {
collection = [];
lists.set(parent, collection);
}
collection.push(list);
list.forEach(item => used.add(item));
}
};
// hashes list: s, vh, v, h
const kHashes = 4;
const visit = (element: Element | SelectorRoot, produceHashes: boolean): { size: number, hashes?: string[] } => {
const consider = shouldConsider(element);
let size = 1;
let maps: Map<string, Element[]>[] | undefined;
if (consider)
maps = new Array(kHashes).fill(0).map(_ => new Map());
let structure: string[] | undefined;
if (produceHashes)
structure = [element.nodeName];
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
const childResult = visit(child, consider);
size += childResult.size;
if (consider) {
for (let i = 0; i < childResult.hashes!.length; i++) {
if (childResult.hashes![i])
add(maps![i], child, childResult.hashes![i]);
}
}
if (structure)
structure.push(child.nodeName);
}
if (consider) {
const used = new Set<Element>();
maps!.forEach(map => mark(element, map, used));
}
let hashes: string[] | undefined;
if (produceHashes) {
const box = getBox(element as Element);
hashes = [];
hashes.push((structure!.length >= 4) || (size >= 10) ? structure!.join('') : '');
hashes.push(`${element.nodeName},${(size / 3) | 0},${box.height | 0},${box.width | 0}`);
if (size <= 5)
hashes.push(`${element.nodeName},${(size / 3) | 0},${box.width | 0},${box.left | 0}`);
else
hashes.push(`${element.nodeName},${(size / 3) | 0},${box.width | 0},${box.left | 0},${2 * Math.log(box.height) | 0}`);
if (size <= 5)
hashes.push(`${element.nodeName},${(size / 3) | 0},${box.height | 0},${box.top | 0}`);
else
hashes.push(`${element.nodeName},${(size / 3) | 0},${box.height | 0},${box.top | 0},${2 * Math.log(box.width) | 0}`);
}
return { size, hashes };
};
visit(root, false);
return lists;
}
type Step = {
token: Token;
// Element we point at.
element: Element | SelectorRoot;
// Distance between element and (lca between target and element).
depth: number;
// One step score.
score: number;
// Total path score.
totalScore: number;
previous?: Step;
// Repeat number for ^ steps.s
repeat?: number;
};
type Options = {
genericTagScore: number,
textScore?: number,
imgAltScore?: number,
ariaLabelScore?: number,
detectLists?: boolean,
avoidShortText?: boolean,
usePlaceholders?: boolean,
debug?: boolean
};
const defaultOptions: Options = {
genericTagScore: 10,
textScore: 1,
imgAltScore: 2,
ariaLabelScore: 2,
detectLists: true,
avoidShortText: false,
usePlaceholders: true,
debug: false,
};
type CueType = 'text' | 'tag' | 'imgAlt' | 'ariaLabel';
type Cue = {
type: CueType,
score: number,
elements: Element[],
};
type CueMap = Map<string, Cue>;
type ElementMetrics = {
box: ClientRect,
style: CSSStyleDeclaration,
fontMetric: number,
};
type Lca = {
lcaDepth: number;
lca: Element | SelectorRoot;
anchor: Element | SelectorRoot | undefined;
depth: number; // Distance to lca.
};
type PathCue = {
type: CueType,
score: number,
elements: Element[][],
anchorCount: Map<Element | SelectorRoot, number>,
};
type PreprocessResult = {
pathCues: Map<string, PathCue>,
lcaMap: Map<Element | SelectorRoot, Lca>,
};
type ListIndex = Map<Element | SelectorRoot, number>;
function parentOrRoot(element: Element | SelectorRoot): Element | SelectorRoot | null {
return element.parentNode as Element | SelectorRoot;
}
class Engine {
private _cues = new Map<Element | SelectorRoot, CueMap>();
private _metrics = new Map<Element, ElementMetrics>();
readonly options: Options;
constructor(options: Options = defaultOptions) {
this.options = options;
}
query(root: SelectorRoot, selector: string, all: boolean): Element[] {
const tokens = tokenize(selector);
if (typeof tokens === 'number')
throw new Error('Cannot parse selector at position ' + tokens);
if (!tokens.length)
throw new Error('Empty selector');
if (!this._cues.has(root)) {
const cueMap: CueMap = new Map();
const pathCues = this._preprocess(root, [root], Infinity).pathCues;
for (const [text, cue] of pathCues) {
cueMap.set(text, {
type: cue.type,
score: cue.score,
elements: cue.elements[0]
});
}
this._cues.set(root, cueMap);
}
// Map from the element to the boundary used. We never go outside the boundary when doing '~'.
let currentStep = new Map<Element | SelectorRoot, Element | SelectorRoot>();
currentStep.set(root, root);
for (const token of tokens) {
const nextStep = new Map<Element | SelectorRoot, Element | SelectorRoot>();
for (let [element, boundary] of currentStep) {
let next: (Element | SelectorRoot)[] = [];
if (token.combinator === '^') {
if (element === boundary) {
next = [];
} else {
const parent = parentOrRoot(element);
next = parent ? [parent] : [];
}
} else if (token.combinator === '>') {
boundary = element;
next = this._matchChildren(element, token, all);
} else if (token.combinator === '') {
boundary = element;
next = this._matchSubtree(element, token, all);
} else if (token.combinator === '~') {
while (true) {
next = this._matchSubtree(element, token, all);
if (next.length) {
// Further '~' / '^' will not go outside of this boundary, which is
// a container with both the cue and the target elements inside.
boundary = element;
break;
}
if (element === boundary)
break;
element = parentOrRoot(element)!;
}
}
for (const nextElement of next) {
if (!nextStep.has(nextElement))
nextStep.set(nextElement, boundary);
}
}
currentStep = nextStep;
}
return Array.from(currentStep.keys()).filter(e => e.nodeType === 1 /* Node.ELEMENT_NODE */) as Element[];
}
create(root: SelectorRoot, target: Element, type: SelectorType): string {
const path = pathFromRoot(root, target);
const maxCueCount = type === 'notext' ? 50 : 10;
const { pathCues, lcaMap } = this._preprocess(root, path, maxCueCount);
const lists: ListIndex | undefined = this.options.detectLists ?
this._buildLists(root, path) : undefined;
const queue: Map<Element | SelectorRoot | undefined, Step>[] = path.map(_ => new Map());
const startStep: Step = {
token: { combinator: '' },
element: root,
depth: 0,
score: 0,
totalScore: 0
};
for (let stepDepth = -1; stepDepth < path.length; stepDepth++) {
const stepsMap = stepDepth === -1 ? new Map([[undefined, startStep]]) : queue[stepDepth];
const ancestorDepth = stepDepth === -1 ? 0 : stepDepth;
for (const [text, cue] of pathCues) {
const elements = cue.elements[ancestorDepth];
for (let index = 0; index < elements.length; index++) {
const element = elements[index];
const lca = lcaMap.get(element)!;
const lcaDepth = lca.lcaDepth;
// Always go deeper in the tree.
if (lcaDepth <= stepDepth)
continue;
// 'notext' - do not use elements from the target's subtree.
if (type === 'notext' && lcaDepth === path.length - 1 && lca.depth > 0)
continue;
// 'notext' - do not use target's own text.
if (type === 'notext' && lcaDepth === path.length - 1 && !lca.depth && cue.type !== 'tag')
continue;
const targetAnchor = path[lcaDepth + 1];
if (lists && lca.anchor && targetAnchor && lca.anchor !== targetAnchor) {
const oldList = lists.get(lca.anchor);
// Do not use cues from sibling list items (lca.anchor and targetAnchor).
if (oldList && oldList === lists.get(targetAnchor))
continue;
}
if (cue.type !== 'tag' && !this._isVisible(element))
continue;
const distanceToTarget = path.length - stepDepth;
// Short text can be used more effectively in a smaller scope.
let shortTextScore = 0;
if (this.options.avoidShortText && cue.type === 'text')
shortTextScore = Math.max(0, distanceToTarget - 2 * (text.length - 2));
const score = (cue.score + shortTextScore) * (
// Unique cues are heavily favored.
1 * (index + elements.length * 1000) +
// Larger text is preferred.
5 * (cue.type === 'text' ? this._elementMetrics(element).fontMetric : 1) +
// The closer to the target, the better.
1 * lca.depth
);
for (const [anchor, step] of stepsMap) {
// This ensures uniqueness when resolving the selector.
if (anchor && (cue.anchorCount.get(anchor) || 0) > index)
continue;
let newStep: Step = {
token: {
combinator: stepDepth === -1 ? '' : '~',
text: cue.type === 'text' ? text : undefined,
css: cue.type === 'text' ? undefined : text,
index: index || undefined,
},
previous: step,
depth: lca.depth,
element,
score,
totalScore: step.totalScore + score
};
let nextStep = queue[lcaDepth].get(lca.anchor);
if (!nextStep || nextStep.totalScore > newStep.totalScore)
queue[lcaDepth].set(lca.anchor, newStep);
// Try going to the ancestor.
if (newStep.depth) {
newStep = {
token: { combinator: '^' },
previous: newStep,
depth: 0,
element: lca.lca,
score: 2000 * newStep.depth,
totalScore: newStep.totalScore + 2000 * newStep.depth,
repeat: newStep.depth
};
nextStep = queue[lcaDepth].get(undefined);
if (!nextStep || nextStep.totalScore > newStep.totalScore)
queue[lcaDepth].set(undefined, newStep);
}
}
}
}
}
let best: Step | undefined;
for (const [, step] of queue[path.length - 1]) {
if (!best || step.totalScore < best.totalScore)
best = step;
}
if (!best)
return '';
const tokens: Token[] = new Array(best.depth).fill({ combinator: '^' });
while (best && best !== startStep) {
for (let repeat = best.repeat || 1; repeat; repeat--)
tokens.push(best.token);
best = best.previous;
}
tokens.reverse();
return this._serialize(tokens);
}
private _textMetric(text: string): number {
// Text which looks like a float number or counter is most likely volatile.
if (/^\$?[\d,]+(\.\d+|(\.\d+)?[kKmMbBgG])?$/.test(text))
return 12;
const num = Number(text);
// Large numbers are likely volatile.
if (!isNaN(num) && (num >= 32 || num < 0))
return 6;
return 1;
}
private _elementMetrics(element: Element): ElementMetrics {
let metrics = this._metrics.get(element);
if (!metrics) {
const style = element.ownerDocument ?
element.ownerDocument.defaultView!.getComputedStyle(element) :
({} as CSSStyleDeclaration);
const box = element.getBoundingClientRect();
const fontSize = (parseInt(style.fontSize || '', 10) || 12) / 12; // default 12 px
const fontWeight = (parseInt(style.fontWeight || '', 10) || 400) / 400; // default normal weight
let fontMetric = fontSize * (1 + (fontWeight - 1) / 5);
fontMetric = 1 / Math.exp(fontMetric - 1);
metrics = { box, style, fontMetric };
this._metrics.set(element, metrics);
}
return metrics;
}
private _isVisible(element: Element): boolean {
const metrics = this._elementMetrics(element);
return metrics.box.width > 1 && metrics.box.height > 1;
}
private _preprocess(root: SelectorRoot, path: (Element | SelectorRoot)[], maxCueCount: number): PreprocessResult {
const pathCues = new Map<string, PathCue>();
const lcaMap = new Map<Element | SelectorRoot, Lca>();
const textScore = this.options.textScore || 1;
const appendCue = (text: string, type: CueType, score: number, element: Element, lca: Lca, textValue: string) => {
let pathCue = pathCues.get(text);
if (!pathCue) {
pathCue = { type, score: (textValue ? this._textMetric(textValue) : 1) * score, elements: [], anchorCount: new Map() };
for (let i = 0; i < path.length; i++)
pathCue.elements.push([]);
pathCues.set(text, pathCue);
}
for (let index = lca.lcaDepth; index >= 0; index--) {
const elements = pathCue.elements[index];
if (elements.length < maxCueCount)
elements.push(element);
}
if (lca.anchor)
pathCue.anchorCount.set(lca.anchor, 1 + (pathCue.anchorCount.get(lca.anchor) || 0));
};
const appendElementCues = (element: Element, lca: Lca, detached: boolean) => {
const nodeName = element.nodeName;
if (!detached && this.options.usePlaceholders && nodeName === 'INPUT') {
const placeholder = element.getAttribute('placeholder');
if (placeholder)
appendCue(JSON.stringify(placeholder), 'text', textScore, element, lca, placeholder);
}
if (!detached && nodeName === 'INPUT' && element.getAttribute('type') === 'button') {
const value = element.getAttribute('value');
if (value)
appendCue(JSON.stringify(value), 'text', textScore, element, lca, value);
}
if (!nodeName.startsWith('<pseudo') && !nodeName.startsWith('::'))
appendCue(nodeName, 'tag', this.options.genericTagScore, element, lca, '');
if (this.options.imgAltScore && nodeName === 'IMG') {
const alt = element.getAttribute('alt');
if (alt)
appendCue(`img[alt=${JSON.stringify(alt)}]`, 'imgAlt', this.options.imgAltScore, element, lca, alt);
}
if (this.options.ariaLabelScore) {
const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel)
appendCue(JSON.stringify(`[aria-label=${JSON.stringify(ariaLabel)}]`), 'ariaLabel', this.options.ariaLabelScore, element, lca, ariaLabel);
}
};
const visit = (element: Element | SelectorRoot, lca: Lca, depth: number) => {
// Check for elements STYLE, NOSCRIPT, SCRIPT, OPTION and other elements
// that have |display:none| behavior.
const detached = !(element as HTMLElement).offsetParent;
if (element.nodeType === 1 /* Node.ELEMENT_NODE */)
appendElementCues(element as Element, lca, detached);
lcaMap.set(element, lca);
for (let childNode = element.firstChild; childNode; childNode = childNode.nextSibling) {
if (element.nodeType === 1 /* Node.ELEMENT_NODE */ && !detached && childNode.nodeType === 3 /* Node.TEXT_NODE */ && childNode.nodeValue) {
const textValue = childNode.nodeValue.trim();
if (textValue)
appendCue(JSON.stringify(textValue), 'text', textScore, element as Element, lca, textValue);
}
if (childNode.nodeType !== 1 /* Node.ELEMENT_NODE */)
continue;
const childElement = childNode as Element;
if (childElement.nodeName.startsWith('<pseudo:'))
continue;
if (path[depth + 1] === childElement) {
const childLca = { depth: 0, lca: childElement, lcaDepth: depth + 1, anchor: (undefined as Element | SelectorRoot | undefined) };
visit(childElement, childLca, depth + 1);
} else {
const childLca = { depth: lca.depth + 1, lca: lca.lca, lcaDepth: lca.lcaDepth, anchor: lca.anchor || element };
visit(childElement, childLca, depth + 1);
}
}
};
visit(root, { depth: 0, lca: root, lcaDepth: 0, anchor: undefined }, 0);
return { pathCues: pathCues, lcaMap };
}
private _filterCues(cues: CueMap, root: Element | SelectorRoot): CueMap {
const result = new Map();
for (const [text, cue] of cues) {
const filtered = cue.elements.filter(element => root.contains(element));
if (!filtered.length)
continue;
const newCue: Cue = { type: cue.type, score: cue.score, elements: filtered };
result.set(text, newCue);
}
return result;
}
private _buildLists(root: Element | SelectorRoot, path: (Element | SelectorRoot)[]): ListIndex {
const pathSet = new Set(path);
const map = detectLists(root, e => pathSet.has(e), e => this._elementMetrics(e).box);
const result: ListIndex = new Map();
let listNumber = 1;
for (const collection of map.values()) {
for (const list of collection) {
for (const child of list)
result.set(child, listNumber);
++listNumber;
}
}
return result;
}
private _matchChildren(parent: Element | SelectorRoot, token: Token, all: boolean): Element[] {
const result: Element[] = [];
if (token.index !== undefined)
all = false;
let index = token.index || 0;
if (token.css !== undefined) {
for (let child = parent.firstElementChild; child; child = child.nextElementSibling) {
if (child.matches(token.css) && (all || !index--)) {
result.push(child);
if (!all)
return result;
}
}
return result;
}
if (token.text !== undefined) {
const cue = this._getCues(parent).get(token.text);
if (!cue || cue.type !== 'text')
return [];
for (const element of cue.elements) {
if (parentOrRoot(element) === parent && (all || !index--)) {
result.push(element);
if (!all)
return result;
}
}
return result;
}
throw new Error('Unsupported token');
}
private _matchSubtree(root: Element | SelectorRoot, token: Token, all: boolean): Element[] {
const result: Element[] = [];
if (token.index !== undefined)
all = false;
let index = token.index || 0;
if (token.css !== undefined) {
if (root.nodeType === 1 /* Node.ELEMENT_NODE */) {
const rootElement = root as Element;
if (rootElement.matches(token.css) && (all || !index--)) {
result.push(rootElement);
if (!all)
return result;
}
}
const queried = root.querySelectorAll(token.css);
if (all)
result.push(...Array.from(queried));
else if (queried.length > index)
result.push(queried.item(index));
return result;
}
if (token.text !== undefined) {
const texts = this._getCues(root);
const cue = texts.get(token.text);
if (!cue || cue.type !== 'text')
return result;
if (all)
return cue.elements;
if (index < cue.elements.length)
result.push(cue.elements[index]);
return result;
}
throw new Error('Unsupported token');
}
private _getCues(element: Element | SelectorRoot): CueMap {
if (!this._cues.has(element)) {
let parent = element;
while (!this._cues.has(parent))
parent = parentOrRoot(parent)!;
this._cues.set(element, this._filterCues(this._cues.get(parent)!, element));
}
return this._cues.get(element)!;
}
private _serialize(tokens: Token[]): string {
const result = tokens.map(token => (token.combinator === '' ? ' ' : token.combinator) +
(token.text !== undefined ? token.text : '') +
(token.css !== undefined ? token.css : '') +
(token.index !== undefined ? '#' + token.index : '')).join('');
if (result[0] !== ' ')
throw new Error('First token is wrong');
return result.substring(1);
}
}
const ZSSelectorEngine: SelectorEngine = {
create(root: SelectorRoot, element: Element, type?: SelectorType): string {
return new Engine().create(root, element, type || 'default');
},
query(root: SelectorRoot, selector: string): Element | undefined {
return new Engine().query(root, selector, false /* all */)[0];
},
queryAll(root: SelectorRoot, selector: string): Element[] {
return new Engine().query(root, selector, true /* all */);
}
};
(ZSSelectorEngine as any).test = () => {
const elements = Array.from(document.querySelectorAll('*')).slice(1500, 2000);
console.time('test'); // eslint-disable-line no-console
const failures = elements.filter((e, index) => {
const name = e.tagName.toUpperCase();
if (name === 'SCRIPT' || name === 'STYLE' || name === 'NOSCRIPT' || name === 'META' || name === 'LINK' || name === 'OPTION')
return false;
if (index % 100 === 0)
console.log(`${index} / ${elements.length}`); // eslint-disable-line no-console
if (e.nodeName.toLowerCase().startsWith('<pseudo:'))
e = e.parentElement!;
while (e && e.namespaceURI && e.namespaceURI.endsWith('svg') && e.nodeName.toLowerCase() !== 'svg')
e = e.parentElement!;
try {
document.documentElement.style.outline = '1px solid red';
const selector = new Engine().create(document.documentElement, e, 'default');
document.documentElement.style.outline = '1px solid green';
const e2 = new Engine().query(document.documentElement, selector, false)[0];
return e !== e2;
} catch (e) {
return false;
}
});
console.timeEnd('test'); // eslint-disable-line no-console
console.log(failures); // eslint-disable-line no-console
};
export default ZSSelectorEngine;

View File

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

View File

@ -36,27 +36,27 @@ export class ExecutionContext {
this._logger = logger;
}
_doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise<any> {
doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise<any> {
return this._delegate.evaluate(this, returnByValue, pageFunction, ...args);
}
_adoptIfNeeded(handle: JSHandle): Promise<JSHandle> | null {
adoptIfNeeded(handle: JSHandle): Promise<JSHandle> | null {
return null;
}
async evaluateInternal<R>(pageFunction: types.Func0<R>): Promise<R>;
async evaluateInternal<Arg, R>(pageFunction: types.Func1<Arg, R>, arg: Arg): Promise<R>;
async evaluateInternal(pageFunction: never, ...args: never[]): Promise<any> {
return this._doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, ...args);
return this.doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, ...args);
}
async evaluateHandleInternal<R>(pageFunction: types.Func0<R>): Promise<types.SmartHandle<R>>;
async evaluateHandleInternal<Arg, R>(pageFunction: types.Func1<Arg, R>, arg: Arg): Promise<types.SmartHandle<R>>;
async evaluateHandleInternal(pageFunction: never, ...args: never[]): Promise<any> {
return this._doEvaluateInternal(false /* returnByValue */, true /* waitForNavigations */, pageFunction, ...args);
return this.doEvaluateInternal(false /* returnByValue */, true /* waitForNavigations */, pageFunction, ...args);
}
_createHandle(remoteObject: any): JSHandle {
createHandle(remoteObject: any): JSHandle {
return new JSHandle(this, remoteObject);
}
}
@ -74,13 +74,13 @@ export class JSHandle<T = any> {
async evaluate<R, Arg>(pageFunction: types.FuncOn<T, Arg, R>, arg: Arg): Promise<R>;
async evaluate<R>(pageFunction: types.FuncOn<T, void, R>, arg?: any): Promise<R>;
async evaluate<R, Arg>(pageFunction: types.FuncOn<T, Arg, R>, arg: Arg): Promise<R> {
return this._context._doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, this, arg);
return this._context.doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, this, arg);
}
async evaluateHandle<R, Arg>(pageFunction: types.FuncOn<T, Arg, R>, arg: Arg): Promise<types.SmartHandle<R>>;
async evaluateHandle<R>(pageFunction: types.FuncOn<T, void, R>, arg?: any): Promise<types.SmartHandle<R>>;
async evaluateHandle<R, Arg>(pageFunction: types.FuncOn<T, Arg, R>, arg: Arg): Promise<types.SmartHandle<R>> {
return this._context._doEvaluateInternal(false /* returnByValue */, true /* waitForNavigations */, pageFunction, this, arg);
return this._context.doEvaluateInternal(false /* returnByValue */, true /* waitForNavigations */, pageFunction, this, arg);
}
async getProperty(propertyName: string): Promise<JSHandle> {
@ -183,7 +183,7 @@ export async function prepareFunctionCall<T>(
if (arg && (arg instanceof JSHandle)) {
if (arg._disposed)
throw new Error('JSHandle is disposed!');
const adopted = context._adoptIfNeeded(arg);
const adopted = context.adoptIfNeeded(arg);
if (adopted === null)
return pushHandle(Promise.resolve(arg));
toDispose.push(adopted);

View File

@ -16,18 +16,10 @@
import * as dom from './dom';
import * as frames from './frames';
import * as selectorEvaluatorSource from './generated/selectorEvaluatorSource';
import { helper, assert } from './helper';
import SelectorEvaluator from './injected/selectorEvaluator';
import * as js from './javascript';
import * as types from './types';
const kEvaluatorSymbol = Symbol('evaluator');
type EvaluatorData = {
promise: Promise<js.JSHandle<SelectorEvaluator>>,
generation: number,
};
export class Selectors {
readonly _builtinEngines: Set<string>;
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
@ -68,37 +60,13 @@ export class Selectors {
});
}
async _prepareEvaluator(context: dom.FrameExecutionContext): Promise<js.JSHandle<SelectorEvaluator>> {
let data = (context as any)[kEvaluatorSymbol] as EvaluatorData | undefined;
if (data && data.generation !== this._generation) {
data.promise.then(handle => handle.dispose());
data = undefined;
}
if (!data) {
const custom: string[] = [];
for (const [name, { source }] of this._engines)
custom.push(`{ name: '${name}', engine: (${source}) }`);
const source = `
new (${selectorEvaluatorSource.source})([
${custom.join(',\n')}
])
`;
data = {
promise: context._doEvaluateInternal(false /* returnByValue */, false /* waitForNavigations */, source),
generation: this._generation
};
(context as any)[kEvaluatorSymbol] = data;
}
return data.promise;
}
async _query(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
const parsed = this._parseSelector(selector);
const context = this._needsMainContext(parsed) ? await frame._mainContext() : await frame._utilityContext();
const handle = await context.evaluateHandleInternal(
({ evaluator, parsed, scope }) => evaluator.querySelector(parsed, scope || document),
{ evaluator: await this._prepareEvaluator(context), parsed, scope }
);
const injectedScript = await context.injectedScript();
const handle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
return injected.querySelector(parsed, scope || document);
}, { parsed, scope });
const elementHandle = handle.asElement() as dom.ElementHandle<Element> | null;
if (!elementHandle) {
handle.dispose();
@ -115,20 +83,21 @@ export class Selectors {
async _queryArray(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<js.JSHandle<Element[]>> {
const parsed = this._parseSelector(selector);
const context = await frame._mainContext();
const arrayHandle = await context.evaluateHandleInternal(
({ evaluator, parsed, scope }) => evaluator.querySelectorAll(parsed, scope || document),
{ evaluator: await this._prepareEvaluator(context), parsed, scope }
);
const injectedScript = await context.injectedScript();
const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
return injected.querySelectorAll(parsed, scope || document);
}, { parsed, scope });
return arrayHandle;
}
async _queryAll(frame: frames.Frame, selector: string, scope?: dom.ElementHandle, allowUtilityContext?: boolean): Promise<dom.ElementHandle<Element>[]> {
const parsed = this._parseSelector(selector);
const context = !allowUtilityContext || this._needsMainContext(parsed) ? await frame._mainContext() : await frame._utilityContext();
const arrayHandle = await context.evaluateHandleInternal(
({ evaluator, parsed, scope }) => evaluator.querySelectorAll(parsed, scope || document),
{ evaluator: await this._prepareEvaluator(context), parsed, scope }
);
const injectedScript = await context.injectedScript();
const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
return injected.querySelectorAll(parsed, scope || document);
}, { parsed, scope });
const properties = await arrayHandle.getProperties();
arrayHandle.dispose();
const result: dom.ElementHandle<Element>[] = [];
@ -144,42 +113,49 @@ export class Selectors {
_waitForSelectorTask(selector: string, state: 'attached' | 'detached' | 'visible' | 'hidden', deadline: number): { world: 'main' | 'utility', task: (context: dom.FrameExecutionContext) => Promise<js.JSHandle> } {
const parsed = this._parseSelector(selector);
const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ evaluator, parsed, state, timeout }) => {
return evaluator.injected.poll('raf', timeout, () => {
const element = evaluator.querySelector(parsed, document);
switch (state) {
case 'attached':
return element || false;
case 'detached':
return !element;
case 'visible':
return element && evaluator.injected.isVisible(element) ? element : false;
case 'hidden':
return !element || !evaluator.injected.isVisible(element);
}
});
}, { evaluator: await this._prepareEvaluator(context), parsed, state, timeout: helper.timeUntilDeadline(deadline) });
const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, state, timeout }) => {
return injected.poll('raf', timeout, () => {
const element = injected.querySelector(parsed, document);
switch (state) {
case 'attached':
return element || false;
case 'detached':
return !element;
case 'visible':
return element && injected.isVisible(element) ? element : false;
case 'hidden':
return !element || !injected.isVisible(element);
}
});
}, { parsed, state, timeout: helper.timeUntilDeadline(deadline) });
};
return { world: this._needsMainContext(parsed) ? 'main' : 'utility', task };
}
_dispatchEventTask(selector: string, type: string, eventInit: Object, deadline: number): (context: dom.FrameExecutionContext) => Promise<js.JSHandle> {
const parsed = this._parseSelector(selector);
const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ evaluator, parsed, type, eventInit, timeout }) => {
return evaluator.injected.poll('raf', timeout, () => {
const element = evaluator.querySelector(parsed, document);
if (element)
evaluator.injected.dispatchEvent(element, type, eventInit);
return element || false;
});
}, { evaluator: await this._prepareEvaluator(context), parsed, type, eventInit, timeout: helper.timeUntilDeadline(deadline) });
const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, type, eventInit, timeout }) => {
return injected.poll('raf', timeout, () => {
const element = injected.querySelector(parsed, document);
if (element)
injected.dispatchEvent(element, type, eventInit);
return element || false;
});
}, { parsed, type, eventInit, timeout: helper.timeUntilDeadline(deadline) });
};
return task;
}
async _createSelector(name: string, handle: dom.ElementHandle<Element>): Promise<string | undefined> {
const mainContext = await handle._page.mainFrame()._mainContext();
return mainContext.evaluateInternal(({ evaluator, target, name }) => {
return evaluator.engines.get(name)!.create(document.documentElement, target);
}, { evaluator: await this._prepareEvaluator(mainContext), target: handle, name });
const injectedScript = await mainContext.injectedScript();
return injectedScript.evaluate((injected, { target, name }) => {
return injected.engines.get(name)!.create(document.documentElement, target);
}, { target: handle, name });
}
private _parseSelector(selector: string): types.ParsedSelector {

View File

@ -163,3 +163,9 @@ export type ParsedSelector = {
}[],
capture?: number,
};
export type InjectedScriptResult<T = undefined> =
(T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) |
{ status: 'notconnected' } |
{ status: 'timeout' } |
{ status: 'error', error: string };

View File

@ -60,7 +60,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
if (response.wasThrown)
throw new Error('Evaluation failed: ' + response.result.description);
if (!returnByValue)
return context._createHandle(response.result);
return context.createHandle(response.result);
if (response.result.objectId)
return await this._returnObjectByValue(response.result.objectId);
return valueFromRemoteObject(response.result);
@ -196,7 +196,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
for (const property of response.properties) {
if (!property.enumerable)
continue;
result.set(property.name, handle._context._createHandle(property.value));
result.set(property.name, handle._context.createHandle(property.value));
}
return result;
}

View File

@ -473,7 +473,7 @@ export class WKPage implements PageDelegate {
} else {
context = this._contextIdToContext.get(this._mainFrameContextId!)!;
}
return context._createHandle(p);
return context.createHandle(p);
});
this._lastConsoleMessage = {
derivedType,
@ -516,7 +516,7 @@ export class WKPage implements PageDelegate {
private async _onFileChooserOpened(event: {frameId: Protocol.Network.FrameId, element: Protocol.Runtime.RemoteObject}) {
const context = await this._page._frameManager.frame(event.frameId)!._mainContext();
const handle = context._createHandle(event.element).asElement()!;
const handle = context.createHandle(event.element).asElement()!;
this._page._onFileChooserOpened(handle);
}
@ -785,7 +785,7 @@ export class WKPage implements PageDelegate {
}).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) as dom.ElementHandle<T>;
return to.createHandle(result.object) as dom.ElementHandle<T>;
}
async getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}> {

View File

@ -91,7 +91,7 @@ export class WKWorkers {
derivedType = 'timeEnd';
const handles = (parameters || []).map(p => {
return worker._existingExecutionContext!._createHandle(p);
return worker._existingExecutionContext!.createHandle(p);
});
this._page._addConsoleMessage(derivedType, handles, { url, lineNumber: (lineNumber || 1) - 1, columnNumber: (columnNumber || 1) - 1 }, handles.length ? undefined : text);
}

View File

@ -16,7 +16,6 @@
*/
const path = require('path');
const zsSelectorEngineSource = require('../lib/generated/zsSelectorEngineSource');
const {FFOX, CHROMIUM, WEBKIT} = require('./utils').testOptions(browserType);
async function registerEngine(name, script, options) {
@ -444,138 +443,6 @@ describe('ElementHandle.$$ xpath', function() {
});
});
describe('zselector', () => {
beforeAll(async () => {
await registerEngine('z', zsSelectorEngineSource.source);
});
it('query', async ({page}) => {
await page.setContent(`<div>yo</div><div>ya</div><div>ye</div>`);
expect(await page.$eval(`z="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
await page.setContent(`<div foo="baz"></div><div foo="bar space"></div>`);
expect(await page.$eval(`z=[foo="bar space"]`, e => e.outerHTML)).toBe('<div foo="bar space"></div>');
await page.setContent(`<div>yo<span></span></div>`);
expect(await page.$eval(`z=span`, e => e.outerHTML)).toBe('<span></span>');
expect(await page.$eval(`z=div > span`, e => e.outerHTML)).toBe('<span></span>');
expect(await page.$eval(`z=div span`, e => e.outerHTML)).toBe('<span></span>');
expect(await page.$eval(`z="yo" > span`, e => e.outerHTML)).toBe('<span></span>');
expect(await page.$eval(`z="yo" span`, e => e.outerHTML)).toBe('<span></span>');
expect(await page.$eval(`z=span ^`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
expect(await page.$eval(`z=span ~ div`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
expect(await page.$eval(`z=span ~ "yo"`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
await page.setContent(`<div>yo</div><div>yo<span></span></div>`);
expect(await page.$eval(`z="yo"#0`, e => e.outerHTML)).toBe('<div>yo</div>');
expect(await page.$eval(`z="yo"#1`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
expect(await page.$eval(`z="yo" ~ DIV#1`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
expect(await page.$eval(`z=span ~ div#1`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
expect(await page.$eval(`z=span ~ div#0`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
expect(await page.$eval(`z=span ~ "yo"#1 ^ > div`, e => e.outerHTML)).toBe('<div>yo</div>');
expect(await page.$eval(`z=span ~ "yo"#1 ^ > div#1`, e => e.outerHTML)).toBe('<div>yo<span></span></div>');
await page.setContent(`<div>yo<span id="s1"></span></div><div>yo<span id="s2"></span><span id="s3"></span></div>`);
expect(await page.$eval(`z="yo"`, e => e.outerHTML)).toBe('<div>yo<span id="s1"></span></div>');
expect(await page.$$eval(`z="yo"`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<div>yo<span id="s1"></span></div>\n<div>yo<span id="s2"></span><span id="s3"></span></div>');
expect(await page.$$eval(`z="yo"#1`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<div>yo<span id="s2"></span><span id="s3"></span></div>');
expect(await page.$$eval(`z="yo" ~ span`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<span id="s1"></span>\n<span id="s2"></span>\n<span id="s3"></span>');
expect(await page.$$eval(`z="yo"#1 ~ span`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<span id="s2"></span>\n<span id="s3"></span>');
expect(await page.$$eval(`z="yo" ~ span#0`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<span id="s1"></span>\n<span id="s2"></span>');
expect(await page.$$eval(`z="yo" ~ span#1`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<span id="s2"></span>\n<span id="s3"></span>');
});
it('create', async ({page}) => {
await page.setContent(`<div>yo</div><div>ya</div><div>ya</div>`);
expect(await playwright.selectors._createSelector('z', await page.$('div'))).toBe('"yo"');
expect(await playwright.selectors._createSelector('z', await page.$('div:nth-child(2)'))).toBe('"ya"');
expect(await playwright.selectors._createSelector('z', await page.$('div:nth-child(3)'))).toBe('"ya"#1');
await page.setContent(`<img alt="foo bar">`);
expect(await playwright.selectors._createSelector('z', await page.$('img'))).toBe('img[alt="foo bar"]');
await page.setContent(`<div>yo<span></span></div><span></span>`);
expect(await playwright.selectors._createSelector('z', await page.$('span'))).toBe('"yo"~SPAN');
expect(await playwright.selectors._createSelector('z', await page.$('span:nth-child(2)'))).toBe('SPAN#1');
});
it('children of various display parents', async ({page}) => {
await page.setContent(`<body><div style='position: fixed;'><span>yo</span></div></body>`);
expect(await playwright.selectors._createSelector('z', await page.$('span'))).toBe('"yo"');
await page.setContent(`<div style='position: relative;'><span>yo</span></div>`);
expect(await playwright.selectors._createSelector('z', await page.$('span'))).toBe('"yo"');
// "display: none" makes all children text invisible - fallback to tag name.
await page.setContent(`<div style='display: none;'><span>yo</span></div>`);
expect(await playwright.selectors._createSelector('z', await page.$('span'))).toBe('SPAN');
});
it('boundary', async ({page}) => {
await page.setContent(`
<div>hey</div>
<div>hey</div>
<div>hey</div>
<div>
<div>yo</div>
<div>hello</div>
<div>hello</div>
<div>hello</div>
<div>unique</div>
<div>
<div>hey2<span></span><span></span><span></span></div>
<div>hello</div>
</div>
<div>
<div>hey<span></span><span></span><span></span></div>
<div>hello</div>
</div>
</div>
<div>
<div>ya<div>
<div id=first>hello</div>
<div>hello</div>
<div>hello</div>
<div>
<div>hey2<span></span><span></span><span></span></div>
<div>hello</div>
</div>
<div>
<div>hey<span></span><span></span><span></span></div>
<div id=target>hello</div>
</div>
</div>
<div>
<div>ya<div>
<div id=first2>hello</div>
<div>hello</div>
<div>hello</div>
<div>
<div>hey2<span></span><span></span><span></span></div>
<div>hello</div>
</div>
<div>
<div>hey<span></span><span></span><span></span></div>
<div id=target2>hello</div>
</div>
</div>`);
expect(await playwright.selectors._createSelector('z', await page.$('#target'))).toBe('"ya"~"hey"~"hello"');
expect(await page.$eval(`z="ya"~"hey"~"hello"`, e => e.outerHTML)).toBe('<div id="target">hello</div>');
expect(await page.$eval(`z="ya"~"hey"~"unique"`, e => e.outerHTML).catch(e => e.message)).toBe('Error: failed to find element matching selector "z="ya"~"hey"~"unique""');
expect(await page.$$eval(`z="ya" ~ "hey" ~ "hello"`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<div id="target">hello</div>\n<div id="target2">hello</div>');
});
it('should query existing element with zs selector', async({page, server}) => {
await page.goto(server.PREFIX + '/playground.html');
await page.setContent('<html><body><div class="second"><div class="inner">A</div></div></body></html>');
const html = await page.$('z=html');
const second = await html.$('z=.second');
const inner = await second.$('z=.inner');
const content = await page.evaluate(e => e.textContent, inner);
expect(content).toBe('A');
});
});
describe('text selector', () => {
it('query', async ({page}) => {
await page.setContent(`<div>yo</div><div>ya</div><div>\nye </div>`);

View File

@ -18,8 +18,7 @@ const child_process = require('child_process');
const path = require('path');
const files = [
path.join('src', 'injected', 'zsSelectorEngine.webpack.config.js'),
path.join('src', 'injected', 'selectorEvaluator.webpack.config.js'),
path.join('src', 'injected', 'injectedScript.webpack.config.js'),
];
function runOne(runner, file) {