chore: use provided value for the generated test id (#18631)

This commit is contained in:
Pavel Feldman 2022-11-08 12:04:43 -08:00 committed by GitHub
parent 05b623e6b0
commit 0355d8618f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 174 additions and 54 deletions

View file

@ -308,7 +308,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
}
getByTestId(testId: string): Locator {
return this.locator(getByTestIdSelector(testIdAttributeName, testId));
return this.locator(getByTestIdSelector(testIdAttributeName(), testId));
}
getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator {

View file

@ -134,7 +134,7 @@ export class Locator implements api.Locator {
}
getByTestId(testId: string): Locator {
return this.locator(getByTestIdSelector(testIdAttributeName, testId));
return this.locator(getByTestIdSelector(testIdAttributeName(), testId));
}
getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator {
@ -340,7 +340,7 @@ export class FrameLocator implements api.FrameLocator {
}
getByTestId(testId: string): Locator {
return this.locator(getByTestIdSelector(testIdAttributeName, testId));
return this.locator(getByTestIdSelector(testIdAttributeName(), testId));
}
getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator {
@ -384,8 +384,12 @@ export class FrameLocator implements api.FrameLocator {
}
}
export let testIdAttributeName: string = 'data-testid';
let _testIdAttributeName: string = 'data-testid';
export function testIdAttributeName(): string {
return _testIdAttributeName;
}
export function setTestIdAttribute(attributeName: string) {
testIdAttributeName = attributeName;
_testIdAttributeName = attributeName;
}

View file

@ -19,7 +19,7 @@ import type * as channels from '@protocol/channels';
import { ChannelOwner } from './channelOwner';
import type { SelectorEngine } from './types';
import type * as api from '../../types/types';
import { setTestIdAttribute } from './locator';
import { setTestIdAttribute, testIdAttributeName } from './locator';
export class Selectors implements api.Selectors {
private _channels = new Set<SelectorsOwner>();
@ -35,13 +35,16 @@ export class Selectors implements api.Selectors {
setTestIdAttribute(attributeName: string) {
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(e => {});
channel._channel.register(params).catch(() => {});
channel._channel.setTestIdAttributeName({ testIdAttributeName: testIdAttributeName() }).catch(() => {});
}
}

View file

@ -368,6 +368,7 @@ scheme.DebugControllerNavigateParams = tObject({
scheme.DebugControllerNavigateResult = tOptional(tObject({}));
scheme.DebugControllerSetRecorderModeParams = tObject({
mode: tEnum(['inspecting', 'recording', 'none']),
testIdAttributeName: tOptional(tString),
});
scheme.DebugControllerSetRecorderModeResult = tOptional(tObject({}));
scheme.DebugControllerHighlightParams = tObject({
@ -425,6 +426,10 @@ scheme.SelectorsRegisterParams = tObject({
contentScript: tOptional(tBoolean),
});
scheme.SelectorsRegisterResult = tOptional(tObject({}));
scheme.SelectorsSetTestIdAttributeNameParams = tObject({
testIdAttributeName: tString,
});
scheme.SelectorsSetTestIdAttributeNameResult = tOptional(tObject({}));
scheme.BrowserTypeInitializer = tObject({
executablePath: tString,
name: tString,

View file

@ -89,7 +89,7 @@ export class DebugController extends SdkObject {
await p.mainFrame().goto(internalMetadata, url);
}
async setRecorderMode(params: { mode: Mode, file?: string }) {
async setRecorderMode(params: { mode: Mode, file?: string, testIdAttributeName?: string }) {
// TODO: |file| is only used in the legacy mode.
await this._closeBrowsersWithoutPages();
@ -114,8 +114,11 @@ export class DebugController extends SdkObject {
// Toggle the mode.
for (const recorder of await this._allRecorders()) {
recorder.hideHighlightedSelecor();
if (params.mode === 'recording')
if (params.mode === 'recording') {
recorder.setOutput(this._codegenId, params.file);
if (params.testIdAttributeName)
recorder.setTestIdAttributeName(params.testIdAttributeName);
}
recorder.setMode(params.mode);
}
this.setAutoCloseEnabled(true);

View file

@ -29,4 +29,8 @@ export class SelectorsDispatcher extends Dispatcher<Selectors, channels.Selector
async register(params: channels.SelectorsRegisterParams): Promise<void> {
await this._object.register(params.name, params.source, params.contentScript);
}
async setTestIdAttributeName(params: channels.SelectorsSetTestIdAttributeNameParams, metadata?: channels.Metadata | undefined): Promise<void> {
this._object.setTestIdAttributeName(params.testIdAttributeName);
}
}

View file

@ -106,6 +106,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
return new module.exports(
${isUnderTest()},
"${sdkLanguage}",
${JSON.stringify(this.frame._page.selectors.testIdAttributeName())},
${this.frame._page._delegate.rafCountForStablePosition()},
"${this.frame._page._browserContext._browser.options.name}",
[${custom.join(',\n')}]

View file

@ -46,7 +46,7 @@ class Locator {
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.getByTestId = (testId: string): Locator => self.locator(getByTestIdSelector(injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen(), 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));
@ -114,13 +114,13 @@ class ConsoleAPI {
private _selector(element: Element) {
if (!(element instanceof Element))
throw new Error(`Usage: playwright.selector(element).`);
return generateSelector(this._injectedScript, element, true).selector;
return generateSelector(this._injectedScript, element, true, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).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;
const selector = generateSelector(this._injectedScript, element, true, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).selector;
return asLocator(language || 'javascript', selector);
}

View file

@ -82,10 +82,12 @@ export class InjectedScript {
private _highlight: Highlight | undefined;
readonly isUnderTest: boolean;
private _sdkLanguage: Language;
private _testIdAttributeNameForStrictErrorAndConsoleCodegen: string = 'data-testid';
constructor(isUnderTest: boolean, sdkLanguage: Language, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) {
constructor(isUnderTest: boolean, sdkLanguage: Language, testIdAttributeNameForStrictErrorAndConsoleCodegen: string, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) {
this.isUnderTest = isUnderTest;
this._sdkLanguage = sdkLanguage;
this._testIdAttributeNameForStrictErrorAndConsoleCodegen = testIdAttributeNameForStrictErrorAndConsoleCodegen;
this._evaluator = new SelectorEvaluatorImpl(new Map());
this._engines = new Map();
@ -113,6 +115,7 @@ export class InjectedScript {
this._engines.set('internal:text', this._createTextEngine(true, true));
this._engines.set('internal:has-text', this._createInternalHasTextEngine());
this._engines.set('internal:attr', this._createNamedAttributeEngine());
this._engines.set('internal:testid', this._createNamedAttributeEngine());
this._engines.set('internal:role', RoleEngine);
for (const { name, engine } of customEngines)
@ -132,6 +135,10 @@ export class InjectedScript {
return globalThis.eval(expression);
}
testIdAttributeNameForStrictErrorAndConsoleCodegen(): string {
return this._testIdAttributeNameForStrictErrorAndConsoleCodegen;
}
parseSelector(selector: string): ParsedSelector {
const result = parseSelector(selector);
for (const name of allEngineNames(result)) {
@ -141,8 +148,8 @@ export class InjectedScript {
return result;
}
generateSelector(targetElement: Element): string {
return generateSelector(this, targetElement, true).selector;
generateSelector(targetElement: Element, testIdAttributeName: string): string {
return generateSelector(this, targetElement, true, testIdAttributeName).selector;
}
querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined {
@ -1016,7 +1023,7 @@ export class InjectedScript {
strictModeViolationError(selector: ParsedSelector, matches: Element[]): Error {
const infos = matches.slice(0, 10).map(m => ({
preview: this.previewNode(m),
selector: this.generateSelector(m),
selector: this.generateSelector(m, this._testIdAttributeNameForStrictErrorAndConsoleCodegen),
}));
const lines = infos.map((info, i) => `\n ${i + 1}) ${info.preview} aka ${asLocator(this._sdkLanguage, info.selector)}`);
if (infos.length < matches.length)

View file

@ -43,6 +43,7 @@ class Recorder {
private _actionPoint: Point | undefined;
private _actionSelector: string | undefined;
private _highlight: Highlight;
private _testIdAttributeName: string = 'data-testid';
constructor(injectedScript: InjectedScript) {
this._injectedScript = injectedScript;
@ -94,7 +95,8 @@ class Recorder {
return;
}
const { mode, actionPoint, actionSelector, language } = state;
const { mode, actionPoint, actionSelector, language, testIdAttributeName } = state;
this._testIdAttributeName = testIdAttributeName;
this._highlight.setLanguage(language);
if (mode !== this._mode) {
this._mode = mode;
@ -238,7 +240,7 @@ class Recorder {
if (this._mode === 'none')
return;
const activeElement = this._deepActiveElement(document);
const result = activeElement ? generateSelector(this._injectedScript, activeElement, true) : null;
const result = activeElement ? generateSelector(this._injectedScript, activeElement, true, this._testIdAttributeName) : null;
this._activeModel = result && result.selector ? result : null;
if (userGesture)
this._hoveredElement = activeElement as HTMLElement | null;
@ -252,7 +254,7 @@ class Recorder {
return;
}
const hoveredElement = this._hoveredElement;
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement, true);
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement, true, this._testIdAttributeName);
if ((this._hoveredModel && this._hoveredModel.selector === selector))
return;
this._hoveredModel = selector ? { selector, elements } : null;

View file

@ -44,11 +44,11 @@ export function querySelector(injectedScript: InjectedScript, selector: string,
}
}
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, strict: boolean): { selector: string, elements: Element[] } {
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, strict: boolean, testIdAttributeName: string): { selector: string, elements: Element[] } {
injectedScript._evaluator.begin();
try {
targetElement = targetElement.closest('button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]') || targetElement;
const targetTokens = generateSelectorFor(injectedScript, targetElement, strict);
const targetTokens = generateSelectorFor(injectedScript, targetElement, strict, testIdAttributeName);
const bestTokens = targetTokens || cssFallback(injectedScript, targetElement, strict);
const selector = joinTokens(bestTokens);
const parsedSelector = injectedScript.parseSelector(selector);
@ -68,7 +68,7 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][]
return textCandidates.filter(c => c[0].selector[0] !== '/');
}
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, strict: boolean): SelectorToken[] | null {
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, strict: boolean, testIdAttributeName: string): SelectorToken[] | null {
if (targetElement.ownerDocument.documentElement === targetElement)
return [{ engine: 'css', selector: 'html', score: 1 }];
@ -81,7 +81,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
// Do not use regex for parent elements (for performance).
textCandidates = filterRegexTokens(textCandidates);
}
const noTextCandidates = buildCandidates(injectedScript, element, accessibleNameCache).map(token => [token]);
const noTextCandidates = buildCandidates(injectedScript, element, testIdAttributeName, accessibleNameCache).map(token => [token]);
// First check all text and non-text candidates for the element.
let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch, strict);
@ -144,14 +144,13 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
return calculateCached(targetElement, true);
}
function buildCandidates(injectedScript: InjectedScript, element: Element, accessibleNameCache: Map<Element, boolean>): SelectorToken[] {
function buildCandidates(injectedScript: InjectedScript, element: Element, testIdAttributeName: string, accessibleNameCache: Map<Element, boolean>): SelectorToken[] {
const candidates: SelectorToken[] = [];
if (element.getAttribute(testIdAttributeName))
candidates.push({ engine: 'internal:testid', selector: `[${testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(testIdAttributeName)!, true)}]`, score: 1 });
if (element.getAttribute('data-testid'))
candidates.push({ engine: 'internal:attr', selector: `[data-testid=${escapeForAttributeSelector(element.getAttribute('data-testid')!, true)}]`, score: 1 });
for (const attr of ['data-test-id', 'data-test']) {
if (element.getAttribute(attr))
for (const attr of ['data-testid', 'data-test-id', 'data-test']) {
if (attr !== testIdAttributeName && element.getAttribute(attr))
candidates.push({ engine: 'css', selector: `[${attr}=${quoteAttributeValue(element.getAttribute(attr)!)}]`, score: 2 });
}

View file

@ -71,14 +71,15 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
tokens.push(factory.generateLocator(base, 'role', attrSelector.name, { attrs }));
continue;
}
if (part.name === 'internal:attr') {
if (part.name === 'internal:testid') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { name, value, caseSensitive } = attrSelector.attributes[0];
if (name === 'data-testid') {
const { value } = attrSelector.attributes[0];
tokens.push(factory.generateLocator(base, 'test-id', value));
continue;
}
if (part.name === 'internal:attr') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { name, value, caseSensitive } = attrSelector.attributes[0];
const text = value as string | RegExp;
const exact = !!caseSensitive;
if (name === 'placeholder') {

View file

@ -20,7 +20,7 @@ import type { Language } from './locatorGenerators';
import { parseSelector } from './selectorParser';
type TemplateParams = { quote: string, text: string }[];
function parseLocator(locator: string): string {
function parseLocator(locator: string, testIdAttributeName: string): string {
locator = locator
.replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase())
.replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`);
@ -87,7 +87,7 @@ function parseLocator(locator: string): string {
.replace(/regex=/g, '=')
.replace(/,,/g, ',');
return transform(template, params);
return transform(template, params, testIdAttributeName);
}
function countParams(template: string) {
@ -98,7 +98,7 @@ function shiftParams(template: string, sub: number) {
return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`);
}
function transform(template: string, params: TemplateParams): string {
function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
// Recursively handle filter(has=).
while (true) {
const hasMatch = template.match(/filter\(,?has=/);
@ -122,7 +122,7 @@ function transform(template: string, params: TemplateParams): string {
const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas);
const paramsCountInHas = countParams(hasTemplate);
const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas);
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams));
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, testIdAttributeName));
// Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again.
template = template.substring(0, start - 1) + `2=$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end), paramsCountInHas - 1);
@ -139,7 +139,7 @@ function transform(template: string, params: TemplateParams): string {
.replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1')
.replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1')
.replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1')
.replace(/getbytestid\(([^)]+)\)/g, 'internal:attr=[data-testid=$1s]')
.replace(/getbytestid\(([^)]+)\)/g, `internal:testid=[${testIdAttributeName}=$1s]`)
.replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 'internal:attr=[$1=$2]')
.replace(/first(\(\))?/g, 'nth=0')
.replace(/last(\(\))?/g, 'nth=-1')
@ -158,7 +158,7 @@ function transform(template: string, params: TemplateParams): string {
t = t
.replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => {
const param = params[+ordinal - 1];
if (t.startsWith('internal:attr') || t.startsWith('internal:role'))
if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role'))
return new RegExp(param.text) + (suffix || '');
return escapeForTextSelector(new RegExp(param.text, suffix), false);
})
@ -166,7 +166,7 @@ function transform(template: string, params: TemplateParams): string {
const param = params[+ordinal - 1];
if (t.startsWith('internal:has='))
return param.text;
if (t.startsWith('internal:attr') || t.startsWith('internal:role'))
if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role'))
return escapeForAttributeSelector(param.text, suffix === 's');
return escapeForTextSelector(param.text, suffix === 's');
});
@ -174,14 +174,14 @@ function transform(template: string, params: TemplateParams): string {
}).join(' >> ');
}
export function locatorOrSelectorAsSelector(language: Language, locator: string): string {
export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
try {
parseSelector(locator);
return locator;
} catch (e) {
}
try {
const selector = parseLocator(locator);
const selector = parseLocator(locator, testIdAttributeName);
if (digestForComparison(asLocator(language, selector)) === digestForComparison(locator))
return selector;
} catch (e) {

View file

@ -167,7 +167,8 @@ export class Recorder implements InstrumentationListener {
mode: this._mode,
actionPoint,
actionSelector,
language: this._currentLanguage
language: this._currentLanguage,
testIdAttributeName: this._contextRecorder.testIdAttributeName(),
};
return uiState;
});
@ -215,7 +216,7 @@ export class Recorder implements InstrumentationListener {
}
setHighlightedSelector(language: Language, selector: string) {
this._highlightedSelector = locatorOrSelectorAsSelector(language, selector);
this._highlightedSelector = locatorOrSelectorAsSelector(language, selector, this._contextRecorder.testIdAttributeName());
this._refreshOverlay();
}
@ -224,6 +225,10 @@ export class Recorder implements InstrumentationListener {
this._refreshOverlay();
}
setTestIdAttributeName(testIdAttributeName: string) {
this._contextRecorder.setTestIdAttributeName(testIdAttributeName);
}
setOutput(codegenId: string, outputFile: string | undefined) {
this._contextRecorder.setOutput(codegenId, outputFile);
}
@ -339,6 +344,7 @@ class ContextRecorder extends EventEmitter {
private _recorderSources: Source[];
private _throttledOutputFile: ThrottledFile | null = null;
private _orderedLanguages: LanguageGenerator[] = [];
private _testIdAttributeName: string = 'data-testid';
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
super();
@ -383,6 +389,14 @@ class ContextRecorder extends EventEmitter {
this._generator = generator;
}
testIdAttributeName() {
return this._testIdAttributeName;
}
setTestIdAttributeName(testIdAttributeName: string) {
this._testIdAttributeName = testIdAttributeName;
}
setOutput(codegenId: string, outputFile?: string) {
const languages = new Set([
new JavaLanguageGenerator(),
@ -536,7 +550,7 @@ class ContextRecorder extends EventEmitter {
return;
const utility = await parent._utilityContext();
const injected = await utility.injectedScript();
const selector = await injected.evaluate((injected, element) => injected.generateSelector(element as Element), frameElement);
const selector = await injected.evaluate((injected, element) => injected.generateSelector(element as Element, this._testIdAttributeName), frameElement);
return selector;
} catch (e) {
}

View file

@ -33,6 +33,7 @@ export class Selectors {
readonly _builtinEnginesInMainWorld: Set<string>;
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
readonly guid = `selectors@${createGuid()}`;
private _testIdAttributeName: string = 'data-testid';
constructor() {
// Note: keep in sync with InjectedScript class.
@ -46,7 +47,7 @@ export class Selectors {
'data-test-id', 'data-test-id:light',
'data-test', 'data-test:light',
'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text',
'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role',
'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid',
]);
this._builtinEnginesInMainWorld = new Set([
'_react', '_vue',
@ -65,6 +66,14 @@ export class Selectors {
this._engines.set(name, { source, contentScript });
}
testIdAttributeName(): string {
return this._testIdAttributeName;
}
setTestIdAttributeName(testIdAttributeName: string) {
this._testIdAttributeName = testIdAttributeName;
}
unregisterAll() {
this._engines.clear();
}

View file

@ -34,7 +34,7 @@ function getByAttributeTextSelector(attrName: string, text: string | RegExp, opt
}
export function getByTestIdSelector(testIdAttributeName: string, testId: string): string {
return getByAttributeTextSelector(testIdAttributeName, testId, { exact: true });
return `internal:testid=[${testIdAttributeName}=${escapeForAttributeSelector(testId, true)}]`;
}
export function getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string {

View file

@ -653,9 +653,10 @@ export type DebugControllerNavigateOptions = {
export type DebugControllerNavigateResult = void;
export type DebugControllerSetRecorderModeParams = {
mode: 'inspecting' | 'recording' | 'none',
testIdAttributeName?: string,
};
export type DebugControllerSetRecorderModeOptions = {
testIdAttributeName?: string,
};
export type DebugControllerSetRecorderModeResult = void;
export type DebugControllerHighlightParams = {
@ -763,6 +764,7 @@ export interface SelectorsEventTarget {
export interface SelectorsChannel extends SelectorsEventTarget, Channel {
_type_Selectors: boolean;
register(params: SelectorsRegisterParams, metadata?: Metadata): Promise<SelectorsRegisterResult>;
setTestIdAttributeName(params: SelectorsSetTestIdAttributeNameParams, metadata?: Metadata): Promise<SelectorsSetTestIdAttributeNameResult>;
}
export type SelectorsRegisterParams = {
name: string,
@ -773,6 +775,13 @@ export type SelectorsRegisterOptions = {
contentScript?: boolean,
};
export type SelectorsRegisterResult = void;
export type SelectorsSetTestIdAttributeNameParams = {
testIdAttributeName: string,
};
export type SelectorsSetTestIdAttributeNameOptions = {
};
export type SelectorsSetTestIdAttributeNameResult = void;
export interface SelectorsEvents {
}

View file

@ -692,6 +692,7 @@ DebugController:
- inspecting
- recording
- none
testIdAttributeName: string?
highlight:
parameters:
@ -796,6 +797,9 @@ Selectors:
source: string
contentScript: boolean?
setTestIdAttributeName:
parameters:
testIdAttributeName: string
BrowserType:
type: interface

View file

@ -30,6 +30,7 @@ export type UIState = {
actionPoint?: Point;
actionSelector?: string;
language: 'javascript' | 'python' | 'java' | 'csharp';
testIdAttributeName: string;
};
export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused';

View file

@ -149,7 +149,7 @@ export class Backend extends EventEmitter {
await this._send('navigate', params);
}
async setMode(params: { mode: 'none' | 'inspecting' | 'recording', language?: string, file?: string }) {
async setMode(params: { mode: 'none' | 'inspecting' | 'recording', language?: string, file?: string, testIdAttributeName?: string }) {
await this._send('setRecorderMode', params);
}

View file

@ -182,3 +182,33 @@ test('test', async ({ page }) => {
await page.getByRole('button').click();
expect(events).toHaveLength(length);
});
test('should record custom data-testid', async ({ backend, connectedBrowser }) => {
const events = [];
backend.on('sourceChanged', event => events.push(event));
await backend.setMode({ mode: 'recording', testIdAttributeName: 'data-custom-id' });
const context = await connectedBrowser._newContextForReuse();
const [page] = context.pages();
await page.setContent(`<div data-custom-id='one'>One</div>`);
await page.locator('div').click();
await expect.poll(() => events[events.length - 1]).toEqual({
header: `import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {`,
footer: `});`,
actions: [
` await page.goto('about:blank');`,
` await page.getByTestId('one').click();`,
],
text: `import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.goto('about:blank');
await page.getByTestId('one').click();
});`
});
});

View file

@ -29,7 +29,7 @@ function generateForSelector(selector: string) {
const result: any = {};
for (const lang of ['javascript', 'python', 'java', 'csharp']) {
const locatorString = asLocator(lang, selector, false);
expect.soft(parseLocator(lang, locatorString), lang + ' mismatch').toBe(selector);
expect.soft(parseLocator(lang, locatorString, 'data-testid'), lang + ' mismatch').toBe(selector);
result[lang] = locatorString;
}
return result;

View file

@ -88,7 +88,31 @@ it.describe('selector generator', () => {
it('should prefer data-testid', async ({ page }) => {
await page.setContent(`<div>Text</div><div>Text</div><div data-testid=a>Text</div><div>Text</div>`);
expect(await generate(page, '[data-testid="a"]')).toBe('internal:attr=[data-testid=\"a\"s]');
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(`
<div>
<div></div>
<div>
<div></div>
<div></div>
</div>
</div>
<div>
<div class='foo bar:0' data-custom-id='One'>
</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')`);
});
it('should handle first non-unique data-testid', async ({ page }) => {
@ -99,7 +123,7 @@ it.describe('selector generator', () => {
<div data-testid=a>
Text
</div>`);
expect(await generate(page, 'div[mark="1"]')).toBe('internal:attr=[data-testid=\"a\"s] >> nth=0');
expect(await generate(page, 'div[mark="1"]')).toBe('internal:testid=[data-testid=\"a\"s] >> nth=0');
});
it('should handle second non-unique data-testid', async ({ page }) => {
@ -110,7 +134,7 @@ it.describe('selector generator', () => {
<div data-testid=a mark=1>
Text
</div>`);
expect(await generate(page, 'div[mark="1"]')).toBe(`internal:attr=[data-testid=\"a\"s] >> nth=1`);
expect(await generate(page, 'div[mark="1"]')).toBe(`internal:testid=[data-testid=\"a\"s] >> nth=1`);
});
it('should use readable id', async ({ page }) => {