feat(frame-selector): intial implementation (#10018)

This commit is contained in:
Pavel Feldman 2021-11-04 12:28:35 -08:00 committed by GitHub
parent 9b4d9460eb
commit 78e99249a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 291 additions and 44 deletions

View file

@ -83,19 +83,19 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameInitializer
}
async evalOnSelector(params: channels.FrameEvalOnSelectorParams, metadata: CallMetadata): Promise<channels.FrameEvalOnSelectorResult> {
return { value: serializeResult(await this._frame.evalOnSelectorAndWaitForSignals(params.selector, !!params.strict, params.expression, params.isFunction, parseArgument(params.arg))) };
return { value: serializeResult(await this._frame.evalOnSelectorAndWaitForSignals(metadata, params.selector, !!params.strict, params.expression, params.isFunction, parseArgument(params.arg))) };
}
async evalOnSelectorAll(params: channels.FrameEvalOnSelectorAllParams, metadata: CallMetadata): Promise<channels.FrameEvalOnSelectorAllResult> {
return { value: serializeResult(await this._frame.evalOnSelectorAllAndWaitForSignals(params.selector, params.expression, params.isFunction, parseArgument(params.arg))) };
return { value: serializeResult(await this._frame.evalOnSelectorAllAndWaitForSignals(metadata, params.selector, params.expression, params.isFunction, parseArgument(params.arg))) };
}
async querySelector(params: channels.FrameQuerySelectorParams, metadata: CallMetadata): Promise<channels.FrameQuerySelectorResult> {
return { element: ElementHandleDispatcher.fromNullable(this._scope, await this._frame.querySelector(params.selector, params)) };
return { element: ElementHandleDispatcher.fromNullable(this._scope, await this._frame.querySelector(metadata, params.selector, params)) };
}
async querySelectorAll(params: channels.FrameQuerySelectorAllParams, metadata: CallMetadata): Promise<channels.FrameQuerySelectorAllResult> {
const elements = await this._frame.querySelectorAll(params.selector);
const elements = await this._frame.querySelectorAll(metadata, params.selector);
return { elements: elements.map(e => ElementHandleDispatcher.from(this._scope, e)) };
}

View file

