mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore(test runner): split loadAllTests into parts (#21674)
This commit is contained in:
parent
99d8f6e7de
commit
b149d132a6
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user