chore: allow configuring test id attribute for codegen (#22716)
Fixes: https://github.com/microsoft/playwright/issues/22653
This commit is contained in:
parent
a01df2ff5b
commit
116fb349ce
|
|
@ -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'],
|
['-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()],
|
['--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'],
|
['--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) {
|
]).action(function(url, options) {
|
||||||
codegen(options, url, options.target, options.output).catch(logErrorAndExit);
|
codegen(options, url).catch(logErrorAndExit);
|
||||||
}).addHelpText('afterAll', `
|
}).addHelpText('afterAll', `
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|
@ -527,7 +528,8 @@ async function open(options: Options, url: string | undefined, language: string)
|
||||||
await openPage(context, url);
|
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);
|
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH);
|
||||||
await context._enableRecorder({
|
await context._enableRecorder({
|
||||||
language,
|
language,
|
||||||
|
|
@ -536,6 +538,7 @@ async function codegen(options: Options, url: string | undefined, language: stri
|
||||||
device: options.device,
|
device: options.device,
|
||||||
saveStorage: options.saveStorage,
|
saveStorage: options.saveStorage,
|
||||||
mode: 'recording',
|
mode: 'recording',
|
||||||
|
testIdAttributeName,
|
||||||
outputFile: outputFile ? path.resolve(outputFile) : undefined,
|
outputFile: outputFile ? path.resolve(outputFile) : undefined,
|
||||||
handleSIGINT: false,
|
handleSIGINT: false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
|
||||||
device?: string,
|
device?: string,
|
||||||
saveStorage?: string,
|
saveStorage?: string,
|
||||||
mode?: 'recording' | 'inspecting',
|
mode?: 'recording' | 'inspecting',
|
||||||
|
testIdAttributeName?: string,
|
||||||
outputFile?: string,
|
outputFile?: string,
|
||||||
handleSIGINT?: boolean,
|
handleSIGINT?: boolean,
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -881,6 +881,7 @@ scheme.BrowserContextRecorderSupplementEnableParams = tObject({
|
||||||
language: tOptional(tString),
|
language: tOptional(tString),
|
||||||
mode: tOptional(tEnum(['inspecting', 'recording'])),
|
mode: tOptional(tEnum(['inspecting', 'recording'])),
|
||||||
pauseOnNextStatement: tOptional(tBoolean),
|
pauseOnNextStatement: tOptional(tBoolean),
|
||||||
|
testIdAttributeName: tOptional(tString),
|
||||||
launchOptions: tOptional(tAny),
|
launchOptions: tOptional(tAny),
|
||||||
contextOptions: tOptional(tAny),
|
contextOptions: tOptional(tAny),
|
||||||
device: tOptional(tString),
|
device: tOptional(tString),
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ export class InjectedScript {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
generateSelector(targetElement: Element, testIdAttributeName: string): string {
|
generateSelector(targetElement: Element, testIdAttributeName: string, omitInternalEngines?: boolean): string {
|
||||||
return generateSelector(this, targetElement, testIdAttributeName).selector;
|
return generateSelector(this, targetElement, testIdAttributeName).selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
injectedScript._evaluator.begin();
|
||||||
try {
|
try {
|
||||||
targetElement = targetElement.closest('button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]') || targetElement;
|
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 bestTokens = targetTokens || cssFallback(injectedScript, targetElement);
|
||||||
const selector = joinTokens(bestTokens);
|
const selector = joinTokens(bestTokens);
|
||||||
const parsedSelector = injectedScript.parseSelector(selector);
|
const parsedSelector = injectedScript.parseSelector(selector);
|
||||||
|
|
@ -98,7 +98,7 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][]
|
||||||
return textCandidates.filter(c => c[0].selector[0] !== '/');
|
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)
|
if (targetElement.ownerDocument.documentElement === targetElement)
|
||||||
return [{ engine: 'css', selector: 'html', score: 1 }];
|
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).
|
// Do not use regex for parent elements (for performance).
|
||||||
textCandidates = filterRegexTokens(textCandidates);
|
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.
|
// First check all text and non-text candidates for the element.
|
||||||
let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch);
|
let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch);
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export class Recorder implements InstrumentationListener {
|
||||||
actionPoint,
|
actionPoint,
|
||||||
actionSelector,
|
actionSelector,
|
||||||
language: this._currentLanguage,
|
language: this._currentLanguage,
|
||||||
testIdAttributeName: this._context.selectors().testIdAttributeName(),
|
testIdAttributeName: this._contextRecorder.testIdAttributeName(),
|
||||||
};
|
};
|
||||||
return uiState;
|
return uiState;
|
||||||
});
|
});
|
||||||
|
|
@ -349,7 +349,6 @@ class ContextRecorder extends EventEmitter {
|
||||||
private _recorderSources: Source[];
|
private _recorderSources: Source[];
|
||||||
private _throttledOutputFile: ThrottledFile | null = null;
|
private _throttledOutputFile: ThrottledFile | null = null;
|
||||||
private _orderedLanguages: LanguageGenerator[] = [];
|
private _orderedLanguages: LanguageGenerator[] = [];
|
||||||
private _testIdAttributeName: string = 'data-testid';
|
|
||||||
private _listeners: RegisteredListener[] = [];
|
private _listeners: RegisteredListener[] = [];
|
||||||
|
|
||||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||||
|
|
@ -542,6 +541,10 @@ class ContextRecorder extends EventEmitter {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testIdAttributeName(): string {
|
||||||
|
return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid';
|
||||||
|
}
|
||||||
|
|
||||||
private async _findFrameSelector(frame: Frame, parent: Frame): Promise<string | undefined> {
|
private async _findFrameSelector(frame: Frame, parent: Frame): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const frameElement = await frame.frameElement();
|
const frameElement = await frame.frameElement();
|
||||||
|
|
@ -549,7 +552,9 @@ class ContextRecorder extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
const utility = await parent._utilityContext();
|
const utility = await parent._utilityContext();
|
||||||
const injected = await utility.injectedScript();
|
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;
|
return selector;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1613,6 +1613,7 @@ export type BrowserContextRecorderSupplementEnableParams = {
|
||||||
language?: string,
|
language?: string,
|
||||||
mode?: 'inspecting' | 'recording',
|
mode?: 'inspecting' | 'recording',
|
||||||
pauseOnNextStatement?: boolean,
|
pauseOnNextStatement?: boolean,
|
||||||
|
testIdAttributeName?: string,
|
||||||
launchOptions?: any,
|
launchOptions?: any,
|
||||||
contextOptions?: any,
|
contextOptions?: any,
|
||||||
device?: string,
|
device?: string,
|
||||||
|
|
@ -1625,6 +1626,7 @@ export type BrowserContextRecorderSupplementEnableOptions = {
|
||||||
language?: string,
|
language?: string,
|
||||||
mode?: 'inspecting' | 'recording',
|
mode?: 'inspecting' | 'recording',
|
||||||
pauseOnNextStatement?: boolean,
|
pauseOnNextStatement?: boolean,
|
||||||
|
testIdAttributeName?: string,
|
||||||
launchOptions?: any,
|
launchOptions?: any,
|
||||||
contextOptions?: any,
|
contextOptions?: any,
|
||||||
device?: string,
|
device?: string,
|
||||||
|
|
|
||||||
|
|
@ -1113,6 +1113,7 @@ BrowserContext:
|
||||||
- inspecting
|
- inspecting
|
||||||
- recording
|
- recording
|
||||||
pauseOnNextStatement: boolean?
|
pauseOnNextStatement: boolean?
|
||||||
|
testIdAttributeName: string?
|
||||||
launchOptions: json?
|
launchOptions: json?
|
||||||
contextOptions: json?
|
contextOptions: json?
|
||||||
device: string?
|
device: string?
|
||||||
|
|
|
||||||
|
|
@ -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();`);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export { expect } from '@playwright/test';
|
||||||
type CLITestArgs = {
|
type CLITestArgs = {
|
||||||
recorderPageGetter: () => Promise<Page>;
|
recorderPageGetter: () => Promise<Page>;
|
||||||
closeRecorder: () => Promise<void>;
|
closeRecorder: () => Promise<void>;
|
||||||
openRecorder: () => Promise<Recorder>;
|
openRecorder: (options?: { testIdAttributeName: string }) => Promise<Recorder>;
|
||||||
runCLI: (args: string[], options?: { autoExitWhen?: string }) => CLIMock;
|
runCLI: (args: string[], options?: { autoExitWhen?: string }) => CLIMock;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -76,8 +76,8 @@ export const test = contextTest.extend<CLITestArgs>({
|
||||||
},
|
},
|
||||||
|
|
||||||
openRecorder: async ({ page, recorderPageGetter }, run) => {
|
openRecorder: async ({ page, recorderPageGetter }, run) => {
|
||||||
await run(async () => {
|
await run(async (options?: { testIdAttributeName?: string }) => {
|
||||||
await (page.context() as any)._enableRecorder({ language: 'javascript', mode: 'recording' });
|
await (page.context() as any)._enableRecorder({ language: 'javascript', mode: 'recording', ...options });
|
||||||
return new Recorder(page, await recorderPageGetter());
|
return new Recorder(page, await recorderPageGetter());
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue