Skip to content

Commit

Permalink
feat: make JSHandle generic (#140)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
JoelEinbinder authored Dec 6, 2019
1 parent e4fad11 commit 39b22b4
Show file tree
Hide file tree
Showing 14 changed files with 84 additions and 54 deletions.
2 changes: 1 addition & 1 deletion src/chromium/ExecutionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
4 changes: 2 additions & 2 deletions src/chromium/JSHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
6 changes: 3 additions & 3 deletions src/chromium/Page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/chromium/Playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
59 changes: 39 additions & 20 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -154,7 +154,7 @@ export class ElementHandle extends js.JSHandle {
this._world = context._domWorld;
}

asElement(): ElementHandle | null {
asElement(): ElementHandle<T> | null {
return this;
}

Expand All @@ -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;
}
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
}

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/firefox/ExecutionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/firefox/JSHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

6 changes: 3 additions & 3 deletions src/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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));
}
Expand Down
12 changes: 8 additions & 4 deletions src/injected/injected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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') || '';
Expand Down
10 changes: 5 additions & 5 deletions src/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -38,7 +38,7 @@ export class ExecutionContext {
}
}

export class JSHandle {
export class JSHandle<T = any> {
readonly _context: ExecutionContext;
readonly _remoteObject: any;
_disposed = false;
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand Down
Loading

0 comments on commit 39b22b4

Please sign in to comment.