mirror of
https://github.com/microsoft/playwright.git
synced 2024-10-28 06:07:53 +03:00
chore: refactor injected script harness (#2259)
This commit is contained in:
parent
9c7e43a83b
commit
99b7aaace8
@ -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;
|
||||
}
|
||||
|
@ -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()!;
|
||||
}
|
||||
}
|
||||
|
||||
|
37
src/dom.ts
37
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<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')
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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>;
|
||||
}
|
||||
|
||||
|
@ -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;
|
@ -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')),
|
||||
]
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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')),
|
||||
]
|
||||
};
|
@ -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);
|
||||
|
116
src/selectors.ts
116
src/selectors.ts
@ -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 {
|
||||
|
@ -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 };
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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}> {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>`);
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user