chore(test runner): split loadAllTests into parts (#21674)

This commit is contained in:
Dmitry Gozman 2023-03-15 13:40:58 -07:00 committed by GitHub
parent 99d8f6e7de
commit b149d132a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 123 additions and 110 deletions

View File

@ -36,99 +36,108 @@ export type ProjectWithTestGroups = {
testGroups: TestGroup[]; testGroups: TestGroup[];
}; };
export async function loadAllTests(mode: 'out-of-process' | 'in-process', config: FullConfigInternal, projectsToIgnore: Set<FullProjectInternal>, additionalFileMatcher: Matcher | undefined, errors: TestError[], shouldFilterOnly: boolean): Promise<{ rootSuite: Suite, projectsWithTestGroups: ProjectWithTestGroups[] }> { export async function collectProjectsAndTestFiles(config: FullConfigInternal, projectsToIgnore: Set<FullProjectInternal>, additionalFileMatcher: Matcher | undefined) {
const projects = filterProjects(config.projects, config._internal.cliProjectFilter); const fsCache = new Map();
const sourceMapCache = new Map();
// Interpret cli parameters.
const cliFileFilters = createFileFiltersFromArguments(config._internal.cliArgs);
const grepMatcher = config._internal.cliGrep ? createTitleMatcher(forceRegExp(config._internal.cliGrep)) : () => true;
const grepInvertMatcher = config._internal.cliGrepInvert ? createTitleMatcher(forceRegExp(config._internal.cliGrepInvert)) : () => false;
const cliTitleMatcher = (title: string) => !grepInvertMatcher(title) && grepMatcher(title);
const cliFileMatcher = config._internal.cliArgs.length ? createFileMatcherFromArguments(config._internal.cliArgs) : null; const cliFileMatcher = config._internal.cliArgs.length ? createFileMatcherFromArguments(config._internal.cliArgs) : null;
// First collect all files for the projects in the command line, don't apply any file filters.
const allFilesForProject = new Map<FullProjectInternal, string[]>();
for (const project of filterProjects(config.projects, config._internal.cliProjectFilter)) {
if (projectsToIgnore.has(project))
continue;
const files = await collectFilesForProject(project, fsCache);
allFilesForProject.set(project, files);
}
// Filter files based on the file filters, eliminate the empty projects.
const filesToRunByProject = new Map<FullProjectInternal, string[]>(); const filesToRunByProject = new Map<FullProjectInternal, string[]>();
let topLevelProjects: FullProjectInternal[]; for (const [project, files] of allFilesForProject) {
let dependencyProjects: FullProjectInternal[]; const matchedFiles = await Promise.all(files.map(async file => {
// Collect files, categorize top level and dependency projects. if (additionalFileMatcher && !additionalFileMatcher(file))
{ return;
const fsCache = new Map(); if (cliFileMatcher) {
const sourceMapCache = new Map(); if (!cliFileMatcher(file) && !await isPotentiallyJavaScriptFileWithSourceMap(file, sourceMapCache))
// First collect all files for the projects in the command line, don't apply any file filters.
const allFilesForProject = new Map<FullProjectInternal, string[]>();
for (const project of projects) {
if (projectsToIgnore.has(project))
continue;
const files = await collectFilesForProject(project, fsCache);
allFilesForProject.set(project, files);
}
// Filter files based on the file filters, eliminate the empty projects.
for (const [project, files] of allFilesForProject) {
const matchedFiles = await Promise.all(files.map(async file => {
if (additionalFileMatcher && !additionalFileMatcher(file))
return; return;
if (cliFileMatcher) { }
if (!cliFileMatcher(file) && !await isPotentiallyJavaScriptFileWithSourceMap(file, sourceMapCache)) return file;
return; }));
} const filteredFiles = matchedFiles.filter(Boolean) as string[];
return file; if (filteredFiles.length)
})); filesToRunByProject.set(project, filteredFiles);
const filteredFiles = matchedFiles.filter(Boolean) as string[]; }
if (filteredFiles.length)
filesToRunByProject.set(project, filteredFiles);
}
const projectClosure = buildProjectsClosure([...filesToRunByProject.keys()]); // (Re-)add all files for dependent projects, disregard filters.
topLevelProjects = projectClosure.filter(p => p._internal.type === 'top-level' && !projectsToIgnore.has(p)); const projectClosure = buildProjectsClosure([...filesToRunByProject.keys()]).filter(p => !projectsToIgnore.has(p));
dependencyProjects = projectClosure.filter(p => p._internal.type === 'dependency' && !projectsToIgnore.has(p)); for (const project of projectClosure) {
if (project._internal.type === 'dependency') {
// (Re-)add all files for dependent projects, disregard filters.
for (const project of dependencyProjects) {
filesToRunByProject.delete(project); filesToRunByProject.delete(project);
const files = allFilesForProject.get(project) || await collectFilesForProject(project, fsCache); const files = allFilesForProject.get(project) || await collectFilesForProject(project, fsCache);
filesToRunByProject.set(project, files); filesToRunByProject.set(project, files);
} }
} }
// Load all test files and create a preprocessed root. Child suites are files there. return filesToRunByProject;
const fileSuites: Suite[] = []; }
{
const loaderHost = mode === 'out-of-process' ? new OutOfProcessLoaderHost(config) : new InProcessLoaderHost(config);
const allTestFiles = new Set<string>();
for (const files of filesToRunByProject.values())
files.forEach(file => allTestFiles.add(file));
for (const file of allTestFiles) {
const fileSuite = await loaderHost.loadTestFile(file, errors);
fileSuites.push(fileSuite);
}
await loaderHost.stop();
// Check that no test file imports another test file. export async function loadFileSuites(mode: 'out-of-process' | 'in-process', config: FullConfigInternal, filesToRunByProject: Map<FullProjectInternal, string[]>, errors: TestError[]): Promise<Map<FullProjectInternal, Suite[]>> {
// Loader must be stopped first, since it popuplates the dependency tree. // Determine all files to load.
for (const file of allTestFiles) { const allTestFiles = new Set<string>();
for (const dependency of dependenciesForTestFile(file)) { for (const files of filesToRunByProject.values())
if (allTestFiles.has(dependency)) { files.forEach(file => allTestFiles.add(file));
const importer = path.relative(config.rootDir, file);
const importee = path.relative(config.rootDir, dependency); // Load test files.
errors.push({ const fileSuiteByFile = new Map<string, Suite>();
message: `Error: test file "${importer}" should not import test file "${importee}"`, const loaderHost = mode === 'out-of-process' ? new OutOfProcessLoaderHost(config) : new InProcessLoaderHost(config);
location: { file, line: 1, column: 1 }, for (const file of allTestFiles) {
}); const fileSuite = await loaderHost.loadTestFile(file, errors);
} fileSuiteByFile.set(file, fileSuite);
errors.push(...createDuplicateTitlesErrors(config, fileSuite));
}
await loaderHost.stop();
// Check that no test file imports another test file.
// Loader must be stopped first, since it popuplates the dependency tree.
for (const file of allTestFiles) {
for (const dependency of dependenciesForTestFile(file)) {
if (allTestFiles.has(dependency)) {
const importer = path.relative(config.rootDir, file);
const importee = path.relative(config.rootDir, dependency);
errors.push({
message: `Error: test file "${importer}" should not import test file "${importee}"`,
location: { file, line: 1, column: 1 },
});
} }
} }
} }
// Complain about duplicate titles. // Collect file suites for each project.
errors.push(...createDuplicateTitlesErrors(config, fileSuites)); const fileSuitesByProject = new Map<FullProjectInternal, Suite[]>();
for (const [project, files] of filesToRunByProject) {
const suites = files.map(file => fileSuiteByFile.get(file)).filter(Boolean) as Suite[];
fileSuitesByProject.set(project, suites);
}
return fileSuitesByProject;
}
// Create root suites with clones for the projects. export async function createRootSuiteAndTestGroups(config: FullConfigInternal, fileSuitesByProject: Map<FullProjectInternal, Suite[]>, errors: TestError[], shouldFilterOnly: boolean): Promise<{ rootSuite: Suite, projectsWithTestGroups: ProjectWithTestGroups[] }> {
// Create root suite, where each child will be a project suite with cloned file suites inside it.
const rootSuite = new Suite('', 'root'); const rootSuite = new Suite('', 'root');
// First iterate top-level projects to focus only, then add all other projects. // First add top-level projects, so that we can filterOnly and shard just top-level.
for (const project of topLevelProjects) {
rootSuite._addSuite(await createProjectSuite(fileSuites, project, { cliFileFilters, cliTitleMatcher, testIdMatcher: config._internal.testIdMatcher }, filesToRunByProject.get(project)!)); // Interpret cli parameters.
const cliFileFilters = createFileFiltersFromArguments(config._internal.cliArgs);
const grepMatcher = config._internal.cliGrep ? createTitleMatcher(forceRegExp(config._internal.cliGrep)) : () => true;
const grepInvertMatcher = config._internal.cliGrepInvert ? createTitleMatcher(forceRegExp(config._internal.cliGrepInvert)) : () => false;
const cliTitleMatcher = (title: string) => !grepInvertMatcher(title) && grepMatcher(title);
// Clone file suites for top-level projects.
for (const [project, fileSuites] of fileSuitesByProject) {
if (project._internal.type === 'top-level')
rootSuite._addSuite(await createProjectSuite(fileSuites, project, { cliFileFilters, cliTitleMatcher, testIdMatcher: config._internal.testIdMatcher }));
}
}
// Complain about only. // Complain about only.
if (config.forbidOnly) { if (config.forbidOnly) {
@ -170,37 +179,34 @@ export async function loadAllTests(mode: 'out-of-process' | 'in-process', config
// Remove now-empty top-level projects. // Remove now-empty top-level projects.
projectsWithTestGroups = projectsWithTestGroups.filter(p => p.testGroups.length > 0); projectsWithTestGroups = projectsWithTestGroups.filter(p => p.testGroups.length > 0);
// Re-build the closure, project set might have changed.
const shardedProjectClosure = buildProjectsClosure(projectsWithTestGroups.map(p => p.project));
topLevelProjects = shardedProjectClosure.filter(p => p._internal.type === 'top-level' && !projectsToIgnore.has(p));
dependencyProjects = shardedProjectClosure.filter(p => p._internal.type === 'dependency' && !projectsToIgnore.has(p));
} }
// Prepend the projects that are dependencies. // Now prepend dependency projects.
for (const project of dependencyProjects) { {
const projectSuite = await createProjectSuite(fileSuites, project, { cliFileFilters: [], cliTitleMatcher: undefined }, filesToRunByProject.get(project)!); // Filtering only and sharding might have reduced the number of top-level projects.
rootSuite._prependSuite(projectSuite); // Build the project closure to only include dependencies that are still needed.
const testGroups = createTestGroups(projectSuite, config.workers); const projectClosure = new Set(buildProjectsClosure(projectsWithTestGroups.map(p => p.project)));
projectsWithTestGroups.push({ project, projectSuite, testGroups });
// Clone file suites for dependency projects.
for (const [project, fileSuites] of fileSuitesByProject) {
if (project._internal.type === 'dependency' && projectClosure.has(project)) {
const projectSuite = await createProjectSuite(fileSuites, project, { cliFileFilters: [], cliTitleMatcher: undefined });
rootSuite._prependSuite(projectSuite);
const testGroups = createTestGroups(projectSuite, config.workers);
projectsWithTestGroups.push({ project, projectSuite, testGroups });
}
}
} }
return { rootSuite, projectsWithTestGroups }; return { rootSuite, projectsWithTestGroups };
} }
async function createProjectSuite(fileSuites: Suite[], project: FullProjectInternal, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher, testIdMatcher?: Matcher }, files: string[]): Promise<Suite> { async function createProjectSuite(fileSuites: Suite[], project: FullProjectInternal, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher, testIdMatcher?: Matcher }): Promise<Suite> {
const fileSuitesMap = new Map<string, Suite>();
for (const fileSuite of fileSuites)
fileSuitesMap.set(fileSuite._requireFile, fileSuite);
const projectSuite = new Suite(project.name, 'project'); const projectSuite = new Suite(project.name, 'project');
projectSuite._projectConfig = project; projectSuite._projectConfig = project;
if (project._internal.fullyParallel) if (project._internal.fullyParallel)
projectSuite._parallelMode = 'parallel'; projectSuite._parallelMode = 'parallel';
for (const file of files) { for (const fileSuite of fileSuites) {
const fileSuite = fileSuitesMap.get(file);
if (!fileSuite)
continue;
for (let repeatEachIndex = 0; repeatEachIndex < project.repeatEach; repeatEachIndex++) { for (let repeatEachIndex = 0; repeatEachIndex < project.repeatEach; repeatEachIndex++) {
const builtSuite = buildFileSuiteForProject(project, fileSuite, repeatEachIndex); const builtSuite = buildFileSuiteForProject(project, fileSuite, repeatEachIndex);
projectSuite._addSuite(builtSuite); projectSuite._addSuite(builtSuite);
@ -238,22 +244,20 @@ function createForbidOnlyErrors(onlyTestsAndSuites: (TestCase | Suite)[]): TestE
return errors; return errors;
} }
function createDuplicateTitlesErrors(config: FullConfigInternal, fileSuites: Suite[]): TestError[] { function createDuplicateTitlesErrors(config: FullConfigInternal, fileSuite: Suite): TestError[] {
const errors: TestError[] = []; const errors: TestError[] = [];
for (const fileSuite of fileSuites) { const testsByFullTitle = new Map<string, TestCase>();
const testsByFullTitle = new Map<string, TestCase>(); for (const test of fileSuite.allTests()) {
for (const test of fileSuite.allTests()) { const fullTitle = test.titlePath().slice(1).join(' ');
const fullTitle = test.titlePath().slice(1).join(' '); const existingTest = testsByFullTitle.get(fullTitle);
const existingTest = testsByFullTitle.get(fullTitle); if (existingTest) {
if (existingTest) { const error: TestError = {
const error: TestError = { message: `Error: duplicate test title "${fullTitle}", first declared in ${buildItemLocation(config.rootDir, existingTest)}`,
message: `Error: duplicate test title "${fullTitle}", first declared in ${buildItemLocation(config.rootDir, existingTest)}`, location: test.location,
location: test.location, };
}; errors.push(error);
errors.push(error);
}
testsByFullTitle.set(fullTitle, test);
} }
testsByFullTitle.set(fullTitle, test);
} }
return errors; return errors;
} }

View File

@ -26,7 +26,7 @@ import type { Task } from './taskRunner';
import { TaskRunner } from './taskRunner'; import { TaskRunner } from './taskRunner';
import type { Suite } from '../common/test'; import type { Suite } from '../common/test';
import type { FullConfigInternal, FullProjectInternal } from '../common/types'; import type { FullConfigInternal, FullProjectInternal } from '../common/types';
import { loadAllTests, loadGlobalHook, type ProjectWithTestGroups } from './loadUtils'; import { collectProjectsAndTestFiles, createRootSuiteAndTestGroups, loadFileSuites, loadGlobalHook, type ProjectWithTestGroups } from './loadUtils';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
const removeFolderAsync = promisify(rimraf); const removeFolderAsync = promisify(rimraf);
@ -151,7 +151,9 @@ function createRemoveOutputDirsTask(): Task<TaskRunnerState> {
function createLoadTask(mode: 'out-of-process' | 'in-process', shouldFilterOnly: boolean, projectsToIgnore = new Set<FullProjectInternal>(), additionalFileMatcher?: Matcher): Task<TaskRunnerState> { function createLoadTask(mode: 'out-of-process' | 'in-process', shouldFilterOnly: boolean, projectsToIgnore = new Set<FullProjectInternal>(), additionalFileMatcher?: Matcher): Task<TaskRunnerState> {
return async (context, errors) => { return async (context, errors) => {
const { config } = context; const { config } = context;
const loaded = await loadAllTests(mode, config, projectsToIgnore, additionalFileMatcher, errors, shouldFilterOnly); const filesToRunByProject = await collectProjectsAndTestFiles(config, projectsToIgnore, additionalFileMatcher);
const fileSuitesByProject = await loadFileSuites(mode, config, filesToRunByProject, errors);
const loaded = await createRootSuiteAndTestGroups(config, fileSuitesByProject, errors, shouldFilterOnly);
context.rootSuite = loaded.rootSuite; context.rootSuite = loaded.rootSuite;
context.projectsWithTestGroups = loaded.projectsWithTestGroups; context.projectsWithTestGroups = loaded.projectsWithTestGroups;
// Fail when no tests. // Fail when no tests.

View File

@ -131,6 +131,13 @@ export function createTestGroups(projectSuite: Suite, workers: number): TestGrou
} }
export function filterForShard(shard: { total: number, current: number }, testGroups: TestGroup[]): Set<TestGroup> { export function filterForShard(shard: { total: number, current: number }, testGroups: TestGroup[]): Set<TestGroup> {
// Note that sharding works based on test groups.
// This means parallel files will be sharded by single tests,
// while non-parallel files will be sharded by the whole file.
//
// Shards are still balanced by the number of tests, not files,
// even in the case of non-paralleled files.
let shardableTotal = 0; let shardableTotal = 0;
for (const group of testGroups) for (const group of testGroups)
shardableTotal += group.tests.length; shardableTotal += group.tests.length;