diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index d952c9aa94..21ce066a62 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -102,8 +102,6 @@ export abstract class BrowserContext extends SdkObject { setSelectors(selectors: Selectors) { this._selectors = selectors; - for (const page of this.pages()) - page.selectors = selectors; } selectors(): Selectors { diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index bccb829dca..214e8cdd47 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -95,7 +95,8 @@ export class FrameExecutionContext extends js.ExecutionContext { injectedScript(): Promise> { if (!this._injectedScriptPromise) { const custom: string[] = []; - for (const [name, { source }] of this.frame._page.selectors._engines) + const selectorsRegistry = this.frame._page.context().selectors(); + for (const [name, { source }] of selectorsRegistry._engines) custom.push(`{ name: '${name}', engine: (${source}) }`); const sdkLanguage = this.frame._page.context()._browser.options.sdkLanguage; const source = ` @@ -106,7 +107,7 @@ export class FrameExecutionContext extends js.ExecutionContext { globalThis, ${isUnderTest()}, "${sdkLanguage}", - ${JSON.stringify(this.frame._page.selectors.testIdAttributeName())}, + ${JSON.stringify(selectorsRegistry.testIdAttributeName())}, ${this.frame._page._delegate.rafCountForStablePosition()}, "${this.frame._page._browserContext._browser.options.name}", [${custom.join(',\n')}] @@ -754,27 +755,15 @@ export class ElementHandle extends js.JSHandle { } async querySelector(selector: string, options: types.StrictOptions): Promise { - const pair = await this._frame.resolveFrameForSelectorNoWait(selector, options, this); - if (!pair) - return null; - const { frame, info } = pair; - // 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); + return this._frame.selectors.query(selector, options, this); } async querySelectorAll(selector: string): Promise[]> { - const pair = await this._frame.resolveFrameForSelectorNoWait(selector, {}, this); - if (!pair) - return []; - const { frame, info } = pair; - // 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 */); + return this._frame.selectors.queryAll(selector, this); } async evalOnSelectorAndWaitForSignals(selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise { - const pair = 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 = pair ? await this._page.selectors.query(pair.frame, pair.info, this._frame === pair.frame ? this : undefined) : null; + const handle = await this._frame.selectors.query(selector, { strict }, this); if (!handle) throw new Error(`Error: failed to find element matching selector "${selector}"`); const result = await handle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg); @@ -783,12 +772,7 @@ export class ElementHandle extends js.JSHandle { } async evalOnSelectorAllAndWaitForSignals(selector: string, expression: string, isFunction: boolean | undefined, arg: any): Promise { - const pair = await this._frame.resolveFrameForSelectorNoWait(selector, {}, this); - if (!pair) - throw new Error(`Error: failed to find frame for selector "${selector}"`); - const { frame, info } = pair; - // If we end up in the same frame => use the scope again, line above was noop. - const arrayHandle = await this._page.selectors._queryArrayInMainWorld(frame, info, this._frame === frame ? this : undefined); + const arrayHandle = await this._frame.selectors.queryArrayInMainWorld(selector, this); const result = await arrayHandle.evaluateExpressionAndWaitForSignals(expression, isFunction, true, arg); arrayHandle.dispose(); return result; diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts new file mode 100644 index 0000000000..df05f926f6 --- /dev/null +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -0,0 +1,156 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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 { type Frame } from './frames'; +import type * as types from './types'; +import { stringifySelector, type ParsedSelector, splitSelectorByFrame } from './isomorphic/selectorParser'; +import { type FrameExecutionContext, type ElementHandle } from './dom'; +import { type JSHandle } from './javascript'; +import { type InjectedScript } from './injected/injectedScript'; + +export type SelectorInfo = { + parsed: ParsedSelector, + world: types.World, + strict: boolean, +}; + +export type SelectorInFrame = { + frame: Frame; + info: SelectorInfo; + scope?: ElementHandle; +}; + +export class FrameSelectors { + readonly frame: Frame; + + constructor(frame: Frame) { + this.frame = frame; + } + + private _parseSelector(selector: string | ParsedSelector, options?: types.StrictOptions): SelectorInfo { + const strict = typeof options?.strict === 'boolean' ? options.strict : !!this.frame._page.context()._options.strictSelectors; + return this.frame._page.context().selectors().parseSelector(selector, strict); + } + + async query(selector: string, options?: types.StrictOptions, scope?: ElementHandle): Promise | null> { + const resolved = await this.resolveInjectedForSelector(selector, options, scope); + // Be careful, |this.frame| can be different from |resolved.frame|. + if (!resolved) + return null; + const handle = await resolved.injected.evaluateHandle((injected, { info, scope }) => { + return injected.querySelector(info.parsed, scope || document, info.strict); + }, { info: resolved.info, scope: resolved.scope }); + const elementHandle = handle.asElement() as ElementHandle | null; + if (!elementHandle) { + handle.dispose(); + return null; + } + return adoptIfNeeded(elementHandle, await resolved.frame._mainContext()); + } + + async queryArrayInMainWorld(selector: string, scope?: ElementHandle): Promise> { + const resolved = await this.resolveInjectedForSelector(selector, { mainWorld: true }, scope); + // Be careful, |this.frame| can be different from |resolved.frame|. + if (!resolved) + throw new Error(`Error: failed to find frame for selector "${selector}"`); + return await resolved.injected.evaluateHandle((injected, { info, scope }) => { + return injected.querySelectorAll(info.parsed, scope || document); + }, { info: resolved.info, scope: resolved.scope }); + } + + async queryCount(selector: string): Promise { + const resolved = await this.resolveInjectedForSelector(selector); + // Be careful, |this.frame| can be different from |resolved.frame|. + if (!resolved) + throw new Error(`Error: failed to find frame for selector "${selector}"`); + return await resolved.injected.evaluate((injected, { info }) => { + return injected.querySelectorAll(info.parsed, document).length; + }, { info: resolved.info }); + } + + async queryAll(selector: string, scope?: ElementHandle): Promise[]> { + const resolved = await this.resolveInjectedForSelector(selector, {}, scope); + // Be careful, |this.frame| can be different from |resolved.frame|. + if (!resolved) + return []; + const arrayHandle = await resolved.injected.evaluateHandle((injected, { info, scope }) => { + return injected.querySelectorAll(info.parsed, scope || document); + }, { info: resolved.info, scope: resolved.scope }); + + const properties = await arrayHandle.getProperties(); + arrayHandle.dispose(); + + // Note: adopting elements one by one may be slow. If we encounter the issue here, + // we might introduce 'useMainContext' option or similar to speed things up. + const targetContext = await resolved.frame._mainContext(); + const result: Promise>[] = []; + for (const property of properties.values()) { + const elementHandle = property.asElement() as ElementHandle; + if (elementHandle) + result.push(adoptIfNeeded(elementHandle, targetContext)); + else + property.dispose(); + } + return Promise.all(result); + } + + async resolveFrameForSelector(selector: string, options: types.StrictOptions = {}, scope?: ElementHandle): Promise { + let frame: Frame = this.frame; + const frameChunks = splitSelectorByFrame(selector); + + for (let i = 0; i < frameChunks.length - 1; ++i) { + const info = this._parseSelector(frameChunks[i], options); + const context = await frame._context(info.world); + const injectedScript = await context.injectedScript(); + const handle = await injectedScript.evaluateHandle((injected, { info, scope, selectorString }) => { + const element = injected.querySelector(info.parsed, scope || document, info.strict); + if (element && element.nodeName !== 'IFRAME' && element.nodeName !== 'FRAME') + throw injected.createStacklessError(`Selector "${selectorString}" resolved to ${injected.previewNode(element)},