chore: allow configuring test id attribute for codegen (#22716)

Fixes: https://github.com/microsoft/playwright/issues/22653
This commit is contained in:
Pavel Feldman 2023-04-29 12:04:33 -07:00 committed by GitHub
parent a01df2ff5b
commit 116fb349ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 42 additions and 13 deletions

View file

@ -64,8 +64,9 @@ commandWithOpenOptions('codegen [url]', 'open page and generate code for user ac
['-o, --output <file name>', 'saves the generated script to a file'],
['--target <language>', `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java`, codegenId()],
['--save-trace <filename>', 'record a trace for the session and save it to a file'],
['--test-id-attribute <attributeName>', '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,
});

View file

@ -375,6 +375,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
device?: string,
saveStorage?: string,
mode?: 'recording' | 'inspecting',
testIdAttributeName?: string,
outputFile?: string,
handleSIGINT?: boolean,
}) {

View file

@ -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),

View file

@ -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;
}

View file

@ -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);

View file

@ -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<string | undefined> {
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) {
}

View file

@ -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,

View file

@ -1113,6 +1113,7 @@ BrowserContext:
- inspecting
- recording
pauseOnNextStatement: boolean?
testIdAttributeName: string?
launchOptions: json?
contextOptions: json?
device: string?

View file

@ -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(`<div my-test-id="foo">Hello</div>`);
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();`);
});

View file

@ -27,7 +27,7 @@ export { expect } from '@playwright/test';
type CLITestArgs = {
recorderPageGetter: () => Promise<Page>;
closeRecorder: () => Promise<void>;
openRecorder: () => Promise<Recorder>;
openRecorder: (options?: { testIdAttributeName: string }) => Promise<Recorder>;
runCLI: (args: string[], options?: { autoExitWhen?: string }) => CLIMock;
};
@ -76,8 +76,8 @@ export const test = contextTest.extend<CLITestArgs>({
},
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());
});
},