@ -55,7 +55,37 @@ export function parseSelector(selector: string): ParsedSelector {
};
}
export function stringifySelector(selector: ParsedSelector): string {
export function splitSelectorByFrame(selectorText: string): ParsedSelector[] {
const selector = parseSelector(selectorText);
const result: ParsedSelector[] = [];
let chunk: ParsedSelector = {
parts: [],
};
let chunkStartIndex = 0;
for (let i = 0; i < selector.parts.length; ++i) {
const part = selector.parts[i];
if (part.name === 'content-frame') {
result.push(chunk);
chunk = { parts: [] };
chunkStartIndex = i + 1;
continue;
}
if (selector.capture === i)
chunk.capture = i - chunkStartIndex;
chunk.parts.push(part);
}
if (!chunk.parts.length)
throw new Error(`Selector cannot end with "content-frame", while parsing selector ${selectorText}`);
result.push(chunk);
if (typeof selector.capture === 'number' && typeof result[result.length - 1].capture !== 'number')
throw new Error(`Can not capture the selector before diving into the frame. Only use * after the last "content-frame"`);
return result;
}
export function stringifySelector(selector: string | ParsedSelector): string {
if (typeof selector === 'string')
return selector;
return selector.parts.map((p, i) => `${i === selector.capture ? '*' : ''}${p.name}=${p.source}`).join(' >> ');
}

View file

@ -33,6 +33,8 @@ import { CallMetadata, internalCallMetadata, SdkObject } from './instrumentation
import type InjectedScript from './injected/injectedScript';
import type { ElementStateWithoutStable, FrameExpectParams, InjectedScriptPoll, InjectedScriptProgress } from './injected/injectedScript';
import { isSessionClosedError } from './common/protocolError';
import { splitSelectorByFrame, stringifySelector } from './common/selectorParser';
import { SelectorInfo } from './selectors';
type ContextData = {
contextPromise: ManualPromise<dom.FrameExecutionContext | Error>;
@ -704,9 +706,15 @@ export class Frame extends SdkObject {
return value;
}
async querySelector(selector: string, options: types.StrictOptions): Promise<dom.ElementHandle<Element> | null> {
async querySelector(metadata: CallMetadata, selector: string, options: types.StrictOptions): Promise<dom.ElementHandle<Element> | null> {
debugLogger.log('api', ` finding element using the selector "${selector}"`);
return this._page.selectors.query(this, selector, options);
const controller = new ProgressController(metadata, this);
return controller.run(progress => this._innerQuerySelector(progress, selector, options));
}
private async _innerQuerySelector(progress: Progress, selector: string, options: types.StrictOptions): Promise<dom.ElementHandle<Element> | null> {
const { frame, info } = await this._resolveFrame(progress, selector, options);
return this._page.selectors.query(frame, info, options);
}
async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions & { omitReturnValue?: boolean } = {}): Promise<dom.ElementHandle<Element> | null> {
@ -718,12 +726,12 @@ export class Frame extends SdkObject {
const { state = 'visible' } = options;
if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
throw new Error(`state: expected one of (attached|detached|visible|hidden)`);
const info = this._page.parseSelector(selector, options);
const task = dom.waitForSelectorTask(info, state, options.omitReturnValue);
return controller.run(async progress => {
progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
while (progress.isRunning()) {
const result = await this._scheduleRerunnableHandleTask(progress, info.world, task);
const { frame, info } = await this._resolveFrame(progress, selector, options);
const task = dom.waitForSelectorTask(info, state, options.omitReturnValue);
const result = await frame._scheduleRerunnableHandleTask(progress, info.world, task);
if (!result.asElement()) {
result.dispose();
return null;
@ -752,24 +760,35 @@ export class Frame extends SdkObject {
await this._page._doSlowMo();
}
async evalOnSelectorAndWaitForSignals(selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const handle = await this.querySelector(selector, { strict });
if (!handle)
throw new Error(`Error: failed to find element matching selector "${selector}"`);
const result = await handle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
handle.dispose();
return result;
async evalOnSelectorAndWaitForSignals(metadata: CallMetadata, selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
const handle = await this._innerQuerySelector(progress, selector, { strict });
if (!handle)
throw new Error(`Error: failed to find element matching selector "${selector}"`);
const result = await handle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
handle.dispose();
return result;
});
}
async evalOnSelectorAllAndWaitForSignals(selector: string, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const arrayHandle = await this._page.selectors._queryArray(this, selector);
const result = await arrayHandle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
arrayHandle.dispose();
return result;
async evalOnSelectorAllAndWaitForSignals(metadata: CallMetadata, selector: string, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
const { frame, info } = await this._resolveFrame(progress, selector, {});
const arrayHandle = await this._page.selectors._queryArray(frame, info);
const result = await arrayHandle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
arrayHandle.dispose();
return result;
});
}
async querySelectorAll(selector: string): Promise<dom.ElementHandle<Element>[]> {
return this._page.selectors._queryAll(this, selector, undefined, true /* adoptToMain */);
async querySelectorAll(metadata: CallMetadata, selector: string): Promise<dom.ElementHandle<Element>[]> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
const { frame, info } = await this._resolveFrame(progress, selector, {});
return this._page.selectors._queryAll(frame, info, undefined, true /* adoptToMain */);
});
}
async content(): Promise<string> {
@ -954,11 +973,11 @@ export class Frame extends SdkObject {
selector: string,
strict: boolean | undefined,
action: (handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> {
const info = this._page.parseSelector(selector, { strict });
while (progress.isRunning()) {
progress.log(`waiting for selector "${selector}"`);
const { frame, info } = await this._resolveFrame(progress, selector, { strict });
const task = dom.waitForSelectorTask(info, 'attached');
const handle = await this._scheduleRerunnableHandleTask(progress, info.world, task);
const handle = await frame._scheduleRerunnableHandleTask(progress, info.world, task);
const element = handle.asElement() as dom.ElementHandle<Element>;
progress.cleanupWhenAborted(() => {
// Do not await here to avoid being blocked, either by stalled
@ -1078,7 +1097,7 @@ export class Frame extends SdkObject {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
progress.log(` checking visibility of "${selector}"`);
const element = await this.querySelector(selector, options);
const element = await this._innerQuerySelector(progress, selector, options);
return element ? await element.isVisible() : false;
}, this._page._timeoutSettings.timeout({}));
}
@ -1276,12 +1295,35 @@ export class Frame extends SdkObject {
taskData: T,
options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean, querySelectorAll?: boolean, logScale?: boolean, omitAttached?: boolean } = {}): Promise<R> {
const info = this._page.parseSelector(selector, options);
const callbackText = body.toString();
const data = this._contextData.get(options.mainWorld ? 'main' : info.world)!;
return controller.run(async progress => {
progress.log(`waiting for selector "${selector}"`);
while (progress.isRunning()) {
progress.log(`waiting for selector "${selector}"`);
const { frame, info } = await this._resolveFrame(progress, selector, options);
try {
return await frame._scheduleRerunnableTaskInFrame(progress, info, callbackText, taskData, options);
} catch (e) {
if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e))
throw e;
continue;
}
}
return undefined as any;
}, this._page._timeoutSettings.timeout(options));
}
private async _scheduleRerunnableTaskInFrame<T, R>(
progress: Progress,
info: SelectorInfo,
callbackText: string,
taskData: T,
options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean, querySelectorAll?: boolean, logScale?: boolean, omitAttached?: boolean }): Promise<R> {
if (!progress.isRunning())
progress.throwIfAborted();
const data = this._contextData.get(options.mainWorld ? 'main' : info!.world)!;
{
const rerunnableTask = new RerunnableTask<R>(data, progress, injectedScript => {
return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale, omitAttached, snapshotName }) => {
const callback = injected.eval(callbackText) as DomTaskBody<T, R, Element | undefined>;
@ -1321,13 +1363,12 @@ export class Frame extends SdkObject {
});
}, { info, taskData, callbackText, querySelectorAll: options.querySelectorAll, logScale: options.logScale, omitAttached: options.omitAttached, snapshotName: progress.metadata.afterSnapshot });
}, true);
if (this._detached)
rerunnableTask.terminate(new Error('Frame got detached.'));
if (data.context)
rerunnableTask.rerun(data.context);
return await rerunnableTask.promise!;
}, this._page._timeoutSettings.timeout(options));
}
}
private _scheduleRerunnableHandleTask<T>(progress: Progress, world: types.World, task: dom.SchedulableTask<T>): Promise<js.SmartHandle<T>> {
@ -1399,6 +1440,33 @@ export class Frame extends SdkObject {
return injectedScript.extend(source, arg);
}, { source, arg });
}
private async _resolveFrame(progress: Progress, selector: string, options: types.StrictOptions & types.TimeoutOptions): Promise<{ frame: Frame, info: SelectorInfo }> {
const elementPath: dom.ElementHandle<Element>[] = [];
progress.cleanupWhenAborted(() => {
// Do not await here to avoid being blocked, either by stalled
// page (e.g. alert) or unresolved navigation in Chromium.
for (const element of elementPath)
element.dispose();
});
let frame: Frame = this;
const frameChunks = splitSelectorByFrame(selector);
for (let i = 0; i < frameChunks.length - 1 && progress.isRunning(); ++i) {
const info = this._page.parseSelector(frameChunks[i], options);
const task = dom.waitForSelectorTask(info, 'attached');
const handle = await frame._scheduleRerunnableHandleTask(progress, info.world, task);
const element = handle.asElement() as dom.ElementHandle<Element>;
if (i < frameChunks.length - 1) {
frame = (await element.contentFrame())!;
element.dispose();
if (!frame)
throw new Error(`Selector "${stringifySelector(info.parsed)}" resolved to ${element.preview()}, <iframe> was expected`);
}
}
return { frame, info: this._page.parseSelector(frameChunks[frameChunks.length - 1], options) };
}
}
class RerunnableTask<T> {
@ -1509,3 +1577,4 @@ function verifyLifecycle(name: string, waitUntil: types.LifecycleEvent): types.L
throw new Error(`${name}: expected one of (load|domcontentloaded|networkidle|commit)`);
return waitUntil;
}

