diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 87640b9163..5f7ab119e1 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -18,8 +18,10 @@ import { assert } from '../utils'; import type * as channels from '@protocol/channels'; import { ChannelOwner } from './channelOwner'; -import { FrameLocator, getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector, Locator } from './locator'; -import type { ByRoleOptions, LocatorOptions } from './locator'; +import { FrameLocator, Locator, testIdAttributeName } from './locator'; +import type { LocatorOptions } from './locator'; +import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils'; +import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; import { ElementHandle, convertSelectOptionValues, convertInputFiles } from './elementHandle'; import { assertMaxArguments, JSHandle, serializeArgument, parseResult } from './jsHandle'; import fs from 'fs'; @@ -306,7 +308,7 @@ export class Frame extends ChannelOwner implements api.Fr } getByTestId(testId: string): Locator { - return this.locator(getByTestIdSelector(testId)); + return this.locator(getByTestIdSelector(testIdAttributeName, testId)); } getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 14c65ac20e..02e44808e4 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -19,29 +19,20 @@ import type * as api from '../../types/types'; import type * as channels from '@protocol/channels'; import type { ParsedStackTrace } from '../utils/stackTrace'; import * as util from 'util'; -import { isString, monotonicTime } from '../utils'; +import { monotonicTime } from '../utils'; import { ElementHandle } from './elementHandle'; import type { Frame } from './frame'; import type { FilePayload, FrameExpectOptions, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; import { parseResult, serializeArgument } from './jsHandle'; -import { escapeForAttributeSelector, escapeForTextSelector } from '../utils/isomorphic/stringUtils'; +import { escapeForTextSelector } from '../utils/isomorphic/stringUtils'; +import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; +import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils'; export type LocatorOptions = { hasText?: string | RegExp; has?: Locator; }; -export type ByRoleOptions = LocatorOptions & { - checked?: boolean; - disabled?: boolean; - expanded?: boolean; - includeHidden?: boolean; - level?: number; - name?: string | RegExp; - pressed?: boolean; - selected?: boolean; -}; - export class Locator implements api.Locator { _frame: Frame; _selector: string; @@ -143,7 +134,7 @@ export class Locator implements api.Locator { } getByTestId(testId: string): Locator { - return this.locator(getByTestIdSelector(testId)); + return this.locator(getByTestIdSelector(testIdAttributeName, testId)); } getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { @@ -349,7 +340,7 @@ export class FrameLocator implements api.FrameLocator { } getByTestId(testId: string): Locator { - return this.locator(getByTestIdSelector(testId)); + return this.locator(getByTestIdSelector(testIdAttributeName, testId)); } getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { @@ -393,60 +384,8 @@ export class FrameLocator implements api.FrameLocator { } } -let testIdAttributeName: string = 'data-testid'; +export let testIdAttributeName: string = 'data-testid'; export function setTestIdAttribute(attributeName: string) { testIdAttributeName = attributeName; } - -function getByAttributeTextSelector(attrName: string, text: string | RegExp, options?: { exact?: boolean }): string { - if (!isString(text)) - return `internal:attr=[${attrName}=${text}]`; - return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, options?.exact || false)}]`; -} - -export function getByTestIdSelector(testId: string): string { - return getByAttributeTextSelector(testIdAttributeName, testId, { exact: true }); -} - - -export function getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string { - return 'internal:label=' + escapeForTextSelector(text, !!options?.exact); -} - -export function getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { - return getByAttributeTextSelector('alt', text, options); -} - -export function getByTitleSelector(text: string | RegExp, options?: { exact?: boolean }): string { - return getByAttributeTextSelector('title', text, options); -} - -export function getByPlaceholderSelector(text: string | RegExp, options?: { exact?: boolean }): string { - return getByAttributeTextSelector('placeholder', text, options); -} - -export function getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { - return 'internal:text=' + escapeForTextSelector(text, !!options?.exact); -} - -export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string { - const props: string[][] = []; - if (options.checked !== undefined) - props.push(['checked', String(options.checked)]); - if (options.disabled !== undefined) - props.push(['disabled', String(options.disabled)]); - if (options.selected !== undefined) - props.push(['selected', String(options.selected)]); - if (options.expanded !== undefined) - props.push(['expanded', String(options.expanded)]); - if (options.includeHidden !== undefined) - props.push(['include-hidden', String(options.includeHidden)]); - if (options.level !== undefined) - props.push(['level', String(options.level)]); - if (options.name !== undefined) - props.push(['name', isString(options.name) ? escapeForAttributeSelector(options.name, false) : String(options.name)]); - if (options.pressed !== undefined) - props.push(['pressed', String(options.pressed)]); - return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`; -} diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 1092be9b90..a89f61f47f 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -45,7 +45,8 @@ import { Frame, verifyLoadState } from './frame'; import { HarRouter } from './harRouter'; import { Keyboard, Mouse, Touchscreen } from './input'; import { assertMaxArguments, JSHandle, parseResult, serializeArgument } from './jsHandle'; -import type { ByRoleOptions, FrameLocator, Locator, LocatorOptions } from './locator'; +import type { FrameLocator, Locator, LocatorOptions } from './locator'; +import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; import type { RouteHandlerCallback } from './network'; import { Response, Route, RouteHandler, validateHeaders, WebSocket } from './network'; import type { Request } from './network'; diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index c728b233c8..a28b5b7b12 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -14,40 +14,54 @@ * limitations under the License. */ +import type { ByRoleOptions } from '../../utils/isomorphic/locatorUtils'; +import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../../utils/isomorphic/locatorUtils'; import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; +import { asLocator } from '../isomorphic/locatorGenerators'; +import type { Language } from '../isomorphic/locatorGenerators'; import { type InjectedScript } from './injectedScript'; import { generateSelector } from './selectorGenerator'; -function createLocator(injectedScript: InjectedScript, initial: string, options?: { hasText?: string | RegExp }) { - class Locator { - selector: string; - element: Element | undefined; - elements: Element[]; +const selectorSymbol = Symbol('selector'); +const injectedScriptSymbol = Symbol('injectedScript'); - constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) { - this.selector = selector; - if (options?.hasText) - this.selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; - if (options?.has) - this.selector += ` >> internal:has=` + JSON.stringify(options.has.selector); - const parsed = injectedScript.parseSelector(this.selector); +class Locator { + element: Element | undefined; + elements: Element[] | undefined; + + constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, has?: Locator }) { + (this as any)[selectorSymbol] = selector; + (this as any)[injectedScriptSymbol] = injectedScript; + if (options?.hasText) + selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; + if (options?.has) + selector += ` >> internal:has=` + JSON.stringify((options.has as any)[selectorSymbol]); + if (selector) { + const parsed = injectedScript.parseSelector(selector); this.element = injectedScript.querySelector(parsed, document, false); this.elements = injectedScript.querySelectorAll(parsed, document); } - - locator(selector: string, options?: { hasText: string | RegExp, has?: Locator }): Locator { - return new Locator(this.selector ? this.selector + ' >> ' + selector : selector, options); - } + const selectorBase = selector; + const self = this as any; + self.locator = (selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator => { + return new Locator(injectedScript, selectorBase ? selectorBase + ' >> ' + selector : selector, options); + }; + self.getByTestId = (testId: string): Locator => self.locator(getByTestIdSelector('data-testid', testId)); + self.getByAltText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByAltTextSelector(text, options)); + self.getByLabel = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByLabelSelector(text, options)); + self.getByPlaceholder = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByPlaceholderSelector(text, options)); + self.getByText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTextSelector(text, options)); + self.getByTitle = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTitleSelector(text, options)); + self.getByRole = (role: string, options: ByRoleOptions = {}): Locator => self.locator(getByRoleSelector(role, options)); } - return new Locator(initial, options); } type ConsoleAPIInterface = { $: (selector: string) => void; $$: (selector: string) => void; - locator: (selector: string, options?: { hasText: string | RegExp, has?: any }) => any; inspect: (selector: string) => void; selector: (element: Element) => void; + generateLocator: (element: Element, language?: Language) => void; resume: () => void; }; @@ -69,10 +83,11 @@ class ConsoleAPI { window.playwright = { $: (selector: string, strict?: boolean) => this._querySelector(selector, !!strict), $$: (selector: string) => this._querySelectorAll(selector), - locator: (selector: string, options?: { hasText?: string | RegExp }) => createLocator(this._injectedScript, selector, options), inspect: (selector: string) => this._inspect(selector), selector: (element: Element) => this._selector(element), + generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language), resume: () => this._resume(), + ...new Locator(injectedScript, ''), }; } @@ -102,6 +117,13 @@ class ConsoleAPI { return generateSelector(this._injectedScript, element, true).selector; } + private _generateLocator(element: Element, language?: Language) { + if (!(element instanceof Element)) + throw new Error(`Usage: playwright.locator(element).`); + const selector = generateSelector(this._injectedScript, element, true).selector; + return asLocator(language || 'javascript', selector); + } + private _resume() { window.__pw_resume().catch(() => {}); } diff --git a/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts new file mode 100644 index 0000000000..07d7c2514f --- /dev/null +++ b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts @@ -0,0 +1,79 @@ +/** + * 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 { escapeForAttributeSelector, escapeForTextSelector, isString } from './stringUtils'; + +export type ByRoleOptions = { + checked?: boolean; + disabled?: boolean; + expanded?: boolean; + includeHidden?: boolean; + level?: number; + name?: string | RegExp; + pressed?: boolean; + selected?: boolean; +}; + +function getByAttributeTextSelector(attrName: string, text: string | RegExp, options?: { exact?: boolean }): string { + if (!isString(text)) + return `internal:attr=[${attrName}=${text}]`; + return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, options?.exact || false)}]`; +} + +export function getByTestIdSelector(testIdAttributeName: string, testId: string): string { + return getByAttributeTextSelector(testIdAttributeName, testId, { exact: true }); +} + +export function getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return 'internal:label=' + escapeForTextSelector(text, !!options?.exact); +} + +export function getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector('alt', text, options); +} + +export function getByTitleSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector('title', text, options); +} + +export function getByPlaceholderSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector('placeholder', text, options); +} + +export function getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return 'internal:text=' + escapeForTextSelector(text, !!options?.exact); +} + +export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string { + const props: string[][] = []; + if (options.checked !== undefined) + props.push(['checked', String(options.checked)]); + if (options.disabled !== undefined) + props.push(['disabled', String(options.disabled)]); + if (options.selected !== undefined) + props.push(['selected', String(options.selected)]); + if (options.expanded !== undefined) + props.push(['expanded', String(options.expanded)]); + if (options.includeHidden !== undefined) + props.push(['include-hidden', String(options.includeHidden)]); + if (options.level !== undefined) + props.push(['level', String(options.level)]); + if (options.name !== undefined) + props.push(['name', isString(options.name) ? escapeForAttributeSelector(options.name, false) : String(options.name)]); + if (options.pressed !== undefined) + props.push(['pressed', String(options.pressed)]); + return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`; +} diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index 1504fa29e2..05f4d1dbcf 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -27,6 +27,10 @@ export function escapeWithQuotes(text: string, char: string = '\'') { throw new Error('Invalid escape char'); } +export function isString(obj: any): obj is string { + return typeof obj === 'string' || obj instanceof String; +} + export function toTitleCase(name: string) { return name.charAt(0).toUpperCase() + name.substring(1); }