feat(strict): add context-level selector strictness (#8290)

This commit is contained in:
Pavel Feldman 2021-08-18 12:51:45 -07:00 committed by GitHub
parent 1426f66ccd
commit 6ef76e333e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 119 additions and 20 deletions

View file

@ -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]>>

View file

@ -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,

View file

@ -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:

View file

@ -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),

View file

@ -672,7 +672,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async querySelector(selector: string, options: types.StrictOptions): Promise<ElementHandle | null> {
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<ElementHandle<Element>[]> {
@ -680,7 +680,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
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 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<T extends Node = Node> extends js.JSHandle<T> {
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 => {

View file

@ -684,7 +684,7 @@ export class Frame extends SdkObject {
async querySelector(selector: string, options: types.StrictOptions): Promise<dom.ElementHandle<Element> | 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<dom.ElementHandle<Element> | 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<void> {
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<Element>) => Promise<R | 'error:notconnected'>): Promise<R> {
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<string | null> {
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<string> {
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<string> {
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<string | null> {
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<string> {
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<boolean> {
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}"`);

View file

@ -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 {

View file

@ -68,13 +68,13 @@ export class Selectors {
this._engines.clear();
}
async _query(frame: frames.Frame, selector: string, strict: boolean, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
const info = this._parseSelector(selector, strict);
async query(frame: frames.Frame, selector: string, options: { strict?: boolean }, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | 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<Element> | null;
if (!elementHandle) {
handle.dispose();
@ -85,7 +85,7 @@ export class Selectors {
}
async _queryArray(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<js.JSHandle<Element[]>> {
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<dom.ElementHandle<Element>[]> {
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) {

View file

@ -274,6 +274,7 @@ export type BrowserContextOptions = {
omitContent?: boolean,
path: string
},
strictSelectors?: boolean,
proxy?: ProxySettings,
baseURL?: string,
_debugName?: string,

View file

@ -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(`<span>span1</span><div><span>target</span></div>`);
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(`<span>span1</span><div><span>target</span></div>`);
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(`<span>span1</span><div><span>target</span></div>`);
expect(await page.textContent('span', { strict: false })).toBe('span1');
});
});

28
types/types.d.ts vendored
View file

@ -8510,6 +8510,13 @@ export interface BrowserType<Unused = {}> {
*/
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)