View file

@ -34,6 +34,7 @@ import { debugLogger } from '../utils/debugLogger';
import { SelectorInfo, Selectors } from './selectors';
import { CallMetadata, SdkObject } from './instrumentation';
import { Artifact } from './artifact';
import { ParsedSelector } from './common/selectorParser';
export interface PageDelegate {
readonly rawMouse: input.RawMouse;
@ -515,7 +516,7 @@ export class Page extends SdkObject {
this.emit(Page.Events.PageError, error);
}
parseSelector(selector: string, options?: types.StrictOptions): SelectorInfo {
parseSelector(selector: string | ParsedSelector, options?: types.StrictOptions): SelectorInfo {
const strict = typeof options?.strict === 'boolean' ? options.strict : !!this.context()._options.strictSelectors;
return this.selectors.parseSelector(selector, strict);
}

View file

@ -18,7 +18,7 @@ import * as dom from './dom';
import * as frames from './frames';
import * as js from './javascript';
import * as types from './types';
import { ParsedSelector, parseSelector } from './common/selectorParser';
import { ParsedSelector, parseSelector, stringifySelector } from './common/selectorParser';
import { createGuid } from '../utils/utils';
export type SelectorInfo = {
@ -44,7 +44,7 @@ export class Selectors {
'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light',
'data-test', 'data-test:light',
'nth', 'visible'
'nth', 'visible', 'content-frame'
]);
this._builtinEnginesInMainWorld = new Set([
'_react', '_vue',
@ -67,8 +67,8 @@ export class Selectors {
this._engines.clear();
}
async query(frame: frames.Frame, selector: string, options: { strict?: boolean }, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
const info = frame._page.parseSelector(selector, options);
async query(frame: frames.Frame, selector: string | SelectorInfo, options: { strict?: boolean }, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
const info = typeof selector === 'string' ? frame._page.parseSelector(selector, options) : selector;
const context = await frame._context(info.world);
const injectedScript = await context.injectedScript();
const handle = await injectedScript.evaluateHandle((injected, { parsed, scope, strict }) => {
@ -83,8 +83,8 @@ export class Selectors {
return this._adoptIfNeeded(elementHandle, mainContext);
}
async _queryArray(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<js.JSHandle<Element[]>> {
const info = this.parseSelector(selector, false);
async _queryArray(frame: frames.Frame, selector: string | SelectorInfo, scope?: dom.ElementHandle): Promise<js.JSHandle<Element[]>> {
const info = typeof selector === 'string' ? this.parseSelector(selector, false) : selector;
const context = await frame._mainContext();
const injectedScript = await context.injectedScript();
const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
@ -93,8 +93,8 @@ export class Selectors {
return arrayHandle;
}
async _queryAll(frame: frames.Frame, selector: string, scope?: dom.ElementHandle, adoptToMain?: boolean): Promise<dom.ElementHandle<Element>[]> {
const info = this.parseSelector(selector, false);
async _queryAll(frame: frames.Frame, selector: string | SelectorInfo, scope?: dom.ElementHandle, adoptToMain?: boolean): Promise<dom.ElementHandle<Element>[]> {
const info = typeof selector === 'string' ? frame._page.parseSelector(selector) : selector;
const context = await frame._context(info.world);
const injectedScript = await context.injectedScript();
const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
@ -126,13 +126,13 @@ export class Selectors {
return adopted;
}
parseSelector(selector: string, strict: boolean): SelectorInfo {
const parsed = parseSelector(selector);
parseSelector(selector: string | ParsedSelector, strict: boolean): SelectorInfo {
const parsed = typeof selector === 'string' ? parseSelector(selector) : selector;
let needsMainWorld = false;
for (const part of parsed.parts) {
const custom = this._engines.get(part.name);
if (!custom && !this._builtinEngines.has(part.name))
throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`);
throw new Error(`Unknown engine "${part.name}" while parsing selector ${stringifySelector(parsed)}`);
if (custom && !custom.contentScript)
needsMainWorld = true;
if (this._builtinEnginesInMainWorld.has(part.name))

View file

@ -0,0 +1,147 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Page } from 'playwright-core';
import { test as it, expect } from './pageTest';
async function routeIframe(page: Page) {
await page.route('**/empty.html', route => {
route.fulfill({
body: '<iframe src="iframe.html"></iframe>',
contentType: 'text/html'
}).catch(() => {});
});
await page.route('**/iframe.html', route => {
route.fulfill({
body: `
<html>
<div>
<button>Hello iframe</button>
<iframe src="iframe-2.html"></iframe>
</div>
<span>1</span>
<span>2</span>
</html>`,
contentType: 'text/html'
}).catch(() => {});
});
await page.route('**/iframe-2.html', route => {
route.fulfill({
body: '<html><button>Hello nested iframe</button></html>',
contentType: 'text/html'
}).catch(() => {});
});
}
it('should work in iframe', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true >> button');
await button.waitFor();
expect(await button.innerText()).toBe('Hello iframe');
await expect(button).toHaveText('Hello iframe');
await button.click();
});
it('should work in nested iframe', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true >> iframe >> content-frame=true >> button');
await button.waitFor();
expect(await button.innerText()).toBe('Hello nested iframe');
await expect(button).toHaveText('Hello nested iframe');
await button.click();
});
it('should work for $ and $$', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const element = await page.$('iframe >> content-frame=true >> button');
expect(await element.textContent()).toBe('Hello iframe');
const elements = await page.$$('iframe >> content-frame=true >> span');
expect(elements).toHaveLength(2);
});
it('should work for $eval', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const value = await page.$eval('iframe >> content-frame=true >> button', b => b.nodeName);
expect(value).toBe('BUTTON');
});
it('should work for $$eval', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const value = await page.$$eval('iframe >> content-frame=true >> span', ss => ss.map(s => s.textContent));
expect(value).toEqual(['1', '2']);
});
it('should not allow dangling content-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true');
const error = await button.click().catch(e => e);
expect(error.message).toContain('Selector cannot end with');
expect(error.message).toContain('iframe >> content-frame=true');
});
it('should not allow capturing before content-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('*css=iframe >> content-frame=true >> div');
const error = await await button.click().catch(e => e);
expect(error.message).toContain('Can not capture the selector before diving into the frame');
});
it('should capture after the content-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const div = page.locator('iframe >> content-frame=true >> *css=div >> button');
expect(await div.innerHTML()).toContain('<button>');
});
it('should click in lazy iframe', async ({ page, server }) => {
await page.route('**/iframe.html', route => {
route.fulfill({
body: '<html><button>Hello iframe</button></html>',
contentType: 'text/html'
}).catch(() => {});
});
// empty pge
await page.goto(server.EMPTY_PAGE);
// add blank iframe
setTimeout(() => {
page.evaluate(() => {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
});
// navigate iframe
setTimeout(() => {
page.evaluate(() => document.querySelector('iframe').src = 'iframe.html');
}, 500);
}, 500);
// Click in iframe
const button = page.locator('iframe >> content-frame=true >> button');
const [, text] = await Promise.all([
button.click(),
button.innerText(),
expect(button).toHaveText('Hello iframe')
]);
expect(text).toBe('Hello iframe');
});