feat(iframe): make iframe selectors work w/ element handles (#10063)

This commit is contained in:
Pavel Feldman 2021-11-05 10:06:04 -08:00 committed by GitHub
parent 729ebe49c7
commit f19864890f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 266 additions and 118 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> { async evalOnSelector(params: channels.FrameEvalOnSelectorParams, metadata: CallMetadata): Promise<channels.FrameEvalOnSelectorResult> {
return { value: serializeResult(await this._frame.evalOnSelectorAndWaitForSignals(metadata, params.selector, !!params.strict, params.expression, params.isFunction, parseArgument(params.arg))) }; return { value: serializeResult(await this._frame.evalOnSelectorAndWaitForSignals(params.selector, !!params.strict, params.expression, params.isFunction, parseArgument(params.arg))) };
} }
async evalOnSelectorAll(params: channels.FrameEvalOnSelectorAllParams, metadata: CallMetadata): Promise<channels.FrameEvalOnSelectorAllResult> { async evalOnSelectorAll(params: channels.FrameEvalOnSelectorAllParams, metadata: CallMetadata): Promise<channels.FrameEvalOnSelectorAllResult> {
return { value: serializeResult(await this._frame.evalOnSelectorAllAndWaitForSignals(metadata, params.selector, params.expression, params.isFunction, parseArgument(params.arg))) }; return { value: serializeResult(await this._frame.evalOnSelectorAllAndWaitForSignals(params.selector, params.expression, params.isFunction, parseArgument(params.arg))) };
} }
async querySelector(params: channels.FrameQuerySelectorParams, metadata: CallMetadata): Promise<channels.FrameQuerySelectorResult> { async querySelector(params: channels.FrameQuerySelectorParams, metadata: CallMetadata): Promise<channels.FrameQuerySelectorResult> {
return { element: ElementHandleDispatcher.fromNullable(this._scope, await this._frame.querySelector(metadata, params.selector, params)) }; return { element: ElementHandleDispatcher.fromNullable(this._scope, await this._frame.querySelector(params.selector, params)) };
} }
async querySelectorAll(params: channels.FrameQuerySelectorAllParams, metadata: CallMetadata): Promise<channels.FrameQuerySelectorAllResult> { async querySelectorAll(params: channels.FrameQuerySelectorAllParams, metadata: CallMetadata): Promise<channels.FrameQuerySelectorAllResult> {
const elements = await this._frame.querySelectorAll(metadata, params.selector); const elements = await this._frame.querySelectorAll(params.selector);
return { elements: elements.map(e => ElementHandleDispatcher.from(this._scope, e)) }; return { elements: elements.map(e => ElementHandleDispatcher.from(this._scope, e)) };
} }

View file

@ -65,6 +65,8 @@ export function splitSelectorByFrame(selectorText: string): ParsedSelector[] {
for (let i = 0; i < selector.parts.length; ++i) { for (let i = 0; i < selector.parts.length; ++i) {
const part = selector.parts[i]; const part = selector.parts[i];
if (part.name === 'content-frame') { if (part.name === 'content-frame') {
if (!chunk.parts.length)
throw new Error('Selector cannot start with "content-frame", select the iframe first');
result.push(chunk); result.push(chunk);
chunk = { parts: [] }; chunk = { parts: [] };
chunkStartIndex = i + 1; chunkStartIndex = i + 1;

View file

@ -109,10 +109,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
declare readonly _context: FrameExecutionContext; declare readonly _context: FrameExecutionContext;
readonly _page: Page; readonly _page: Page;
declare readonly _objectId: string; declare readonly _objectId: string;
private _frame: frames.Frame;
constructor(context: FrameExecutionContext, objectId: string) { constructor(context: FrameExecutionContext, objectId: string) {
super(context, 'node', undefined, objectId); super(context, 'node', undefined, objectId);
this._page = context.frame._page; this._page = context.frame._page;
this._frame = context.frame;
this._initializePreview().catch(e => {}); this._initializePreview().catch(e => {});
} }
@ -127,7 +129,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async evaluateInUtility<R, Arg>(pageFunction: js.Func1<[js.JSHandle<InjectedScript>, ElementHandle<T>, Arg], R>, arg: Arg): Promise<R | 'error:notconnected'> { async evaluateInUtility<R, Arg>(pageFunction: js.Func1<[js.JSHandle<InjectedScript>, ElementHandle<T>, Arg], R>, arg: Arg): Promise<R | 'error:notconnected'> {
try { try {
const utility = await this._context.frame._utilityContext(); const utility = await this._frame._utilityContext();
return await utility.evaluate(pageFunction, [await utility.injectedScript(), this, arg]); return await utility.evaluate(pageFunction, [await utility.injectedScript(), this, arg]);
} catch (e) { } catch (e) {
if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e))
@ -138,7 +140,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async evaluateHandleInUtility<R, Arg>(pageFunction: js.Func1<[js.JSHandle<InjectedScript>, ElementHandle<T>, Arg], R>, arg: Arg): Promise<js.JSHandle<R> | 'error:notconnected'> { async evaluateHandleInUtility<R, Arg>(pageFunction: js.Func1<[js.JSHandle<InjectedScript>, ElementHandle<T>, Arg], R>, arg: Arg): Promise<js.JSHandle<R> | 'error:notconnected'> {
try { try {
const utility = await this._context.frame._utilityContext(); const utility = await this._frame._utilityContext();
return await utility.evaluateHandle(pageFunction, [await utility.injectedScript(), this, arg]); return await utility.evaluateHandle(pageFunction, [await utility.injectedScript(), this, arg]);
} catch (e) { } catch (e) {
if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e))
@ -149,7 +151,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async evaluatePoll<R, Arg>(progress: Progress, pageFunction: js.Func1<[js.JSHandle<InjectedScript>, ElementHandle<T>, Arg], InjectedScriptPoll<R>>, arg: Arg): Promise<R | 'error:notconnected'> { async evaluatePoll<R, Arg>(progress: Progress, pageFunction: js.Func1<[js.JSHandle<InjectedScript>, ElementHandle<T>, Arg], InjectedScriptPoll<R>>, arg: Arg): Promise<R | 'error:notconnected'> {
try { try {
const utility = await this._context.frame._utilityContext(); const utility = await this._frame._utilityContext();
const poll = await utility.evaluateHandle(pageFunction, [await utility.injectedScript(), this, arg]); const poll = await utility.evaluateHandle(pageFunction, [await utility.injectedScript(), this, arg]);
const pollHandler = new InjectedScriptPollHandler(progress, poll); const pollHandler = new InjectedScriptPollHandler(progress, poll);
return await pollHandler.finish(); return await pollHandler.finish();
@ -227,7 +229,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async dispatchEvent(type: string, eventInit: Object = {}) { async dispatchEvent(type: string, eventInit: Object = {}) {
const main = await this._context.frame._mainContext(); const main = await this._frame._mainContext();
await this._page._frameManager.waitForSignalsCreatedBy(null, false /* noWaitFor */, async () => { await this._page._frameManager.waitForSignalsCreatedBy(null, false /* noWaitFor */, async () => {
return main.evaluate(([injected, node, { type, eventInit }]) => injected.dispatchEvent(node, type, eventInit), [await main.injectedScript(), this, { type, eventInit }] as const); return main.evaluate(([injected, node, { type, eventInit }]) => injected.dispatchEvent(node, type, eventInit), [await main.injectedScript(), this, { type, eventInit }] as const);
}); });
@ -690,15 +692,21 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async querySelector(selector: string, options: types.StrictOptions): Promise<ElementHandle | null> { async querySelector(selector: string, options: types.StrictOptions): Promise<ElementHandle | null> {
return this._page.selectors.query(this._context.frame, selector, options, this); const { frame, info } = await this._frame.resolveFrameForSelectorNoWait(selector, options, this);
// If we end up in the same frame => use the scope again, line above was noop.
return this._page.selectors.query(frame, info, this._frame === frame ? this : undefined);
} }
async querySelectorAll(selector: string): Promise<ElementHandle<Element>[]> { async querySelectorAll(selector: string): Promise<ElementHandle<Element>[]> {
return this._page.selectors._queryAll(this._context.frame, selector, this, true /* adoptToMain */); const { frame, info } = await this._frame.resolveFrameForSelectorNoWait(selector, {}, this);
// If we end up in the same frame => use the scope again, line above was noop.
return this._page.selectors._queryAll(frame, info, this._frame === frame ? this : undefined, true /* adoptToMain */);
} }
async evalOnSelectorAndWaitForSignals(selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> { async evalOnSelectorAndWaitForSignals(selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const handle = await this._page.selectors.query(this._context.frame, selector, { strict }, this); const { frame, info } = await this._frame.resolveFrameForSelectorNoWait(selector, { strict }, this);
// If we end up in the same frame => use the scope again, line above was noop.
const handle = await this._page.selectors.query(frame, info, this._frame === frame ? this : undefined);
if (!handle) if (!handle)
throw new Error(`Error: failed to find element matching selector "${selector}"`); throw new Error(`Error: failed to find element matching selector "${selector}"`);
const result = await handle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg); const result = await handle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
@ -707,7 +715,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async evalOnSelectorAllAndWaitForSignals(selector: string, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> { async evalOnSelectorAllAndWaitForSignals(selector: string, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const arrayHandle = await this._page.selectors._queryArray(this._context.frame, selector, this); const { frame, info } = await this._frame.resolveFrameForSelectorNoWait(selector, {}, this);
// If we end up in the same frame => use the scope again, line above was noop.
const arrayHandle = await this._page.selectors._queryArray(frame, info, this._frame === frame ? this : undefined);
const result = await arrayHandle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg); const result = await arrayHandle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
arrayHandle.dispose(); arrayHandle.dispose();
return result; return result;
@ -760,21 +770,27 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const { state = 'visible' } = options; const { state = 'visible' } = options;
if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
throw new Error(`state: expected one of (attached|detached|visible|hidden)`); throw new Error(`state: expected one of (attached|detached|visible|hidden)`);
const info = this._page.parseSelector(selector, options);
const task = waitForSelectorTask(info, state, false, this);
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
const context = await this._context.frame._context(info.world); return this._frame.retryWithProgress(progress, selector, options, async (frame, info, continuePolling) => {
const injected = await context.injectedScript(); // If we end up in the same frame => use the scope again, line above was noop.
const pollHandler = new InjectedScriptPollHandler(progress, await task(injected)); const task = waitForSelectorTask(info, state, false, frame === this._frame ? this : undefined);
const result = await pollHandler.finishHandle(); const context = await frame._context(info.world);
if (!result.asElement()) { const injected = await context.injectedScript();
result.dispose(); const pollHandler = new InjectedScriptPollHandler(progress, await task(injected));
return null; const result = await pollHandler.finishHandle();
} if (!result.asElement()) {
const handle = result.asElement() as ElementHandle<Element>; result.dispose();
return handle._adoptTo(await this._context.frame._mainContext()); return null;
}
const handle = result.asElement() as ElementHandle<Element>;
try {
return await handle._adoptTo(await frame._mainContext());
} catch (e) {
return continuePolling;
}
}, this);
}, this._page._timeoutSettings.timeout(options)); }, this._page._timeoutSettings.timeout(options));
} }

View file

@ -555,7 +555,8 @@ export class FFPage implements PageDelegate {
const parent = frame.parentFrame(); const parent = frame.parentFrame();
if (!parent) if (!parent)
throw new Error('Frame has been detached.'); throw new Error('Frame has been detached.');
const handles = await this._page.selectors._queryAll(parent, 'frame,iframe', undefined); const info = this._page.parseSelector('frame,iframe');
const handles = await this._page.selectors._queryAll(parent, info);
const items = await Promise.all(handles.map(async handle => { const items = await Promise.all(handles.map(async handle => {
const frame = await handle.contentFrame().catch(e => null); const frame = await handle.contentFrame().catch(e => null);
return { handle, frame }; return { handle, frame };

View file

@ -458,6 +458,10 @@ export class Frame extends SdkObject {
this._subtreeLifecycleEvents.add('commit'); this._subtreeLifecycleEvents.add('commit');
} }
isDetached(): boolean {
return this._detached;
}
_onLifecycleEvent(event: types.LifecycleEvent) { _onLifecycleEvent(event: types.LifecycleEvent) {
if (this._firedLifecycleEvents.has(event)) if (this._firedLifecycleEvents.has(event))
return; return;
@ -706,15 +710,10 @@ export class Frame extends SdkObject {
return value; return value;
} }
async querySelector(metadata: CallMetadata, selector: string, options: types.StrictOptions): Promise<dom.ElementHandle<Element> | null> { async querySelector(selector: string, options: types.StrictOptions): Promise<dom.ElementHandle<Element> | null> {
debugLogger.log('api', ` finding element using the selector "${selector}"`); debugLogger.log('api', ` finding element using the selector "${selector}"`);
const controller = new ProgressController(metadata, this); const { frame, info } = await this.resolveFrameForSelectorNoWait(selector, options);
return controller.run(progress => this._innerQuerySelector(progress, selector, options)); return this._page.selectors.query(frame, info);
}
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> { async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions & { omitReturnValue?: boolean } = {}): Promise<dom.ElementHandle<Element> | null> {
@ -728,8 +727,7 @@ export class Frame extends SdkObject {
throw new Error(`state: expected one of (attached|detached|visible|hidden)`); throw new Error(`state: expected one of (attached|detached|visible|hidden)`);
return controller.run(async progress => { return controller.run(async progress => {
progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
while (progress.isRunning()) { return this.retryWithProgress(progress, selector, options, async (frame, info, continuePolling) => {
const { frame, info } = await this._resolveFrame(progress, selector, options);
const task = dom.waitForSelectorTask(info, state, options.omitReturnValue); const task = dom.waitForSelectorTask(info, state, options.omitReturnValue);
const result = await frame._scheduleRerunnableHandleTask(progress, info.world, task); const result = await frame._scheduleRerunnableHandleTask(progress, info.world, task);
if (!result.asElement()) { if (!result.asElement()) {
@ -738,18 +736,13 @@ export class Frame extends SdkObject {
} }
if ((options as any).__testHookBeforeAdoptNode) if ((options as any).__testHookBeforeAdoptNode)
await (options as any).__testHookBeforeAdoptNode(); await (options as any).__testHookBeforeAdoptNode();
const handle = result.asElement() as dom.ElementHandle<Element>;
try { try {
const handle = result.asElement() as dom.ElementHandle<Element>; return await handle._adoptTo(await frame._mainContext());
const adopted = await handle._adoptTo(await this._mainContext());
return adopted;
} catch (e) { } catch (e) {
// Navigated while trying to adopt the node. return continuePolling;
if (js.isJavaScriptErrorInEvaluate(e))
throw e;
result.dispose();
} }
} });
return null;
}, this._page._timeoutSettings.timeout(options)); }, this._page._timeoutSettings.timeout(options));
} }
@ -760,35 +753,27 @@ export class Frame extends SdkObject {
await this._page._doSlowMo(); await this._page._doSlowMo();
} }
async evalOnSelectorAndWaitForSignals(metadata: CallMetadata, selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> { async evalOnSelectorAndWaitForSignals(selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const controller = new ProgressController(metadata, this); const { frame, info } = await this.resolveFrameForSelectorNoWait(selector, { strict });
return controller.run(async progress => { const handle = await this._page.selectors.query(frame, info);
const handle = await this._innerQuerySelector(progress, selector, { strict }); if (!handle)
if (!handle) throw new Error(`Error: failed to find element matching selector "${selector}"`);
throw new Error(`Error: failed to find element matching selector "${selector}"`); const result = await handle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
const result = await handle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg); handle.dispose();
handle.dispose(); return result;
return result;
});
} }
async evalOnSelectorAllAndWaitForSignals(metadata: CallMetadata, selector: string, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> { async evalOnSelectorAllAndWaitForSignals(selector: string, expression: string, isFunction: boolean | undefined, arg: any): Promise<any> {
const controller = new ProgressController(metadata, this); const { frame, info } = await this.resolveFrameForSelectorNoWait(selector, {});
return controller.run(async progress => { const arrayHandle = await this._page.selectors._queryArray(frame, info);
const { frame, info } = await this._resolveFrame(progress, selector, {}); const result = await arrayHandle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg);
const arrayHandle = await this._page.selectors._queryArray(frame, info); arrayHandle.dispose();
const result = await arrayHandle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg); return result;
arrayHandle.dispose();
return result;
});
} }
async querySelectorAll(metadata: CallMetadata, selector: string): Promise<dom.ElementHandle<Element>[]> { async querySelectorAll(selector: string): Promise<dom.ElementHandle<Element>[]> {
const controller = new ProgressController(metadata, this); const { frame, info } = await this.resolveFrameForSelectorNoWait(selector, {});
return controller.run(async progress => { return this._page.selectors._queryAll(frame, info, undefined, true /* adoptToMain */);
const { frame, info } = await this._resolveFrame(progress, selector, {});
return this._page.selectors._queryAll(frame, info, undefined, true /* adoptToMain */);
});
} }
async content(): Promise<string> { async content(): Promise<string> {
@ -968,31 +953,55 @@ export class Frame extends SdkObject {
return result!; return result!;
} }
async retryWithProgress<R>(
progress: Progress,
selector: string,
options: types.StrictOptions & types.TimeoutOptions,
action: (frame: Frame, info: SelectorInfo, continuePolling: symbol) => Promise<R | symbol>,
scope?: dom.ElementHandle): Promise<R> {
const continuePolling = Symbol('continuePolling');
while (progress.isRunning()) {
const { frame, info } = await this.resolveFrameForSelector(progress, selector, options, scope);
try {
const result = await action(frame, info, continuePolling);
if (result === continuePolling)
continue;
return result as R;
} catch (e) {
// Always fail on JavaScript errors or when the main connection is closed.
if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e))
throw e;
// If error has happened in the detached inner frame, ignore it, keep polling.
if (frame !== this && frame.isDetached())
continue;
throw e;
}
}
progress.throwIfAborted();
return undefined as any;
}
private async _retryWithProgressIfNotConnected<R>( private async _retryWithProgressIfNotConnected<R>(
progress: Progress, progress: Progress,
selector: string, selector: string,
strict: boolean | undefined, strict: boolean | undefined,
action: (handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> { action: (handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> {
while (progress.isRunning()) { return this.retryWithProgress(progress, selector, { strict }, async (frame, info, continuePolling) => {
progress.log(`waiting for selector "${selector}"`); progress.log(`waiting for selector "${selector}"`);
const { frame, info } = await this._resolveFrame(progress, selector, { strict });
const task = dom.waitForSelectorTask(info, 'attached'); const task = dom.waitForSelectorTask(info, 'attached');
const handle = await frame._scheduleRerunnableHandleTask(progress, info.world, task); const handle = await frame._scheduleRerunnableHandleTask(progress, info.world, task);
const element = handle.asElement() as dom.ElementHandle<Element>; const element = handle.asElement() as dom.ElementHandle<Element>;
progress.cleanupWhenAborted(() => { try {
// Do not await here to avoid being blocked, either by stalled const result = await action(element);
// page (e.g. alert) or unresolved navigation in Chromium. if (result === 'error:notconnected') {
element.dispose(); progress.log('element was detached from the DOM, retrying');
}); return continuePolling;
const result = await action(element); }
element.dispose(); return result;
if (result === 'error:notconnected') { } finally {
progress.log('element was detached from the DOM, retrying'); element?.dispose();
continue;
} }
return result; });
}
return undefined as any;
} }
async click(metadata: CallMetadata, selector: string, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) { async click(metadata: CallMetadata, selector: string, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
@ -1097,7 +1106,8 @@ export class Frame extends SdkObject {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
progress.log(` checking visibility of "${selector}"`); progress.log(` checking visibility of "${selector}"`);
const element = await this._innerQuerySelector(progress, selector, options); const { frame, info } = await this.resolveFrameForSelector(progress, selector, options);
const element = await this._page.selectors.query(frame, info);
return element ? await element.isVisible() : false; return element ? await element.isVisible() : false;
}, this._page._timeoutSettings.timeout({})); }, this._page._timeoutSettings.timeout({}));
} }
@ -1214,10 +1224,10 @@ export class Frame extends SdkObject {
// Reached the expected state! // Reached the expected state!
return result; return result;
}, { ...options, isArray }, { strict: true, querySelectorAll: isArray, mainWorld, omitAttached: true, logScale: true, ...options }).catch(e => { }, { ...options, isArray }, { strict: true, querySelectorAll: isArray, mainWorld, omitAttached: true, logScale: true, ...options }).catch(e => {
if (js.isJavaScriptErrorInEvaluate(e))
throw e;
// Q: Why not throw upon isSessionClosedError(e) as in other places? // Q: Why not throw upon isSessionClosedError(e) as in other places?
// A: We want user to receive a friendly message containing the last intermediate result. // A: We want user to receive a friendly message containing the last intermediate result.
if (js.isJavaScriptErrorInEvaluate(e))
throw e;
return { received: controller.lastIntermediateResult(), matches: options.isNot, log: metadata.log }; return { received: controller.lastIntermediateResult(), matches: options.isNot, log: metadata.log };
}); });
} }
@ -1298,18 +1308,10 @@ export class Frame extends SdkObject {
const callbackText = body.toString(); const callbackText = body.toString();
return controller.run(async progress => { return controller.run(async progress => {
while (progress.isRunning()) { return this.retryWithProgress(progress, selector, options, async (frame, info) => {
progress.log(`waiting for selector "${selector}"`); progress.log(`waiting for selector "${selector}"`);
const { frame, info } = await this._resolveFrame(progress, selector, options); return await frame._scheduleRerunnableTaskInFrame(progress, info, callbackText, taskData, 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)); }, this._page._timeoutSettings.timeout(options));
} }
@ -1319,10 +1321,9 @@ export class Frame extends SdkObject {
callbackText: string, callbackText: string,
taskData: T, taskData: T,
options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean, querySelectorAll?: boolean, logScale?: boolean, omitAttached?: boolean }): Promise<R> { options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean, querySelectorAll?: boolean, logScale?: boolean, omitAttached?: boolean }): Promise<R> {
if (!progress.isRunning()) progress.throwIfAborted();
progress.throwIfAborted();
const data = this._contextData.get(options.mainWorld ? 'main' : info!.world)!; const data = this._contextData.get(options.mainWorld ? 'main' : info!.world)!;
// This potentially runs in a sub-frame.
{ {
const rerunnableTask = new RerunnableTask<R>(data, progress, injectedScript => { const rerunnableTask = new RerunnableTask<R>(data, progress, injectedScript => {
return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale, omitAttached, snapshotName }) => { return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale, omitAttached, snapshotName }) => {
@ -1441,7 +1442,7 @@ export class Frame extends SdkObject {
}, { source, arg }); }, { source, arg });
} }
private async _resolveFrame(progress: Progress, selector: string, options: types.StrictOptions & types.TimeoutOptions): Promise<{ frame: Frame, info: SelectorInfo }> { async resolveFrameForSelector(progress: Progress, selector: string, options: types.StrictOptions & types.TimeoutOptions, scope?: dom.ElementHandle): Promise<{ frame: Frame, info: SelectorInfo }> {
const elementPath: dom.ElementHandle<Element>[] = []; const elementPath: dom.ElementHandle<Element>[] = [];
progress.cleanupWhenAborted(() => { progress.cleanupWhenAborted(() => {
// Do not await here to avoid being blocked, either by stalled // Do not await here to avoid being blocked, either by stalled
@ -1450,20 +1451,35 @@ export class Frame extends SdkObject {
element.dispose(); element.dispose();
}); });
let frame: Frame = this; let frame: Frame | null = this;
const frameChunks = splitSelectorByFrame(selector); const frameChunks = splitSelectorByFrame(selector);
for (let i = 0; i < frameChunks.length - 1 && progress.isRunning(); ++i) { for (let i = 0; i < frameChunks.length - 1 && progress.isRunning(); ++i) {
const info = this._page.parseSelector(frameChunks[i], options); const info = this._page.parseSelector(frameChunks[i], options);
const task = dom.waitForSelectorTask(info, 'attached'); const task = dom.waitForSelectorTask(info, 'attached', false, i === 0 ? scope : undefined);
const handle = await frame._scheduleRerunnableHandleTask(progress, info.world, task); const handle = await frame._scheduleRerunnableHandleTask(progress, info.world, task);
const element = handle.asElement() as dom.ElementHandle<Element>; const element = handle.asElement() as dom.ElementHandle<Element>;
if (i < frameChunks.length - 1) { frame = await element.contentFrame();
frame = (await element.contentFrame())!; element.dispose();
element.dispose(); if (!frame)
if (!frame) throw new Error(`Selector "${stringifySelector(info.parsed)}" resolved to ${element.preview()}, <iframe> was expected`);
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) };
}
async resolveFrameForSelectorNoWait(selector: string, options: types.StrictOptions & types.TimeoutOptions, scope?: dom.ElementHandle): Promise<{ frame: Frame, info: SelectorInfo }> {
let frame: Frame | null = this;
const frameChunks = splitSelectorByFrame(selector);
for (let i = 0; i < frameChunks.length - 1; ++i) {
const info = this._page.parseSelector(frameChunks[i], options);
const element: dom.ElementHandle<Element> | null = await this._page.selectors.query(frame, info, i === 0 ? scope : undefined);
if (!element)
throw new Error(`Could not find frame while resolving "${stringifySelector(info.parsed)}"`);
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) }; return { frame, info: this._page.parseSelector(frameChunks[frameChunks.length - 1], options) };
} }
@ -1519,6 +1535,10 @@ class RerunnableTask<T> {
this._reject(e); this._reject(e);
} }
// Unlike other places, we don't check frame for being detached since the whole scope of this
// evaluation is within the frame's execution context. So we only let JavaScript errors and
// session termination errors go through.
// We will try again in the new execution context. // We will try again in the new execution context.
} }
} }

View file

@ -67,8 +67,7 @@ export class Selectors {
this._engines.clear(); this._engines.clear();
} }
async query(frame: frames.Frame, selector: string | SelectorInfo, options: { strict?: boolean }, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> { async query(frame: frames.Frame, info: SelectorInfo, 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 context = await frame._context(info.world);
const injectedScript = await context.injectedScript(); const injectedScript = await context.injectedScript();
const handle = await injectedScript.evaluateHandle((injected, { parsed, scope, strict }) => { const handle = await injectedScript.evaluateHandle((injected, { parsed, scope, strict }) => {
@ -83,8 +82,7 @@ export class Selectors {
return this._adoptIfNeeded(elementHandle, mainContext); return this._adoptIfNeeded(elementHandle, mainContext);
} }
async _queryArray(frame: frames.Frame, selector: string | SelectorInfo, scope?: dom.ElementHandle): Promise<js.JSHandle<Element[]>> { async _queryArray(frame: frames.Frame, info: SelectorInfo, scope?: dom.ElementHandle): Promise<js.JSHandle<Element[]>> {
const info = typeof selector === 'string' ? this.parseSelector(selector, false) : selector;
const context = await frame._mainContext(); const context = await frame._mainContext();
const injectedScript = await context.injectedScript(); const injectedScript = await context.injectedScript();
const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => { const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
@ -93,7 +91,7 @@ export class Selectors {
return arrayHandle; return arrayHandle;
} }
async _queryAll(frame: frames.Frame, selector: string | SelectorInfo, scope?: dom.ElementHandle, adoptToMain?: boolean): Promise<dom.ElementHandle<Element>[]> { async _queryAll(frame: frames.Frame, selector: SelectorInfo, scope?: dom.ElementHandle, adoptToMain?: boolean): Promise<dom.ElementHandle<Element>[]> {
const info = typeof selector === 'string' ? frame._page.parseSelector(selector) : selector; const info = typeof selector === 'string' ? frame._page.parseSelector(selector) : selector;
const context = await frame._context(info.world); const context = await frame._context(info.world);
const injectedScript = await context.injectedScript(); const injectedScript = await context.injectedScript();

View file

@ -920,7 +920,8 @@ export class WKPage implements PageDelegate {
const parent = frame.parentFrame(); const parent = frame.parentFrame();
if (!parent) if (!parent)
throw new Error('Frame has been detached.'); throw new Error('Frame has been detached.');
const handles = await this._page.selectors._queryAll(parent, 'frame,iframe', undefined); const info = this._page.parseSelector('frame,iframe');
const handles = await this._page.selectors._queryAll(parent, info);
const items = await Promise.all(handles.map(async handle => { const items = await Promise.all(handles.map(async handle => {
const frame = await handle.contentFrame().catch(e => null); const frame = await handle.contentFrame().catch(e => null);
return { handle, frame }; return { handle, frame };

View file

@ -46,7 +46,7 @@ async function routeIframe(page: Page) {
}); });
} }
it('should work in iframe', async ({ page, server }) => { it('should work for iframe', async ({ page, server }) => {
await routeIframe(page); await routeIframe(page);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true >> button'); const button = page.locator('iframe >> content-frame=true >> button');
@ -56,7 +56,17 @@ it('should work in iframe', async ({ page, server }) => {
await button.click(); await button.click();
}); });
it('should work in nested iframe', async ({ page, server }) => { it('should work for iframe (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const button = await body.waitForSelector('iframe >> content-frame=true >> button');
expect(await button.innerText()).toBe('Hello iframe');
expect(await button.textContent()).toBe('Hello iframe');
await button.click();
});
it('should work for nested iframe', async ({ page, server }) => {
await routeIframe(page); await routeIframe(page);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true >> iframe >> content-frame=true >> button'); const button = page.locator('iframe >> content-frame=true >> iframe >> content-frame=true >> button');
@ -66,6 +76,16 @@ it('should work in nested iframe', async ({ page, server }) => {
await button.click(); await button.click();
}); });
it('should work for nested iframe (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const button = await body.waitForSelector('iframe >> content-frame=true >> iframe >> content-frame=true >> button');
expect(await button.innerText()).toBe('Hello nested iframe');
expect(await button.textContent()).toBe('Hello nested iframe');
await button.click();
});
it('should work for $ and $$', async ({ page, server }) => { it('should work for $ and $$', async ({ page, server }) => {
await routeIframe(page); await routeIframe(page);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -75,6 +95,16 @@ it('should work for $ and $$', async ({ page, server }) => {
expect(elements).toHaveLength(2); expect(elements).toHaveLength(2);
}); });
it('should work for $ and $$ (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const element = await body.$('iframe >> content-frame=true >> button');
expect(await element.textContent()).toBe('Hello iframe');
const elements = await body.$$('iframe >> content-frame=true >> span');
expect(elements).toHaveLength(2);
});
it('should work for $eval', async ({ page, server }) => { it('should work for $eval', async ({ page, server }) => {
await routeIframe(page); await routeIframe(page);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -82,6 +112,14 @@ it('should work for $eval', async ({ page, server }) => {
expect(value).toBe('BUTTON'); expect(value).toBe('BUTTON');
}); });
it('should work for $eval (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const value = await body.$eval('iframe >> content-frame=true >> button', b => b.nodeName);
expect(value).toBe('BUTTON');
});
it('should work for $$eval', async ({ page, server }) => { it('should work for $$eval', async ({ page, server }) => {
await routeIframe(page); await routeIframe(page);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -89,6 +127,14 @@ it('should work for $$eval', async ({ page, server }) => {
expect(value).toEqual(['1', '2']); expect(value).toEqual(['1', '2']);
}); });
it('should work for $$eval (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const value = await body.$$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 }) => { it('should not allow dangling content-frame', async ({ page, server }) => {
await routeIframe(page); await routeIframe(page);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -98,6 +144,13 @@ it('should not allow dangling content-frame', async ({ page, server }) => {
expect(error.message).toContain('iframe >> content-frame=true'); expect(error.message).toContain('iframe >> content-frame=true');
}); });
it('should not allow leading content-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const error = await page.waitForSelector('content-frame=true >> button').catch(e => e);
expect(error.message).toContain('Selector cannot start with');
});
it('should not allow capturing before content-frame', async ({ page, server }) => { it('should not allow capturing before content-frame', async ({ page, server }) => {
await routeIframe(page); await routeIframe(page);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -145,3 +198,60 @@ it('should click in lazy iframe', async ({ page, server }) => {
]); ]);
expect(text).toBe('Hello iframe'); expect(text).toBe('Hello iframe');
}); });
it('waitFor should survive frame reattach', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true >> button:has-text("Hello nested iframe")');
const promise = button.waitFor();
await page.locator('iframe').evaluate(e => e.remove());
await page.evaluate(() => {
const iframe = document.createElement('iframe');
iframe.src = 'iframe-2.html';
document.body.appendChild(iframe);
});
await promise;
});
it('click should survive frame reattach', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true >> button:has-text("Hello nested iframe")');
const promise = button.click();
await page.locator('iframe').evaluate(e => e.remove());
await page.evaluate(() => {
const iframe = document.createElement('iframe');
iframe.src = 'iframe-2.html';
document.body.appendChild(iframe);
});
await promise;
});
it('click should survive iframe navigation', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> content-frame=true >> button:has-text("Hello nested iframe")');
const promise = button.click();
page.locator('iframe').evaluate(e => (e as HTMLIFrameElement).src = 'iframe-2.html');
await promise;
});
it('click should survive navigation', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.PREFIX + '/iframe.html');
const promise = page.click('button:has-text("Hello nested iframe")');
await page.waitForTimeout(100);
await page.goto(server.PREFIX + '/iframe-2.html');
await promise;
});
it('should fail if element removed while waiting on element handle', async ({ page, server }) => {
it.fixme();
await routeIframe(page);
await page.goto(server.PREFIX + '/iframe.html');
const button = await page.$('button');
const promise = button.waitForSelector('something');
await page.waitForTimeout(100);
await page.evaluate(() => document.body.innerText = '');
await promise;
});