chore(selectors): move selectors logic to selectors.ts (#1536)
This encapsulates selectors logic in one place, in a preparation for more complex scenarios like main-world selectors or piercing frames. Note: we had `Page.fill should wait for visible visibilty` test, but we do not actually wait for visible in page.fill(). It happened to pass due to lucky evaluation order. References #1316.
This commit is contained in:
parent
1e007b264c
commit
60942d0af5
74
src/dom.ts
74
src/dom.ts
|
|
@ -18,12 +18,11 @@ import * as frames from './frames';
|
||||||
import * as input from './input';
|
import * as input from './input';
|
||||||
import * as js from './javascript';
|
import * as js from './javascript';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import * as injectedSource from './generated/injectedSource';
|
|
||||||
import { assert, helper, debugError } from './helper';
|
import { assert, helper, debugError } from './helper';
|
||||||
import Injected from './injected/injected';
|
import Injected from './injected/injected';
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import * as platform from './platform';
|
import * as platform from './platform';
|
||||||
import { Selectors } from './selectors';
|
import { selectors } from './selectors';
|
||||||
|
|
||||||
export type PointerActionOptions = {
|
export type PointerActionOptions = {
|
||||||
modifiers?: input.Modifier[];
|
modifiers?: input.Modifier[];
|
||||||
|
|
@ -36,9 +35,7 @@ export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOpti
|
||||||
|
|
||||||
export class FrameExecutionContext extends js.ExecutionContext {
|
export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
readonly frame: frames.Frame;
|
readonly frame: frames.Frame;
|
||||||
|
|
||||||
private _injectedPromise?: Promise<js.JSHandle>;
|
private _injectedPromise?: Promise<js.JSHandle>;
|
||||||
private _injectedGeneration = -1;
|
|
||||||
|
|
||||||
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) {
|
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) {
|
||||||
super(delegate);
|
super(delegate);
|
||||||
|
|
@ -64,58 +61,13 @@ export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
_injected(): Promise<js.JSHandle<Injected>> {
|
_injected(): Promise<js.JSHandle<Injected>> {
|
||||||
const selectors = Selectors._instance();
|
|
||||||
if (this._injectedPromise && selectors._generation !== this._injectedGeneration) {
|
|
||||||
this._injectedPromise.then(handle => handle.dispose());
|
|
||||||
this._injectedPromise = undefined;
|
|
||||||
}
|
|
||||||
if (!this._injectedPromise) {
|
if (!this._injectedPromise) {
|
||||||
const custom: string[] = [];
|
this._injectedPromise = selectors._prepareEvaluator(this).then(evaluator => {
|
||||||
for (const [name, source] of selectors._engines)
|
return this.evaluateHandleInternal(evaluator => evaluator.injected, evaluator);
|
||||||
custom.push(`{ name: '${name}', engine: (${source}) }`);
|
});
|
||||||
const source = `
|
|
||||||
new (${injectedSource.source})([
|
|
||||||
${custom.join(',\n')}
|
|
||||||
])
|
|
||||||
`;
|
|
||||||
this._injectedPromise = this._doEvaluateInternal(false /* returnByValue */, false /* waitForNavigations */, source);
|
|
||||||
this._injectedGeneration = selectors._generation;
|
|
||||||
}
|
}
|
||||||
return this._injectedPromise;
|
return this._injectedPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _$(selector: string, scope?: ElementHandle): Promise<ElementHandle<Element> | null> {
|
|
||||||
const handle = await this.evaluateHandleInternal(
|
|
||||||
({ injected, selector, scope }) => injected.querySelector(selector, scope || document),
|
|
||||||
{ injected: await this._injected(), selector, scope }
|
|
||||||
);
|
|
||||||
if (!handle.asElement())
|
|
||||||
handle.dispose();
|
|
||||||
return handle.asElement() as ElementHandle<Element>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _$array(selector: string, scope?: ElementHandle): Promise<js.JSHandle<Element[]>> {
|
|
||||||
const arrayHandle = await this.evaluateHandleInternal(
|
|
||||||
({ injected, selector, scope }) => injected.querySelectorAll(selector, scope || document),
|
|
||||||
{ injected: await this._injected(), selector, scope }
|
|
||||||
);
|
|
||||||
return arrayHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _$$(selector: string, scope?: ElementHandle): Promise<ElementHandle<Element>[]> {
|
|
||||||
const arrayHandle = await this._$array(selector, scope);
|
|
||||||
const properties = await arrayHandle.getProperties();
|
|
||||||
arrayHandle.dispose();
|
|
||||||
const result: ElementHandle<Element>[] = [];
|
|
||||||
for (const property of properties.values()) {
|
|
||||||
const elementHandle = property.asElement() as ElementHandle<Element>;
|
|
||||||
if (elementHandle)
|
|
||||||
result.push(elementHandle);
|
|
||||||
else
|
|
||||||
property.dispose();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
||||||
|
|
@ -369,28 +321,32 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
$(selector: string): Promise<ElementHandle | null> {
|
$(selector: string): Promise<ElementHandle | null> {
|
||||||
return this._context._$(selector, this);
|
// TODO: this should be ownerFrame() instead.
|
||||||
|
return selectors._query(this._context.frame, selector, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
$$(selector: string): Promise<ElementHandle<Element>[]> {
|
$$(selector: string): Promise<ElementHandle<Element>[]> {
|
||||||
return this._context._$$(selector, this);
|
// TODO: this should be ownerFrame() instead.
|
||||||
|
return selectors._queryAll(this._context.frame, selector, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R>;
|
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R>;
|
||||||
async $eval<R>(selector: string, pageFunction: types.FuncOn<Element, void, R>, arg?: any): Promise<R>;
|
async $eval<R>(selector: string, pageFunction: types.FuncOn<Element, void, R>, arg?: any): Promise<R>;
|
||||||
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R> {
|
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R> {
|
||||||
const elementHandle = await this._context._$(selector, this);
|
// TODO: this should be ownerFrame() instead.
|
||||||
if (!elementHandle)
|
const handle = await selectors._query(this._context.frame, selector, this);
|
||||||
|
if (!handle)
|
||||||
throw new Error(`Error: failed to find element matching selector "${selector}"`);
|
throw new Error(`Error: failed to find element matching selector "${selector}"`);
|
||||||
const result = await elementHandle.evaluate(pageFunction, arg);
|
const result = await handle.evaluate(pageFunction, arg);
|
||||||
elementHandle.dispose();
|
handle.dispose();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R>;
|
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R>;
|
||||||
async $$eval<R>(selector: string, pageFunction: types.FuncOn<Element[], void, R>, arg?: any): Promise<R>;
|
async $$eval<R>(selector: string, pageFunction: types.FuncOn<Element[], void, R>, arg?: any): Promise<R>;
|
||||||
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R> {
|
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R> {
|
||||||
const arrayHandle = await this._context._$array(selector, this);
|
// TODO: this should be ownerFrame() instead.
|
||||||
|
const arrayHandle = await selectors._queryArray(this._context.frame, selector, this);
|
||||||
const result = await arrayHandle.evaluate(pageFunction, arg);
|
const result = await arrayHandle.evaluate(pageFunction, arg);
|
||||||
arrayHandle.dispose();
|
arrayHandle.dispose();
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { FFExecutionContext } from './ffExecutionContext';
|
||||||
import { RawKeyboardImpl, RawMouseImpl } from './ffInput';
|
import { RawKeyboardImpl, RawMouseImpl } from './ffInput';
|
||||||
import { FFNetworkManager, headersArray } from './ffNetworkManager';
|
import { FFNetworkManager, headersArray } from './ffNetworkManager';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
|
import { selectors } from '../selectors';
|
||||||
|
|
||||||
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
||||||
|
|
||||||
|
|
@ -467,8 +468,7 @@ export class FFPage implements PageDelegate {
|
||||||
const parent = frame.parentFrame();
|
const parent = frame.parentFrame();
|
||||||
if (!parent)
|
if (!parent)
|
||||||
throw new Error('Frame has been detached.');
|
throw new Error('Frame has been detached.');
|
||||||
const context = await parent._utilityContext();
|
const handles = await selectors._queryAll(parent, 'iframe', undefined, true /* allowUtilityContext */);
|
||||||
const handles = await context._$$('iframe');
|
|
||||||
const items = await Promise.all(handles.map(async handle => {
|
const items = await Promise.all(handles.map(async handle => {
|
||||||
const frame = await handle.contentFrame().catch(e => null);
|
const frame = await handle.contentFrame().catch(e => null);
|
||||||
return { handle, frame };
|
return { handle, frame };
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { Events } from './events';
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import { ConsoleMessage } from './console';
|
import { ConsoleMessage } from './console';
|
||||||
import * as platform from './platform';
|
import * as platform from './platform';
|
||||||
|
import { selectors } from './selectors';
|
||||||
|
|
||||||
type ContextType = 'main' | 'utility';
|
type ContextType = 'main' | 'utility';
|
||||||
type ContextData = {
|
type ContextData = {
|
||||||
|
|
@ -427,15 +428,7 @@ export class Frame {
|
||||||
}
|
}
|
||||||
|
|
||||||
async $(selector: string): Promise<dom.ElementHandle<Element> | null> {
|
async $(selector: string): Promise<dom.ElementHandle<Element> | null> {
|
||||||
const utilityContext = await this._utilityContext();
|
return selectors._query(this, selector);
|
||||||
const mainContext = await this._mainContext();
|
|
||||||
const handle = await utilityContext._$(selector);
|
|
||||||
if (handle && handle._context !== mainContext) {
|
|
||||||
const adopted = this._page._delegate.adoptElementHandle(handle, mainContext);
|
|
||||||
handle.dispose();
|
|
||||||
return adopted;
|
|
||||||
}
|
|
||||||
return handle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForSelector(selector: string, options?: types.WaitForElementOptions): Promise<dom.ElementHandle<Element> | null> {
|
async waitForSelector(selector: string, options?: types.WaitForElementOptions): Promise<dom.ElementHandle<Element> | null> {
|
||||||
|
|
@ -445,8 +438,8 @@ export class Frame {
|
||||||
if (!['attached', 'detached', 'visible', 'hidden'].includes(waitFor))
|
if (!['attached', 'detached', 'visible', 'hidden'].includes(waitFor))
|
||||||
throw new Error(`Unsupported waitFor option "${waitFor}"`);
|
throw new Error(`Unsupported waitFor option "${waitFor}"`);
|
||||||
|
|
||||||
const task = waitForSelectorTask(selector, waitFor, timeout);
|
const { world, task } = selectors._waitForSelectorTask(selector, waitFor, timeout);
|
||||||
const result = await this._scheduleRerunnableTask(task, 'utility', timeout, `selector "${selectorToString(selector, waitFor)}"`);
|
const result = await this._scheduleRerunnableTask(task, world, timeout, `selector "${selectorToString(selector, waitFor)}"`);
|
||||||
if (!result.asElement()) {
|
if (!result.asElement()) {
|
||||||
result.dispose();
|
result.dispose();
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -464,28 +457,25 @@ export class Frame {
|
||||||
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R>;
|
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R>;
|
||||||
async $eval<R>(selector: string, pageFunction: types.FuncOn<Element, void, R>, arg?: any): Promise<R>;
|
async $eval<R>(selector: string, pageFunction: types.FuncOn<Element, void, R>, arg?: any): Promise<R>;
|
||||||
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R> {
|
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R> {
|
||||||
const context = await this._mainContext();
|
const handle = await this.$(selector);
|
||||||
const elementHandle = await context._$(selector);
|
if (!handle)
|
||||||
if (!elementHandle)
|
|
||||||
throw new Error(`Error: failed to find element matching selector "${selector}"`);
|
throw new Error(`Error: failed to find element matching selector "${selector}"`);
|
||||||
const result = await elementHandle.evaluate(pageFunction, arg);
|
const result = await handle.evaluate(pageFunction, arg);
|
||||||
elementHandle.dispose();
|
handle.dispose();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R>;
|
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R>;
|
||||||
async $$eval<R>(selector: string, pageFunction: types.FuncOn<Element[], void, R>, arg?: any): Promise<R>;
|
async $$eval<R>(selector: string, pageFunction: types.FuncOn<Element[], void, R>, arg?: any): Promise<R>;
|
||||||
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R> {
|
async $$eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element[], Arg, R>, arg: Arg): Promise<R> {
|
||||||
const context = await this._mainContext();
|
const arrayHandle = await selectors._queryArray(this, selector);
|
||||||
const arrayHandle = await context._$array(selector);
|
|
||||||
const result = await arrayHandle.evaluate(pageFunction, arg);
|
const result = await arrayHandle.evaluate(pageFunction, arg);
|
||||||
arrayHandle.dispose();
|
arrayHandle.dispose();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async $$(selector: string): Promise<dom.ElementHandle<Element>[]> {
|
async $$(selector: string): Promise<dom.ElementHandle<Element>[]> {
|
||||||
const context = await this._mainContext();
|
return selectors._queryAll(this, selector);
|
||||||
return context._$$(selector);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async content(): Promise<string> {
|
async content(): Promise<string> {
|
||||||
|
|
@ -746,8 +736,8 @@ export class Frame {
|
||||||
|
|
||||||
private async _waitForSelectorInUtilityContext(selector: string, options?: types.WaitForElementOptions): Promise<dom.ElementHandle<Element>> {
|
private async _waitForSelectorInUtilityContext(selector: string, options?: types.WaitForElementOptions): Promise<dom.ElementHandle<Element>> {
|
||||||
const { timeout = this._page._timeoutSettings.timeout(), waitFor = 'attached' } = (options || {});
|
const { timeout = this._page._timeoutSettings.timeout(), waitFor = 'attached' } = (options || {});
|
||||||
const task = waitForSelectorTask(selector, waitFor, timeout);
|
const { world, task } = selectors._waitForSelectorTask(selector, waitFor, timeout);
|
||||||
const result = await this._scheduleRerunnableTask(task, 'utility', timeout, `selector "${selectorToString(selector, waitFor)}"`);
|
const result = await this._scheduleRerunnableTask(task, world, timeout, `selector "${selectorToString(selector, waitFor)}"`);
|
||||||
return result.asElement() as dom.ElementHandle<Element>;
|
return result.asElement() as dom.ElementHandle<Element>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -765,9 +755,7 @@ export class Frame {
|
||||||
|
|
||||||
const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, timeout, arg }) => {
|
const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, timeout, arg }) => {
|
||||||
const innerPredicate = new Function('arg', predicateBody);
|
const innerPredicate = new Function('arg', predicateBody);
|
||||||
return injected.poll(polling, undefined, timeout, (element: Element | undefined): any => {
|
return injected.poll(polling, timeout, () => innerPredicate(arg));
|
||||||
return innerPredicate(arg);
|
|
||||||
});
|
|
||||||
}, { injected: await context._injected(), predicateBody, polling, timeout, arg });
|
}, { injected: await context._injected(), predicateBody, polling, timeout, arg });
|
||||||
return this._scheduleRerunnableTask(task, 'main', timeout) as any as types.SmartHandle<R>;
|
return this._scheduleRerunnableTask(task, 'main', timeout) as any as types.SmartHandle<R>;
|
||||||
}
|
}
|
||||||
|
|
@ -832,24 +820,6 @@ export class Frame {
|
||||||
|
|
||||||
type Task = (context: dom.FrameExecutionContext) => Promise<js.JSHandle>;
|
type Task = (context: dom.FrameExecutionContext) => Promise<js.JSHandle>;
|
||||||
|
|
||||||
function waitForSelectorTask(selector: string, waitFor: 'attached' | 'detached' | 'visible' | 'hidden', timeout: number): Task {
|
|
||||||
return async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, selector, waitFor, timeout }) => {
|
|
||||||
const polling = (waitFor === 'attached' || waitFor === 'detached') ? 'mutation' : 'raf';
|
|
||||||
return injected.poll(polling, selector, timeout, (element: Element | undefined): Element | boolean => {
|
|
||||||
switch (waitFor) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, { injected: await context._injected(), selector, waitFor, timeout });
|
|
||||||
}
|
|
||||||
|
|
||||||
class RerunnableTask {
|
class RerunnableTask {
|
||||||
readonly promise: Promise<js.JSHandle>;
|
readonly promise: Promise<js.JSHandle>;
|
||||||
private _contextData: ContextData;
|
private _contextData: ContextData;
|
||||||
|
|
|
||||||
|
|
@ -14,144 +14,11 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
|
||||||
import { Utils } from './utils';
|
|
||||||
import { CSSEngine } from './cssSelectorEngine';
|
|
||||||
import { XPathEngine } from './xpathSelectorEngine';
|
|
||||||
import { TextEngine } from './textSelectorEngine';
|
|
||||||
import * as types from '../types';
|
import * as types from '../types';
|
||||||
|
|
||||||
function createAttributeEngine(attribute: string): SelectorEngine {
|
type Predicate = () => any;
|
||||||
const engine: SelectorEngine = {
|
|
||||||
create(root: SelectorRoot, target: Element): string | undefined {
|
|
||||||
const value = target.getAttribute(attribute);
|
|
||||||
if (!value)
|
|
||||||
return;
|
|
||||||
if (root.querySelector(`[${attribute}=${value}]`) === target)
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
|
|
||||||
query(root: SelectorRoot, selector: string): Element | undefined {
|
|
||||||
return root.querySelector(`[${attribute}=${selector}]`) || undefined;
|
|
||||||
},
|
|
||||||
|
|
||||||
queryAll(root: SelectorRoot, selector: string): Element[] {
|
|
||||||
return Array.from(root.querySelectorAll(`[${attribute}=${selector}]`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return engine;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ParsedSelector = { engine: SelectorEngine, selector: string }[];
|
|
||||||
type Predicate = (element: Element | undefined) => any;
|
|
||||||
|
|
||||||
class Injected {
|
class Injected {
|
||||||
readonly utils: Utils;
|
|
||||||
readonly engines: Map<string, SelectorEngine>;
|
|
||||||
|
|
||||||
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
|
|
||||||
this.utils = new Utils();
|
|
||||||
this.engines = new Map();
|
|
||||||
// Note: keep predefined names in sync with Selectors class.
|
|
||||||
this.engines.set('css', CSSEngine);
|
|
||||||
this.engines.set('xpath', XPathEngine);
|
|
||||||
this.engines.set('text', TextEngine);
|
|
||||||
this.engines.set('id', createAttributeEngine('id'));
|
|
||||||
this.engines.set('data-testid', createAttributeEngine('data-testid'));
|
|
||||||
this.engines.set('data-test-id', createAttributeEngine('data-test-id'));
|
|
||||||
this.engines.set('data-test', createAttributeEngine('data-test'));
|
|
||||||
for (const {name, engine} of customEngines)
|
|
||||||
this.engines.set(name, engine);
|
|
||||||
}
|
|
||||||
|
|
||||||
querySelector(selector: string, root: Node): Element | undefined {
|
|
||||||
const parsed = this._parseSelector(selector);
|
|
||||||
if (!(root as any)['querySelector'])
|
|
||||||
throw new Error('Node is not queryable.');
|
|
||||||
return this._querySelectorRecursively(root as SelectorRoot, parsed, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _querySelectorRecursively(root: SelectorRoot, parsed: ParsedSelector, index: number): Element | undefined {
|
|
||||||
const current = parsed[index];
|
|
||||||
root = (root as Element).shadowRoot || root;
|
|
||||||
if (index === parsed.length - 1)
|
|
||||||
return current.engine.query(root, current.selector);
|
|
||||||
const all = current.engine.queryAll(root, current.selector);
|
|
||||||
for (const next of all) {
|
|
||||||
const result = this._querySelectorRecursively(next, parsed, index + 1);
|
|
||||||
if (result)
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
querySelectorAll(selector: string, root: Node): Element[] {
|
|
||||||
const parsed = this._parseSelector(selector);
|
|
||||||
if (!(root as any)['querySelectorAll'])
|
|
||||||
throw new Error('Node is not queryable.');
|
|
||||||
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
|
|
||||||
for (const { engine, selector } of parsed) {
|
|
||||||
const newSet = new Set<Element>();
|
|
||||||
for (const prev of set) {
|
|
||||||
for (const next of engine.queryAll((prev as Element).shadowRoot || prev, selector)) {
|
|
||||||
if (newSet.has(next))
|
|
||||||
continue;
|
|
||||||
newSet.add(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set = newSet;
|
|
||||||
}
|
|
||||||
return Array.from(set) as Element[];
|
|
||||||
}
|
|
||||||
|
|
||||||
private _parseSelector(selector: string): ParsedSelector {
|
|
||||||
let index = 0;
|
|
||||||
let quote: string | undefined;
|
|
||||||
let start = 0;
|
|
||||||
const result: ParsedSelector = [];
|
|
||||||
const append = () => {
|
|
||||||
const part = selector.substring(start, index).trim();
|
|
||||||
const eqIndex = part.indexOf('=');
|
|
||||||
let name: string;
|
|
||||||
let body: string;
|
|
||||||
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-]+$/)) {
|
|
||||||
name = part.substring(0, eqIndex).trim();
|
|
||||||
body = part.substring(eqIndex + 1);
|
|
||||||
} else if (part.startsWith('"')) {
|
|
||||||
name = 'text';
|
|
||||||
body = part;
|
|
||||||
} else if (/^\(*\/\//.test(part)) {
|
|
||||||
// If selector starts with '//' or '//' prefixed with multiple opening
|
|
||||||
// parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817
|
|
||||||
name = 'xpath';
|
|
||||||
body = part;
|
|
||||||
} else {
|
|
||||||
name = 'css';
|
|
||||||
body = part;
|
|
||||||
}
|
|
||||||
const engine = this.engines.get(name.toLowerCase());
|
|
||||||
if (!engine)
|
|
||||||
throw new Error(`Unknown engine ${name} while parsing selector ${selector}`);
|
|
||||||
result.push({ engine, selector: body });
|
|
||||||
};
|
|
||||||
while (index < selector.length) {
|
|
||||||
const c = selector[index];
|
|
||||||
if (c === '\\' && index + 1 < selector.length) {
|
|
||||||
index += 2;
|
|
||||||
} else if (c === quote) {
|
|
||||||
quote = undefined;
|
|
||||||
index++;
|
|
||||||
} else if (!quote && c === '>' && selector[index + 1] === '>') {
|
|
||||||
append();
|
|
||||||
index += 2;
|
|
||||||
start = index;
|
|
||||||
} else {
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
append();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
isVisible(element: Element): boolean {
|
isVisible(element: Element): boolean {
|
||||||
if (!element.ownerDocument || !element.ownerDocument.defaultView)
|
if (!element.ownerDocument || !element.ownerDocument.defaultView)
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -162,13 +29,12 @@ class Injected {
|
||||||
return !!(rect.top || rect.bottom || rect.width || rect.height);
|
return !!(rect.top || rect.bottom || rect.width || rect.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _pollMutation(selector: string | undefined, predicate: Predicate, timeout: number): Promise<any> {
|
private _pollMutation(predicate: Predicate, timeout: number): Promise<any> {
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
if (timeout)
|
if (timeout)
|
||||||
setTimeout(() => timedOut = true, timeout);
|
setTimeout(() => timedOut = true, timeout);
|
||||||
|
|
||||||
const element = selector === undefined ? undefined : this.querySelector(selector, document);
|
const success = predicate();
|
||||||
const success = predicate(element);
|
|
||||||
if (success)
|
if (success)
|
||||||
return Promise.resolve(success);
|
return Promise.resolve(success);
|
||||||
|
|
||||||
|
|
@ -180,8 +46,7 @@ class Injected {
|
||||||
fulfill();
|
fulfill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const element = selector === undefined ? undefined : this.querySelector(selector, document);
|
const success = predicate();
|
||||||
const success = predicate(element);
|
|
||||||
if (success) {
|
if (success) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
fulfill(success);
|
fulfill(success);
|
||||||
|
|
@ -195,7 +60,7 @@ class Injected {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _pollRaf(selector: string | undefined, predicate: Predicate, timeout: number): Promise<any> {
|
private _pollRaf(predicate: Predicate, timeout: number): Promise<any> {
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
if (timeout)
|
if (timeout)
|
||||||
setTimeout(() => timedOut = true, timeout);
|
setTimeout(() => timedOut = true, timeout);
|
||||||
|
|
@ -208,8 +73,7 @@ class Injected {
|
||||||
fulfill();
|
fulfill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const element = selector === undefined ? undefined : this.querySelector(selector, document);
|
const success = predicate();
|
||||||
const success = predicate(element);
|
|
||||||
if (success)
|
if (success)
|
||||||
fulfill(success);
|
fulfill(success);
|
||||||
else
|
else
|
||||||
|
|
@ -220,7 +84,7 @@ class Injected {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _pollInterval(selector: string | undefined, pollInterval: number, predicate: Predicate, timeout: number): Promise<any> {
|
private _pollInterval(pollInterval: number, predicate: Predicate, timeout: number): Promise<any> {
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
if (timeout)
|
if (timeout)
|
||||||
setTimeout(() => timedOut = true, timeout);
|
setTimeout(() => timedOut = true, timeout);
|
||||||
|
|
@ -232,8 +96,7 @@ class Injected {
|
||||||
fulfill();
|
fulfill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const element = selector === undefined ? undefined : this.querySelector(selector, document);
|
const success = predicate();
|
||||||
const success = predicate(element);
|
|
||||||
if (success)
|
if (success)
|
||||||
fulfill(success);
|
fulfill(success);
|
||||||
else
|
else
|
||||||
|
|
@ -244,12 +107,12 @@ class Injected {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
poll(polling: 'raf' | 'mutation' | number, selector: string | undefined, timeout: number, predicate: Predicate): Promise<any> {
|
poll(polling: 'raf' | 'mutation' | number, timeout: number, predicate: Predicate): Promise<any> {
|
||||||
if (polling === 'raf')
|
if (polling === 'raf')
|
||||||
return this._pollRaf(selector, predicate, timeout);
|
return this._pollRaf(predicate, timeout);
|
||||||
if (polling === 'mutation')
|
if (polling === 'mutation')
|
||||||
return this._pollMutation(selector, predicate, timeout);
|
return this._pollMutation(predicate, timeout);
|
||||||
return this._pollInterval(selector, polling, predicate, timeout);
|
return this._pollInterval(polling, predicate, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
getElementBorderWidth(node: Node): { left: number; top: number; } {
|
getElementBorderWidth(node: Node): { left: number; top: number; } {
|
||||||
|
|
@ -375,7 +238,7 @@ class Injected {
|
||||||
|
|
||||||
let lastRect: types.Rect | undefined;
|
let lastRect: types.Rect | undefined;
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
const result = await this.poll('raf', undefined, timeout, () => {
|
const result = await this.poll('raf', timeout, () => {
|
||||||
// First raf happens in the same animation frame as evaluation, so it does not produce
|
// First raf happens in the same animation frame as evaluation, so it does not produce
|
||||||
// any client rect difference compared to synchronous call. We skip the synchronous call
|
// any client rect difference compared to synchronous call. We skip the synchronous call
|
||||||
// and only force layout during actual rafs as a small optimisation.
|
// and only force layout during actual rafs as a small optimisation.
|
||||||
|
|
@ -395,15 +258,37 @@ class Injected {
|
||||||
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
||||||
if (!element)
|
if (!element)
|
||||||
throw new Error('Element is not attached to the DOM');
|
throw new Error('Element is not attached to the DOM');
|
||||||
const result = await this.poll('raf', undefined, timeout, () => {
|
const result = await this.poll('raf', timeout, () => {
|
||||||
let hitElement = this.utils.deepElementFromPoint(document, point.x, point.y);
|
let hitElement = this._deepElementFromPoint(document, point.x, point.y);
|
||||||
while (hitElement && hitElement !== element)
|
while (hitElement && hitElement !== element)
|
||||||
hitElement = this.utils.parentElementOrShadowHost(hitElement);
|
hitElement = this._parentElementOrShadowHost(hitElement);
|
||||||
return hitElement === element;
|
return hitElement === element;
|
||||||
});
|
});
|
||||||
if (!result)
|
if (!result)
|
||||||
throw new Error(`waiting for element to receive mouse events failed: timeout ${timeout}ms exceeded`);
|
throw new Error(`waiting for element to receive mouse events failed: timeout ${timeout}ms exceeded`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _parentElementOrShadowHost(element: Element): Element | undefined {
|
||||||
|
if (element.parentElement)
|
||||||
|
return element.parentElement;
|
||||||
|
if (!element.parentNode)
|
||||||
|
return;
|
||||||
|
if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host)
|
||||||
|
return (element.parentNode as ShadowRoot).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _deepElementFromPoint(document: Document, x: number, y: number): Element | undefined {
|
||||||
|
let container: Document | ShadowRoot | null = document;
|
||||||
|
let element: Element | undefined;
|
||||||
|
while (container) {
|
||||||
|
const innerElement = container.elementFromPoint(x, y) as Element | undefined;
|
||||||
|
if (!innerElement || element === innerElement)
|
||||||
|
break;
|
||||||
|
element = innerElement;
|
||||||
|
container = element.shadowRoot;
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Injected;
|
export default Injected;
|
||||||
|
|
|
||||||
102
src/injected/selectorEvaluator.ts
Normal file
102
src/injected/selectorEvaluator.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* 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 { CSSEngine } from './cssSelectorEngine';
|
||||||
|
import { XPathEngine } from './xpathSelectorEngine';
|
||||||
|
import { TextEngine } from './textSelectorEngine';
|
||||||
|
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||||
|
import Injected from './injected';
|
||||||
|
import * as types from '../types';
|
||||||
|
|
||||||
|
function createAttributeEngine(attribute: string): SelectorEngine {
|
||||||
|
const engine: SelectorEngine = {
|
||||||
|
create(root: SelectorRoot, target: Element): string | undefined {
|
||||||
|
const value = target.getAttribute(attribute);
|
||||||
|
if (!value)
|
||||||
|
return;
|
||||||
|
if (root.querySelector(`[${attribute}=${value}]`) === target)
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
|
||||||
|
query(root: SelectorRoot, selector: string): Element | undefined {
|
||||||
|
return root.querySelector(`[${attribute}=${selector}]`) || undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
queryAll(root: SelectorRoot, selector: string): Element[] {
|
||||||
|
return Array.from(root.querySelectorAll(`[${attribute}=${selector}]`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
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', CSSEngine);
|
||||||
|
this.engines.set('xpath', XPathEngine);
|
||||||
|
this.engines.set('text', TextEngine);
|
||||||
|
this.engines.set('id', createAttributeEngine('id'));
|
||||||
|
this.engines.set('data-testid', createAttributeEngine('data-testid'));
|
||||||
|
this.engines.set('data-test-id', createAttributeEngine('data-test-id'));
|
||||||
|
this.engines.set('data-test', createAttributeEngine('data-test'));
|
||||||
|
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[index];
|
||||||
|
root = (root as Element).shadowRoot || root;
|
||||||
|
if (index === selector.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 result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] {
|
||||||
|
if (!(root as any)['querySelectorAll'])
|
||||||
|
throw new Error('Node is not queryable.');
|
||||||
|
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
|
||||||
|
for (const { name, body } of selector) {
|
||||||
|
const newSet = new Set<Element>();
|
||||||
|
for (const prev of set) {
|
||||||
|
for (const next of this.engines.get(name)!.queryAll((prev as Element).shadowRoot || prev, body)) {
|
||||||
|
if (newSet.has(next))
|
||||||
|
continue;
|
||||||
|
newSet.add(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set = newSet;
|
||||||
|
}
|
||||||
|
return Array.from(set) as Element[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectorEvaluator;
|
||||||
|
|
@ -18,7 +18,7 @@ const path = require('path');
|
||||||
const InlineSource = require('./webpack-inline-source-plugin.js');
|
const InlineSource = require('./webpack-inline-source-plugin.js');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: path.join(__dirname, 'injected.ts'),
|
entry: path.join(__dirname, 'selectorEvaluator.ts'),
|
||||||
devtool: 'source-map',
|
devtool: 'source-map',
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
|
|
@ -36,10 +36,10 @@ module.exports = {
|
||||||
extensions: [ '.tsx', '.ts', '.js' ]
|
extensions: [ '.tsx', '.ts', '.js' ]
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
filename: 'injectedSource.js',
|
filename: 'selectorEvaluatorSource.js',
|
||||||
path: path.resolve(__dirname, '../../lib/injected/packed')
|
path: path.resolve(__dirname, '../../lib/injected/packed')
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new InlineSource(path.join(__dirname, '..', 'generated', 'injectedSource.ts')),
|
new InlineSource(path.join(__dirname, '..', 'generated', 'selectorEvaluatorSource.ts')),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
@ -1,39 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class Utils {
|
|
||||||
parentElementOrShadowHost(element: Element): Element | undefined {
|
|
||||||
if (element.parentElement)
|
|
||||||
return element.parentElement;
|
|
||||||
if (!element.parentNode)
|
|
||||||
return;
|
|
||||||
if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host)
|
|
||||||
return (element.parentNode as ShadowRoot).host;
|
|
||||||
}
|
|
||||||
|
|
||||||
deepElementFromPoint(document: Document, x: number, y: number): Element | undefined {
|
|
||||||
let container: Document | ShadowRoot | null = document;
|
|
||||||
let element: Element | undefined;
|
|
||||||
while (container) {
|
|
||||||
const innerElement = container.elementFromPoint(x, y) as Element | undefined;
|
|
||||||
if (!innerElement || element === innerElement)
|
|
||||||
break;
|
|
||||||
element = innerElement;
|
|
||||||
container = element.shadowRoot;
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
176
src/selectors.ts
176
src/selectors.ts
|
|
@ -15,29 +15,35 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as dom from './dom';
|
import * as dom from './dom';
|
||||||
|
import * as frames from './frames';
|
||||||
|
import * as selectorEvaluatorSource from './generated/selectorEvaluatorSource';
|
||||||
import { helper } from './helper';
|
import { helper } from './helper';
|
||||||
|
import SelectorEvaluator from './injected/selectorEvaluator';
|
||||||
|
import * as js from './javascript';
|
||||||
|
import * as types from './types';
|
||||||
|
|
||||||
let selectors: Selectors;
|
const kEvaluatorSymbol = Symbol('evaluator');
|
||||||
|
type EvaluatorData = {
|
||||||
|
promise: Promise<js.JSHandle<SelectorEvaluator>>,
|
||||||
|
generation: number,
|
||||||
|
};
|
||||||
|
|
||||||
export class Selectors {
|
export class Selectors {
|
||||||
|
readonly _builtinEngines: Set<string>;
|
||||||
readonly _engines: Map<string, string>;
|
readonly _engines: Map<string, string>;
|
||||||
_generation = 0;
|
_generation = 0;
|
||||||
|
|
||||||
static _instance() {
|
|
||||||
if (!selectors)
|
|
||||||
selectors = new Selectors();
|
|
||||||
return selectors;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Note: keep in sync with Injected class.
|
||||||
|
this._builtinEngines = new Set(['css', 'xpath', 'text', 'id', 'data-testid', 'data-test-id', 'data-test']);
|
||||||
this._engines = new Map();
|
this._engines = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(name: string, script: string | Function | { path?: string, content?: string }): Promise<void> {
|
async register(name: string, script: string | Function | { path?: string, content?: string }): Promise<void> {
|
||||||
if (!name.match(/^[a-zA-Z_0-9-]+$/))
|
if (!name.match(/^[a-zA-Z_0-9-]+$/))
|
||||||
throw new Error('Selector engine name may only contain [a-zA-Z0-9_] characters');
|
throw new Error('Selector engine name may only contain [a-zA-Z0-9_] characters');
|
||||||
// Note: keep in sync with Injected class, and also keep 'zs' for future.
|
// Note: we keep 'zs' for future use.
|
||||||
if (['css', 'xpath', 'text', 'id', 'zs', 'data-testid', 'data-test-id', 'data-test'].includes(name))
|
if (this._builtinEngines.has(name) || name === 'zs')
|
||||||
throw new Error(`"${name}" is a predefined selector engine`);
|
throw new Error(`"${name}" is a predefined selector engine`);
|
||||||
const source = await helper.evaluationScript(script, undefined, false);
|
const source = await helper.evaluationScript(script, undefined, false);
|
||||||
if (this._engines.has(name))
|
if (this._engines.has(name))
|
||||||
|
|
@ -46,10 +52,156 @@ export class Selectors {
|
||||||
++this._generation;
|
++this._generation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = await frame._utilityContext();
|
||||||
|
const handle = await context.evaluateHandleInternal(
|
||||||
|
({ evaluator, parsed, scope }) => evaluator.querySelector(parsed, scope || document),
|
||||||
|
{ evaluator: await this._prepareEvaluator(context), parsed, scope }
|
||||||
|
);
|
||||||
|
const elementHandle = handle.asElement() as dom.ElementHandle<Element> | null;
|
||||||
|
if (!elementHandle) {
|
||||||
|
handle.dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mainContext = await frame._mainContext();
|
||||||
|
if (elementHandle._context === mainContext)
|
||||||
|
return elementHandle;
|
||||||
|
const adopted = frame._page._delegate.adoptElementHandle(elementHandle, mainContext);
|
||||||
|
elementHandle.dispose();
|
||||||
|
return adopted;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
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 ? 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 properties = await arrayHandle.getProperties();
|
||||||
|
arrayHandle.dispose();
|
||||||
|
const result: dom.ElementHandle<Element>[] = [];
|
||||||
|
for (const property of properties.values()) {
|
||||||
|
const elementHandle = property.asElement() as dom.ElementHandle<Element>;
|
||||||
|
if (elementHandle)
|
||||||
|
result.push(elementHandle);
|
||||||
|
else
|
||||||
|
property.dispose();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
_waitForSelectorTask(selector: string, waitFor: 'attached' | 'detached' | 'visible' | 'hidden', timeout: 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, waitFor, timeout }) => {
|
||||||
|
const polling = (waitFor === 'attached' || waitFor === 'detached') ? 'mutation' : 'raf';
|
||||||
|
return evaluator.injected.poll(polling, timeout, () => {
|
||||||
|
const element = evaluator.querySelector(parsed, document);
|
||||||
|
switch (waitFor) {
|
||||||
|
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, waitFor, timeout });
|
||||||
|
return { world: 'utility', task };
|
||||||
|
}
|
||||||
|
|
||||||
async _createSelector(name: string, handle: dom.ElementHandle<Element>): Promise<string | undefined> {
|
async _createSelector(name: string, handle: dom.ElementHandle<Element>): Promise<string | undefined> {
|
||||||
const mainContext = await handle._page.mainFrame()._mainContext();
|
const mainContext = await handle._page.mainFrame()._mainContext();
|
||||||
return mainContext.evaluateInternal(({ injected, target, name }) => {
|
return mainContext.evaluateInternal(({ evaluator, target, name }) => {
|
||||||
return injected.engines.get(name)!.create(document.documentElement, target);
|
return evaluator.engines.get(name)!.create(document.documentElement, target);
|
||||||
}, { injected: await mainContext._injected(), target: handle, name });
|
}, { evaluator: await this._prepareEvaluator(mainContext), target: handle, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseSelector(selector: string): types.ParsedSelector {
|
||||||
|
let index = 0;
|
||||||
|
let quote: string | undefined;
|
||||||
|
let start = 0;
|
||||||
|
const result: types.ParsedSelector = [];
|
||||||
|
const append = () => {
|
||||||
|
const part = selector.substring(start, index).trim();
|
||||||
|
const eqIndex = part.indexOf('=');
|
||||||
|
let name: string;
|
||||||
|
let body: string;
|
||||||
|
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-]+$/)) {
|
||||||
|
name = part.substring(0, eqIndex).trim();
|
||||||
|
body = part.substring(eqIndex + 1);
|
||||||
|
} else if (part.startsWith('"')) {
|
||||||
|
name = 'text';
|
||||||
|
body = part;
|
||||||
|
} else if (/^\(*\/\//.test(part)) {
|
||||||
|
// If selector starts with '//' or '//' prefixed with multiple opening
|
||||||
|
// parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817
|
||||||
|
name = 'xpath';
|
||||||
|
body = part;
|
||||||
|
} else {
|
||||||
|
name = 'css';
|
||||||
|
body = part;
|
||||||
|
}
|
||||||
|
name = name.toLowerCase();
|
||||||
|
if (!this._builtinEngines.has(name) && !this._engines.has(name))
|
||||||
|
throw new Error(`Unknown engine ${name} while parsing selector ${selector}`);
|
||||||
|
result.push({ name, body });
|
||||||
|
};
|
||||||
|
while (index < selector.length) {
|
||||||
|
const c = selector[index];
|
||||||
|
if (c === '\\' && index + 1 < selector.length) {
|
||||||
|
index += 2;
|
||||||
|
} else if (c === quote) {
|
||||||
|
quote = undefined;
|
||||||
|
index++;
|
||||||
|
} else if (!quote && c === '>' && selector[index + 1] === '>') {
|
||||||
|
append();
|
||||||
|
index += 2;
|
||||||
|
start = index;
|
||||||
|
} else {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const selectors = new Selectors();
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { DeviceDescriptors } from '../deviceDescriptors';
|
||||||
import { Chromium } from './chromium';
|
import { Chromium } from './chromium';
|
||||||
import { WebKit } from './webkit';
|
import { WebKit } from './webkit';
|
||||||
import { Firefox } from './firefox';
|
import { Firefox } from './firefox';
|
||||||
|
import { selectors } from '../selectors';
|
||||||
|
|
||||||
for (const className in api) {
|
for (const className in api) {
|
||||||
if (typeof (api as any)[className] === 'function')
|
if (typeof (api as any)[className] === 'function')
|
||||||
|
|
@ -33,7 +34,7 @@ type PlaywrightOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Playwright {
|
export class Playwright {
|
||||||
readonly selectors = api.Selectors._instance();
|
readonly selectors = selectors;
|
||||||
readonly devices: types.Devices;
|
readonly devices: types.Devices;
|
||||||
readonly errors: { TimeoutError: typeof TimeoutError };
|
readonly errors: { TimeoutError: typeof TimeoutError };
|
||||||
readonly chromium: (Chromium|undefined);
|
readonly chromium: (Chromium|undefined);
|
||||||
|
|
|
||||||
|
|
@ -144,3 +144,8 @@ export type JSCoverageOptions = {
|
||||||
resetOnNavigation?: boolean,
|
resetOnNavigation?: boolean,
|
||||||
reportAnonymousScripts?: boolean,
|
reportAnonymousScripts?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ParsedSelector = {
|
||||||
|
name: string,
|
||||||
|
body: string,
|
||||||
|
}[];
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import * as platform from '../platform';
|
||||||
import { getAccessibilityTree } from './wkAccessibility';
|
import { getAccessibilityTree } from './wkAccessibility';
|
||||||
import { WKProvisionalPage } from './wkProvisionalPage';
|
import { WKProvisionalPage } from './wkProvisionalPage';
|
||||||
import { WKBrowserContext } from './wkBrowser';
|
import { WKBrowserContext } from './wkBrowser';
|
||||||
|
import { selectors } from '../selectors';
|
||||||
|
|
||||||
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
||||||
const BINDING_CALL_MESSAGE = '__playwright_binding_call__';
|
const BINDING_CALL_MESSAGE = '__playwright_binding_call__';
|
||||||
|
|
@ -730,8 +731,7 @@ export class WKPage implements PageDelegate {
|
||||||
const parent = frame.parentFrame();
|
const parent = frame.parentFrame();
|
||||||
if (!parent)
|
if (!parent)
|
||||||
throw new Error('Frame has been detached.');
|
throw new Error('Frame has been detached.');
|
||||||
const context = await parent._utilityContext();
|
const handles = await selectors._queryAll(parent, 'iframe', undefined, true /* allowUtilityContext */);
|
||||||
const handles = await context._$$('iframe');
|
|
||||||
const items = await Promise.all(handles.map(async handle => {
|
const items = await Promise.all(handles.map(async handle => {
|
||||||
const frame = await handle.contentFrame().catch(e => null);
|
const frame = await handle.contentFrame().catch(e => null);
|
||||||
return { handle, frame };
|
return { handle, frame };
|
||||||
|
|
|
||||||
|
|
@ -978,19 +978,6 @@ module.exports.describe = function({testRunner, expect, headless, playwright, FF
|
||||||
await page.fill('textarea', 123).catch(e => error = e);
|
await page.fill('textarea', 123).catch(e => error = e);
|
||||||
expect(error.message).toContain('Value must be string.');
|
expect(error.message).toContain('Value must be string.');
|
||||||
});
|
});
|
||||||
it('should wait for visible visibilty', async({page, server}) => {
|
|
||||||
await page.goto(server.PREFIX + '/input/textarea.html');
|
|
||||||
await page.fill('input', 'some value');
|
|
||||||
expect(await page.evaluate(() => result)).toBe('some value');
|
|
||||||
|
|
||||||
await page.goto(server.PREFIX + '/input/textarea.html');
|
|
||||||
await page.$eval('input', i => i.style.display = 'none');
|
|
||||||
await Promise.all([
|
|
||||||
page.fill('input', 'some value'),
|
|
||||||
page.$eval('input', i => i.style.display = 'block'),
|
|
||||||
]);
|
|
||||||
expect(await page.evaluate(() => result)).toBe('some value');
|
|
||||||
});
|
|
||||||
it('should throw on disabled and readonly elements', async({page, server}) => {
|
it('should throw on disabled and readonly elements', async({page, server}) => {
|
||||||
await page.goto(server.PREFIX + '/input/textarea.html');
|
await page.goto(server.PREFIX + '/input/textarea.html');
|
||||||
await page.$eval('input', i => i.disabled = true);
|
await page.$eval('input', i => i.disabled = true);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const path = require('path');
|
||||||
|
|
||||||
const files = [
|
const files = [
|
||||||
path.join('src', 'injected', 'zsSelectorEngine.webpack.config.js'),
|
path.join('src', 'injected', 'zsSelectorEngine.webpack.config.js'),
|
||||||
path.join('src', 'injected', 'injected.webpack.config.js'),
|
path.join('src', 'injected', 'selectorEvaluator.webpack.config.js'),
|
||||||
path.join('src', 'web.webpack.config.js'),
|
path.join('src', 'web.webpack.config.js'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue