chore: turn RunPhase in a class with helper methods (#17721)

This commit is contained in:
Yury Semikhatsky 2022-09-30 09:12:06 -07:00 committed by GitHub
parent 5424c8c385
commit ef32cab423
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -54,12 +54,98 @@ const readDirAsync = promisify(fs.readdir);
const readFileAsync = promisify(fs.readFile); const readFileAsync = promisify(fs.readFile);
export const kDefaultConfigFiles = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs']; export const kDefaultConfigFiles = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'];
// Project group is a sequence of run phases. type ProjectConstraints = {
type RunPhase = {
projectName: string; projectName: string;
testFileMatcher: Matcher; testFileMatcher: Matcher;
testTitleMatcher: Matcher; testTitleMatcher: Matcher;
}[]; };
// Project group is a sequence of run phases.
class RunPhase {
static collectRunPhases(options: RunOptions, config: FullConfigInternal): RunPhase[] {
let projectGroup = options.projectGroup;
if (options.projectFilter) {
if (projectGroup)
throw new Error('--group option can not be combined with --project');
} else {
if (!projectGroup && config.groups?.default && !options.testFileFilters?.length)
projectGroup = 'default';
if (projectGroup) {
if (config.shard)
throw new Error(`Project group '${projectGroup}' cannot be combined with --shard`);
}
}
const phases: RunPhase[] = [];
if (projectGroup) {
const group = config.groups?.[projectGroup];
if (!group)
throw new Error(`Cannot find project group '${projectGroup}' in the config`);
for (const entry of group) {
if (isString(entry)) {
phases.push(new RunPhase([{
projectName: entry,
testFileMatcher: () => true,
testTitleMatcher: () => true,
}]));
} else {
const phase: ProjectConstraints[] = [];
for (const p of entry) {
if (isString(p)) {
phase.push({
projectName: p,
testFileMatcher: () => true,
testTitleMatcher: () => true,
});
} else {
const testMatch = p.testMatch ? createFileMatcher(p.testMatch) : () => true;
const testIgnore = p.testIgnore ? createFileMatcher(p.testIgnore) : () => false;
const grep = p.grep ? createTitleMatcher(p.grep) : () => true;
const grepInvert = p.grepInvert ? createTitleMatcher(p.grepInvert) : () => false;
const projects = isString(p.project) ? [p.project] : p.project;
phase.push(...projects.map(projectName => ({
projectName,
testFileMatcher: (file: string) => !testIgnore(file) && testMatch(file),
testTitleMatcher: (title: string) => !grepInvert(title) && grep(title),
})));
}
}
phases.push(new RunPhase(phase));
}
}
} else {
const testFileMatcher = fileMatcherFrom(options.testFileFilters);
const testTitleMatcher = options.testTitleMatcher;
const projects = options.projectFilter ?? config.projects.map(p => p.name);
phases.push(new RunPhase(projects.map(projectName => ({
projectName,
testFileMatcher,
testTitleMatcher
}))));
}
return phases;
}
constructor(private _projectWithConstraints: ProjectConstraints[]) {
}
projectNames(): string[] {
return this._projectWithConstraints.map(p => p.projectName);
}
testFileMatcher(projectName: string) {
return this._projectEntry(projectName).testFileMatcher;
}
testTitleMatcher(projectName: string) {
return this._projectEntry(projectName).testTitleMatcher;
}
private _projectEntry(projectName: string) {
projectName = projectName.toLocaleLowerCase();
return this._projectWithConstraints.find(p => p.projectName.toLocaleLowerCase() === projectName)!;
}
}
type RunOptions = { type RunOptions = {
listOnly?: boolean; listOnly?: boolean;
@ -207,11 +293,11 @@ export class Runner {
async listTestFiles(configFile: string, projectNames: string[] | undefined): Promise<any> { async listTestFiles(configFile: string, projectNames: string[] | undefined): Promise<any> {
const projects = projectNames ?? this._loader.fullConfig().projects.map(p => p.name); const projects = projectNames ?? this._loader.fullConfig().projects.map(p => p.name);
const phase: RunPhase = projects.map(projectName => ({ const phase = new RunPhase(projects.map(projectName => ({
projectName, projectName,
testFileMatcher: () => true, testFileMatcher: () => true,
testTitleMatcher: () => true, testTitleMatcher: () => true,
})); })));
const filesByProject = await this._collectFiles(phase); const filesByProject = await this._collectFiles(phase);
const report: any = { const report: any = {
projects: [] projects: []
@ -227,76 +313,6 @@ export class Runner {
return report; return report;
} }
private _collectRunPhases(options: RunOptions) {
const config = this._loader.fullConfig();
let projectGroup = options.projectGroup;
if (options.projectFilter) {
if (projectGroup)
throw new Error('--group option can not be combined with --project');
} else {
if (!projectGroup && config.groups?.default && !options.testFileFilters.length)
projectGroup = 'default';
if (projectGroup) {
if (config.shard)
throw new Error(`Project group '${projectGroup}' cannot be combined with --shard`);
}
}
const phases: RunPhase[] = [];
if (projectGroup) {
const group = config.groups?.[projectGroup];
if (!group)
throw new Error(`Cannot find project group '${projectGroup}' in the config`);
for (const entry of group) {
if (isString(entry)) {
phases.push([{
projectName: entry,
testFileMatcher: () => true,
testTitleMatcher: () => true,
}]);
} else {
const phase: RunPhase = [];
phases.push(phase);
for (const p of entry) {
if (isString(p)) {
phase.push({
projectName: p,
testFileMatcher: () => true,
testTitleMatcher: () => true,
});
} else {
const testMatch = p.testMatch ? createFileMatcher(p.testMatch) : () => true;
const testIgnore = p.testIgnore ? createFileMatcher(p.testIgnore) : () => false;
const grep = p.grep ? createTitleMatcher(p.grep) : () => true;
const grepInvert = p.grepInvert ? createTitleMatcher(p.grepInvert) : () => false;
const projects = isString(p.project) ? [p.project] : p.project;
phase.push(...projects.map(projectName => ({
projectName,
testFileMatcher: (file: string) => !testIgnore(file) && testMatch(file),
testTitleMatcher: (title: string) => !grepInvert(title) && grep(title),
})));
}
}
}
}
} else {
phases.push(this._runPhaseFromOptions(options));
}
return phases;
}
private _runPhaseFromOptions(options: RunOptions): RunPhase {
const testFileMatcher = fileMatcherFrom(options.testFileFilters);
const testTitleMatcher = options.testTitleMatcher;
const projects = options.projectFilter ?? this._loader.fullConfig().projects.map(p => p.name);
return projects.map(projectName => ({
projectName,
testFileMatcher,
testTitleMatcher
}));
}
private _collectProjects(projectNames: string[]): FullProjectInternal[] { private _collectProjects(projectNames: string[]): FullProjectInternal[] {
const projectsToFind = new Set<string>(); const projectsToFind = new Set<string>();
const unknownProjects = new Map<string, string>(); const unknownProjects = new Map<string, string>();
@ -322,9 +338,7 @@ export class Runner {
} }
private async _collectFiles(runPhase: RunPhase): Promise<Map<FullProjectInternal, string[]>> { private async _collectFiles(runPhase: RunPhase): Promise<Map<FullProjectInternal, string[]>> {
const projectNames = runPhase.map(p => p.projectName); const projects = this._collectProjects(runPhase.projectNames());
const projects = this._collectProjects(projectNames);
const projectToGroupEntry = new Map(runPhase.map(p => [p.projectName.toLocaleLowerCase(), p]));
const files = new Map<FullProjectInternal, string[]>(); const files = new Map<FullProjectInternal, string[]>();
for (const project of projects) { for (const project of projects) {
const allFiles = await collectFiles(project.testDir, project._respectGitIgnore); const allFiles = await collectFiles(project.testDir, project._respectGitIgnore);
@ -332,21 +346,20 @@ export class Runner {
const testIgnore = createFileMatcher(project.testIgnore); const testIgnore = createFileMatcher(project.testIgnore);
const extensions = ['.js', '.ts', '.mjs', '.tsx', '.jsx']; const extensions = ['.js', '.ts', '.mjs', '.tsx', '.jsx'];
const testFileExtension = (file: string) => extensions.includes(path.extname(file)); const testFileExtension = (file: string) => extensions.includes(path.extname(file));
const testFileFilter = projectToGroupEntry.get(project.name.toLocaleLowerCase())!.testFileMatcher; const testFileFilter = runPhase.testFileMatcher(project.name);
const testFiles = allFiles.filter(file => !testIgnore(file) && testMatch(file) && testFileFilter(file) && testFileExtension(file)); const testFiles = allFiles.filter(file => !testIgnore(file) && testMatch(file) && testFileFilter(file) && testFileExtension(file));
files.set(project, testFiles); files.set(project, testFiles);
} }
return files; return files;
} }
private async _run(options: RunOptions): Promise<FullResult> { private async _collectTestGroups(options: RunOptions, fatalErrors: TestError[]): Promise<{ rootSuite: Suite, concurrentTestGroups: TestGroup[][] }> {
const config = this._loader.fullConfig(); const config = this._loader.fullConfig();
const fatalErrors: TestError[] = []; // Each entry is an array of test groups that can run concurrently. All
// Each entry is an array of test groups that can be run concurrently. All
// test groups from the previos entries must finish before entry starts. // test groups from the previos entries must finish before entry starts.
const concurrentTestGroups = []; const concurrentTestGroups = [];
const rootSuite = new Suite('', 'root'); const rootSuite = new Suite('', 'root');
const runPhases = this._collectRunPhases(options); const runPhases = RunPhase.collectRunPhases(options, config);
assert(runPhases.length > 0); assert(runPhases.length > 0);
for (const phase of runPhases) { for (const phase of runPhases) {
// TODO: do not collect files for each project multiple times. // TODO: do not collect files for each project multiple times.
@ -356,8 +369,7 @@ export class Runner {
for (const files of filesByProject.values()) for (const files of filesByProject.values())
files.forEach(file => allTestFiles.add(file)); files.forEach(file => allTestFiles.add(file));
// Add all tests.
// 1. Add all tests.
const preprocessRoot = new Suite('', 'root'); const preprocessRoot = new Suite('', 'root');
for (const file of allTestFiles) { for (const file of allTestFiles) {
const fileSuite = await this._loader.loadTestFile(file, 'runner'); const fileSuite = await this._loader.loadTestFile(file, 'runner');
@ -366,28 +378,28 @@ export class Runner {
preprocessRoot._addSuite(fileSuite); preprocessRoot._addSuite(fileSuite);
} }
// 2. Complain about duplicate titles. // Complain about duplicate titles.
const duplicateTitlesError = createDuplicateTitlesError(config, preprocessRoot); const duplicateTitlesError = createDuplicateTitlesError(config, preprocessRoot);
if (duplicateTitlesError) if (duplicateTitlesError)
fatalErrors.push(duplicateTitlesError); fatalErrors.push(duplicateTitlesError);
// 3. Filter tests to respect line/column filter. // Filter tests to respect line/column filter.
// TODO: figure out how this is supposed to work with groups. // TODO: figure out how this is supposed to work with groups.
if (options.testFileFilters.length) if (options.testFileFilters.length)
filterByFocusedLine(preprocessRoot, options.testFileFilters); filterByFocusedLine(preprocessRoot, options.testFileFilters);
// 4. Complain about only. // Complain about only.
if (config.forbidOnly) { if (config.forbidOnly) {
const onlyTestsAndSuites = preprocessRoot._getOnlyItems(); const onlyTestsAndSuites = preprocessRoot._getOnlyItems();
if (onlyTestsAndSuites.length > 0) if (onlyTestsAndSuites.length > 0)
fatalErrors.push(createForbidOnlyError(config, onlyTestsAndSuites)); fatalErrors.push(createForbidOnlyError(config, onlyTestsAndSuites));
} }
// 5. Filter only. // Filter only.
if (!options.listOnly) if (!options.listOnly)
filterOnly(preprocessRoot); filterOnly(preprocessRoot);
// 6. Generate projects. // Generate projects.
const fileSuites = new Map<string, Suite>(); const fileSuites = new Map<string, Suite>();
for (const fileSuite of preprocessRoot.suites) for (const fileSuite of preprocessRoot.suites)
fileSuites.set(fileSuite._requireFile, fileSuite); fileSuites.set(fileSuite._requireFile, fileSuite);
@ -397,7 +409,8 @@ export class Runner {
const grepMatcher = createTitleMatcher(project.grep); const grepMatcher = createTitleMatcher(project.grep);
const grepInvertMatcher = project.grepInvert ? createTitleMatcher(project.grepInvert) : null; const grepInvertMatcher = project.grepInvert ? createTitleMatcher(project.grepInvert) : null;
// TODO: also apply title matcher from options. // TODO: also apply title matcher from options.
const groupTitleMatcher = phase.find(p => p.projectName.toLocaleLowerCase() === project.name.toLocaleLowerCase())!.testTitleMatcher; const groupTitleMatcher = phase.testTitleMatcher(project.name);
const projectSuite = new Suite(project.name, 'project'); const projectSuite = new Suite(project.name, 'project');
projectSuite._projectConfig = project; projectSuite._projectConfig = project;
if (project._fullyParallel) if (project._fullyParallel)
@ -424,13 +437,22 @@ export class Runner {
const testGroups = createTestGroups(projectSuites, config.workers); const testGroups = createTestGroups(projectSuites, config.workers);
concurrentTestGroups.push(testGroups); concurrentTestGroups.push(testGroups);
} }
return { rootSuite, concurrentTestGroups };
}
// 7. Fail when no tests. private async _run(options: RunOptions): Promise<FullResult> {
const config = this._loader.fullConfig();
const fatalErrors: TestError[] = [];
// Each entry is an array of test groups that can be run concurrently. All
// test groups from the previos entries must finish before entry starts.
const { rootSuite, concurrentTestGroups } = await this._collectTestGroups(options, fatalErrors);
// Fail when no tests.
let total = rootSuite.allTests().length; let total = rootSuite.allTests().length;
if (!total && !options.passWithNoTests) if (!total && !options.passWithNoTests)
fatalErrors.push(createNoTestsError()); fatalErrors.push(createNoTestsError());
// 8. Compute shards. // Compute shards.
const shard = config.shard; const shard = config.shard;
if (shard) { if (shard) {
assert(!options.projectGroup); assert(!options.projectGroup);
@ -465,25 +487,25 @@ export class Runner {
config._maxConcurrentTestGroups = Math.max(...concurrentTestGroups.map(g => g.length)); config._maxConcurrentTestGroups = Math.max(...concurrentTestGroups.map(g => g.length));
// 9. Report begin // Report begin
this._reporter.onBegin?.(config, rootSuite); this._reporter.onBegin?.(config, rootSuite);
// 10. Bail out on errors prior to running global setup. // Bail out on errors prior to running global setup.
if (fatalErrors.length) { if (fatalErrors.length) {
for (const error of fatalErrors) for (const error of fatalErrors)
this._reporter.onError?.(error); this._reporter.onError?.(error);
return { status: 'failed' }; return { status: 'failed' };
} }
// 11. Bail out if list mode only, don't do any work. // Bail out if list mode only, don't do any work.
if (options.listOnly) if (options.listOnly)
return { status: 'passed' }; return { status: 'passed' };
// 12. Remove output directores. // Remove output directores.
if (!this._removeOutputDirs(options)) if (!this._removeOutputDirs(options))
return { status: 'failed' }; return { status: 'failed' };
// 13. Run Global setup. // Run Global setup.
const result: FullResult = { status: 'passed' }; const result: FullResult = { status: 'passed' };
const globalTearDown = await this._performGlobalSetup(config, rootSuite, result); const globalTearDown = await this._performGlobalSetup(config, rootSuite, result);
if (result.status !== 'passed') if (result.status !== 'passed')
@ -498,7 +520,7 @@ export class Runner {
].join('\n'))); ].join('\n')));
} }
// 14. Run tests. // Run tests.
try { try {
let sigintWatcher; let sigintWatcher;