diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 47a819e4db..c84241b45e 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -64,8 +64,9 @@ commandWithOpenOptions('codegen [url]', 'open page and generate code for user ac ['-o, --output ', 'saves the generated script to a file'], ['--target ', `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java`, codegenId()], ['--save-trace ', 'record a trace for the session and save it to a file'], + ['--test-id-attribute ', 'use the specified attribute to generate data test ID selectors'], ]).action(function(url, options) { - codegen(options, url, options.target, options.output).catch(logErrorAndExit); + codegen(options, url).catch(logErrorAndExit); }).addHelpText('afterAll', ` Examples: @@ -527,7 +528,8 @@ async function open(options: Options, url: string | undefined, language: string) await openPage(context, url); } -async function codegen(options: Options, url: string | undefined, language: string, outputFile?: string) { +async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) { + const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); await context._enableRecorder({ language, @@ -536,6 +538,7 @@ async function codegen(options: Options, url: string | undefined, language: stri device: options.device, saveStorage: options.saveStorage, mode: 'recording', + testIdAttributeName, outputFile: outputFile ? path.resolve(outputFile) : undefined, handleSIGINT: false, }); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 7999255af9..67e57944f8 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -375,6 +375,7 @@ export class BrowserContext extends ChannelOwner device?: string, saveStorage?: string, mode?: 'recording' | 'inspecting', + testIdAttributeName?: string, outputFile?: string, handleSIGINT?: boolean, }) { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index b4fef0d2c1..8a16efc7d1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -881,6 +881,7 @@ scheme.BrowserContextRecorderSupplementEnableParams = tObject({ language: tOptional(tString), mode: tOptional(tEnum(['inspecting', 'recording'])), pauseOnNextStatement: tOptional(tBoolean), + testIdAttributeName: tOptional(tString), launchOptions: tOptional(tAny), contextOptions: tOptional(tAny), device: tOptional(tString), diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 963b66c651..9225fdd38b 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -152,7 +152,7 @@ export class InjectedScript { return result; } - generateSelector(targetElement: Element, testIdAttributeName: string): string { + generateSelector(targetElement: Element, testIdAttributeName: string, omitInternalEngines?: boolean): string { return generateSelector(this, targetElement, testIdAttributeName).selector; } diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 966ea15d7b..5fc79d5a51 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -74,11 +74,11 @@ export function querySelector(injectedScript: InjectedScript, selector: string, } } -export function generateSelector(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string): { selector: string, elements: Element[] } { +export function generateSelector(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string, omitInternalEngines?: boolean): { 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, testIdAttributeName); + const targetTokens = generateSelectorFor(injectedScript, targetElement, testIdAttributeName, omitInternalEngines); const bestTokens = targetTokens || cssFallback(injectedScript, targetElement); const selector = joinTokens(bestTokens); const parsedSelector = injectedScript.parseSelector(selector); @@ -98,7 +98,7 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][] return textCandidates.filter(c => c[0].selector[0] !== '/'); } -function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string): SelectorToken[] | null { +function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string, omitInternalEngines?: boolean): SelectorToken[] | null { if (targetElement.ownerDocument.documentElement === targetElement) return [{ engine: 'css', selector: 'html', score: 1 }]; @@ -111,7 +111,9 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem // Do not use regex for parent elements (for performance). textCandidates = filterRegexTokens(textCandidates); } - const noTextCandidates = buildCandidates(injectedScript, element, testIdAttributeName, accessibleNameCache).map(token => [token]); + const noTextCandidates = buildCandidates(injectedScript, element, testIdAttributeName, accessibleNameCache) + .filter(token => !omitInternalEngines || !token.engine.startsWith('internal:')) + .map(token => [token]); // First check all text and non-text candidates for the element. let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch); diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 0dfbfab0b0..8aa65a5922 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -173,7 +173,7 @@ export class Recorder implements InstrumentationListener { actionPoint, actionSelector, language: this._currentLanguage, - testIdAttributeName: this._context.selectors().testIdAttributeName(), + testIdAttributeName: this._contextRecorder.testIdAttributeName(), }; return uiState; }); @@ -349,7 +349,6 @@ class ContextRecorder extends EventEmitter { private _recorderSources: Source[]; private _throttledOutputFile: ThrottledFile | null = null; private _orderedLanguages: LanguageGenerator[] = []; - private _testIdAttributeName: string = 'data-testid'; private _listeners: RegisteredListener[] = []; constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { @@ -542,6 +541,10 @@ class ContextRecorder extends EventEmitter { return fallback; } + testIdAttributeName(): string { + return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; + } + private async _findFrameSelector(frame: Frame, parent: Frame): Promise { try { const frameElement = await frame.frameElement(); @@ -549,7 +552,9 @@ 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, this._testIdAttributeName), frameElement); + const selector = await injected.evaluate((injected, element) => { + return injected.generateSelector(element as Element, '', true); + }, frameElement); return selector; } catch (e) { } diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 7e968288b7..c0966dfa0b 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1613,6 +1613,7 @@ export type BrowserContextRecorderSupplementEnableParams = { language?: string, mode?: 'inspecting' | 'recording', pauseOnNextStatement?: boolean, + testIdAttributeName?: string, launchOptions?: any, contextOptions?: any, device?: string, @@ -1625,6 +1626,7 @@ export type BrowserContextRecorderSupplementEnableOptions = { language?: string, mode?: 'inspecting' | 'recording', pauseOnNextStatement?: boolean, + testIdAttributeName?: string, launchOptions?: any, contextOptions?: any, device?: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 473bea0e02..5947cecfbb 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1113,6 +1113,7 @@ BrowserContext: - inspecting - recording pauseOnNextStatement: boolean? + testIdAttributeName: string? launchOptions: json? contextOptions: json? device: string? diff --git a/tests/library/inspector/cli-codegen-2.spec.ts b/tests/library/inspector/cli-codegen-2.spec.ts index cf7fb62889..47aff40d2f 100644 --- a/tests/library/inspector/cli-codegen-2.spec.ts +++ b/tests/library/inspector/cli-codegen-2.spec.ts @@ -536,3 +536,17 @@ test.describe('cli codegen', () => { }); }); + +test('should --test-id-attribute', async ({ page, openRecorder }) => { + const recorder = await openRecorder({ testIdAttributeName: 'my-test-id' }); + + await recorder.setContentAndWait(`
Hello
`); + await page.click('[my-test-id=foo]'); + const sources = await recorder.waitForOutput('JavaScript', `page.getByTestId`); + + expect.soft(sources.get('JavaScript').text).toContain(`await page.getByTestId('foo').click()`); + expect.soft(sources.get('Java').text).toContain(`page.getByTestId("foo").click()`); + expect.soft(sources.get('Python').text).toContain(`page.get_by_test_id("foo").click()`); + expect.soft(sources.get('Python Async').text).toContain(`await page.get_by_test_id("foo").click()`); + expect.soft(sources.get('C#').text).toContain(`await page.GetByTestId("foo").ClickAsync();`); +}); diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index 58f06fac77..04111a39d6 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -27,7 +27,7 @@ export { expect } from '@playwright/test'; type CLITestArgs = { recorderPageGetter: () => Promise; closeRecorder: () => Promise; - openRecorder: () => Promise; + openRecorder: (options?: { testIdAttributeName: string }) => Promise; runCLI: (args: string[], options?: { autoExitWhen?: string }) => CLIMock; }; @@ -76,8 +76,8 @@ export const test = contextTest.extend({ }, openRecorder: async ({ page, recorderPageGetter }, run) => { - await run(async () => { - await (page.context() as any)._enableRecorder({ language: 'javascript', mode: 'recording' }); + await run(async (options?: { testIdAttributeName?: string }) => { + await (page.context() as any)._enableRecorder({ language: 'javascript', mode: 'recording', ...options }); return new Recorder(page, await recorderPageGetter()); }); },