diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index d4a4ccad07017..6763ae2790c35 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -539,6 +539,8 @@ export class InjectedScript { queryAll(root: SelectorRoot, body: any) { if (body === 'enter-frame') return []; + if (body === 'pierce-frames') + return []; if (body === 'return-empty') return []; if (body === 'component') { diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts index 9da6c48cd0a8c..0e084733c55cf 100644 --- a/packages/isomorphic/selectorParser.ts +++ b/packages/isomorphic/selectorParser.ts @@ -91,19 +91,33 @@ export function parseSelector(selector: string): ParsedSelector { }; } -export function splitSelectorByFrame(selectorText: string): ParsedSelector[] { +// Splits a selector into per-frame chunks separated by "enter-frame" boundaries. When the selector +// starts with the "pierce-frames" token, `pierce` is set globally and no "enter-frame" boundaries +// are allowed (piercing already searches every descendant frame), so `chunks` holds a single chunk. +export function splitSelectorByFrame(selectorText: string): { pierce: boolean, chunks: ParsedSelector[] } { const selector = parseSelector(selectorText); - const result: ParsedSelector[] = []; + const chunks: ParsedSelector[] = []; let chunk: ParsedSelector = { parts: [], }; + let pierce = false; let chunkStartIndex = 0; for (let i = 0; i < selector.parts.length; ++i) { const part = selector.parts[i]; + if (part.name === 'internal:control' && part.body === 'pierce-frames') { + // Piercing is a whole-page operation, so it only makes sense as the very first token. + if (i !== 0) + throw new InvalidSelectorError(`"pierce-frames" is only allowed as the first selector token, while parsing selector ${selectorText}`); + pierce = true; + chunkStartIndex = i + 1; + continue; + } if (part.name === 'internal:control' && part.body === 'enter-frame') { + if (pierce) + throw new InvalidSelectorError(`Entering frames is not allowed while piercing frames, while parsing selector ${selectorText}`); if (!chunk.parts.length) throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first'); - result.push(chunk); + chunks.push(chunk); chunk = { parts: [] }; chunkStartIndex = i + 1; continue; @@ -114,10 +128,12 @@ export function splitSelectorByFrame(selectorText: string): ParsedSelector[] { } if (!chunk.parts.length) throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`); - result.push(chunk); - if (typeof selector.capture === 'number' && typeof result[result.length - 1].capture !== 'number') + chunks.push(chunk); + if (typeof selector.capture === 'number' && typeof chunks[chunks.length - 1].capture !== 'number') throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`); - return result; + if (typeof selector.capture === 'number' && pierce) + throw new InvalidSelectorError(`Can not *-capture inside a frame-piercing selector, while parsing selector ${selectorText}`); + return { pierce, chunks }; } function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) { diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index e6b071d9713ed..039e318fdc21b 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -17,10 +17,12 @@ import { InvalidSelectorError, splitSelectorByFrame, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser'; import { asLocator } from '@isomorphic/locatorGenerators'; +import { NonRecoverableDOMError } from './dom'; + import type { ElementHandle, FrameExecutionContext } from './dom'; import type { Frame } from './frames'; import type { InjectedScript } from '@injected/injectedScript'; -import type { JSHandle, SmartHandle } from './javascript'; +import type { JSHandle, SmartHandle, Unboxed } from './javascript'; import type * as types from './types'; import type { ParsedSelector } from '@isomorphic/selectorParser'; @@ -31,13 +33,13 @@ export type SelectorInfo = { strict: boolean, }; -export type SelectorInFrame = { +type SelectorInFrame = { frame: Frame; info: SelectorInfo; scope?: ElementHandle; }; -type MatchedElementsCallback = (data: { injected: InjectedScript, elements: Element[], info: SelectorInfo }, arg: Arg) => R | Promise; +type MatchedElementsCallback = (data: { injected: InjectedScript, elements: Element[], info: SelectorInfo }, arg: Unboxed) => R | Promise; export class FrameSelectors { readonly frame: Frame; @@ -117,19 +119,36 @@ export class FrameSelectors { return jumptToFrame; } - private async _resolveFrameForSelector(selector: string, options: types.StrictOptions = {}, scope?: ElementHandle): Promise { - let frame: Frame = this.frame; - const frameChunks = splitSelectorByFrame(selector); - - for (const chunk of frameChunks) { + private async _resolveFramesForSelector(selector: string, options: types.StrictOptions = {}, scope?: ElementHandle): Promise { + const { pierce, chunks } = splitSelectorByFrame(selector); + for (const chunk of chunks) { visitAllSelectorParts(chunk, (part, nested) => { if (nested && part.name === 'internal:control' && part.body === 'enter-frame') { const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); throw new InvalidSelectorError(`Frame locators are not allowed inside composite locators, while querying "${locator}"`); } + if (nested && pierce) { + const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); + throw new InvalidSelectorError(`Composite locators are not supported with piercing frames, while querying "${locator}"`); + } }); } + if (pierce) { + const parsed = chunks[0]; // Only one chunk is allowed with pierce. + if (parsed.parts.some((part, index) => part.name === 'nth' && index !== parsed.parts.length - 1)) { + const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); + throw new InvalidSelectorError(`nth can only be the last locator when piercing frames, while querying "${locator}"`); + } + return await this._resolveFramePiercingSelector(parsed, options, scope); + } + + const result = await this._resolveChainedSelector(selector, options, chunks, scope); + return result ? [result] : []; + } + + private async _resolveChainedSelector(selector: string, options: types.StrictOptions, frameChunks: ParsedSelector[], scope: ElementHandle | undefined): Promise { + let frame: Frame = this.frame; for (let i = 0; i < frameChunks.length - 1; ++i) { const info = this._parseSelector(frameChunks[i], options); frame = this._jumpToAriaRefFrameIfNeeded(selector, info, frame); @@ -158,13 +177,143 @@ export class FrameSelectors { return { frame, info: lastChunk, scope }; } - private async _resolveInjectedForSelector(selector: string, options: types.StrictOptions & { mainWorld?: boolean }, scope?: ElementHandle): Promise<{ frame: Frame, info: SelectorInfo, injected: JSHandle, scope?: ElementHandle } | null> { - const resolved = await this._resolveFrameForSelector(selector, options, scope); - if (!resolved) - return null; - const context = await resolved.frame.context(options.mainWorld ? 'main' : resolved.info.world); - const injected = await context.injectedScript(); - return { frame: resolved.frame, info: resolved.info, injected, scope: resolved.scope }; + private async _resolveFramePiercingSelector(parsed: ParsedSelector, options: types.StrictOptions, scope: ElementHandle | undefined) { + const candidates = new Map>(); + const infos = parsed.parts.map(part => this._parseSelector({ parts: [part] }, options)); + for (const frame of this.frame._page.frameManager.frames()) + await this._pierceFramesRecursivelyIfNotSeen(frame, infos, scope, 0, candidates); + const result: SelectorInFrame[] = []; + for (const [frame, matches] of candidates) { + for (const match of matches) { + const suffix = infos.slice(match); + const partialInfo: SelectorInfo = { + parsed: { parts: suffix.map(info => info.parsed.parts[0]) }, + world: suffix.some(info => info.world === 'main') ? 'main' : 'utility', + strict: !!options.strict, + }; + result.push({ frame, info: partialInfo }); + } + } + return result; + } + + private async _pierceFramesRecursivelyIfNotSeen(frame: Frame, infos: SelectorInfo[], scope: ElementHandle | undefined, startIndex: number, result: Map>) { + let set = result.get(frame); + if (!set) { + set = new Set(); + result.set(frame, set); + } + if (!set.has(startIndex)) { + set.add(startIndex); + await this._pierceFramesRecursively(frame, infos, undefined, startIndex, result); + } + } + + private async _pierceFramesRecursively(frame: Frame, infos: SelectorInfo[], scope: ElementHandle | undefined, startIndex: number, result: Map>) { + const doWork = async (context: FrameExecutionContext) => { + const injected = await context.injectedScript(); + const frameCandidatesHandle = await injected.evaluateHandle((injected, { infos, scope, startIndex }) => { + const frameElements = injected.querySelectorAll(injected.parseSelector('css=frame,iframe'), scope || document); + const result = frameElements.map(frameElement => ({ frameElement, matches: [] as number[] })); + + let roots = [scope || document]; + for (let index = startIndex; index < infos.length; index++) { + const next = new Set(); + for (const root of roots) { + const all = injected.querySelectorAll(infos[index].parsed, root); + for (const element of all) + next.add(element); + } + roots = [...next]; + if (index + 1 < infos.length && !['nth', 'visible'].includes(infos[index + 1].parsed.parts[0].name)) { + for (const { frameElement, matches } of result) { + if (roots.some(root => injected.utils.isInsideScope(root, frameElement))) + matches.push(index); + } + } + } + return result; + }, { infos, scope, startIndex }); + + const count = await frameCandidatesHandle.evaluate(x => x.length).catch(() => 0); + for (let i = 0; i < count; ++i) { + try { + const frameElement = await frameCandidatesHandle.evaluateHandle((list, i) => list[i].frameElement, i) as ElementHandle; + const childFrame = await frame._page.delegate.getContentFrame(frameElement).catch(() => null); + if (childFrame) { + const matches = await frameCandidatesHandle.evaluate((list, i) => list[i].matches, i) as number[]; + for (const match of matches) + await this._pierceFramesRecursivelyIfNotSeen(childFrame, infos, undefined, match + 1, result); + } + } catch { + // Ignore errors for this frame candidate. + } + } + frameCandidatesHandle.dispose(); + }; + + const noStall = frame !== this.frame; + const world = infos.some(info => info.world === 'main') ? 'main' : 'utility'; + const context = noStall ? frame.existingContext(world) : await frame.context(world); + if (!context) + return; + + if (noStall) + await frame.raceAgainstEvaluationStallingEvents(() => doWork(context)).catch(() => {}); + else + await doWork(context); + } + + private async _callOnSelectorInternal( + selector: string, + options: types.StrictOptions & { mainWorld?: boolean, callWithoutMatches?: boolean, scope?: ElementHandle, markTargets?: 'all' | 'first' | 'none' }, + pageFunction: MatchedElementsCallback, + arg: Arg, + returnByValue: boolean, + ): Promise<{ frame: Frame, info: SelectorInfo, result: R | SmartHandle } | null> { + const resolved = await this._resolveFramesForSelector(selector, options, options.scope); + let aggregatedResult: { frame: Frame, info: SelectorInfo, result: R | SmartHandle } | null = null; + const noStall = resolved.length > 1; + for (const { frame, info, scope } of resolved) { + const world = options.mainWorld ? 'main' : info.world; + const context = noStall ? frame.existingContext(world) : await frame.context(world); + if (!context) + continue; + const getResult = async () => { + const injected = await context.injectedScript(); + const method = returnByValue ? 'evaluate' : 'evaluateHandle'; + const evalResult = await injected[method]((injected, params) => { + const elements = injected.querySelectorAll(params.info.parsed, params.scope || document); + if (params.markTargets === 'all') + injected.markTargetElements(new Set(elements)); + else if (params.markTargets === 'first' && elements.length) + injected.markTargetElements(new Set([elements[0]])); + else if (params.markTargets === 'first') + injected.markTargetElements(new Set()); + injected.checkDeprecatedSelectorUsage(params.info.parsed, elements); + if (params.info.strict && elements.length > 1) + throw injected.strictModeViolationError(params.info.parsed, elements); + if (!elements.length && !params.callWithoutMatches) + return '--playwright--no--result--value--'; + const func = injected.eval('(' + params.functionText + ')') as MatchedElementsCallback; + return func({ injected, elements, info: params.info }, params.arg); + }, { info, scope, functionText: String(pageFunction), arg, callWithoutMatches: options.callWithoutMatches, markTargets: options.markTargets, returnByValue }); + if (returnByValue && evalResult === '--playwright--no--result--value--') + return; + if (!returnByValue && (evalResult as JSHandle)._value === '--playwright--no--result--value--') { + (evalResult as JSHandle).dispose(); + return; + } + return { result: evalResult as R | SmartHandle }; + }; + const maybeResult = noStall ? await frame.raceAgainstEvaluationStallingEvents(getResult).catch(() => undefined) : await getResult(); + if (!maybeResult) + continue; + if (aggregatedResult) + throw new NonRecoverableDOMError(`Pierce-frame mode matched elements from multiple frames.`); + aggregatedResult = { frame, info, result: maybeResult.result }; + } + return aggregatedResult; } async callOnSelector( @@ -173,14 +322,8 @@ export class FrameSelectors { pageFunction: MatchedElementsCallback, arg: Arg, ): Promise<{ frame: Frame, info: SelectorInfo, result: R } | null> { - const resolved = await this._resolveInjectedForSelector(selector, options, options.scope); - if (!resolved) - return null; - const result = await resolved.injected.evaluate(callMatchedElements, { info: resolved.info, scope: resolved.scope, functionText: String(pageFunction), arg, callWithoutMatches: !!options.callWithoutMatches, markTargets: options.markTargets }) as R; - // callMatchedElements returns undefined when there were no matches and it skipped the page function. - if (!options.callWithoutMatches && result === undefined) - return null; - return { frame: resolved.frame, info: resolved.info, result }; + const result = await this._callOnSelectorInternal(selector, options, pageFunction, arg, true /* returnByValue */); + return result as { frame: Frame, info: SelectorInfo, result: R } | null; } async callOnSelectorHandle( @@ -189,36 +332,11 @@ export class FrameSelectors { pageFunction: MatchedElementsCallback, arg: Arg, ): Promise<{ result: SmartHandle } | null> { - const resolved = await this._resolveInjectedForSelector(selector, options, options.scope); - if (!resolved) - return null; - const result = await resolved.injected.evaluateHandle(callMatchedElements, { info: resolved.info, scope: resolved.scope, functionText: String(pageFunction), arg, callWithoutMatches: false, markTargets: options.markTargets }) as SmartHandle; - // A skipped page function returns undefined, which has no object id (unlike a matched element/object). - if (!result._objectId) { - result.dispose(); - return null; - } - return { result }; + const result = await this._callOnSelectorInternal(selector, { ...options, callWithoutMatches: false }, pageFunction, arg, false /* returnByValue */); + return result as { frame: Frame, info: SelectorInfo, result: SmartHandle } | null; } } -function callMatchedElements(injected: InjectedScript, { info, scope, functionText, arg, callWithoutMatches, markTargets }: { info: SelectorInfo, scope: Node | undefined, functionText: string, arg: any, callWithoutMatches: boolean, markTargets?: 'all' | 'first' | 'none' }): any { - const elements = injected.querySelectorAll(info.parsed, scope || document); - if (markTargets === 'all') - injected.markTargetElements(new Set(elements)); - else if (markTargets === 'first' && elements.length) - injected.markTargetElements(new Set([elements[0]])); - else if (markTargets === 'first') - injected.markTargetElements(new Set()); - injected.checkDeprecatedSelectorUsage(info.parsed, elements); - if (info.strict && elements.length > 1) - throw injected.strictModeViolationError(info.parsed, elements); - if (!elements.length && !callWithoutMatches) - return undefined; - const pageFunction = injected.eval('(' + functionText + ')'); - return pageFunction({ injected, elements, info }, arg); -} - async function adoptIfNeeded(handle: ElementHandle, context: FrameExecutionContext): Promise> { if (handle._context === context) return handle; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 3184bce5f9ee6..9d9f1b6ffab15 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -85,7 +85,7 @@ export type NavigationEvent = { isPublic?: boolean; }; -type ElementCallback = (injected: InjectedScript, element: Element, data: T) => R; +type ElementCallback = (injected: InjectedScript, element: Element, data: js.Unboxed) => R; export class NavigationAbortedError extends Error { readonly documentId?: string; @@ -619,7 +619,7 @@ export class Frame extends SdkObject { nonStallingRawEvaluateInExistingMainContext(expression: string): Promise { return this.raceAgainstEvaluationStallingEvents(() => { - const context = this._existingMainContext(); + const context = this.existingContext('main'); if (!context) throw new Error('Frame does not yet have a main execution context'); return context.rawEvaluateJSON(expression); @@ -798,8 +798,10 @@ export class Frame extends SdkObject { return this.context('main'); } - private _existingMainContext(): dom.FrameExecutionContext | null { - return this._contextData.get('main')?.context || null; + existingContext(world: types.World): dom.FrameExecutionContext | null { + if (this._page.delegate.noUtilityWorld?.()) + world = 'main'; + return this._contextData.get(world)?.context || null; } utilityContext(): Promise { @@ -1780,7 +1782,7 @@ export class Frame extends SdkObject { const resolved = await progress.race(this.selectors.callOnSelector(selector, { ...options, scope, markTargets: 'first' }, ({ injected, elements }, { callbackText, taskData }) => { const callback = injected.eval(callbackText) as ElementCallback; const element = elements[0]; - const value = callback(injected, element, taskData); + const value = callback(injected, element, taskData as js.Unboxed); if (!value) return { success: false }; const log = ` locator resolved to ${injected.previewNode(element)}`; diff --git a/packages/playwright-core/src/server/javascript.ts b/packages/playwright-core/src/server/javascript.ts index 17aaf1683e229..0f549a0273542 100644 --- a/packages/playwright-core/src/server/javascript.ts +++ b/packages/playwright-core/src/server/javascript.ts @@ -31,7 +31,7 @@ interface TaggedAsElementHandle { __elementhandle: T; } type NoHandles = Arg extends TaggedAsJSHandle ? never : (Arg extends object ? { [Key in keyof Arg]: NoHandles } : Arg); -type Unboxed = +export type Unboxed = Arg extends TaggedAsElementHandle ? T : Arg extends TaggedAsJSHandle ? T : Arg extends NoHandles ? Arg : diff --git a/tests/page/selectors-frame.spec.ts b/tests/page/selectors-frame.spec.ts index 3ccfb6acd14af..8a305157bbc91 100644 --- a/tests/page/selectors-frame.spec.ts +++ b/tests/page/selectors-frame.spec.ts @@ -40,7 +40,7 @@ async function routeIframe(page: Page) { }); await page.route('**/iframe-2.html', route => { route.fulfill({ - body: '', + body: '', contentType: 'text/html' }).catch(() => {}); }); @@ -310,3 +310,87 @@ it('should non work for non-frame', async ({ page, server }) => { expect(error.message).toContain('
'); expect(error.message).toContain('', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/a.html', route => { + route.fulfill({ body: '
one
', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/b.html', route => { + route.fulfill({ body: 'twothree', contentType: 'text/html' }).catch(() => {}); + }); + await page.goto(server.EMPTY_PAGE); + + const texts = await page.$$eval('internal:control=pierce-frames >> span', els => els.map(e => e.textContent)); + expect(texts).toEqual(['two', 'three']); +}); + +it('should throw when piercing frames matches multiple frames', async ({ page, server }) => { + await page.route('**/empty.html', route => { + route.fulfill({ body: '', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/a.html', route => { + route.fulfill({ body: '
one
', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/b.html', route => { + route.fulfill({ body: '
two
', contentType: 'text/html' }).catch(() => {}); + }); + await page.goto(server.EMPTY_PAGE); + + // Make sure both child frames have their
before piercing, otherwise resolution + // may transiently collapse to a single frame. + await expect.poll(() => page.frames().length).toBe(3); + for (const frame of page.frames()) { + if (frame !== page.mainFrame()) + await frame.waitForSelector('div'); + } + + const error = await page.locator('internal:control=pierce-frames >> div').innerHTML().catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); +}); + +it('should not allow pierce-frames in the middle of a selector', async ({ page, server }) => { + await routeIframe(page); + await page.goto(server.EMPTY_PAGE); + const error = await page.locator('iframe >> internal:control=pierce-frames >> div').waitFor().catch(e => e); + expect(error.message).toContain('"pierce-frames" is only allowed as the first selector token'); +}); + +it('should not allow entering frames while piercing', async ({ page, server }) => { + await routeIframe(page); + await page.goto(server.EMPTY_PAGE); + const error = await page.locator('internal:control=pierce-frames >> iframe >> internal:control=enter-frame >> div').waitFor().catch(e => e); + expect(error.message).toContain('Entering frames is not allowed while piercing frames'); +});