fix(elementhandle): contentFrame and ownerFrame work in various scenarios (#311)

Drive-by: use evaluateInUtility for various utility evals.
This commit is contained in:
Dmitry Gozman 2019-12-19 15:19:22 -08:00 committed by Pavel Feldman
parent 7e90292834
commit 12ac458614
8 changed files with 207 additions and 39 deletions

View File

@ -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<frames.Frame | null> {
// 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';
}

View File

@ -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<js.JSHandle>;
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<any> {
@ -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<T extends Node = Node> extends js.JSHandle<T> {
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<T> | null {
return this;
}
_evaluateInUtility: types.EvaluateOn<T> = async (pageFunction, ...args) => {
const utility = await this._context.frame._utilityContext();
return utility.evaluate(pageFunction, this, ...args);
}
async ownerFrame(): Promise<frames.Frame | null> {
return this._page._delegate.getOwnerFrame(this);
}
async contentFrame(): Promise<frames.Frame | null> {
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<T extends Node = Node> extends js.JSHandle<T> {
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<T extends Node = Node> extends js.JSHandle<T> {
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<T extends Node = Node> extends js.JSHandle<T> {
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<void> {
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<T extends Node = Node> extends js.JSHandle<T> {
}
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<T extends Node = Node> extends js.JSHandle<T> {
}
isIntersectingViewport(): Promise<boolean> {
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;

View File

@ -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<Node>): Promise<types.Rect | null> {
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<frames.Frame | null> {
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<frames.Frame | null> {
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<types.Quad[] | null> {
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)

View File

@ -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<any> {
return this._delegate.evaluate(this, returnByValue, pageFunction, ...args);
}

View File

@ -57,7 +57,8 @@ export interface PageDelegate {
isElementHandle(remoteObject: any): boolean;
adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>>;
getContentFrame(handle: dom.ElementHandle): Promise<frames.Frame | null>;
getContentFrame(handle: dom.ElementHandle): Promise<frames.Frame | null>; // Only called for frame owner elements.
getOwnerFrame(handle: dom.ElementHandle): Promise<frames.Frame | null>;
getContentQuads(handle: dom.ElementHandle): Promise<types.Quad[] | null>;
layoutViewport(): Promise<{ width: number, height: number }>;
setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise<void>;

View File

@ -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<frames.Frame | null> {
return handle._context.frame;
}
isElementHandle(remoteObject: any): boolean {
return (remoteObject as Protocol.Runtime.RemoteObject).subtype === 'node';
}

View File

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

View File

@ -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);