feat: make JSHandle generic (#140)

This makes it so that JSHandles and ElementHandles are aware of what types they point to. As a fun bonus, `$eval('input')` knows its going to get an HTMLInputElement.

Most of this patch is casting things where previously we just assumed ElementHandles held the right kind of node. This gets us closer to being able to turn on `noImplicityAny` as well.

#6
This commit is contained in:
Joel Einbinder 2019-12-05 16:26:09 -08:00 committed by GitHub
parent e4fad11c16
commit 39b22b41c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 84 additions and 54 deletions

View File

@ -149,7 +149,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
await releaseObject(this._client, toRemoteObject(handle));
}
async handleJSONValue(handle: js.JSHandle): Promise<any> {
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
const remoteObject = toRemoteObject(handle);
if (remoteObject.objectId) {
const response = await this._client.send('Runtime.callFunctionOn', {

View File

@ -99,11 +99,11 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
await handle.evaluate(input.setFileInputFunction, files);
}
async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise<dom.ElementHandle> {
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.DOMWorld): Promise<dom.ElementHandle<T>> {
const nodeInfo = await this._client.send('DOM.describeNode', {
objectId: toRemoteObject(handle).objectId,
});
return this.adoptBackendNodeId(nodeInfo.node.backendNodeId, to);
return this.adoptBackendNodeId(nodeInfo.node.backendNodeId, to) as Promise<dom.ElementHandle<T>>;
}
async adoptBackendNodeId(backendNodeId: Protocol.DOM.BackendNodeId, to: dom.DOMWorld): Promise<dom.ElementHandle> {

View File

@ -223,7 +223,7 @@ export class Page extends EventEmitter {
this._timeoutSettings.setDefaultTimeout(timeout);
}
async $(selector: string | types.Selector): Promise<dom.ElementHandle | null> {
async $(selector: string | types.Selector): Promise<dom.ElementHandle<Element> | null> {
return this.mainFrame().$(selector);
}
@ -240,11 +240,11 @@ export class Page extends EventEmitter {
return this.mainFrame().$$eval(selector, pageFunction, ...args as any);
}
async $$(selector: string | types.Selector): Promise<dom.ElementHandle[]> {
async $$(selector: string | types.Selector): Promise<dom.ElementHandle<Element>[]> {
return this.mainFrame().$$(selector);
}
async $x(expression: string): Promise<dom.ElementHandle[]> {
async $x(expression: string): Promise<dom.ElementHandle<Element>[]> {
return this.mainFrame().$x(expression);
}

View File

@ -35,7 +35,7 @@ export class Playwright {
this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium');
}
launch(options: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise<Browser> {
launch(options?: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise<Browser> {
return this._launcher.launch(options);
}

View File

@ -24,10 +24,10 @@ export interface DOMWorldDelegate {
boundingBox(handle: ElementHandle): Promise<types.Rect | null>;
screenshot(handle: ElementHandle, options?: types.ScreenshotOptions): Promise<string | Buffer>;
setInputFiles(handle: ElementHandle, files: input.FilePayload[]): Promise<void>;
adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise<ElementHandle>;
adoptElementHandle<T extends Node>(handle: ElementHandle<T>, to: DOMWorld): Promise<ElementHandle<T>>;
}
export type ScopedSelector = types.Selector & { scope?: ElementHandle };
type ScopedSelector = types.Selector & { scope?: ElementHandle };
type ResolvedSelector = { scope?: ElementHandle, selector: string, visible?: boolean, disposeScope?: boolean };
export class DOMWorld {
@ -60,7 +60,7 @@ export class DOMWorld {
return this._injectedPromise;
}
async adoptElementHandle(handle: ElementHandle): Promise<ElementHandle> {
async adoptElementHandle<T extends Node>(handle: ElementHandle<T>): Promise<ElementHandle<T>> {
assert(handle.executionContext() !== this.context, 'Should not adopt to the same context');
return this.delegate.adoptElementHandle(handle, this);
}
@ -75,10 +75,10 @@ export class DOMWorld {
return { scope: selector.scope, selector: normalizeSelector(selector.selector), visible: selector.visible };
}
async $(selector: string | ScopedSelector): Promise<ElementHandle | null> {
async $(selector: string | ScopedSelector): Promise<ElementHandle<Element> | null> {
const resolved = await this.resolveSelector(selector);
const handle = await this.context.evaluateHandle(
(injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined) => {
(injected: Injected, selector: string, scope?: Node, visible?: boolean) => {
const element = injected.querySelector(selector, scope || document);
if (visible === undefined || !element)
return element;
@ -93,10 +93,10 @@ export class DOMWorld {
return handle.asElement();
}
async $$(selector: string | ScopedSelector): Promise<ElementHandle[]> {
async $$(selector: string | ScopedSelector): Promise<ElementHandle<Element>[]> {
const resolved = await this.resolveSelector(selector);
const arrayHandle = await this.context.evaluateHandle(
(injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined) => {
(injected: Injected, selector: string, scope?: Node, visible?: boolean) => {
const elements = injected.querySelectorAll(selector, scope || document);
if (visible !== undefined)
return elements.filter(element => injected.isVisible(element) === visible);
@ -131,7 +131,7 @@ export class DOMWorld {
$$eval: types.$$Eval<string | ScopedSelector> = async (selector, pageFunction, ...args) => {
const resolved = await this.resolveSelector(selector);
const arrayHandle = await this.context.evaluateHandle(
(injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined) => {
(injected: Injected, selector: string, scope?: Node, visible?: boolean) => {
const elements = injected.querySelectorAll(selector, scope || document);
if (visible !== undefined)
return elements.filter(element => injected.isVisible(element) === visible);
@ -145,7 +145,7 @@ export class DOMWorld {
}
}
export class ElementHandle extends js.JSHandle {
export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
private readonly _world: DOMWorld;
constructor(context: js.ExecutionContext, remoteObject: any) {
@ -154,7 +154,7 @@ export class ElementHandle extends js.JSHandle {
this._world = context._domWorld;
}
asElement(): ElementHandle | null {
asElement(): ElementHandle<T> | null {
return this;
}
@ -163,13 +163,15 @@ export class ElementHandle extends js.JSHandle {
}
async _scrollIntoViewIfNeeded() {
const error = await this.evaluate(async (element, pageJavascriptEnabled) => {
if (!element.isConnected)
const error = await this.evaluate(async (node: Node, pageJavascriptEnabled: boolean) => {
if (!node.isConnected)
return 'Node is detached from document';
if (element.nodeType !== Node.ELEMENT_NODE)
if (node.nodeType !== Node.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
const element = node as Element;
// force-scroll if page's javascript is disabled.
if (!pageJavascriptEnabled) {
//@ts-ignore because only Chromium still supports 'instant'
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
return false;
}
@ -183,8 +185,10 @@ export class ElementHandle extends js.JSHandle {
// there are rafs.
requestAnimationFrame(() => {});
});
if (visibleRatio !== 1.0)
if (visibleRatio !== 1.0) {
//@ts-ignore because only Chromium still supports 'instant'
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
}
return false;
}, this._world.delegate.isJavascriptEnabled());
if (error)
@ -336,13 +340,25 @@ export class ElementHandle extends js.JSHandle {
}
async setInputFiles(...files: (string|input.FilePayload)[]) {
const multiple = await this.evaluate((element: HTMLInputElement) => !!element.multiple);
const multiple = await this.evaluate((node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE || (node as Element).tagName !== 'INPUT')
throw new Error('Node is not an HTMLInputElement');
const input = node as HTMLInputElement;
return input.multiple;
});
assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!');
await this._world.delegate.setInputFiles(this, await input.loadFiles(files));
}
async focus() {
await this.evaluate(element => element.focus());
const errorMessage = await this.evaluate((element: Node) => {
if (!element['focus'])
return 'Node is not an HTML or SVG element.';
(element as HTMLElement|SVGElement).focus();
return false;
});
if (errorMessage)
throw new Error(errorMessage);
}
async type(text: string, options: { delay: (number | undefined); } | undefined) {
@ -374,7 +390,7 @@ export class ElementHandle extends js.JSHandle {
return this._world.$(this._scopedSelector(selector));
}
$$(selector: string | types.Selector): Promise<ElementHandle[]> {
$$(selector: string | types.Selector): Promise<ElementHandle<Element>[]> {
return this._world.$$(this._scopedSelector(selector));
}
@ -386,12 +402,15 @@ export class ElementHandle extends js.JSHandle {
return this._world.$$eval(this._scopedSelector(selector), pageFunction, ...args as any);
}
$x(expression: string): Promise<ElementHandle[]> {
$x(expression: string): Promise<ElementHandle<Element>[]> {
return this._world.$$({ scope: this, selector: 'xpath=' + expression });
}
isIntersectingViewport(): Promise<boolean> {
return this.evaluate(async element => {
return this.evaluate(async (node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE)
throw new Error('Node is not of type HTMLElement');
const element = node as Element;
const visibleRatio = await new Promise(resolve => {
const observer = new IntersectionObserver(entries => {
resolve(entries[0].intersectionRatio);
@ -441,7 +460,7 @@ export function waitForFunctionTask(pageFunction: Function | string, options: ty
export function waitForSelectorTask(selector: string | types.Selector, timeout: number): Task {
return async (domWorld: DOMWorld) => {
const resolved = await domWorld.resolveSelector(selector);
return domWorld.context.evaluateHandle((injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined, timeout: number) => {
return domWorld.context.evaluateHandle((injected: Injected, selector: string, scope: Node | undefined, visible: boolean | undefined, timeout: number) => {
if (visible !== undefined)
return injected.pollRaf(predicate, timeout);
return injected.pollMutation(predicate, timeout);

View File

@ -134,7 +134,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
});
}
async handleJSONValue(handle: js.JSHandle): Promise<any> {
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
const payload = handle._remoteObject;
if (!payload.objectId)
return deserializeValue(payload);

View File

@ -22,6 +22,7 @@ import * as types from '../types';
import * as frames from '../frames';
import { JugglerSession } from './Connection';
import { FrameManager } from './FrameManager';
import { Protocol } from './protocol';
export class DOMWorldDelegate implements dom.DOMWorldDelegate {
readonly keyboard: input.Keyboard;
@ -101,13 +102,13 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
await handle.evaluate(input.setFileInputFunction, files);
}
async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise<dom.ElementHandle> {
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.DOMWorld): Promise<dom.ElementHandle<T>> {
assert(false, 'Multiple isolated worlds are not implemented');
return handle;
}
}
function toRemoteObject(handle: dom.ElementHandle): any {
function toRemoteObject(handle: dom.ElementHandle): Protocol.RemoteObject {
return handle._remoteObject;
}

View File

@ -122,12 +122,12 @@ export class Frame {
return context.evaluate(pageFunction, ...args as any);
}
async $(selector: string | types.Selector): Promise<dom.ElementHandle | null> {
async $(selector: string | types.Selector): Promise<dom.ElementHandle<Element> | null> {
const domWorld = await this._mainDOMWorld();
return domWorld.$(types.clearSelector(selector));
}
async $x(expression: string): Promise<dom.ElementHandle[]> {
async $x(expression: string): Promise<dom.ElementHandle<Element>[]> {
const domWorld = await this._mainDOMWorld();
return domWorld.$$('xpath=' + expression);
}
@ -142,7 +142,7 @@ export class Frame {
return domWorld.$$eval(selector, pageFunction, ...args as any);
}
async $$(selector: string | types.Selector): Promise<dom.ElementHandle[]> {
async $$(selector: string | types.Selector): Promise<dom.ElementHandle<Element>[]> {
const domWorld = await this._mainDOMWorld();
return domWorld.$$(types.clearSelector(selector));
}

View File

@ -17,9 +17,11 @@ class Injected {
this.engines.set(engine.name, engine);
}
querySelector(selector: string, root: SelectorRoot): Element | undefined {
querySelector(selector: string, root: Node): Element | undefined {
const parsed = this._parseSelector(selector);
let element = root;
if (!root["querySelector"])
throw new Error('Node is not queryable.');
let element = root as SelectorRoot;
for (const { engine, selector } of parsed) {
const next = engine.query((element as Element).shadowRoot || element, selector);
if (!next)
@ -29,9 +31,11 @@ class Injected {
return element as Element;
}
querySelectorAll(selector: string, root: SelectorRoot): Element[] {
querySelectorAll(selector: string, root: Node): Element[] {
const parsed = this._parseSelector(selector);
let set = new Set<SelectorRoot>([ root ]);
if (!root["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) {

View File

@ -287,9 +287,10 @@ export class Mouse {
}
}
export const selectFunction = (element: HTMLSelectElement, ...optionsToSelect: (Node | SelectOption)[]) => {
if (element.nodeName.toLowerCase() !== 'select')
export const selectFunction = (node: Node, ...optionsToSelect: (Node | SelectOption)[]) => {
if (node.nodeName.toLowerCase() !== 'select')
throw new Error('Element is not a <select> element.');
const element = node as HTMLSelectElement;
const options = Array.from(element.options);
element.value = undefined;
@ -315,9 +316,10 @@ export const selectFunction = (element: HTMLSelectElement, ...optionsToSelect: (
return options.filter(option => option.selected).map(option => option.value);
};
export const fillFunction = (element: HTMLElement) => {
if (element.nodeType !== Node.ELEMENT_NODE)
export const fillFunction = (node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
const element = node as HTMLElement;
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
const type = input.getAttribute('type') || '';

View File

@ -10,7 +10,7 @@ export interface ExecutionContextDelegate {
getProperties(handle: JSHandle): Promise<Map<string, JSHandle>>;
releaseHandle(handle: JSHandle): Promise<void>;
handleToString(handle: JSHandle, includeType: boolean): string;
handleJSONValue(handle: JSHandle): Promise<any>;
handleJSONValue<T>(handle: JSHandle<T>): Promise<T>;
}
export class ExecutionContext {
@ -38,7 +38,7 @@ export class ExecutionContext {
}
}
export class JSHandle {
export class JSHandle<T = any> {
readonly _context: ExecutionContext;
readonly _remoteObject: any;
_disposed = false;
@ -52,11 +52,11 @@ export class JSHandle {
return this._context;
}
evaluate: types.EvaluateOn = (pageFunction, ...args) => {
evaluate: types.EvaluateOn<T> = (pageFunction, ...args) => {
return this._context.evaluate(pageFunction, this, ...args);
}
evaluateHandle: types.EvaluateHandleOn = (pageFunction, ...args) => {
evaluateHandle: types.EvaluateHandleOn<T> = (pageFunction, ...args) => {
return this._context.evaluateHandle(pageFunction, this, ...args);
}
@ -76,7 +76,7 @@ export class JSHandle {
return this._context._delegate.getProperties(this);
}
jsonValue(): Promise<any> {
jsonValue(): Promise<T> {
return this._context._delegate.handleJSONValue(this);
}

View File

@ -3,17 +3,21 @@
import * as js from './javascript';
import { helper } from './helper';
import * as dom from './dom';
type Boxed<Args extends any[]> = { [Index in keyof Args]: Args[Index] | js.JSHandle };
type Boxed<Args extends any[]> = { [Index in keyof Args]: Args[Index] | js.JSHandle<Args[Index]> };
type PageFunction<Args extends any[], R = any> = string | ((...args: Args) => R | Promise<R>);
type PageFunctionOn<On, Args extends any[], R = any> = string | ((on: On, ...args: Args) => R | Promise<R>);
type Handle<T> = T extends Node ? dom.ElementHandle<T> : js.JSHandle<T>;
type ElementForSelector<T> = T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element;
export type Evaluate = <Args extends any[], R>(pageFunction: PageFunction<Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandle = <Args extends any[]>(pageFunction: PageFunction<Args>, ...args: Boxed<Args>) => Promise<js.JSHandle>;
export type $Eval<S = string | Selector> = <Args extends any[], R>(selector: S, pageFunction: PageFunctionOn<Element, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type $$Eval<S = string | Selector> = <Args extends any[], R>(selector: S, pageFunction: PageFunctionOn<Element[], Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateOn = <Args extends any[], R>(pageFunction: PageFunctionOn<any, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandleOn = <Args extends any[]>(pageFunction: PageFunctionOn<any, Args>, ...args: Boxed<Args>) => Promise<js.JSHandle>;
export type EvaluateHandle = <Args extends any[], R>(pageFunction: PageFunction<Args, R>, ...args: Boxed<Args>) => Promise<Handle<R>>;
export type $Eval<O = string | Selector> = <Args extends any[], R, S extends O>(selector: S, pageFunction: PageFunctionOn<ElementForSelector<S>, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type $$Eval<O = string | Selector> = <Args extends any[], R, S extends O>(selector: S, pageFunction: PageFunctionOn<ElementForSelector<S>[], Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateOn<T> = <Args extends any[], R>(pageFunction: PageFunctionOn<T, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandleOn<T> = <Args extends any[], R>(pageFunction: PageFunctionOn<T, Args, R>, ...args: Boxed<Args>) => Promise<Handle<R>>;
export type Rect = { x: number, y: number, width: number, height: number };
export type Point = { x: number, y: number };

View File

@ -289,7 +289,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
await releaseObject(this._session, toRemoteObject(handle));
}
async handleJSONValue(handle: js.JSHandle): Promise<any> {
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
const remoteObject = toRemoteObject(handle);
if (remoteObject.objectId) {
const response = await this._session.send('Runtime.callFunctionOn', {

View File

@ -98,7 +98,7 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
await this._client.send('DOM.setInputFiles', { objectId, files });
}
async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise<dom.ElementHandle> {
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.DOMWorld): Promise<dom.ElementHandle<T>> {
assert(false, 'Multiple isolated worlds are not implemented');
return handle;
}