From 78e99249a3e07ada23c2ab5f3776b38f7cecc2ca Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 4 Nov 2021 12:28:35 -0800 Subject: [PATCH] feat(frame-selector): intial implementation (#10018) --- .../src/dispatchers/frameDispatcher.ts | 8 +- .../src/server/common/selectorParser.ts | 32 +++- packages/playwright-core/src/server/frames.ts | 123 +++++++++++---- packages/playwright-core/src/server/page.ts | 3 +- .../playwright-core/src/server/selectors.ts | 22 +-- tests/page/selectors-frame.spec.ts | 147 ++++++++++++++++++ 6 files changed, 291 insertions(+), 44 deletions(-) create mode 100644 tests/page/selectors-frame.spec.ts diff --git a/packages/playwright-core/src/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/dispatchers/frameDispatcher.ts index 914cc7e9c1..9c1a73c9a1 100644 --- a/packages/playwright-core/src/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/frameDispatcher.ts @@ -83,19 +83,19 @@ export class FrameDispatcher extends Dispatcher { - 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 { - 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 { - 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 { - 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)) }; } diff --git a/packages/playwright-core/src/server/common/selectorParser.ts b/packages/playwright-core/src/server/common/selectorParser.ts index e0c46dd3c2..7d14293203 100644 --- a/packages/playwright-core/src/server/common/selectorParser.ts +++ b/packages/playwright-core/src/server/common/selectorParser.ts @@ -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(' >> '); } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 361f84ad81..208aa65023 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -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; @@ -704,9 +706,15 @@ export class Frame extends SdkObject { return value; } - async querySelector(selector: string, options: types.StrictOptions): Promise | null> { + async querySelector(metadata: CallMetadata, selector: string, options: types.StrictOptions): Promise | 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 | 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 | 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 { - 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 { + 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 { - 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 { + 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[]> { - return this._page.selectors._queryAll(this, selector, undefined, true /* adoptToMain */); + async querySelectorAll(metadata: CallMetadata, selector: string): Promise[]> { + 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 { @@ -954,11 +973,11 @@ export class Frame extends SdkObject { selector: string, strict: boolean | undefined, action: (handle: dom.ElementHandle) => Promise): Promise { - 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; 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 { - 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( + progress: Progress, + info: SelectorInfo, + callbackText: string, + taskData: T, + options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean, querySelectorAll?: boolean, logScale?: boolean, omitAttached?: boolean }): Promise { + if (!progress.isRunning()) + progress.throwIfAborted(); + const data = this._contextData.get(options.mainWorld ? 'main' : info!.world)!; + + { const rerunnableTask = new RerunnableTask(data, progress, injectedScript => { return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale, omitAttached, snapshotName }) => { const callback = injected.eval(callbackText) as DomTaskBody; @@ -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(progress: Progress, world: types.World, task: dom.SchedulableTask): Promise> { @@ -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[] = []; + 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; + if (i < frameChunks.length - 1) { + frame = (await element.contentFrame())!; + element.dispose(); + if (!frame) + throw new Error(`Selector "${stringifySelector(info.parsed)}" resolved to ${element.preview()}, ', + contentType: 'text/html' + }).catch(() => {}); + }); + await page.route('**/iframe.html', route => { + route.fulfill({ + body: ` + +
+ + +
+ 1 + 2 + `, + contentType: 'text/html' + }).catch(() => {}); + }); + await page.route('**/iframe-2.html', route => { + route.fulfill({ + body: '', + 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('', + 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'); +});