From 39b22b41c5b4d2286b8bde859d0183a692a9d614 Mon Sep 17 00:00:00 2001 From: Joel Einbinder Date: Thu, 5 Dec 2019 16:26:09 -0800 Subject: [PATCH] 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 --- src/chromium/ExecutionContext.ts | 2 +- src/chromium/JSHandle.ts | 4 +-- src/chromium/Page.ts | 6 ++-- src/chromium/Playwright.ts | 2 +- src/dom.ts | 59 +++++++++++++++++++++----------- src/firefox/ExecutionContext.ts | 2 +- src/firefox/JSHandle.ts | 5 +-- src/frames.ts | 6 ++-- src/injected/injected.ts | 12 ++++--- src/input.ts | 10 +++--- src/javascript.ts | 10 +++--- src/types.ts | 16 +++++---- src/webkit/ExecutionContext.ts | 2 +- src/webkit/JSHandle.ts | 2 +- 14 files changed, 84 insertions(+), 54 deletions(-) diff --git a/src/chromium/ExecutionContext.ts b/src/chromium/ExecutionContext.ts index 89d69a952d..12a4443a48 100644 --- a/src/chromium/ExecutionContext.ts +++ b/src/chromium/ExecutionContext.ts @@ -149,7 +149,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { await releaseObject(this._client, toRemoteObject(handle)); } - async handleJSONValue(handle: js.JSHandle): Promise { + async handleJSONValue(handle: js.JSHandle): Promise { const remoteObject = toRemoteObject(handle); if (remoteObject.objectId) { const response = await this._client.send('Runtime.callFunctionOn', { diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index 8163f5c3f4..8551557d97 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -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 { + async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise> { 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>; } async adoptBackendNodeId(backendNodeId: Protocol.DOM.BackendNodeId, to: dom.DOMWorld): Promise { diff --git a/src/chromium/Page.ts b/src/chromium/Page.ts index a9a68ad8d6..c6ab4d6a6b 100644 --- a/src/chromium/Page.ts +++ b/src/chromium/Page.ts @@ -223,7 +223,7 @@ export class Page extends EventEmitter { this._timeoutSettings.setDefaultTimeout(timeout); } - async $(selector: string | types.Selector): Promise { + async $(selector: string | types.Selector): Promise | 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 { + async $$(selector: string | types.Selector): Promise[]> { return this.mainFrame().$$(selector); } - async $x(expression: string): Promise { + async $x(expression: string): Promise[]> { return this.mainFrame().$x(expression); } diff --git a/src/chromium/Playwright.ts b/src/chromium/Playwright.ts index 076af17086..b9e22f27c5 100644 --- a/src/chromium/Playwright.ts +++ b/src/chromium/Playwright.ts @@ -35,7 +35,7 @@ export class Playwright { this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium'); } - launch(options: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise { + launch(options?: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise { return this._launcher.launch(options); } diff --git a/src/dom.ts b/src/dom.ts index 458ecb53c0..17bb39404c 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -24,10 +24,10 @@ export interface DOMWorldDelegate { boundingBox(handle: ElementHandle): Promise; screenshot(handle: ElementHandle, options?: types.ScreenshotOptions): Promise; setInputFiles(handle: ElementHandle, files: input.FilePayload[]): Promise; - adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise; + adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise>; } -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 { + async adoptElementHandle(handle: ElementHandle): Promise> { 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 { + async $(selector: string | ScopedSelector): Promise | 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 { + async $$(selector: string | ScopedSelector): Promise[]> { 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 = 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 extends js.JSHandle { 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 | 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 { + $$(selector: string | types.Selector): Promise[]> { 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 { + $x(expression: string): Promise[]> { return this._world.$$({ scope: this, selector: 'xpath=' + expression }); } isIntersectingViewport(): Promise { - 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); diff --git a/src/firefox/ExecutionContext.ts b/src/firefox/ExecutionContext.ts index 19ed5058db..cdb61b750e 100644 --- a/src/firefox/ExecutionContext.ts +++ b/src/firefox/ExecutionContext.ts @@ -134,7 +134,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { }); } - async handleJSONValue(handle: js.JSHandle): Promise { + async handleJSONValue(handle: js.JSHandle): Promise { const payload = handle._remoteObject; if (!payload.objectId) return deserializeValue(payload); diff --git a/src/firefox/JSHandle.ts b/src/firefox/JSHandle.ts index c3e6f10fcc..139667a9c8 100644 --- a/src/firefox/JSHandle.ts +++ b/src/firefox/JSHandle.ts @@ -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 { + async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise> { 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; } diff --git a/src/frames.ts b/src/frames.ts index fefe473a21..36ed432d70 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -122,12 +122,12 @@ export class Frame { return context.evaluate(pageFunction, ...args as any); } - async $(selector: string | types.Selector): Promise { + async $(selector: string | types.Selector): Promise | null> { const domWorld = await this._mainDOMWorld(); return domWorld.$(types.clearSelector(selector)); } - async $x(expression: string): Promise { + async $x(expression: string): Promise[]> { 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 { + async $$(selector: string | types.Selector): Promise[]> { const domWorld = await this._mainDOMWorld(); return domWorld.$$(types.clearSelector(selector)); } diff --git a/src/injected/injected.ts b/src/injected/injected.ts index 7f46facc3b..046ffa3169 100644 --- a/src/injected/injected.ts +++ b/src/injected/injected.ts @@ -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([ root ]); + if (!root["querySelectorAll"]) + throw new Error('Node is not queryable.'); + let set = new Set([ root as SelectorRoot ]); for (const { engine, selector } of parsed) { const newSet = new Set(); for (const prev of set) { diff --git a/src/input.ts b/src/input.ts index 5e4e2885e3..829886ded1 100644 --- a/src/input.ts +++ b/src/input.ts @@ -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