diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 8468447d5d..41fad4a2e7 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -511,6 +511,13 @@ contexts override the proxy, global proxy will be never used and can be any stri `launch({ proxy: { server: 'http://per-context' } })`. ::: +## context-option-strict +- `strictSelectors` <[boolean]> + +It specified, enables strict selectors mode for this context. In the strict selectors mode all operations +on selectors that imply single target DOM element will throw when more than one element matches the selector. +See [Locator] to learn more about the strict mode. + ## select-options-values * langs: java, js, csharp - `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> @@ -637,6 +644,7 @@ using the [`method: AndroidDevice.setDefaultTimeout`] method. - %%-context-option-recordvideo-%% - %%-context-option-recordvideo-dir-%% - %%-context-option-recordvideo-size-%% +- %%-context-option-strict-%% ## browser-option-args - `args` <[Array]<[string]>> diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 1d2d2b5a50..de06908f9b 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -342,6 +342,7 @@ export type BrowserTypeLaunchPersistentContextParams = { omitContent?: boolean, path: string, }, + strictSelectors?: boolean, userDataDir: string, slowMo?: number, }; @@ -413,6 +414,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { omitContent?: boolean, path: string, }, + strictSelectors?: boolean, slowMo?: number, }; export type BrowserTypeLaunchPersistentContextResult = { @@ -504,6 +506,7 @@ export type BrowserNewContextParams = { omitContent?: boolean, path: string, }, + strictSelectors?: boolean, proxy?: { server: string, bypass?: string, @@ -562,6 +565,7 @@ export type BrowserNewContextOptions = { omitContent?: boolean, path: string, }, + strictSelectors?: boolean, proxy?: { server: string, bypass?: string, @@ -2768,6 +2772,7 @@ export type ElectronLaunchParams = { height: number, }, }, + strictSelectors?: boolean, timezoneId?: string, }; export type ElectronLaunchOptions = { @@ -2803,6 +2808,7 @@ export type ElectronLaunchOptions = { height: number, }, }, + strictSelectors?: boolean, timezoneId?: string, }; export type ElectronLaunchResult = { @@ -3134,6 +3140,7 @@ export type AndroidDeviceLaunchBrowserParams = { omitContent?: boolean, path: string, }, + strictSelectors?: boolean, proxy?: { server: string, bypass?: string, @@ -3179,6 +3186,7 @@ export type AndroidDeviceLaunchBrowserOptions = { omitContent?: boolean, path: string, }, + strictSelectors?: boolean, proxy?: { server: string, bypass?: string, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 063000673f..b1926e77f2 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -323,6 +323,7 @@ ContextOptions: properties: omitContent: boolean? path: string + strictSelectors: boolean? Playwright: @@ -2393,6 +2394,7 @@ Electron: properties: width: number height: number + strictSelectors: boolean? timezoneId: string? returns: @@ -2659,6 +2661,7 @@ AndroidDevice: properties: omitContent: boolean? path: string + strictSelectors: boolean? proxy: type: object? properties: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index ed92a9d3c2..a42463e709 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -251,6 +251,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { omitContent: tOptional(tBoolean), path: tString, })), + strictSelectors: tOptional(tBoolean), userDataDir: tString, slowMo: tOptional(tNumber), }); @@ -311,6 +312,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { omitContent: tOptional(tBoolean), path: tString, })), + strictSelectors: tOptional(tBoolean), proxy: tOptional(tObject({ server: tString, bypass: tOptional(tString), @@ -1090,6 +1092,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { height: tNumber, })), })), + strictSelectors: tOptional(tBoolean), timezoneId: tOptional(tString), }); scheme.ElectronApplicationBrowserWindowParams = tObject({ @@ -1232,6 +1235,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { omitContent: tOptional(tBoolean), path: tString, })), + strictSelectors: tOptional(tBoolean), proxy: tOptional(tObject({ server: tString, bypass: tOptional(tString), diff --git a/src/server/dom.ts b/src/server/dom.ts index e3fc075b75..e6992b63a5 100644 --- a/src/server/dom.ts +++ b/src/server/dom.ts @@ -672,7 +672,7 @@ export class ElementHandle extends js.JSHandle { } async querySelector(selector: string, options: types.StrictOptions): Promise { - return this._page.selectors._query(this._context.frame, selector, !!options.strict, this); + return this._page.selectors.query(this._context.frame, selector, options, this); } async querySelectorAll(selector: string): Promise[]> { @@ -680,7 +680,7 @@ export class ElementHandle extends js.JSHandle { } async evalOnSelectorAndWaitForSignals(selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any): Promise { - const handle = await this._page.selectors._query(this._context.frame, selector, strict, this); + const handle = await this._page.selectors.query(this._context.frame, 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); @@ -743,7 +743,7 @@ export class ElementHandle extends js.JSHandle { 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.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = waitForSelectorTask(info, state, this); const controller = new ProgressController(metadata, this); return controller.run(async progress => { diff --git a/src/server/frames.ts b/src/server/frames.ts index 07c09effcd..09cccf4f67 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -684,7 +684,7 @@ export class Frame extends SdkObject { async querySelector(selector: string, options: types.StrictOptions): Promise | null> { debugLogger.log('api', ` finding element using the selector "${selector}"`); - return this._page.selectors._query(this, selector, !!options.strict); + return this._page.selectors.query(this, selector, options); } async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions = {}): Promise | null> { @@ -696,7 +696,7 @@ 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.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.waitForSelectorTask(info, state); return controller.run(async progress => { progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); @@ -725,7 +725,7 @@ export class Frame extends SdkObject { async dispatchEvent(metadata: CallMetadata, selector: string, type: string, eventInit?: Object, options: types.QueryOnSelectorOptions = {}): Promise { const controller = new ProgressController(metadata, this); - const info = this._page.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.dispatchEventTask(info, type, eventInit || {}); await controller.run(async progress => { progress.log(`Dispatching "${type}" event on selector "${selector}"...`); @@ -938,7 +938,7 @@ export class Frame extends SdkObject { selector: string, strict: boolean, action: (handle: dom.ElementHandle) => Promise): Promise { - const info = this._page.selectors._parseSelector(selector, strict); + const info = this._page.parseSelector(selector, { strict }); while (progress.isRunning()) { progress.log(`waiting for selector "${selector}"`); const task = dom.waitForSelectorTask(info, 'attached'); @@ -1031,7 +1031,7 @@ export class Frame extends SdkObject { async textContent(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise { const controller = new ProgressController(metadata, this); - const info = this._page.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.textContentTask(info); return controller.run(async progress => { progress.log(` retrieving textContent from "${selector}"`); @@ -1041,7 +1041,7 @@ export class Frame extends SdkObject { async innerText(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise { const controller = new ProgressController(metadata, this); - const info = this._page.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.innerTextTask(info); return controller.run(async progress => { progress.log(` retrieving innerText from "${selector}"`); @@ -1052,7 +1052,7 @@ export class Frame extends SdkObject { async innerHTML(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise { const controller = new ProgressController(metadata, this); - const info = this._page.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.innerHTMLTask(info); return controller.run(async progress => { progress.log(` retrieving innerHTML from "${selector}"`); @@ -1062,7 +1062,7 @@ export class Frame extends SdkObject { async getAttribute(metadata: CallMetadata, selector: string, name: string, options: types.QueryOnSelectorOptions = {}): Promise { const controller = new ProgressController(metadata, this); - const info = this._page.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.getAttributeTask(info, name); return controller.run(async progress => { progress.log(` retrieving attribute "${name}" from "${selector}"`); @@ -1072,7 +1072,7 @@ export class Frame extends SdkObject { async inputValue(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}): Promise { const controller = new ProgressController(metadata, this); - const info = this._page.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.inputValueTask(info); return controller.run(async progress => { progress.log(` retrieving value from "${selector}"`); @@ -1082,7 +1082,7 @@ export class Frame extends SdkObject { private async _checkElementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions = {}): Promise { const controller = new ProgressController(metadata, this); - const info = this._page.selectors._parseSelector(selector, !!options.strict); + const info = this._page.parseSelector(selector, options); const task = dom.elementStateTask(info, state); const result = await controller.run(async progress => { progress.log(` checking "${state}" state of "${selector}"`); diff --git a/src/server/page.ts b/src/server/page.ts index 35940bb794..b49e4a8e7e 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -30,7 +30,7 @@ import { FileChooser } from './fileChooser'; import { Progress, ProgressController } from './progress'; import { assert, isError } from '../utils/utils'; import { debugLogger } from '../utils/debugLogger'; -import { Selectors } from './selectors'; +import { SelectorInfo, Selectors } from './selectors'; import { CallMetadata, SdkObject } from './instrumentation'; import { Artifact } from './artifact'; @@ -506,6 +506,11 @@ export class Page extends SdkObject { firePageError(error: Error) { this.emit(Page.Events.PageError, error); } + + parseSelector(selector: string, options?: types.StrictOptions): SelectorInfo { + const strict = typeof options?.strict === 'boolean' ? options.strict : !!this.context()._options.strictSelectors; + return this.selectors.parseSelector(selector, strict); + } } export class Worker extends SdkObject { diff --git a/src/server/selectors.ts b/src/server/selectors.ts index 4dd5e6aaa5..e6989c88dd 100644 --- a/src/server/selectors.ts +++ b/src/server/selectors.ts @@ -68,13 +68,13 @@ export class Selectors { this._engines.clear(); } - async _query(frame: frames.Frame, selector: string, strict: boolean, scope?: dom.ElementHandle): Promise | null> { - const info = this._parseSelector(selector, strict); + async query(frame: frames.Frame, selector: string, options: { strict?: boolean }, scope?: dom.ElementHandle): Promise | null> { + const info = frame._page.parseSelector(selector, options); const context = await frame._context(info.world); const injectedScript = await context.injectedScript(); const handle = await injectedScript.evaluateHandle((injected, { parsed, scope, strict }) => { return injected.querySelector(parsed, scope || document, strict); - }, { parsed: info.parsed, scope, strict }); + }, { parsed: info.parsed, scope, strict: info.strict }); const elementHandle = handle.asElement() as dom.ElementHandle | null; if (!elementHandle) { handle.dispose(); @@ -85,7 +85,7 @@ export class Selectors { } async _queryArray(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise> { - const info = this._parseSelector(selector, false); + const info = this.parseSelector(selector, false); const context = await frame._mainContext(); const injectedScript = await context.injectedScript(); const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => { @@ -95,7 +95,7 @@ export class Selectors { } async _queryAll(frame: frames.Frame, selector: string, scope?: dom.ElementHandle, adoptToMain?: boolean): Promise[]> { - const info = this._parseSelector(selector, false); + const info = this.parseSelector(selector, false); const context = await frame._context(info.world); const injectedScript = await context.injectedScript(); const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => { @@ -127,7 +127,7 @@ export class Selectors { return adopted; } - _parseSelector(selector: string, strict: boolean): SelectorInfo { + parseSelector(selector: string, strict: boolean): SelectorInfo { const parsed = parseSelector(selector); let needsMainWorld = false; for (const part of parsed.parts) { diff --git a/src/server/types.ts b/src/server/types.ts index 9eb4890070..8b20414c6f 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -274,6 +274,7 @@ export type BrowserContextOptions = { omitContent?: boolean, path: string }, + strictSelectors?: boolean, proxy?: ProxySettings, baseURL?: string, _debugName?: string, diff --git a/tests/browsercontext-strict.spec.ts b/tests/browsercontext-strict.spec.ts new file mode 100644 index 0000000000..9a9befa0cb --- /dev/null +++ b/tests/browsercontext-strict.spec.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { browserTest as it, expect } from './config/browserTest'; + +it('should not fail page.textContent in non-strict mode', async ({ page }) => { + await page.setContent(`span1
target
`); + expect(await page.textContent('span', { strict: false })).toBe('span1'); +}); + +it.describe('strict context mode', () => { + it.use({ + contextOptions: async ({ contextOptions }, use) => { + const options = { ...contextOptions, strictSelectors: true }; + await use(options); + } + }); + + it('should fail page.textContent in strict mode', async ({ page }) => { + await page.setContent(`span1
target
`); + const error = await page.textContent('span').catch(e => e); + expect(error.message).toContain('strict mode violation'); + }); + + it('should opt out of strict mode', async ({ page }) => { + await page.setContent(`span1
target
`); + expect(await page.textContent('span', { strict: false })).toBe('span1'); + }); +}); diff --git a/types/types.d.ts b/types/types.d.ts index cff6557dc5..2f413e531d 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -8510,6 +8510,13 @@ export interface BrowserType { */ slowMo?: number; + /** + * It specified, enables strict selectors mode for this context. In the strict selectors mode all operations on selectors + * that imply single target DOM element will throw when more than one element matches the selector. See [Locator] to learn + * more about the strict mode. + */ + strictSelectors?: boolean; + /** * Maximum time in milliseconds to wait for the browser instance to start. Defaults to `30000` (30 seconds). Pass `0` to * disable timeout. @@ -9562,6 +9569,13 @@ export interface AndroidDevice { height: number; }; + /** + * It specified, enables strict selectors mode for this context. In the strict selectors mode all operations on selectors + * that imply single target DOM element will throw when more than one element matches the selector. See [Locator] to learn + * more about the strict mode. + */ + strictSelectors?: boolean; + /** * Changes the timezone of the context. See * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) @@ -10417,6 +10431,13 @@ export interface Browser extends EventEmitter { }>; }; + /** + * It specified, enables strict selectors mode for this context. In the strict selectors mode all operations on selectors + * that imply single target DOM element will throw when more than one element matches the selector. See [Locator] to learn + * more about the strict mode. + */ + strictSelectors?: boolean; + /** * Changes the timezone of the context. See * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) @@ -12535,6 +12556,13 @@ export interface BrowserContextOptions { }>; }; + /** + * It specified, enables strict selectors mode for this context. In the strict selectors mode all operations on selectors + * that imply single target DOM element will throw when more than one element matches the selector. See [Locator] to learn + * more about the strict mode. + */ + strictSelectors?: boolean; + /** * Changes the timezone of the context. See * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1)