chore: expose new locator apis on window.playwright (#18595)

This commit is contained in:
Pavel Feldman 2022-11-07 09:06:13 -08:00 committed by GitHub
parent 05471df8bb
commit 8432d1592f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 138 additions and 91 deletions

View file

@ -18,8 +18,10 @@
import { assert } from '../utils'; import { assert } from '../utils';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { FrameLocator, getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector, Locator } from './locator'; import { FrameLocator, Locator, testIdAttributeName } from './locator';
import type { ByRoleOptions, LocatorOptions } 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 { ElementHandle, convertSelectOptionValues, convertInputFiles } from './elementHandle';
import { assertMaxArguments, JSHandle, serializeArgument, parseResult } from './jsHandle'; import { assertMaxArguments, JSHandle, serializeArgument, parseResult } from './jsHandle';
import fs from 'fs'; import fs from 'fs';
@ -306,7 +308,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
} }
getByTestId(testId: string): Locator { getByTestId(testId: string): Locator {
return this.locator(getByTestIdSelector(testId)); return this.locator(getByTestIdSelector(testIdAttributeName, testId));
} }
getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator {

View file

@ -19,29 +19,20 @@ import type * as api from '../../types/types';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import type { ParsedStackTrace } from '../utils/stackTrace'; import type { ParsedStackTrace } from '../utils/stackTrace';
import * as util from 'util'; import * as util from 'util';
import { isString, monotonicTime } from '../utils'; import { monotonicTime } from '../utils';
import { ElementHandle } from './elementHandle'; import { ElementHandle } from './elementHandle';
import type { Frame } from './frame'; import type { Frame } from './frame';
import type { FilePayload, FrameExpectOptions, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; import type { FilePayload, FrameExpectOptions, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
import { parseResult, serializeArgument } from './jsHandle'; 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 = { export type LocatorOptions = {
hasText?: string | RegExp; hasText?: string | RegExp;
has?: Locator; 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 { export class Locator implements api.Locator {
_frame: Frame; _frame: Frame;
_selector: string; _selector: string;
@ -143,7 +134,7 @@ export class Locator implements api.Locator {
} }
getByTestId(testId: string): Locator { getByTestId(testId: string): Locator {
return this.locator(getByTestIdSelector(testId)); return this.locator(getByTestIdSelector(testIdAttributeName, testId));
} }
getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator {
@ -349,7 +340,7 @@ export class FrameLocator implements api.FrameLocator {
} }
getByTestId(testId: string): Locator { getByTestId(testId: string): Locator {
return this.locator(getByTestIdSelector(testId)); return this.locator(getByTestIdSelector(testIdAttributeName, testId));
} }
getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { 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) { export function setTestIdAttribute(attributeName: string) {
testIdAttributeName = attributeName; 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('')}`;
}

View file

@ -45,7 +45,8 @@ import { Frame, verifyLoadState } from './frame';
import { HarRouter } from './harRouter'; import { HarRouter } from './harRouter';
import { Keyboard, Mouse, Touchscreen } from './input'; import { Keyboard, Mouse, Touchscreen } from './input';
import { assertMaxArguments, JSHandle, parseResult, serializeArgument } from './jsHandle'; 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 type { RouteHandlerCallback } from './network';
import { Response, Route, RouteHandler, validateHeaders, WebSocket } from './network'; import { Response, Route, RouteHandler, validateHeaders, WebSocket } from './network';
import type { Request } from './network'; import type { Request } from './network';

View file

@ -14,40 +14,54 @@
* limitations under the License. * 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 { escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
import { asLocator } from '../isomorphic/locatorGenerators';
import type { Language } from '../isomorphic/locatorGenerators';
import { type InjectedScript } from './injectedScript'; import { type InjectedScript } from './injectedScript';
import { generateSelector } from './selectorGenerator'; import { generateSelector } from './selectorGenerator';
function createLocator(injectedScript: InjectedScript, initial: string, options?: { hasText?: string | RegExp }) { const selectorSymbol = Symbol('selector');
class Locator { const injectedScriptSymbol = Symbol('injectedScript');
selector: string;
element: Element | undefined;
elements: Element[];
constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) { class Locator {
this.selector = selector; element: Element | undefined;
if (options?.hasText) elements: Element[] | undefined;
this.selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
if (options?.has) constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
this.selector += ` >> internal:has=` + JSON.stringify(options.has.selector); (this as any)[selectorSymbol] = selector;
const parsed = injectedScript.parseSelector(this.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.element = injectedScript.querySelector(parsed, document, false);
this.elements = injectedScript.querySelectorAll(parsed, document); this.elements = injectedScript.querySelectorAll(parsed, document);
} }
const selectorBase = selector;
locator(selector: string, options?: { hasText: string | RegExp, has?: Locator }): Locator { const self = this as any;
return new Locator(this.selector ? this.selector + ' >> ' + selector : selector, options); 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 = { type ConsoleAPIInterface = {
$: (selector: string) => void; $: (selector: string) => void;
$$: (selector: string) => void; $$: (selector: string) => void;
locator: (selector: string, options?: { hasText: string | RegExp, has?: any }) => any;
inspect: (selector: string) => void; inspect: (selector: string) => void;
selector: (element: Element) => void; selector: (element: Element) => void;
generateLocator: (element: Element, language?: Language) => void;
resume: () => void; resume: () => void;
}; };
@ -69,10 +83,11 @@ class ConsoleAPI {
window.playwright = { window.playwright = {
$: (selector: string, strict?: boolean) => this._querySelector(selector, !!strict), $: (selector: string, strict?: boolean) => this._querySelector(selector, !!strict),
$$: (selector: string) => this._querySelectorAll(selector), $$: (selector: string) => this._querySelectorAll(selector),
locator: (selector: string, options?: { hasText?: string | RegExp }) => createLocator(this._injectedScript, selector, options),
inspect: (selector: string) => this._inspect(selector), inspect: (selector: string) => this._inspect(selector),
selector: (element: Element) => this._selector(element), selector: (element: Element) => this._selector(element),
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
resume: () => this._resume(), resume: () => this._resume(),
...new Locator(injectedScript, ''),
}; };
} }
@ -102,6 +117,13 @@ class ConsoleAPI {
return generateSelector(this._injectedScript, element, true).selector; 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() { private _resume() {
window.__pw_resume().catch(() => {}); window.__pw_resume().catch(() => {});
} }

View file

@ -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('')}`;
}

View file

@ -27,6 +27,10 @@ export function escapeWithQuotes(text: string, char: string = '\'') {
throw new Error('Invalid escape char'); 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) { export function toTitleCase(name: string) {
return name.charAt(0).toUpperCase() + name.substring(1); return name.charAt(0).toUpperCase() + name.substring(1);
} }