From 12ac45861478be48d82612b3a7014a63aa9748ba Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 19 Dec 2019 15:19:22 -0800 Subject: [PATCH] fix(elementhandle): contentFrame and ownerFrame work in various scenarios (#311) Drive-by: use evaluateInUtility for various utility evals. --- src/chromium/FrameManager.ts | 26 ++++++- src/dom.ts | 48 ++++++------ src/firefox/FrameManager.ts | 12 ++- src/javascript.ts | 5 -- src/page.ts | 3 +- src/webkit/FrameManager.ts | 8 +- test/elementhandle.spec.js | 138 +++++++++++++++++++++++++++++++++++ test/waittask.spec.js | 6 +- 8 files changed, 207 insertions(+), 39 deletions(-) diff --git a/src/chromium/FrameManager.ts b/src/chromium/FrameManager.ts index 2e09a499e0..00b57dc113 100644 --- a/src/chromium/FrameManager.ts +++ b/src/chromium/FrameManager.ts @@ -198,7 +198,7 @@ export class FrameManager implements PageDelegate { if (!context) return; this._contextIdToContext.delete(executionContextId); - context.frame()._contextDestroyed(context); + context.frame._contextDestroyed(context); } _onExecutionContextsCleared() { @@ -368,11 +368,33 @@ export class FrameManager implements PageDelegate { const nodeInfo = await this._client.send('DOM.describeNode', { objectId: toRemoteObject(handle).objectId }); - if (typeof nodeInfo.node.frameId !== 'string') + if (!nodeInfo || typeof nodeInfo.node.frameId !== 'string') return null; return this._page._frameManager.frame(nodeInfo.node.frameId); } + async getOwnerFrame(handle: dom.ElementHandle): Promise { + // document.documentElement has frameId of the owner frame. + const documentElement = await handle.evaluateHandle(node => { + const doc = node as Document; + if (doc.documentElement && doc.documentElement.ownerDocument === doc) + return doc.documentElement; + return node.ownerDocument ? node.ownerDocument.documentElement : null; + }); + if (!documentElement) + return null; + const remoteObject = toRemoteObject(documentElement); + if (!remoteObject.objectId) + return null; + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: remoteObject.objectId + }); + const frame = nodeInfo && typeof nodeInfo.node.frameId === 'string' ? + this._page._frameManager.frame(nodeInfo.node.frameId) : null; + await documentElement.dispose(); + return frame; + } + isElementHandle(remoteObject: any): boolean { return (remoteObject as Protocol.Runtime.RemoteObject).subtype === 'node'; } diff --git a/src/dom.ts b/src/dom.ts index 3b536f82d1..a9d2fccbe3 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -12,17 +12,13 @@ import Injected from './injected/injected'; import { Page } from './page'; export class FrameExecutionContext extends js.ExecutionContext { - private readonly _frame: frames.Frame; + readonly frame: frames.Frame; private _injectedPromise?: Promise; constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) { super(delegate); - this._frame = frame; - } - - frame(): frames.Frame | null { - return this._frame; + this.frame = frame; } async _evaluate(returnByValue: boolean, pageFunction: string | Function, ...args: any[]): Promise { @@ -39,7 +35,7 @@ export class FrameExecutionContext extends js.ExecutionContext { const adopted = await Promise.all(args.map(async arg => { if (!needsAdoption(arg)) return arg; - const adopted = this._frame._page._delegate.adoptElementHandle(arg, this); + const adopted = this.frame._page._delegate.adoptElementHandle(arg, this); toDispose.push(adopted); return adopted; })); @@ -53,7 +49,7 @@ export class FrameExecutionContext extends js.ExecutionContext { } _createHandle(remoteObject: any): js.JSHandle | null { - if (this._frame._page._delegate.isElementHandle(remoteObject)) + if (this.frame._page._delegate.isElementHandle(remoteObject)) return new ElementHandle(this, remoteObject); return super._createHandle(remoteObject); } @@ -111,23 +107,31 @@ export class ElementHandle extends js.JSHandle { constructor(context: FrameExecutionContext, remoteObject: any) { super(context, remoteObject); - this._page = context.frame()._page; - } - - frame(): frames.Frame { - return this._context.frame(); + this._page = context.frame._page; } asElement(): ElementHandle | null { return this; } + _evaluateInUtility: types.EvaluateOn = async (pageFunction, ...args) => { + const utility = await this._context.frame._utilityContext(); + return utility.evaluate(pageFunction, this, ...args); + } + + async ownerFrame(): Promise { + return this._page._delegate.getOwnerFrame(this); + } + async contentFrame(): Promise { + const isFrameElement = await this._evaluateInUtility(node => node && (node instanceof HTMLIFrameElement || node instanceof HTMLFrameElement)); + if (!isFrameElement) + return null; return this._page._delegate.getContentFrame(this); } async _scrollIntoViewIfNeeded() { - const error = await this.evaluate(async (node: Node, pageJavascriptEnabled: boolean) => { + const error = await this._evaluateInUtility(async (node: Node, pageJavascriptEnabled: boolean) => { if (!node.isConnected) return 'Node is detached from document'; if (node.nodeType !== Node.ELEMENT_NODE) @@ -165,7 +169,7 @@ export class ElementHandle extends js.JSHandle { return this._clickablePoint(); let r = await this._viewportPointAndScroll(relativePoint); if (r.scrollX || r.scrollY) { - const error = await this.evaluate((element, scrollX, scrollY) => { + const error = await this._evaluateInUtility((element, scrollX, scrollY) => { if (!element.ownerDocument || !element.ownerDocument.defaultView) return 'Node does not have a containing window'; element.ownerDocument.defaultView.scrollBy(scrollX, scrollY); @@ -222,7 +226,7 @@ export class ElementHandle extends js.JSHandle { private async _viewportPointAndScroll(relativePoint: types.Point): Promise<{point: types.Point, scrollX: number, scrollY: number}> { const [box, border] = await Promise.all([ this.boundingBox(), - this.evaluate((node: Node) => { + this._evaluateInUtility((node: Node) => { if (node.nodeType !== Node.ELEMENT_NODE) return { x: 0, y: 0 }; const style = node.ownerDocument.defaultView.getComputedStyle(node as Element); @@ -292,19 +296,19 @@ export class ElementHandle extends js.JSHandle { 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); + return this._evaluateInUtility(input.selectFunction, ...options); } async fill(value: string): Promise { assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"'); - const error = await this.evaluate(input.fillFunction); + const error = await this._evaluateInUtility(input.fillFunction); if (error) throw new Error(error); await this._page.keyboard.sendCharacters(value); } - async setInputFiles(...files: (string|input.FilePayload)[]) { - const multiple = await this.evaluate((node: Node) => { + async setInputFiles(...files: (string | input.FilePayload)[]) { + const multiple = await this._evaluateInUtility((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; @@ -315,7 +319,7 @@ export class ElementHandle extends js.JSHandle { } async focus() { - const errorMessage = await this.evaluate((element: Node) => { + const errorMessage = await this._evaluateInUtility((element: Node) => { if (!element['focus']) return 'Node is not an HTML or SVG element.'; (element as HTMLElement|SVGElement).focus(); @@ -372,7 +376,7 @@ export class ElementHandle extends js.JSHandle { } isIntersectingViewport(): Promise { - return this.evaluate(async (node: Node) => { + return this._evaluateInUtility(async (node: Node) => { if (node.nodeType !== Node.ELEMENT_NODE) throw new Error('Node is not of type HTMLElement'); const element = node as Element; diff --git a/src/firefox/FrameManager.ts b/src/firefox/FrameManager.ts index 4a8ee48723..33d5fe9726 100644 --- a/src/firefox/FrameManager.ts +++ b/src/firefox/FrameManager.ts @@ -94,7 +94,7 @@ export class FrameManager implements PageDelegate { if (!context) return; this._contextIdToContext.delete(executionContextId); - context.frame()._contextDestroyed(context as dom.FrameExecutionContext); + context.frame._contextDestroyed(context as dom.FrameExecutionContext); } _onNavigationStarted() { @@ -237,7 +237,7 @@ export class FrameManager implements PageDelegate { } getBoundingBoxForScreenshot(handle: dom.ElementHandle): Promise { - const frameId = handle._context.frame()._id; + const frameId = handle._context.frame._id; return this._session.send('Page.getBoundingBox', { frameId, objectId: handle._remoteObject.objectId, @@ -268,7 +268,7 @@ export class FrameManager implements PageDelegate { async getContentFrame(handle: dom.ElementHandle): Promise { const { frameId } = await this._session.send('Page.contentFrame', { - frameId: handle._context.frame()._id, + frameId: handle._context.frame._id, objectId: toRemoteObject(handle).objectId, }); if (!frameId) @@ -276,6 +276,10 @@ export class FrameManager implements PageDelegate { return this._page._frameManager.frame(frameId); } + async getOwnerFrame(handle: dom.ElementHandle): Promise { + return handle._context.frame; + } + isElementHandle(remoteObject: any): boolean { return remoteObject.subtype === 'node'; } @@ -301,7 +305,7 @@ export class FrameManager implements PageDelegate { async getContentQuads(handle: dom.ElementHandle): Promise { const result = await this._session.send('Page.getContentQuads', { - frameId: handle._context.frame()._id, + frameId: handle._context.frame._id, objectId: toRemoteObject(handle).objectId, }).catch(debugError); if (!result) diff --git a/src/javascript.ts b/src/javascript.ts index 71b5d3207d..a1fcb00e73 100644 --- a/src/javascript.ts +++ b/src/javascript.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import * as frames from './frames'; import * as types from './types'; import * as dom from './dom'; @@ -20,10 +19,6 @@ export class ExecutionContext { this._delegate = delegate; } - frame(): frames.Frame | null { - return null; - } - _evaluate(returnByValue: boolean, pageFunction: string | Function, ...args: any[]): Promise { return this._delegate.evaluate(this, returnByValue, pageFunction, ...args); } diff --git a/src/page.ts b/src/page.ts index c8570a76c7..e47ed8557b 100644 --- a/src/page.ts +++ b/src/page.ts @@ -57,7 +57,8 @@ export interface PageDelegate { isElementHandle(remoteObject: any): boolean; adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise>; - getContentFrame(handle: dom.ElementHandle): Promise; + getContentFrame(handle: dom.ElementHandle): Promise; // Only called for frame owner elements. + getOwnerFrame(handle: dom.ElementHandle): Promise; getContentQuads(handle: dom.ElementHandle): Promise; layoutViewport(): Promise<{ width: number, height: number }>; setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise; diff --git a/src/webkit/FrameManager.ts b/src/webkit/FrameManager.ts index 80595dfe5a..5bb790b6e6 100644 --- a/src/webkit/FrameManager.ts +++ b/src/webkit/FrameManager.ts @@ -130,7 +130,7 @@ export class FrameManager implements PageDelegate { disconnectFromTarget() { for (const context of this._contextIdToContext.values()) { (context._delegate as ExecutionContextDelegate)._dispose(); - context.frame()._contextDestroyed(context); + context.frame._contextDestroyed(context); } this._contextIdToContext.clear(); } @@ -160,7 +160,7 @@ export class FrameManager implements PageDelegate { _onFrameNavigated(framePayload: Protocol.Page.Frame, initial: boolean) { const frame = this._page._frameManager.frame(framePayload.id); for (const [contextId, context] of this._contextIdToContext) { - if (context.frame() === frame) { + if (context.frame === frame) { (context._delegate as ExecutionContextDelegate)._dispose(); this._contextIdToContext.delete(contextId); frame._contextDestroyed(context); @@ -374,6 +374,10 @@ export class FrameManager implements PageDelegate { return this._page._frameManager.frame(nodeInfo.contentFrameId); } + async getOwnerFrame(handle: dom.ElementHandle): Promise { + return handle._context.frame; + } + isElementHandle(remoteObject: any): boolean { return (remoteObject as Protocol.Runtime.RemoteObject).subtype === 'node'; } diff --git a/test/elementhandle.spec.js b/test/elementhandle.spec.js index 77b13896b5..e92ebf7254 100644 --- a/test/elementhandle.spec.js +++ b/test/elementhandle.spec.js @@ -75,6 +75,104 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { const frame = await elementHandle.contentFrame(); expect(frame).toBe(page.frames()[1]); }); + it('should work for cross-process iframes', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.CROSS_PROCESS_PREFIX + '/empty.html'); + const elementHandle = await page.$('#frame1'); + const frame = await elementHandle.contentFrame(); + expect(frame).toBe(page.frames()[1]); + }); + it('should work for cross-frame evaluations', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + const elementHandle = await frame.evaluateHandle(() => window.top.document.querySelector('#frame1')); + expect(await elementHandle.contentFrame()).toBe(frame); + }); + it('should return null for non-iframes', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + const elementHandle = await frame.evaluateHandle(() => document.body); + expect(await elementHandle.contentFrame()).toBe(null); + }); + it('should return null for document.documentElement', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + const elementHandle = await frame.evaluateHandle(() => document.documentElement); + expect(await elementHandle.contentFrame()).toBe(null); + }); + }); + + describe('ElementHandle.ownerFrame', function() { + it('should work', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + const elementHandle = await frame.evaluateHandle(() => document.body); + expect(await elementHandle.ownerFrame()).toBe(frame); + }); + it('should work for cross-process iframes', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.CROSS_PROCESS_PREFIX + '/empty.html'); + const frame = page.frames()[1]; + const elementHandle = await frame.evaluateHandle(() => document.body); + expect(await elementHandle.ownerFrame()).toBe(frame); + }); + it('should work for document', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + const elementHandle = await frame.evaluateHandle(() => document); + expect(await elementHandle.ownerFrame()).toBe(frame); + }); + it('should work for iframe elements', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.mainFrame(); + const elementHandle = await frame.evaluateHandle(() => document.querySelector('#frame1')); + expect(await elementHandle.ownerFrame()).toBe(frame); + }); + it.skip(FFOX || WEBKIT)('should work for cross-frame evaluations', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.mainFrame(); + const elementHandle = await frame.evaluateHandle(() => document.querySelector('#frame1').contentWindow.document.body); + expect(await elementHandle.ownerFrame()).toBe(frame.childFrames()[0]); + }); + it('should work for detached elements', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + const divHandle = await page.evaluateHandle(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + return div; + }); + expect(await divHandle.ownerFrame()).toBe(page.mainFrame()); + await page.evaluate(() => { + const div = document.querySelector('div'); + document.body.removeChild(div); + }); + expect(await divHandle.ownerFrame()).toBe(page.mainFrame()); + }); + xit('should work for adopted elements', async({page,server}) => { + await page.goto(server.EMPTY_PAGE); + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.evaluate(url => window.__popup = window.open(url), server.EMPTY_PAGE), + ]); + const divHandle = await page.evaluateHandle(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + return div; + }); + expect(await divHandle.ownerFrame()).toBe(page.mainFrame()); + await page.evaluate(() => { + const div = document.querySelector('div'); + window.__popup.document.body.appendChild(div); + }); + expect(await divHandle.ownerFrame()).toBe(popup.mainFrame()); + }); }); describe('ElementHandle.click', function() { @@ -84,6 +182,13 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { await button.click(); expect(await page.evaluate(() => result)).toBe('Clicked'); }); + it.skip(FFOX)('should work with Node removed', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => delete window['Node']); + const button = await page.$('button'); + await button.click(); + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); it('should work for Shadow DOM v1', async({page, server}) => { await page.goto(server.PREFIX + '/shadow.html'); const buttonHandle = await page.evaluateHandle(() => button); @@ -134,6 +239,13 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { await button.hover(); expect(await page.evaluate(() => document.querySelector('button:hover').id)).toBe('button-6'); }); + it.skip(FFOX)('should work when Node is removed', async({page, server}) => { + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.evaluate(() => delete window['Node']); + const button = await page.$('#button-6'); + await button.hover(); + expect(await page.evaluate(() => document.querySelector('button:hover').id)).toBe('button-6'); + }); }); describe('ElementHandle.isIntersectingViewport', function() { @@ -146,5 +258,31 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { expect(await button.isIntersectingViewport()).toBe(visible); } }); + it.skip(FFOX)('should work when Node is removed', async({page, server}) => { + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + await page.evaluate(() => delete window['Node']); + for (let i = 0; i < 11; ++i) { + const button = await page.$('#btn' + i); + // All but last button are visible. + const visible = i < 10; + expect(await button.isIntersectingViewport()).toBe(visible); + } + }); + }); + + describe('ElementHandle.fill', function() { + it('should fill input', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + const handle = await page.$('input'); + await handle.fill('some value'); + expect(await page.evaluate(() => result)).toBe('some value'); + }); + it.skip(FFOX)('should fill input when Node is removed', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.evaluate(() => delete window['Node']); + const handle = await page.$('input'); + await handle.fill('some value'); + expect(await page.evaluate(() => result)).toBe('some value'); + }); }); }; diff --git a/test/waittask.spec.js b/test/waittask.spec.js index f2671f0de6..382a8994c8 100644 --- a/test/waittask.spec.js +++ b/test/waittask.spec.js @@ -282,7 +282,7 @@ module.exports.describe = function({testRunner, expect, product, playwright, FFO await otherFrame.evaluate(addElement, 'div'); await page.evaluate(addElement, 'div'); const eHandle = await watchdog; - expect(eHandle.frame()).toBe(page.mainFrame()); + expect(await eHandle.ownerFrame()).toBe(page.mainFrame()); }); it('should run in specified frame', async({page, server}) => { @@ -294,7 +294,7 @@ module.exports.describe = function({testRunner, expect, product, playwright, FFO await frame1.evaluate(addElement, 'div'); await frame2.evaluate(addElement, 'div'); const eHandle = await waitForSelectorPromise; - expect(eHandle.frame()).toBe(frame2); + expect(await eHandle.ownerFrame()).toBe(frame2); }); it('should throw when frame is detached', async({page, server}) => { @@ -473,7 +473,7 @@ module.exports.describe = function({testRunner, expect, product, playwright, FFO await frame1.evaluate(addElement, 'div'); await frame2.evaluate(addElement, 'div'); const eHandle = await waitForXPathPromise; - expect(eHandle.frame()).toBe(frame2); + expect(await eHandle.ownerFrame()).toBe(frame2); }); it('should throw when frame is detached', async({page, server}) => { await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE);