chore: use provided value for the generated test id (#18631)
This commit is contained in:
parent
05b623e6b0
commit
0355d8618f
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:testid') {
|
||||
const attrSelector = parseAttributeSelector(part.body as string, true);
|
||||
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];
|
||||
if (name === 'data-testid') {
|
||||
tokens.push(factory.generateLocator(base, 'test-id', value));
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = value as string | RegExp;
|
||||
const exact = !!caseSensitive;
|
||||
if (name === 'placeholder') {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});`
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue