2019-11-27 16:02:31 -08:00
|
|
|
// Copyright (c) Microsoft Corporation.
|
|
|
|
// Licensed under the MIT license.
|
|
|
|
|
|
|
|
import * as frames from './frames';
|
2019-11-27 16:03:51 -08:00
|
|
|
import * as input from './input';
|
|
|
|
import * as js from './javascript';
|
|
|
|
import * as types from './types';
|
2019-11-28 12:50:52 -08:00
|
|
|
import * as injectedSource from './generated/injectedSource';
|
|
|
|
import * as cssSelectorEngineSource from './generated/cssSelectorEngineSource';
|
|
|
|
import * as xpathSelectorEngineSource from './generated/xpathSelectorEngineSource';
|
2019-12-02 13:12:28 -08:00
|
|
|
import { assert, helper } from './helper';
|
|
|
|
import Injected from './injected/injected';
|
2019-11-27 16:02:31 -08:00
|
|
|
|
|
|
|
export interface DOMWorldDelegate {
|
2019-11-28 12:50:52 -08:00
|
|
|
keyboard: input.Keyboard;
|
|
|
|
mouse: input.Mouse;
|
|
|
|
frame: frames.Frame;
|
2019-11-27 16:02:31 -08:00
|
|
|
isJavascriptEnabled(): boolean;
|
2019-12-02 13:12:28 -08:00
|
|
|
isElement(remoteObject: any): boolean;
|
2019-11-27 16:02:31 -08:00
|
|
|
contentFrame(handle: ElementHandle): Promise<frames.Frame | null>;
|
2019-11-27 16:03:51 -08:00
|
|
|
boundingBox(handle: ElementHandle): Promise<types.Rect | null>;
|
2019-11-27 16:02:31 -08:00
|
|
|
screenshot(handle: ElementHandle, options?: any): Promise<string | Buffer>;
|
2019-11-27 16:03:51 -08:00
|
|
|
ensurePointerActionPoint(handle: ElementHandle, relativePoint?: types.Point): Promise<types.Point>;
|
2019-11-27 16:02:31 -08:00
|
|
|
setInputFiles(handle: ElementHandle, files: input.FilePayload[]): Promise<void>;
|
2019-11-28 12:50:52 -08:00
|
|
|
adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise<ElementHandle>;
|
|
|
|
}
|
|
|
|
|
|
|
|
export class DOMWorld {
|
|
|
|
readonly context: js.ExecutionContext;
|
|
|
|
readonly delegate: DOMWorldDelegate;
|
|
|
|
|
|
|
|
private _injectedPromise?: Promise<js.JSHandle>;
|
|
|
|
private _documentPromise?: Promise<ElementHandle>;
|
|
|
|
|
|
|
|
constructor(context: js.ExecutionContext, delegate: DOMWorldDelegate) {
|
|
|
|
this.context = context;
|
|
|
|
this.delegate = delegate;
|
|
|
|
}
|
|
|
|
|
2019-12-02 13:12:28 -08:00
|
|
|
_createHandle(remoteObject: any): ElementHandle | null {
|
|
|
|
if (this.delegate.isElement(remoteObject))
|
|
|
|
return new ElementHandle(this.context, remoteObject);
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2019-11-28 12:50:52 -08:00
|
|
|
injected(): Promise<js.JSHandle> {
|
|
|
|
if (!this._injectedPromise) {
|
|
|
|
const engineSources = [cssSelectorEngineSource.source, xpathSelectorEngineSource.source];
|
|
|
|
const source = `
|
|
|
|
new (${injectedSource.source})([
|
|
|
|
${engineSources.join(',\n')}
|
|
|
|
])
|
|
|
|
`;
|
|
|
|
this._injectedPromise = this.context.evaluateHandle(source);
|
|
|
|
}
|
|
|
|
return this._injectedPromise;
|
|
|
|
}
|
|
|
|
|
|
|
|
_document(): Promise<ElementHandle> {
|
|
|
|
if (!this._documentPromise)
|
|
|
|
this._documentPromise = this.context.evaluateHandle('document').then(handle => handle.asElement()!);
|
|
|
|
return this._documentPromise;
|
|
|
|
}
|
|
|
|
|
|
|
|
async adoptElementHandle(handle: ElementHandle, dispose: boolean): Promise<ElementHandle> {
|
|
|
|
if (handle.executionContext() === this.context)
|
|
|
|
return handle;
|
|
|
|
const adopted = this.delegate.adoptElementHandle(handle, this);
|
|
|
|
if (dispose)
|
|
|
|
await handle.dispose();
|
|
|
|
return adopted;
|
|
|
|
}
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
2019-12-02 13:12:28 -08:00
|
|
|
type SelectorRoot = Element | ShadowRoot | Document;
|
|
|
|
|
2019-11-27 16:02:31 -08:00
|
|
|
export class ElementHandle extends js.JSHandle {
|
2019-11-28 12:50:52 -08:00
|
|
|
private readonly _world: DOMWorld;
|
2019-11-27 16:02:31 -08:00
|
|
|
|
2019-12-02 13:12:28 -08:00
|
|
|
constructor(context: js.ExecutionContext, remoteObject: any) {
|
|
|
|
super(context, remoteObject);
|
2019-11-28 12:50:52 -08:00
|
|
|
assert(context._domWorld, 'Element handle should have a dom world');
|
|
|
|
this._world = context._domWorld;
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
asElement(): ElementHandle | null {
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
async contentFrame(): Promise<frames.Frame | null> {
|
2019-11-28 12:50:52 -08:00
|
|
|
return this._world.delegate.contentFrame(this);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async _scrollIntoViewIfNeeded() {
|
|
|
|
const error = await this.evaluate(async (element, pageJavascriptEnabled) => {
|
|
|
|
if (!element.isConnected)
|
|
|
|
return 'Node is detached from document';
|
|
|
|
if (element.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
return 'Node is not of type HTMLElement';
|
|
|
|
// force-scroll if page's javascript is disabled.
|
|
|
|
if (!pageJavascriptEnabled) {
|
|
|
|
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const visibleRatio = await new Promise(resolve => {
|
|
|
|
const observer = new IntersectionObserver(entries => {
|
|
|
|
resolve(entries[0].intersectionRatio);
|
|
|
|
observer.disconnect();
|
|
|
|
});
|
|
|
|
observer.observe(element);
|
|
|
|
// Firefox doesn't call IntersectionObserver callback unless
|
|
|
|
// there are rafs.
|
|
|
|
requestAnimationFrame(() => {});
|
|
|
|
});
|
|
|
|
if (visibleRatio !== 1.0)
|
|
|
|
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
|
|
|
|
return false;
|
2019-11-28 12:50:52 -08:00
|
|
|
}, this._world.delegate.isJavascriptEnabled());
|
2019-11-27 16:02:31 -08:00
|
|
|
if (error)
|
|
|
|
throw new Error(error);
|
|
|
|
}
|
|
|
|
|
2019-11-27 16:03:51 -08:00
|
|
|
async _performPointerAction(action: (point: types.Point) => Promise<void>, options?: input.PointerActionOptions): Promise<void> {
|
2019-11-28 12:50:52 -08:00
|
|
|
const point = await this._world.delegate.ensurePointerActionPoint(this, options ? options.relativePoint : undefined);
|
2019-11-27 16:02:31 -08:00
|
|
|
let restoreModifiers: input.Modifier[] | undefined;
|
|
|
|
if (options && options.modifiers)
|
2019-11-28 12:50:52 -08:00
|
|
|
restoreModifiers = await this._world.delegate.keyboard._ensureModifiers(options.modifiers);
|
2019-11-27 16:02:31 -08:00
|
|
|
await action(point);
|
|
|
|
if (restoreModifiers)
|
2019-11-28 12:50:52 -08:00
|
|
|
await this._world.delegate.keyboard._ensureModifiers(restoreModifiers);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
hover(options?: input.PointerActionOptions): Promise<void> {
|
2019-11-28 12:50:52 -08:00
|
|
|
return this._performPointerAction(point => this._world.delegate.mouse.move(point.x, point.y), options);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
click(options?: input.ClickOptions): Promise<void> {
|
2019-11-28 12:50:52 -08:00
|
|
|
return this._performPointerAction(point => this._world.delegate.mouse.click(point.x, point.y, options), options);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
dblclick(options?: input.MultiClickOptions): Promise<void> {
|
2019-11-28 12:50:52 -08:00
|
|
|
return this._performPointerAction(point => this._world.delegate.mouse.dblclick(point.x, point.y, options), options);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
tripleclick(options?: input.MultiClickOptions): Promise<void> {
|
2019-11-28 12:50:52 -08:00
|
|
|
return this._performPointerAction(point => this._world.delegate.mouse.tripleclick(point.x, point.y, options), options);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async select(...values: (string | ElementHandle | input.SelectOption)[]): Promise<string[]> {
|
|
|
|
const options = values.map(value => typeof value === 'object' ? value : { value });
|
|
|
|
for (const option of options) {
|
|
|
|
if (option instanceof ElementHandle)
|
|
|
|
continue;
|
|
|
|
if (option.value !== undefined)
|
|
|
|
assert(helper.isString(option.value), 'Values must be strings. Found value "' + option.value + '" of type "' + (typeof option.value) + '"');
|
|
|
|
if (option.label !== undefined)
|
|
|
|
assert(helper.isString(option.label), 'Labels must be strings. Found label "' + option.label + '" of type "' + (typeof option.label) + '"');
|
|
|
|
if (option.index !== undefined)
|
|
|
|
assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"');
|
|
|
|
}
|
|
|
|
return this.evaluate(input.selectFunction, ...options);
|
|
|
|
}
|
|
|
|
|
|
|
|
async fill(value: string): Promise<void> {
|
|
|
|
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
|
|
|
|
const error = await this.evaluate(input.fillFunction);
|
|
|
|
if (error)
|
|
|
|
throw new Error(error);
|
|
|
|
await this.focus();
|
2019-11-28 12:50:52 -08:00
|
|
|
await this._world.delegate.keyboard.sendCharacters(value);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async setInputFiles(...files: (string|input.FilePayload)[]) {
|
|
|
|
const multiple = await this.evaluate((element: HTMLInputElement) => !!element.multiple);
|
|
|
|
assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!');
|
2019-11-28 12:50:52 -08:00
|
|
|
await this._world.delegate.setInputFiles(this, await input.loadFiles(files));
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async focus() {
|
|
|
|
await this.evaluate(element => element.focus());
|
|
|
|
}
|
|
|
|
|
|
|
|
async type(text: string, options: { delay: (number | undefined); } | undefined) {
|
|
|
|
await this.focus();
|
2019-11-28 12:50:52 -08:00
|
|
|
await this._world.delegate.keyboard.type(text, options);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async press(key: string, options: { delay?: number; text?: string; } | undefined) {
|
|
|
|
await this.focus();
|
2019-11-28 12:50:52 -08:00
|
|
|
await this._world.delegate.keyboard.press(key, options);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
2019-11-27 16:03:51 -08:00
|
|
|
async boundingBox(): Promise<types.Rect | null> {
|
2019-11-28 12:50:52 -08:00
|
|
|
return this._world.delegate.boundingBox(this);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async screenshot(options: any = {}): Promise<string | Buffer> {
|
2019-11-28 12:50:52 -08:00
|
|
|
return this._world.delegate.screenshot(this, options);
|
2019-11-27 16:02:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async $(selector: string): Promise<ElementHandle | null> {
|
|
|
|
const handle = await this.evaluateHandle(
|
|
|
|
(root: SelectorRoot, selector: string, injected: Injected) => injected.querySelector('css=' + selector, root),
|
2019-11-28 12:50:52 -08:00
|
|
|
selector, await this._world.injected()
|
2019-11-27 16:02:31 -08:00
|
|
|
);
|
|
|
|
const element = handle.asElement();
|
|
|
|
if (element)
|
|
|
|
return element;
|
|
|
|
await handle.dispose();
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
async $$(selector: string): Promise<ElementHandle[]> {
|
|
|
|
const arrayHandle = await this.evaluateHandle(
|
|
|
|
(root: SelectorRoot, selector: string, injected: Injected) => injected.querySelectorAll('css=' + selector, root),
|
2019-11-28 12:50:52 -08:00
|
|
|
selector, await this._world.injected()
|
2019-11-27 16:02:31 -08:00
|
|
|
);
|
|
|
|
const properties = await arrayHandle.getProperties();
|
|
|
|
await arrayHandle.dispose();
|
|
|
|
const result = [];
|
|
|
|
for (const property of properties.values()) {
|
|
|
|
const elementHandle = property.asElement();
|
|
|
|
if (elementHandle)
|
|
|
|
result.push(elementHandle);
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2019-11-27 16:03:51 -08:00
|
|
|
$eval: types.$Eval = async (selector, pageFunction, ...args) => {
|
2019-11-27 16:02:31 -08:00
|
|
|
const elementHandle = await this.$(selector);
|
|
|
|
if (!elementHandle)
|
|
|
|
throw new Error(`Error: failed to find element matching selector "${selector}"`);
|
|
|
|
const result = await elementHandle.evaluate(pageFunction, ...args as any);
|
|
|
|
await elementHandle.dispose();
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2019-11-27 16:03:51 -08:00
|
|
|
$$eval: types.$$Eval = async (selector, pageFunction, ...args) => {
|
2019-11-27 16:02:31 -08:00
|
|
|
const arrayHandle = await this.evaluateHandle(
|
|
|
|
(root: SelectorRoot, selector: string, injected: Injected) => injected.querySelectorAll('css=' + selector, root),
|
2019-11-28 12:50:52 -08:00
|
|
|
selector, await this._world.injected()
|
2019-11-27 16:02:31 -08:00
|
|
|
);
|
|
|
|
|
|
|
|
const result = await arrayHandle.evaluate(pageFunction, ...args as any);
|
|
|
|
await arrayHandle.dispose();
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
async $x(expression: string): Promise<ElementHandle[]> {
|
|
|
|
const arrayHandle = await this.evaluateHandle(
|
|
|
|
(root: SelectorRoot, expression: string, injected: Injected) => injected.querySelectorAll('xpath=' + expression, root),
|
2019-11-28 12:50:52 -08:00
|
|
|
expression, await this._world.injected()
|
2019-11-27 16:02:31 -08:00
|
|
|
);
|
|
|
|
const properties = await arrayHandle.getProperties();
|
|
|
|
await arrayHandle.dispose();
|
|
|
|
const result = [];
|
|
|
|
for (const property of properties.values()) {
|
|
|
|
const elementHandle = property.asElement();
|
|
|
|
if (elementHandle)
|
|
|
|
result.push(elementHandle);
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
isIntersectingViewport(): Promise<boolean> {
|
|
|
|
return this.evaluate(async element => {
|
|
|
|
const visibleRatio = await new Promise(resolve => {
|
|
|
|
const observer = new IntersectionObserver(entries => {
|
|
|
|
resolve(entries[0].intersectionRatio);
|
|
|
|
observer.disconnect();
|
|
|
|
});
|
|
|
|
observer.observe(element);
|
|
|
|
// Firefox doesn't call IntersectionObserver callback unless
|
|
|
|
// there are rafs.
|
|
|
|
requestAnimationFrame(() => {});
|
|
|
|
});
|
|
|
|
return visibleRatio > 0;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|