diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 5f7ab119e1..8044bf0da7 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -308,7 +308,7 @@ export class Frame extends ChannelOwner 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 { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 02e44808e4..20c42b7f68 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -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; } diff --git a/packages/playwright-core/src/client/selectors.ts b/packages/playwright-core/src/client/selectors.ts index 040de211e4..2739be0e8d 100644 --- a/packages/playwright-core/src/client/selectors.ts +++ b/packages/playwright-core/src/client/selectors.ts @@ -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(); @@ -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(() => {}); } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 5f19d08490..9619294dcf 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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, diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index da2f0ed834..9c92e3901b 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -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); diff --git a/packages/playwright-core/src/server/dispatchers/selectorsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/selectorsDispatcher.ts index eb9085dda8..98310d2651 100644 --- a/packages/playwright-core/src/server/dispatchers/selectorsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/selectorsDispatcher.ts @@ -29,4 +29,8 @@ export class SelectorsDispatcher extends Dispatcher { await this._object.register(params.name, params.source, params.contentScript); } + + async setTestIdAttributeName(params: channels.SelectorsSetTestIdAttributeNameParams, metadata?: channels.Metadata | undefined): Promise { + this._object.setTestIdAttributeName(params.testIdAttributeName); + } } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index ce7d0b2510..cd80bf9546 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -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')}] diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index a28b5b7b12..a1c16fb63f 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 2793eec3e1..f37437cfc2 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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) diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 75de6fe827..73231a86cd 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -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; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 5ac7b852a7..a04d7c104c 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -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): SelectorToken[] { +function buildCandidates(injectedScript: InjectedScript, element: Element, testIdAttributeName: string, accessibleNameCache: Map): 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 }); } diff --git a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts index e20fdb8d86..3d1f24bdc7 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts @@ -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') { diff --git a/packages/playwright-core/src/server/isomorphic/locatorParser.ts b/packages/playwright-core/src/server/isomorphic/locatorParser.ts index c3ab9f8b45..099cdb8c76 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorParser.ts @@ -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) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index b1cf0ca150..29e075f3b8 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -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) { } diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index a082f898a4..cfce216fcb 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -33,6 +33,7 @@ export class Selectors { readonly _builtinEnginesInMainWorld: Set; readonly _engines: Map; 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(); } diff --git a/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts index 07d7c2514f..a82c5c2a98 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts @@ -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 { diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index aaf46befbc..aade6ed415 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -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; + setTestIdAttributeName(params: SelectorsSetTestIdAttributeNameParams, metadata?: Metadata): Promise; } 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 { } diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index fb770f173a..1626e94fd1 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -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 diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index ec3a67d8de..5d79b3e263 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -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'; diff --git a/tests/config/debugControllerBackend.ts b/tests/config/debugControllerBackend.ts index a018a295de..cffb65eda7 100644 --- a/tests/config/debugControllerBackend.ts +++ b/tests/config/debugControllerBackend.ts @@ -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); } diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 14ddcd8f2b..faea27dfd5 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -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(`
One
`); + 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(); +});` + }); +}); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index ea95d731cb..8c1646883f 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -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; diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index e3eb310da2..c3c99fff3a 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -88,7 +88,31 @@ it.describe('selector generator', () => { it('should prefer data-testid', async ({ page }) => { await page.setContent(`
Text
Text
Text
Text
`); - 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(` +
+
+
+
+
+
+
+
+
+
+
+
+
`); + const error = await page.locator('.foo').hover().catch(e => e); + expect(error.message).toContain('strict mode violation'); + expect(error.message).toContain('
{ @@ -99,7 +123,7 @@ it.describe('selector generator', () => {
Text
`); - 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', () => {
Text
`); - 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 }) => {