chore: merge Selectors into BrowserContext in the protocol (#36017)

This commit is contained in:
Dmitry Gozman 2025-05-21 08:09:17 +00:00 committed by GitHub
parent 42ea95e1c1
commit 92d4ce30c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 176 additions and 221 deletions

View File

@ -75,7 +75,12 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
}
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {
options = { ...this._browserType._playwright._defaultContextOptions, ...options };
options = {
...this._browserType._playwright._defaultContextOptions,
...options,
selectorEngines: this._browserType._playwright.selectors._selectorEngines,
testIdAttributeName: this._browserType._playwright.selectors._testIdAttributeName,
};
const contextOptions = await prepareBrowserContextParams(this._platform, options);
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = BrowserContext.from(response.context);

View File

@ -93,7 +93,13 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise<BrowserContext> {
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
options = { ...this._playwright._defaultLaunchOptions, ...this._playwright._defaultContextOptions, ...options };
options = {
...this._playwright._defaultLaunchOptions,
...this._playwright._defaultContextOptions,
...options,
selectorEngines: this._playwright.selectors._selectorEngines,
testIdAttributeName: this._playwright.selectors._testIdAttributeName,
};
const contextParams = await prepareBrowserContextParams(this._platform, options);
const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = {
...contextParams,
@ -157,7 +163,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
connection.close();
throw new Error('Malformed endpoint. Did you use BrowserType.launchServer method?');
}
playwright._setSelectors(this._playwright.selectors);
this._playwright.selectors._playwrights.add(playwright);
connection.on('close', () => this._playwright.selectors._playwrights.delete(playwright));
browser = Browser.from(playwright._initializer.preLaunchedBrowser!);
this._didLaunchBrowser(browser, {}, logger);
browser._shouldCloseConnectionOnClose = true;

View File

@ -35,7 +35,6 @@ import { LocalUtils } from './localUtils';
import { Request, Response, Route, WebSocket, WebSocketRoute } from './network';
import { BindingCall, Page } from './page';
import { Playwright } from './playwright';
import { SelectorsOwner } from './selectors';
import { Stream } from './stream';
import { Tracing } from './tracing';
import { Worker } from './worker';
@ -311,9 +310,6 @@ export class Connection extends EventEmitter {
case 'Stream':
result = new Stream(parent, type, guid, initializer);
break;
case 'Selectors':
result = new SelectorsOwner(parent, type, guid, initializer);
break;
case 'SocksSupport':
result = new DummyChannelOwner(parent, type, guid, initializer);
break;

View File

@ -21,7 +21,7 @@ import { ChannelOwner } from './channelOwner';
import { Electron } from './electron';
import { TimeoutError } from './errors';
import { APIRequest } from './fetch';
import { Selectors, SelectorsOwner } from './selectors';
import { Selectors } from './selectors';
import type * as channels from '@protocol/channels';
import type { BrowserContextOptions, LaunchOptions } from 'playwright-core';
@ -62,23 +62,11 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
this._bidiFirefox._playwright = this;
this.devices = this._connection.localUtils()?.devices ?? {};
this.selectors = new Selectors();
this.selectors._playwrights.add(this);
this.errors = { TimeoutError };
const selectorsOwner = SelectorsOwner.from(initializer.selectors);
this.selectors._addChannel(selectorsOwner);
this._connection.on('close', () => {
this.selectors._removeChannel(selectorsOwner);
});
(global as any)._playwrightInstance = this;
}
_setSelectors(selectors: Selectors) {
const selectorsOwner = SelectorsOwner.from(this._initializer.selectors);
this.selectors._removeChannel(selectorsOwner);
this.selectors = selectors;
this.selectors._addChannel(selectorsOwner);
}
static from(channel: channels.PlaywrightChannel): Playwright {
return (channel as any)._object;
}

View File

@ -14,56 +14,36 @@
* limitations under the License.
*/
import { ChannelOwner } from './channelOwner';
import { evaluationScript } from './clientHelper';
import { setTestIdAttribute, testIdAttributeName } from './locator';
import { emptyPlatform } from './platform';
import { setTestIdAttribute } from './locator';
import type { SelectorEngine } from './types';
import type * as api from '../../types/types';
import type * as channels from '@protocol/channels';
import type { Platform } from './platform';
let platform = emptyPlatform;
export function setPlatformForSelectors(p: Platform) {
platform = p;
}
import type { Playwright } from './playwright';
export class Selectors implements api.Selectors {
private _channels = new Set<SelectorsOwner>();
private _registrations: channels.SelectorsRegisterParams[] = [];
_playwrights = new Set<Playwright>();
_selectorEngines: channels.SelectorEngine[] = [];
_testIdAttributeName: string | undefined;
async register(name: string, script: string | (() => SelectorEngine) | { path?: string, content?: string }, options: { contentScript?: boolean } = {}): Promise<void> {
const platform = this._playwrights.values().next().value!._platform;
const source = await evaluationScript(platform, script, undefined, false);
const params = { ...options, name, source };
for (const channel of this._channels)
await channel._channel.register(params);
this._registrations.push(params);
const selectorEngine: channels.SelectorEngine = { ...options, name, source };
for (const playwright of this._playwrights) {
for (const context of playwright._allContexts())
await context._channel.registerSelectorEngine({ selectorEngine });
}
this._selectorEngines.push(selectorEngine);
}
setTestIdAttribute(attributeName: string) {
this._testIdAttributeName = attributeName;
setTestIdAttribute(attributeName);
for (const channel of this._channels)
channel._channel.setTestIdAttributeName({ testIdAttributeName: attributeName }).catch(() => {});
}
_addChannel(channel: SelectorsOwner) {
this._channels.add(channel);
for (const params of this._registrations) {
// This should not fail except for connection closure, but just in case we catch.
channel._channel.register(params).catch(() => {});
channel._channel.setTestIdAttributeName({ testIdAttributeName: testIdAttributeName() }).catch(() => {});
for (const playwright of this._playwrights) {
for (const context of playwright._allContexts())
context._channel.setTestIdAttributeName({ testIdAttributeName: attributeName }).catch(() => {});
}
}
_removeChannel(channel: SelectorsOwner) {
this._channels.delete(channel);
}
}
export class SelectorsOwner extends ChannelOwner<channels.SelectorsChannel> {
static from(browser: channels.SelectorsChannel): SelectorsOwner {
return (browser as any)._object;
}
}

View File

@ -19,14 +19,12 @@ import { BrowserServerLauncherImpl } from './browserServerImpl';
import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlaywright } from './server';
import { nodePlatform } from './server/utils/nodePlatform';
import { Connection } from './client/connection';
import { setPlatformForSelectors } from './client/selectors';
import type { Playwright as PlaywrightAPI } from './client/playwright';
import type { Language } from './utils';
export function createInProcessPlaywright(): PlaywrightAPI {
const playwright = createPlaywright({ sdkLanguage: (process.env.PW_LANG_NAME as Language | undefined) || 'javascript' });
setPlatformForSelectors(nodePlatform);
const clientConnection = new Connection(nodePlatform);
clientConnection.useRawBuffers();
const dispatcherConnection = new DispatcherConnection(true /* local */);

View File

@ -21,13 +21,11 @@ import { Connection } from './client/connection';
import { PipeTransport } from './server/utils/pipeTransport';
import { ManualPromise } from './utils/isomorphic/manualPromise';
import { nodePlatform } from './server/utils/nodePlatform';
import { setPlatformForSelectors } from './client/selectors';
import type { Playwright } from './client/playwright';
export async function start(env: any = {}): Promise<{ playwright: Playwright, stop: () => Promise<void> }> {
setPlatformForSelectors(nodePlatform);
const client = new PlaywrightClient(env);
const playwright = await client._playwright;
(playwright as any).driverProcess = client._driverProcess;

View File

@ -50,8 +50,6 @@ export const methodMetainfo = new Map<string, { internal?: boolean, title?: stri
['SocksSupport.socksData', { internal: true, }],
['SocksSupport.socksError', { internal: true, }],
['SocksSupport.socksEnd', { internal: true, }],
['Selectors.register', { internal: true, }],
['Selectors.setTestIdAttributeName', { internal: true, }],
['BrowserType.launch', { title: 'Launch browser', }],
['BrowserType.launchPersistentContext', { title: 'Launch persistent context', }],
['BrowserType.connectOverCDP', { title: 'Connect over CDP', }],
@ -74,6 +72,8 @@ export const methodMetainfo = new Map<string, { internal?: boolean, title?: stri
['BrowserContext.exposeBinding', { title: 'Expose binding', }],
['BrowserContext.grantPermissions', { title: 'Grant permissions', }],
['BrowserContext.newPage', { title: 'Create new page', }],
['BrowserContext.registerSelectorEngine', { internal: true, }],
['BrowserContext.setTestIdAttributeName', { internal: true, }],
['BrowserContext.setExtraHTTPHeaders', { title: 'Set extra HTTP headers', }],
['BrowserContext.setGeolocation', { title: 'Set geolocation', }],
['BrowserContext.setHTTPCredentials', { title: 'Set HTTP credentials', }],

View File

@ -92,6 +92,11 @@ scheme.ExpectedTextValue = tObject({
ignoreCase: tOptional(tBoolean),
normalizeWhiteSpace: tOptional(tBoolean),
});
scheme.SelectorEngine = tObject({
name: tString,
source: tString,
contentScript: tOptional(tBoolean),
});
scheme.AXNode = tObject({
role: tString,
name: tString,
@ -372,7 +377,6 @@ scheme.PlaywrightInitializer = tObject({
android: tChannel(['Android']),
electron: tChannel(['Electron']),
utils: tOptional(tChannel(['LocalUtils'])),
selectors: tChannel(['Selectors']),
preLaunchedBrowser: tOptional(tChannel(['Browser'])),
preConnectedAndroidDevice: tOptional(tChannel(['AndroidDevice'])),
socksSupport: tOptional(tChannel(['SocksSupport'])),
@ -517,17 +521,6 @@ scheme.SocksSupportSocksEndParams = tObject({
uid: tString,
});
scheme.SocksSupportSocksEndResult = tOptional(tObject({}));
scheme.SelectorsInitializer = tOptional(tObject({}));
scheme.SelectorsRegisterParams = tObject({
name: tString,
source: tString,
contentScript: tOptional(tBoolean),
});
scheme.SelectorsRegisterResult = tOptional(tObject({}));
scheme.SelectorsSetTestIdAttributeNameParams = tObject({
testIdAttributeName: tString,
});
scheme.SelectorsSetTestIdAttributeNameResult = tOptional(tObject({}));
scheme.BrowserTypeInitializer = tObject({
executablePath: tString,
name: tString,
@ -642,6 +635,8 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({
recordHar: tOptional(tType('RecordHarOptions')),
strictSelectors: tOptional(tBoolean),
serviceWorkers: tOptional(tEnum(['allow', 'block'])),
selectorEngines: tOptional(tArray(tType('SelectorEngine'))),
testIdAttributeName: tOptional(tString),
userDataDir: tString,
slowMo: tOptional(tNumber),
});
@ -729,6 +724,8 @@ scheme.BrowserNewContextParams = tObject({
recordHar: tOptional(tType('RecordHarOptions')),
strictSelectors: tOptional(tBoolean),
serviceWorkers: tOptional(tEnum(['allow', 'block'])),
selectorEngines: tOptional(tArray(tType('SelectorEngine'))),
testIdAttributeName: tOptional(tString),
proxy: tOptional(tObject({
server: tString,
bypass: tOptional(tString),
@ -799,6 +796,8 @@ scheme.BrowserNewContextForReuseParams = tObject({
recordHar: tOptional(tType('RecordHarOptions')),
strictSelectors: tOptional(tBoolean),
serviceWorkers: tOptional(tEnum(['allow', 'block'])),
selectorEngines: tOptional(tArray(tType('SelectorEngine'))),
testIdAttributeName: tOptional(tString),
proxy: tOptional(tObject({
server: tString,
bypass: tOptional(tString),
@ -963,6 +962,14 @@ scheme.BrowserContextNewPageParams = tOptional(tObject({}));
scheme.BrowserContextNewPageResult = tObject({
page: tChannel(['Page']),
});
scheme.BrowserContextRegisterSelectorEngineParams = tObject({
selectorEngine: tType('SelectorEngine'),
});
scheme.BrowserContextRegisterSelectorEngineResult = tOptional(tObject({}));
scheme.BrowserContextSetTestIdAttributeNameParams = tObject({
testIdAttributeName: tString,
});
scheme.BrowserContextSetTestIdAttributeNameResult = tOptional(tObject({}));
scheme.BrowserContextSetExtraHTTPHeadersParams = tObject({
headers: tArray(tType('NameValue')),
});
@ -2695,6 +2702,8 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({
recordHar: tOptional(tType('RecordHarOptions')),
strictSelectors: tOptional(tBoolean),
serviceWorkers: tOptional(tEnum(['allow', 'block'])),
selectorEngines: tOptional(tArray(tType('SelectorEngine'))),
testIdAttributeName: tOptional(tString),
pkg: tOptional(tString),
args: tOptional(tArray(tString)),
proxy: tOptional(tObject({

View File

@ -32,6 +32,7 @@ import { InitScript } from './page';
import { Page, PageBinding } from './page';
import { Recorder } from './recorder';
import { RecorderApp } from './recorder/recorderApp';
import { Selectors } from './selectors';
import { Tracing } from './trace/recorder/tracing';
import * as js from './javascript';
import * as rawStorageSource from '../generated/storageScriptSource';
@ -43,7 +44,6 @@ import type { Download } from './download';
import type * as frames from './frames';
import type { CallMetadata } from './instrumentation';
import type { Progress, ProgressController } from './progress';
import type { Selectors } from './selectors';
import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import type { SerializedStorage } from '@injected/storageScript';
import type * as types from './types';
@ -81,7 +81,7 @@ export abstract class BrowserContext extends SdkObject {
readonly _downloads = new Set<Download>();
readonly _browser: Browser;
readonly _browserContextId: string | undefined;
private _selectors?: Selectors;
private _selectors: Selectors;
private _origins = new Set<string>();
readonly _harRecorders = new Map<string, HarRecorder>();
readonly tracing: Tracing;
@ -106,6 +106,7 @@ export abstract class BrowserContext extends SdkObject {
this._browserContextId = browserContextId;
this._isPersistentContext = !browserContextId;
this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill);
this._selectors = new Selectors(options.selectorEngines || [], options.testIdAttributeName);
this.fetchRequest = new BrowserContextAPIRequestContext(this);
@ -120,12 +121,8 @@ export abstract class BrowserContext extends SdkObject {
return this._isPersistentContext;
}
setSelectors(selectors: Selectors) {
this._selectors = selectors;
}
selectors(): Selectors {
return this._selectors || this.attribution.playwright.selectors;
return this._selectors;
}
async _initialize() {
@ -183,6 +180,9 @@ export abstract class BrowserContext extends SdkObject {
static reusableContextHash(params: channels.BrowserNewContextForReuseParams): string {
const paramsCopy = { ...params };
if (paramsCopy.selectorEngines?.length === 0)
delete paramsCopy.selectorEngines;
for (const k of Object.keys(paramsCopy)) {
const key = k as keyof channels.BrowserNewContextForReuseParams;
if (paramsCopy[key] === defaultNewContextParamValues[key])
@ -200,6 +200,8 @@ export abstract class BrowserContext extends SdkObject {
if (params) {
for (const key of paramsThatAllowContextReuse)
(this._options as any)[key] = params[key];
if (params.testIdAttributeName)
this.selectors().setTestIdAttributeName(params.testIdAttributeName);
}
await this._cancelAllRoutesInFlight();
@ -779,6 +781,7 @@ const paramsThatAllowContextReuse: (keyof channels.BrowserNewContextForReusePara
'screen',
'userAgent',
'viewport',
'testIdAttributeName',
];
const defaultNewContextParamValues: channels.BrowserNewContextForReuseParams = {

View File

@ -358,6 +358,14 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
this._subscriptions.delete(params.event);
}
async registerSelectorEngine(params: channels.BrowserContextRegisterSelectorEngineParams): Promise<void> {
this._object.selectors().register(params.selectorEngine);
}
async setTestIdAttributeName(params: channels.BrowserContextSetTestIdAttributeNameParams): Promise<void> {
this._object.selectors().setTestIdAttributeName(params.testIdAttributeName);
}
override _onDispose() {
// Avoid protocol calls for the closed context.
if (!this._context.isClosingOrClosed())

View File

@ -19,7 +19,6 @@ import { BrowserContextDispatcher } from './browserContextDispatcher';
import { CDPSessionDispatcher } from './cdpSessionDispatcher';
import { Dispatcher } from './dispatcher';
import { BrowserContext } from '../browserContext';
import { Selectors } from '../selectors';
import { ArtifactDispatcher } from './artifactDispatcher';
import type { BrowserTypeDispatcher } from './browserTypeDispatcher';
@ -48,7 +47,7 @@ export class BrowserDispatcher extends Dispatcher<Browser, channels.BrowserChann
}
async newContextForReuse(params: channels.BrowserNewContextForReuseParams, metadata: CallMetadata): Promise<channels.BrowserNewContextForReuseResult> {
return await newContextForReuse(this._object, this, params, null, metadata);
return await newContextForReuse(this._object, this, params, metadata);
}
async stopPendingOperations(params: channels.BrowserStopPendingOperationsParams, metadata: CallMetadata): Promise<channels.BrowserStopPendingOperationsResult> {
@ -95,13 +94,9 @@ export class BrowserDispatcher extends Dispatcher<Browser, channels.BrowserChann
export class ConnectedBrowserDispatcher extends Dispatcher<Browser, channels.BrowserChannel, RootDispatcher> implements channels.BrowserChannel {
_type_Browser = true;
private _contexts = new Set<BrowserContext>();
readonly selectors: Selectors;
constructor(scope: RootDispatcher, browser: Browser) {
super(scope, browser, 'Browser', { version: browser.version(), name: browser.options.name });
// When we have a remotely-connected browser, each client gets a fresh Selector instance,
// so that two clients do not interfere between each other.
this.selectors = new Selectors();
}
async newContext(params: channels.BrowserNewContextParams, metadata: CallMetadata): Promise<channels.BrowserNewContextResult> {
@ -109,13 +104,12 @@ export class ConnectedBrowserDispatcher extends Dispatcher<Browser, channels.Bro
params.recordVideo.dir = this._object.options.artifactsDir;
const context = await this._object.newContext(metadata, params);
this._contexts.add(context);
context.setSelectors(this.selectors);
context.on(BrowserContext.Events.Close, () => this._contexts.delete(context));
return { context: new BrowserContextDispatcher(this, context) };
}
async newContextForReuse(params: channels.BrowserNewContextForReuseParams, metadata: CallMetadata): Promise<channels.BrowserNewContextForReuseResult> {
return await newContextForReuse(this._object, this as any as BrowserDispatcher, params, this.selectors, metadata);
return await newContextForReuse(this._object, this as any as BrowserDispatcher, params, metadata);
}
async stopPendingOperations(params: channels.BrowserStopPendingOperationsParams, metadata: CallMetadata): Promise<channels.BrowserStopPendingOperationsResult> {
@ -160,7 +154,7 @@ export class ConnectedBrowserDispatcher extends Dispatcher<Browser, channels.Bro
}
}
async function newContextForReuse(browser: Browser, scope: BrowserDispatcher, params: channels.BrowserNewContextForReuseParams, selectors: Selectors | null, metadata: CallMetadata): Promise<channels.BrowserNewContextForReuseResult> {
async function newContextForReuse(browser: Browser, scope: BrowserDispatcher, params: channels.BrowserNewContextForReuseParams, metadata: CallMetadata): Promise<channels.BrowserNewContextForReuseResult> {
const { context, needsReset } = await browser.newContextForReuse(params, metadata);
if (needsReset) {
const oldContextDispatcher = scope.connection.existingDispatcher<BrowserContextDispatcher>(context);
@ -168,8 +162,6 @@ async function newContextForReuse(browser: Browser, scope: BrowserDispatcher, pa
oldContextDispatcher._dispose();
await context.resetForReuse(metadata, params);
}
if (selectors)
context.setSelectors(selectors);
const contextDispatcher = new BrowserContextDispatcher(scope, context);
return { context: contextDispatcher };
}

View File

@ -24,7 +24,6 @@ import { Dispatcher } from './dispatcher';
import { ElectronDispatcher } from './electronDispatcher';
import { LocalUtilsDispatcher } from './localUtilsDispatcher';
import { APIRequestContextDispatcher } from './networkDispatchers';
import { SelectorsDispatcher } from './selectorsDispatcher';
import { createGuid } from '../utils/crypto';
import { eventsHelper } from '../utils/eventsHelper';
@ -53,7 +52,6 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
android,
electron: new ElectronDispatcher(scope, playwright.electron),
utils: playwright.options.isServer ? undefined : new LocalUtilsDispatcher(scope, playwright),
selectors: new SelectorsDispatcher(scope, browserDispatcher?.selectors || playwright.selectors),
preLaunchedBrowser: browserDispatcher,
preConnectedAndroidDevice: prelaunchedAndroidDeviceDispatcher,
socksSupport: socksProxy ? new SocksSupportDispatcher(scope, socksProxy) : undefined,

View File

@ -1,37 +0,0 @@
/**
* 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 { Dispatcher } from './dispatcher';
import type { RootDispatcher } from './dispatcher';
import type { Selectors } from '../selectors';
import type * as channels from '@protocol/channels';
export class SelectorsDispatcher extends Dispatcher<Selectors, channels.SelectorsChannel, RootDispatcher> implements channels.SelectorsChannel {
_type_Selectors = true;
constructor(scope: RootDispatcher, selectors: Selectors) {
super(scope, selectors, 'Selectors', {});
}
async register(params: channels.SelectorsRegisterParams): Promise<void> {
await this._object.register(params.name, params.source, params.contentScript);
}
async setTestIdAttributeName(params: channels.SelectorsSetTestIdAttributeNameParams): Promise<void> {
this._object.setTestIdAttributeName(params.testIdAttributeName);
}
}

View File

@ -24,7 +24,6 @@ import { DebugController } from './debugController';
import { Electron } from './electron/electron';
import { Firefox } from './firefox/firefox';
import { SdkObject, createInstrumentation } from './instrumentation';
import { Selectors } from './selectors';
import { WebKit } from './webkit/webkit';
import type { BrowserType } from './browserType';
@ -41,7 +40,6 @@ type PlaywrightOptions = {
};
export class Playwright extends SdkObject {
readonly selectors: Selectors;
readonly chromium: BrowserType;
readonly android: Android;
readonly electron: Electron;
@ -74,7 +72,6 @@ export class Playwright extends SdkObject {
this.webkit = new WebKit(this);
this.electron = new Electron(this);
this.android = new Android(this, new AdbBackend());
this.selectors = new Selectors();
this.debugController = new DebugController(this);
}

View File

@ -18,15 +18,16 @@ import { createGuid } from './utils/crypto';
import { InvalidSelectorError, parseSelector, stringifySelector, visitAllSelectorParts } from '../utils/isomorphic/selectorParser';
import type { ParsedSelector } from '../utils/isomorphic/selectorParser';
import type * as channels from '@protocol/channels';
export class Selectors {
private readonly _builtinEngines: Set<string>;
private readonly _builtinEnginesInMainWorld: Set<string>;
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
readonly _engines: Map<string, channels.SelectorEngine>;
readonly guid = `selectors@${createGuid()}`;
private _testIdAttributeName: string = 'data-testid';
private _testIdAttributeName: string;
constructor() {
constructor(engines: channels.SelectorEngine[], testIdAttributeName: string | undefined) {
// Note: keep in sync with InjectedScript class.
this._builtinEngines = new Set([
'css', 'css:light',
@ -49,17 +50,20 @@ export class Selectors {
'_react', '_vue',
]);
this._engines = new Map();
this._testIdAttributeName = testIdAttributeName ?? 'data-testid';
for (const engine of engines)
this.register(engine);
}
async register(name: string, source: string, contentScript: boolean = false): Promise<void> {
if (!name.match(/^[a-zA-Z_0-9-]+$/))
register(engine: channels.SelectorEngine) {
if (!engine.name.match(/^[a-zA-Z_0-9-]+$/))
throw new Error('Selector engine name may only contain [a-zA-Z0-9_] characters');
// Note: we keep 'zs' for future use.
if (this._builtinEngines.has(name) || name === 'zs' || name === 'zs:light')
throw new Error(`"${name}" is a predefined selector engine`);
if (this._engines.has(name))
throw new Error(`"${name}" selector engine has been already registered`);
this._engines.set(name, { source, contentScript });
if (this._builtinEngines.has(engine.name) || engine.name === 'zs' || engine.name === 'zs:light')
throw new Error(`"${engine.name}" is a predefined selector engine`);
if (this._engines.has(engine.name))
throw new Error(`"${engine.name}" selector engine has been already registered`);
this._engines.set(engine.name, engine);
}
testIdAttributeName(): string {
@ -70,10 +74,6 @@ export class Selectors {
this._testIdAttributeName = testIdAttributeName;
}
unregisterAll() {
this._engines.clear();
}
parseSelector(selector: string | ParsedSelector, strict: boolean) {
const parsed = typeof selector === 'string' ? parseSelector(selector) : selector;
let needsMainWorld = false;

View File

@ -52,7 +52,6 @@ export type InitializerTraits<T> =
T extends EventTargetChannel ? EventTargetInitializer :
T extends BrowserChannel ? BrowserInitializer :
T extends BrowserTypeChannel ? BrowserTypeInitializer :
T extends SelectorsChannel ? SelectorsInitializer :
T extends SocksSupportChannel ? SocksSupportInitializer :
T extends DebugControllerChannel ? DebugControllerInitializer :
T extends PlaywrightChannel ? PlaywrightInitializer :
@ -90,7 +89,6 @@ export type EventsTraits<T> =
T extends EventTargetChannel ? EventTargetEvents :
T extends BrowserChannel ? BrowserEvents :
T extends BrowserTypeChannel ? BrowserTypeEvents :
T extends SelectorsChannel ? SelectorsEvents :
T extends SocksSupportChannel ? SocksSupportEvents :
T extends DebugControllerChannel ? DebugControllerEvents :
T extends PlaywrightChannel ? PlaywrightEvents :
@ -128,7 +126,6 @@ export type EventTargetTraits<T> =
T extends EventTargetChannel ? EventTargetEventTarget :
T extends BrowserChannel ? BrowserEventTarget :
T extends BrowserTypeChannel ? BrowserTypeEventTarget :
T extends SelectorsChannel ? SelectorsEventTarget :
T extends SocksSupportChannel ? SocksSupportEventTarget :
T extends DebugControllerChannel ? DebugControllerEventTarget :
T extends PlaywrightChannel ? PlaywrightEventTarget :
@ -217,6 +214,12 @@ export type ExpectedTextValue = {
normalizeWhiteSpace?: boolean,
};
export type SelectorEngine = {
name: string,
source: string,
contentScript?: boolean,
};
export type AXNode = {
role: string,
name: string,
@ -622,7 +625,6 @@ export type PlaywrightInitializer = {
android: AndroidChannel,
electron: ElectronChannel,
utils?: LocalUtilsChannel,
selectors: SelectorsChannel,
preLaunchedBrowser?: BrowserChannel,
preConnectedAndroidDevice?: AndroidDeviceChannel,
socksSupport?: SocksSupportChannel,
@ -897,35 +899,6 @@ export interface SocksSupportEvents {
'socksClosed': SocksSupportSocksClosedEvent;
}
// ----------- Selectors -----------
export type SelectorsInitializer = {};
export interface SelectorsEventTarget {
}
export interface SelectorsChannel extends SelectorsEventTarget, Channel {
_type_Selectors: boolean;
register(params: SelectorsRegisterParams, metadata?: CallMetadata): Promise<SelectorsRegisterResult>;
setTestIdAttributeName(params: SelectorsSetTestIdAttributeNameParams, metadata?: CallMetadata): Promise<SelectorsSetTestIdAttributeNameResult>;
}
export type SelectorsRegisterParams = {
name: string,
source: string,
contentScript?: boolean,
};
export type SelectorsRegisterOptions = {
contentScript?: boolean,
};
export type SelectorsRegisterResult = void;
export type SelectorsSetTestIdAttributeNameParams = {
testIdAttributeName: string,
};
export type SelectorsSetTestIdAttributeNameOptions = {
};
export type SelectorsSetTestIdAttributeNameResult = void;
export interface SelectorsEvents {
}
// ----------- BrowserType -----------
export type BrowserTypeInitializer = {
executablePath: string,
@ -1075,6 +1048,8 @@ export type BrowserTypeLaunchPersistentContextParams = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
userDataDir: string,
slowMo?: number,
};
@ -1157,6 +1132,8 @@ export type BrowserTypeLaunchPersistentContextOptions = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
slowMo?: number,
};
export type BrowserTypeLaunchPersistentContextResult = {
@ -1272,6 +1249,8 @@ export type BrowserNewContextParams = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
proxy?: {
server: string,
bypass?: string,
@ -1339,6 +1318,8 @@ export type BrowserNewContextOptions = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
proxy?: {
server: string,
bypass?: string,
@ -1409,6 +1390,8 @@ export type BrowserNewContextForReuseParams = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
proxy?: {
server: string,
bypass?: string,
@ -1476,6 +1459,8 @@ export type BrowserNewContextForReuseOptions = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
proxy?: {
server: string,
bypass?: string,
@ -1582,6 +1567,8 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
exposeBinding(params: BrowserContextExposeBindingParams, metadata?: CallMetadata): Promise<BrowserContextExposeBindingResult>;
grantPermissions(params: BrowserContextGrantPermissionsParams, metadata?: CallMetadata): Promise<BrowserContextGrantPermissionsResult>;
newPage(params?: BrowserContextNewPageParams, metadata?: CallMetadata): Promise<BrowserContextNewPageResult>;
registerSelectorEngine(params: BrowserContextRegisterSelectorEngineParams, metadata?: CallMetadata): Promise<BrowserContextRegisterSelectorEngineResult>;
setTestIdAttributeName(params: BrowserContextSetTestIdAttributeNameParams, metadata?: CallMetadata): Promise<BrowserContextSetTestIdAttributeNameResult>;
setExtraHTTPHeaders(params: BrowserContextSetExtraHTTPHeadersParams, metadata?: CallMetadata): Promise<BrowserContextSetExtraHTTPHeadersResult>;
setGeolocation(params: BrowserContextSetGeolocationParams, metadata?: CallMetadata): Promise<BrowserContextSetGeolocationResult>;
setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, metadata?: CallMetadata): Promise<BrowserContextSetHTTPCredentialsResult>;
@ -1741,6 +1728,20 @@ export type BrowserContextNewPageOptions = {};
export type BrowserContextNewPageResult = {
page: PageChannel,
};
export type BrowserContextRegisterSelectorEngineParams = {
selectorEngine: SelectorEngine,
};
export type BrowserContextRegisterSelectorEngineOptions = {
};
export type BrowserContextRegisterSelectorEngineResult = void;
export type BrowserContextSetTestIdAttributeNameParams = {
testIdAttributeName: string,
};
export type BrowserContextSetTestIdAttributeNameOptions = {
};
export type BrowserContextSetTestIdAttributeNameResult = void;
export type BrowserContextSetExtraHTTPHeadersParams = {
headers: NameValue[],
};
@ -4757,6 +4758,8 @@ export type AndroidDeviceLaunchBrowserParams = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
pkg?: string,
args?: string[],
proxy?: {
@ -4822,6 +4825,8 @@ export type AndroidDeviceLaunchBrowserOptions = {
recordHar?: RecordHarOptions,
strictSelectors?: boolean,
serviceWorkers?: 'allow' | 'block',
selectorEngines?: SelectorEngine[],
testIdAttributeName?: string,
pkg?: string,
args?: string[],
proxy?: {

View File

@ -154,6 +154,14 @@ ExpectedTextValue:
normalizeWhiteSpace: boolean?
SelectorEngine:
type: object
properties:
name: string
source: string
contentScript: boolean?
AXNode:
type: object
properties:
@ -610,6 +618,11 @@ ContextOptions:
literals:
- allow
- block
selectorEngines:
type: array?
items: SelectorEngine
testIdAttributeName: string?
LocalUtils:
type: interface
@ -780,7 +793,6 @@ Playwright:
android: Android
electron: Electron
utils: LocalUtils?
selectors: Selectors
# Only present when connecting remotely via BrowserType.connect() method.
preLaunchedBrowser: Browser?
# Only present when connecting remotely via Android.connect() method.
@ -996,22 +1008,6 @@ SocksSupport:
parameters:
uid: string
Selectors:
type: interface
commands:
register:
internal: true
parameters:
name: string
source: string
contentScript: boolean?
setTestIdAttributeName:
internal: true
parameters:
testIdAttributeName: string
BrowserType:
type: interface
@ -1265,6 +1261,16 @@ BrowserContext:
returns:
page: Page
registerSelectorEngine:
internal: true
parameters:
selectorEngine: SelectorEngine
setTestIdAttributeName:
internal: true
parameters:
testIdAttributeName: string
setExtraHTTPHeaders:
title: Set extra HTTP headers
parameters:

View File

@ -43,7 +43,7 @@ export const testModeTest = test.extend<TestModeTestFixtures, TestModeWorkerOpti
'driver': new DriverTestMode(),
}[mode];
const playwright = await testMode.setup();
playwright._setSelectors(playwrightLibrary.selectors);
(playwrightLibrary.selectors as any)._playwrights.add(playwright);
await run(playwright);
await testMode.teardown();
}, { scope: 'worker' }],

View File

@ -53,7 +53,6 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat
{ _guid: 'electron', objects: [] },
{ _guid: 'localUtils', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'selectors', objects: [] },
]
};
expectScopeState(browser, GOLDEN_PRECONDITION);
@ -88,7 +87,6 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat
{ _guid: 'electron', objects: [] },
{ _guid: 'localUtils', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'selectors', objects: [] },
]
});
@ -115,7 +113,6 @@ it('should scope CDPSession handles', async ({ browserType, browserName, expectS
{ _guid: 'electron', objects: [] },
{ _guid: 'localUtils', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'selectors', objects: [] },
]
};
expectScopeState(browserType, GOLDEN_PRECONDITION);
@ -137,7 +134,6 @@ it('should scope CDPSession handles', async ({ browserType, browserName, expectS
{ _guid: 'electron', objects: [] },
{ _guid: 'localUtils', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'selectors', objects: [] },
]
});
@ -160,7 +156,6 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) =>
{ _guid: 'electron', objects: [] },
{ _guid: 'localUtils', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'selectors', objects: [] },
]
};
expectScopeState(browserType, GOLDEN_PRECONDITION);
@ -189,7 +184,6 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) =>
{ _guid: 'electron', objects: [] },
{ _guid: 'localUtils', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'selectors', objects: [] },
]
});
@ -234,7 +228,6 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa
{ _guid: 'electron', objects: [] },
{ _guid: 'localUtils', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'selectors', objects: [] },
]
});
});
@ -353,10 +346,6 @@ it('exposeFunction should not leak', async ({ page, expectScopeState, server })
'_guid': 'Playwright',
'objects': [],
},
{
'_guid': 'selectors',
'objects': [],
},
],
});
});

View File

@ -136,9 +136,8 @@ it.describe('selector generator', () => {
expect(await generate(page, '[data-testid="a"]')).toBe('internal:testid=[data-testid=\"a\"s]');
});
it('should use data-testid in strict errors', async ({ page, playwright }) => {
playwright.selectors.setTestIdAttribute('data-custom-id');
await page.setContent(`
it('should use data-testid in strict errors', async ({ contextFactory, page, playwright }) => {
const content = `
<div>
<div></div>
<div>
@ -151,13 +150,27 @@ it.describe('selector generator', () => {
</div>
<div class='foo bar:1' data-custom-id='Two'>
</div>
</div>`);
const error = await page.locator('.foo').hover().catch(e => e);
expect(error.message).toContain('strict mode violation');
expect(error.message).toContain('<div class=\"foo bar:0');
expect(error.message).toContain('<div class=\"foo bar:1');
expect(error.message).toContain(`aka getByTestId('One')`);
expect(error.message).toContain(`aka getByTestId('Two')`);
</div>
`;
const checkPage = async (page: Page) => {
await page.setContent(content);
const error = await page.locator('.foo').hover().catch(e => e);
expect(error.message).toContain('strict mode violation');
expect(error.message).toContain('<div class=\"foo bar:0');
expect(error.message).toContain('<div class=\"foo bar:1');
expect(error.message).toContain(`aka getByTestId('One')`);
expect(error.message).toContain(`aka getByTestId('Two')`);
};
playwright.selectors.setTestIdAttribute('data-custom-id');
// Check page and context that were created before setting the attribute.
await checkPage(page);
const context2 = await contextFactory();
const page2 = await context2.newPage();
// Check page and context that were created after setting the attribute.
await checkPage(page2);
});
it('should handle first non-unique data-testid', async ({ page }) => {