2021-06-06 17:09:53 -07:00
|
|
|
|
/**
|
|
|
|
|
* Copyright 2019 Google Inc. All rights reserved.
|
|
|
|
|
* Modifications copyright (c) Microsoft Corporation.
|
|
|
|
|
*
|
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
|
*
|
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
*
|
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
|
* limitations under the License.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import * as fs from 'fs';
|
|
|
|
|
import * as path from 'path';
|
2023-01-13 13:50:38 -08:00
|
|
|
|
import { raceAgainstTimeout } from 'playwright-core/lib/utils';
|
2022-10-10 16:42:48 -07:00
|
|
|
|
import { colors, minimatch, rimraf } from 'playwright-core/lib/utilsBundle';
|
2021-06-06 17:09:53 -07:00
|
|
|
|
import { promisify } from 'util';
|
2022-10-10 16:42:48 -07:00
|
|
|
|
import type { FullResult, Reporter, TestError } from '../types/testReporter';
|
2022-04-06 13:57:14 -08:00
|
|
|
|
import type { TestGroup } from './dispatcher';
|
|
|
|
|
import { Dispatcher } from './dispatcher';
|
2023-01-17 17:16:36 -08:00
|
|
|
|
import { ConfigLoader } from './configLoader';
|
2022-10-10 16:42:48 -07:00
|
|
|
|
import type { TestRunnerPlugin } from './plugins';
|
|
|
|
|
import { setRunnerToAddPluginsTo } from './plugins';
|
|
|
|
|
import { dockerPlugin } from './plugins/dockerPlugin';
|
|
|
|
|
import { webServerPluginsForConfig } from './plugins/webServerPlugin';
|
2022-11-21 09:23:28 -08:00
|
|
|
|
import { formatError } from './reporters/base';
|
2021-06-06 17:09:53 -07:00
|
|
|
|
import DotReporter from './reporters/dot';
|
2022-10-10 16:42:48 -07:00
|
|
|
|
import EmptyReporter from './reporters/empty';
|
2021-10-04 14:02:56 +05:30
|
|
|
|
import GitHubReporter from './reporters/github';
|
2022-10-10 16:42:48 -07:00
|
|
|
|
import HtmlReporter from './reporters/html';
|
2021-06-06 17:09:53 -07:00
|
|
|
|
import JSONReporter from './reporters/json';
|
|
|
|
|
import JUnitReporter from './reporters/junit';
|
2022-10-10 16:42:48 -07:00
|
|
|
|
import LineReporter from './reporters/line';
|
|
|
|
|
import ListReporter from './reporters/list';
|
|
|
|
|
import { Multiplexer } from './reporters/multiplexer';
|
2022-04-07 19:18:22 -08:00
|
|
|
|
import { SigIntWatcher } from './sigIntWatcher';
|
2022-10-10 16:42:48 -07:00
|
|
|
|
import type { TestCase } from './test';
|
|
|
|
|
import { Suite } from './test';
|
|
|
|
|
import type { Config, FullConfigInternal, FullProjectInternal, ReporterInternal } from './types';
|
2022-10-19 15:05:59 -07:00
|
|
|
|
import { createFileMatcher, createFileMatcherFromFilters, createTitleMatcher, serializeError } from './util';
|
2022-10-12 14:34:22 -07:00
|
|
|
|
import type { Matcher, TestFileFilter } from './util';
|
2022-12-22 17:31:02 -08:00
|
|
|
|
import { setFatalErrorSink } from './globals';
|
2023-01-17 17:16:36 -08:00
|
|
|
|
import { TestLoader } from './testLoader';
|
2023-01-19 15:56:57 -08:00
|
|
|
|
import { buildFileSuiteForProject, filterTests } from './suiteUtils';
|
|
|
|
|
import { PoolBuilder } from './poolBuilder';
|
2021-06-06 17:09:53 -07:00
|
|
|
|
|
2022-02-23 15:10:11 -07:00
|
|
|
|
const removeFolderAsync = promisify(rimraf);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
const readDirAsync = promisify(fs.readdir);
|
|
|
|
|
const readFileAsync = promisify(fs.readFile);
|
2022-01-05 13:44:29 -08:00
|
|
|
|
export const kDefaultConfigFiles = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'];
|
2021-06-06 17:09:53 -07:00
|
|
|
|
|
2022-01-05 15:49:01 -08:00
|
|
|
|
type RunOptions = {
|
|
|
|
|
listOnly?: boolean;
|
2022-09-29 16:39:21 -07:00
|
|
|
|
testFileFilters: TestFileFilter[];
|
|
|
|
|
testTitleMatcher: Matcher;
|
2022-01-05 15:49:01 -08:00
|
|
|
|
projectFilter?: string[];
|
2022-08-29 15:46:34 -07:00
|
|
|
|
passWithNoTests?: boolean;
|
2022-01-05 15:49:01 -08:00
|
|
|
|
};
|
|
|
|
|
|
2022-04-29 12:32:39 -08:00
|
|
|
|
export type ConfigCLIOverrides = {
|
|
|
|
|
forbidOnly?: boolean;
|
|
|
|
|
fullyParallel?: boolean;
|
|
|
|
|
globalTimeout?: number;
|
|
|
|
|
maxFailures?: number;
|
|
|
|
|
outputDir?: string;
|
|
|
|
|
quiet?: boolean;
|
|
|
|
|
repeatEach?: number;
|
|
|
|
|
retries?: number;
|
|
|
|
|
reporter?: string;
|
|
|
|
|
shard?: { current: number, total: number };
|
|
|
|
|
timeout?: number;
|
2022-09-01 05:34:36 -07:00
|
|
|
|
ignoreSnapshots?: boolean;
|
2022-04-29 12:32:39 -08:00
|
|
|
|
updateSnapshots?: 'all'|'none'|'missing';
|
|
|
|
|
workers?: number;
|
|
|
|
|
projects?: { name: string, use?: any }[],
|
|
|
|
|
use?: any;
|
|
|
|
|
};
|
|
|
|
|
|
2021-06-06 17:09:53 -07:00
|
|
|
|
export class Runner {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
private _configLoader: ConfigLoader;
|
2022-08-05 13:41:00 -07:00
|
|
|
|
private _reporter!: ReporterInternal;
|
2022-05-03 13:25:56 -08:00
|
|
|
|
private _plugins: TestRunnerPlugin[] = [];
|
2022-12-22 17:31:02 -08:00
|
|
|
|
private _fatalErrors: TestError[] = [];
|
2021-06-06 17:09:53 -07:00
|
|
|
|
|
2022-04-29 12:32:39 -08:00
|
|
|
|
constructor(configCLIOverrides?: ConfigCLIOverrides) {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
this._configLoader = new ConfigLoader(configCLIOverrides);
|
2022-05-03 13:25:56 -08:00
|
|
|
|
setRunnerToAddPluginsTo(this);
|
2022-12-22 17:31:02 -08:00
|
|
|
|
setFatalErrorSink(this._fatalErrors);
|
2022-05-03 13:25:56 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addPlugin(plugin: TestRunnerPlugin) {
|
|
|
|
|
this._plugins.push(plugin);
|
2022-01-05 13:44:29 -08:00
|
|
|
|
}
|
|
|
|
|
|
2022-04-29 12:32:39 -08:00
|
|
|
|
async loadConfigFromResolvedFile(resolvedConfigFile: string): Promise<FullConfigInternal> {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
return await this._configLoader.loadConfigFile(resolvedConfigFile);
|
2022-03-01 12:56:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
2022-04-28 16:22:20 -07:00
|
|
|
|
loadEmptyConfig(configFileOrDirectory: string): Promise<Config> {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
return this._configLoader.loadEmptyConfig(configFileOrDirectory);
|
2022-03-01 12:56:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static resolveConfigFile(configFileOrDirectory: string): string | null {
|
|
|
|
|
const resolveConfig = (configFile: string) => {
|
2022-01-12 19:52:40 -08:00
|
|
|
|
if (fs.existsSync(configFile))
|
2022-03-01 12:56:26 -08:00
|
|
|
|
return configFile;
|
2022-01-05 13:44:29 -08:00
|
|
|
|
};
|
|
|
|
|
|
2022-03-01 12:56:26 -08:00
|
|
|
|
const resolveConfigFileFromDirectory = (directory: string) => {
|
2022-01-05 13:44:29 -08:00
|
|
|
|
for (const configName of kDefaultConfigFiles) {
|
2022-03-01 12:56:26 -08:00
|
|
|
|
const configFile = resolveConfig(path.resolve(directory, configName));
|
|
|
|
|
if (configFile)
|
|
|
|
|
return configFile;
|
2022-01-05 13:44:29 -08:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(configFileOrDirectory))
|
|
|
|
|
throw new Error(`${configFileOrDirectory} does not exist`);
|
|
|
|
|
if (fs.statSync(configFileOrDirectory).isDirectory()) {
|
|
|
|
|
// When passed a directory, look for a config file inside.
|
2022-03-01 12:56:26 -08:00
|
|
|
|
const configFile = resolveConfigFileFromDirectory(configFileOrDirectory);
|
|
|
|
|
if (configFile)
|
|
|
|
|
return configFile;
|
2022-01-05 13:44:29 -08:00
|
|
|
|
// If there is no config, assume this as a root testing directory.
|
2022-03-01 12:56:26 -08:00
|
|
|
|
return null;
|
2022-01-05 13:44:29 -08:00
|
|
|
|
} else {
|
|
|
|
|
// When passed a file, it must be a config file.
|
2022-03-01 12:56:26 -08:00
|
|
|
|
const configFile = resolveConfig(configFileOrDirectory);
|
|
|
|
|
return configFile!;
|
2022-01-05 13:44:29 -08:00
|
|
|
|
}
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
2021-07-20 01:10:43 +02:00
|
|
|
|
private async _createReporter(list: boolean) {
|
2021-07-20 15:03:01 -05:00
|
|
|
|
const defaultReporters: {[key in BuiltInReporter]: new(arg: any) => Reporter} = {
|
2021-07-20 01:10:43 +02:00
|
|
|
|
dot: list ? ListModeReporter : DotReporter,
|
|
|
|
|
line: list ? ListModeReporter : LineReporter,
|
|
|
|
|
list: list ? ListModeReporter : ListReporter,
|
2021-10-04 14:02:56 +05:30
|
|
|
|
github: GitHubReporter,
|
2021-06-06 17:09:53 -07:00
|
|
|
|
json: JSONReporter,
|
|
|
|
|
junit: JUnitReporter,
|
|
|
|
|
null: EmptyReporter,
|
2021-10-14 10:17:35 -08:00
|
|
|
|
html: HtmlReporter,
|
2021-06-06 17:09:53 -07:00
|
|
|
|
};
|
2021-09-13 20:34:46 -07:00
|
|
|
|
const reporters: Reporter[] = [];
|
2023-01-17 17:16:36 -08:00
|
|
|
|
for (const r of this._configLoader.fullConfig().reporter) {
|
2021-06-06 17:09:53 -07:00
|
|
|
|
const [name, arg] = r;
|
|
|
|
|
if (name in defaultReporters) {
|
|
|
|
|
reporters.push(new defaultReporters[name as keyof typeof defaultReporters](arg));
|
|
|
|
|
} else {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const reporterConstructor = await this._configLoader.loadReporter(name);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
reporters.push(new reporterConstructor(arg));
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-02-03 16:10:39 -08:00
|
|
|
|
if (process.env.PW_TEST_REPORTER) {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const reporterConstructor = await this._configLoader.loadReporter(process.env.PW_TEST_REPORTER);
|
2022-02-03 16:10:39 -08:00
|
|
|
|
reporters.push(new reporterConstructor());
|
|
|
|
|
}
|
|
|
|
|
|
2021-11-03 08:25:16 -07:00
|
|
|
|
const someReporterPrintsToStdio = reporters.some(r => {
|
|
|
|
|
const prints = r.printsToStdio ? r.printsToStdio() : true;
|
|
|
|
|
return prints;
|
|
|
|
|
});
|
|
|
|
|
if (reporters.length && !someReporterPrintsToStdio) {
|
2021-11-11 13:27:50 -08:00
|
|
|
|
// Add a line/dot/list-mode reporter for convenience.
|
2021-11-03 08:25:16 -07:00
|
|
|
|
// Important to put it first, jsut in case some other reporter stalls onEnd.
|
2021-11-11 13:27:50 -08:00
|
|
|
|
if (list)
|
|
|
|
|
reporters.unshift(new ListModeReporter());
|
|
|
|
|
else
|
2022-04-28 12:29:21 +01:00
|
|
|
|
reporters.unshift(!process.env.CI ? new LineReporter({ omitFailures: true }) : new DotReporter());
|
2021-11-03 08:25:16 -07:00
|
|
|
|
}
|
2021-06-06 17:09:53 -07:00
|
|
|
|
return new Multiplexer(reporters);
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-29 16:39:21 -07:00
|
|
|
|
async runAllTests(options: RunOptions): Promise<FullResult> {
|
2022-01-05 15:49:01 -08:00
|
|
|
|
this._reporter = await this._createReporter(!!options.listOnly);
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const config = this._configLoader.fullConfig();
|
2022-08-04 08:09:54 -07:00
|
|
|
|
const result = await raceAgainstTimeout(() => this._run(options), config.globalTimeout);
|
2022-01-31 17:09:04 -08:00
|
|
|
|
let fullResult: FullResult;
|
|
|
|
|
if (result.timedOut) {
|
2022-12-21 09:36:59 -08:00
|
|
|
|
this._reporter.onError?.(createStacklessError(
|
|
|
|
|
`Timed out waiting ${config.globalTimeout / 1000}s for the entire test run`));
|
2022-01-31 17:09:04 -08:00
|
|
|
|
fullResult = { status: 'timedout' };
|
|
|
|
|
} else {
|
|
|
|
|
fullResult = result.result;
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
2022-01-31 17:09:04 -08:00
|
|
|
|
await this._reporter.onEnd?.(fullResult);
|
|
|
|
|
|
|
|
|
|
// Calling process.exit() might truncate large stdout/stderr output.
|
|
|
|
|
// See https://github.com/nodejs/node/issues/6456.
|
|
|
|
|
// See https://github.com/nodejs/node/issues/12921
|
|
|
|
|
await new Promise<void>(resolve => process.stdout.write('', () => resolve()));
|
|
|
|
|
await new Promise<void>(resolve => process.stderr.write('', () => resolve()));
|
|
|
|
|
|
2022-08-05 13:41:00 -07:00
|
|
|
|
await this._reporter._onExit?.();
|
2022-01-31 17:09:04 -08:00
|
|
|
|
return fullResult;
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
2022-11-08 12:05:00 -08:00
|
|
|
|
async listTestFiles(projectNames: string[] | undefined): Promise<any> {
|
2022-10-10 17:56:18 -07:00
|
|
|
|
const projects = this._collectProjects(projectNames);
|
2023-01-18 12:56:03 -08:00
|
|
|
|
const filesByProject = await this._collectFiles(projects, []);
|
2022-01-21 19:11:22 -08:00
|
|
|
|
const report: any = {
|
|
|
|
|
projects: []
|
|
|
|
|
};
|
|
|
|
|
for (const [project, files] of filesByProject) {
|
|
|
|
|
report.projects.push({
|
2022-11-08 12:05:00 -08:00
|
|
|
|
...sanitizeConfigForJSON(project, new Set()),
|
2022-11-17 16:31:04 -08:00
|
|
|
|
files
|
2022-01-21 19:11:22 -08:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return report;
|
|
|
|
|
}
|
|
|
|
|
|
2022-10-10 17:56:18 -07:00
|
|
|
|
private _collectProjects(projectNames?: string[]): FullProjectInternal[] {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const fullConfig = this._configLoader.fullConfig();
|
2022-10-10 17:56:18 -07:00
|
|
|
|
if (!projectNames)
|
|
|
|
|
return [...fullConfig.projects];
|
2022-09-28 18:45:01 -07:00
|
|
|
|
const projectsToFind = new Set<string>();
|
|
|
|
|
const unknownProjects = new Map<string, string>();
|
|
|
|
|
projectNames.forEach(n => {
|
|
|
|
|
const name = n.toLocaleLowerCase();
|
|
|
|
|
projectsToFind.add(name);
|
|
|
|
|
unknownProjects.set(name, n);
|
|
|
|
|
});
|
2022-04-29 15:05:08 -08:00
|
|
|
|
const projects = fullConfig.projects.filter(project => {
|
|
|
|
|
const name = project.name.toLocaleLowerCase();
|
2022-09-28 18:45:01 -07:00
|
|
|
|
unknownProjects.delete(name);
|
2021-09-02 09:29:55 -07:00
|
|
|
|
return projectsToFind.has(name);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
});
|
2022-09-28 18:45:01 -07:00
|
|
|
|
if (unknownProjects.size) {
|
2022-04-29 15:05:08 -08:00
|
|
|
|
const names = fullConfig.projects.map(p => p.name).filter(name => !!name);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
if (!names.length)
|
|
|
|
|
throw new Error(`No named projects are specified in the configuration file`);
|
2021-09-02 09:29:55 -07:00
|
|
|
|
const unknownProjectNames = Array.from(unknownProjects.values()).map(n => `"${n}"`).join(', ');
|
|
|
|
|
throw new Error(`Project(s) ${unknownProjectNames} not found. Available named projects: ${names.map(name => `"${name}"`).join(', ')}`);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
2022-09-28 18:45:01 -07:00
|
|
|
|
return projects;
|
|
|
|
|
}
|
2021-06-06 17:09:53 -07:00
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
private async _collectFiles(projects: FullProjectInternal[], commandLineFileFilters: TestFileFilter[]): Promise<Map<FullProjectInternal, string[]>> {
|
2022-11-01 23:44:30 -07:00
|
|
|
|
const extensions = ['.js', '.ts', '.mjs', '.tsx', '.jsx'];
|
|
|
|
|
const testFileExtension = (file: string) => extensions.includes(path.extname(file));
|
|
|
|
|
const filesByProject = new Map<FullProjectInternal, string[]>();
|
|
|
|
|
const fileToProjectName = new Map<string, string>();
|
2022-11-22 16:22:48 -08:00
|
|
|
|
const commandLineFileMatcher = commandLineFileFilters.length ? createFileMatcherFromFilters(commandLineFileFilters) : () => true;
|
2023-01-12 13:02:54 -08:00
|
|
|
|
|
2021-06-06 17:09:53 -07:00
|
|
|
|
for (const project of projects) {
|
2022-05-26 14:39:51 -07:00
|
|
|
|
const allFiles = await collectFiles(project.testDir, project._respectGitIgnore);
|
2022-04-29 15:05:08 -08:00
|
|
|
|
const testMatch = createFileMatcher(project.testMatch);
|
|
|
|
|
const testIgnore = createFileMatcher(project.testIgnore);
|
2022-11-01 23:44:30 -07:00
|
|
|
|
const testFiles = allFiles.filter(file => {
|
|
|
|
|
if (!testFileExtension(file))
|
|
|
|
|
return false;
|
2022-11-18 11:35:29 -08:00
|
|
|
|
const isTest = !testIgnore(file) && testMatch(file) && commandLineFileMatcher(file);
|
2023-01-18 12:56:03 -08:00
|
|
|
|
if (!isTest)
|
2022-11-01 23:44:30 -07:00
|
|
|
|
return false;
|
|
|
|
|
fileToProjectName.set(file, project.name);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
filesByProject.set(project, testFiles);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
2022-11-18 11:35:29 -08:00
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
return filesByProject;
|
2022-01-21 19:11:22 -08:00
|
|
|
|
}
|
2021-06-06 17:09:53 -07:00
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
private async _collectTestGroups(options: RunOptions): Promise<{ rootSuite: Suite, testGroups: TestGroup[] }> {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const config = this._configLoader.fullConfig();
|
2022-10-10 17:56:18 -07:00
|
|
|
|
const projects = this._collectProjects(options.projectFilter);
|
2023-01-18 12:56:03 -08:00
|
|
|
|
const filesByProject = await this._collectFiles(projects, options.testFileFilters);
|
|
|
|
|
const result = await this._createFilteredRootSuite(options, filesByProject);
|
2022-12-22 17:31:02 -08:00
|
|
|
|
this._fatalErrors.push(...result.fatalErrors);
|
2022-11-22 16:22:48 -08:00
|
|
|
|
const { rootSuite } = result;
|
2022-10-31 14:04:24 -07:00
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
const testGroups = createTestGroups(rootSuite.suites, config.workers);
|
|
|
|
|
return { rootSuite, testGroups };
|
2022-11-22 16:22:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
private async _createFilteredRootSuite(options: RunOptions, filesByProject: Map<FullProjectInternal, string[]>): Promise<{rootSuite: Suite, fatalErrors: TestError[]}> {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const config = this._configLoader.fullConfig();
|
2022-11-22 16:22:48 -08:00
|
|
|
|
const fatalErrors: TestError[] = [];
|
2022-10-31 14:04:24 -07:00
|
|
|
|
const allTestFiles = new Set<string>();
|
|
|
|
|
for (const files of filesByProject.values())
|
|
|
|
|
files.forEach(file => allTestFiles.add(file));
|
|
|
|
|
|
2023-01-20 08:36:31 -08:00
|
|
|
|
// Load all tests.
|
|
|
|
|
const { rootSuite: preprocessRoot, loadErrors } = await this._loadTests(allTestFiles);
|
|
|
|
|
fatalErrors.push(...loadErrors);
|
2022-01-16 08:47:09 -08:00
|
|
|
|
|
2022-10-31 14:04:24 -07:00
|
|
|
|
// Complain about duplicate titles.
|
2022-12-21 09:36:59 -08:00
|
|
|
|
fatalErrors.push(...createDuplicateTitlesErrors(config, preprocessRoot));
|
2022-01-31 17:09:04 -08:00
|
|
|
|
|
2022-10-31 14:04:24 -07:00
|
|
|
|
// Filter tests to respect line/column filter.
|
2023-01-18 12:56:03 -08:00
|
|
|
|
filterByFocusedLine(preprocessRoot, options.testFileFilters);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
|
2022-10-31 14:04:24 -07:00
|
|
|
|
// Complain about only.
|
|
|
|
|
if (config.forbidOnly) {
|
|
|
|
|
const onlyTestsAndSuites = preprocessRoot._getOnlyItems();
|
|
|
|
|
if (onlyTestsAndSuites.length > 0)
|
2022-12-21 09:36:59 -08:00
|
|
|
|
fatalErrors.push(...createForbidOnlyErrors(config, onlyTestsAndSuites));
|
2022-10-31 14:04:24 -07:00
|
|
|
|
}
|
2022-01-31 17:09:04 -08:00
|
|
|
|
|
2022-10-31 14:04:24 -07:00
|
|
|
|
// Filter only.
|
2022-11-22 16:22:48 -08:00
|
|
|
|
if (!options.listOnly)
|
2023-01-18 12:56:03 -08:00
|
|
|
|
filterOnly(preprocessRoot);
|
2022-10-31 14:04:24 -07:00
|
|
|
|
|
|
|
|
|
// Generate projects.
|
|
|
|
|
const fileSuites = new Map<string, Suite>();
|
|
|
|
|
for (const fileSuite of preprocessRoot.suites)
|
|
|
|
|
fileSuites.set(fileSuite._requireFile, fileSuite);
|
|
|
|
|
|
|
|
|
|
const rootSuite = new Suite('', 'root');
|
|
|
|
|
for (const [project, files] of filesByProject) {
|
|
|
|
|
const grepMatcher = createTitleMatcher(project.grep);
|
|
|
|
|
const grepInvertMatcher = project.grepInvert ? createTitleMatcher(project.grepInvert) : null;
|
|
|
|
|
|
2022-11-21 09:23:28 -08:00
|
|
|
|
const titleMatcher = (test: TestCase) => {
|
|
|
|
|
const grepTitle = test.titlePath().join(' ');
|
|
|
|
|
if (grepInvertMatcher?.(grepTitle))
|
|
|
|
|
return false;
|
|
|
|
|
return grepMatcher(grepTitle) && options.testTitleMatcher(grepTitle);
|
|
|
|
|
};
|
|
|
|
|
|
2022-10-31 14:04:24 -07:00
|
|
|
|
const projectSuite = new Suite(project.name, 'project');
|
|
|
|
|
projectSuite._projectConfig = project;
|
|
|
|
|
if (project._fullyParallel)
|
|
|
|
|
projectSuite._parallelMode = 'parallel';
|
|
|
|
|
rootSuite._addSuite(projectSuite);
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
const fileSuite = fileSuites.get(file);
|
|
|
|
|
if (!fileSuite)
|
|
|
|
|
continue;
|
|
|
|
|
for (let repeatEachIndex = 0; repeatEachIndex < project.repeatEach; repeatEachIndex++) {
|
2023-01-19 15:56:57 -08:00
|
|
|
|
const builtSuite = buildFileSuiteForProject(project, fileSuite, repeatEachIndex);
|
|
|
|
|
if (!filterTests(builtSuite, titleMatcher))
|
|
|
|
|
continue;
|
|
|
|
|
projectSuite._addSuite(builtSuite);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
|
|
|
|
}
|
2022-01-31 17:09:04 -08:00
|
|
|
|
}
|
2022-11-22 16:22:48 -08:00
|
|
|
|
return { rootSuite, fatalErrors };
|
2022-09-30 09:12:06 -07:00
|
|
|
|
}
|
|
|
|
|
|
2023-01-20 08:36:31 -08:00
|
|
|
|
private async _loadTests(testFiles: Set<string>): Promise<{ rootSuite: Suite, loadErrors: TestError[] }> {
|
|
|
|
|
const config = this._configLoader.fullConfig();
|
|
|
|
|
const testLoader = new TestLoader(config);
|
|
|
|
|
const loadErrors: TestError[] = [];
|
|
|
|
|
const rootSuite = new Suite('', 'root');
|
|
|
|
|
for (const file of testFiles) {
|
|
|
|
|
const fileSuite = await testLoader.loadTestFile(file, 'loader');
|
|
|
|
|
if (fileSuite._loadError)
|
|
|
|
|
loadErrors.push(fileSuite._loadError);
|
|
|
|
|
// We have to clone only if there maybe subsequent calls of this method.
|
|
|
|
|
rootSuite._addSuite(fileSuite);
|
|
|
|
|
}
|
|
|
|
|
// Generate hashes.
|
|
|
|
|
PoolBuilder.buildForLoader(rootSuite);
|
|
|
|
|
return { rootSuite, loadErrors };
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
private _filterForCurrentShard(rootSuite: Suite, testGroups: TestGroup[]) {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const shard = this._configLoader.fullConfig().shard;
|
2022-10-12 14:34:22 -07:00
|
|
|
|
if (!shard)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
// Each shard includes:
|
2022-11-01 23:44:30 -07:00
|
|
|
|
// - its portion of the regular tests
|
|
|
|
|
// - project setup tests for the projects that have regular tests in this shard
|
2022-10-12 14:34:22 -07:00
|
|
|
|
let shardableTotal = 0;
|
2022-11-01 23:44:30 -07:00
|
|
|
|
for (const group of testGroups)
|
|
|
|
|
shardableTotal += group.tests.length;
|
2022-10-12 14:34:22 -07:00
|
|
|
|
|
|
|
|
|
const shardTests = new Set<TestCase>();
|
|
|
|
|
|
|
|
|
|
// Each shard gets some tests.
|
|
|
|
|
const shardSize = Math.floor(shardableTotal / shard.total);
|
|
|
|
|
// First few shards get one more test each.
|
|
|
|
|
const extraOne = shardableTotal - shardSize * shard.total;
|
|
|
|
|
|
|
|
|
|
const currentShard = shard.current - 1; // Make it zero-based for calculations.
|
|
|
|
|
const from = shardSize * currentShard + Math.min(extraOne, currentShard);
|
|
|
|
|
const to = from + shardSize + (currentShard < extraOne ? 1 : 0);
|
|
|
|
|
let current = 0;
|
2022-11-01 23:44:30 -07:00
|
|
|
|
const shardProjects = new Set<string>();
|
2022-10-31 14:04:24 -07:00
|
|
|
|
const shardTestGroups = [];
|
|
|
|
|
for (const group of testGroups) {
|
2022-11-01 23:44:30 -07:00
|
|
|
|
// Any test group goes to the shard that contains the first test of this group.
|
|
|
|
|
// So, this shard gets any group that starts at [from; to)
|
|
|
|
|
if (current >= from && current < to) {
|
|
|
|
|
shardProjects.add(group.projectId);
|
2022-10-31 14:04:24 -07:00
|
|
|
|
shardTestGroups.push(group);
|
|
|
|
|
for (const test of group.tests)
|
|
|
|
|
shardTests.add(test);
|
2022-10-12 14:34:22 -07:00
|
|
|
|
}
|
2022-11-01 23:44:30 -07:00
|
|
|
|
current += group.tests.length;
|
2022-10-12 14:34:22 -07:00
|
|
|
|
}
|
2022-10-31 14:04:24 -07:00
|
|
|
|
testGroups.length = 0;
|
|
|
|
|
testGroups.push(...shardTestGroups);
|
2022-10-12 14:34:22 -07:00
|
|
|
|
|
2022-11-29 16:02:11 -08:00
|
|
|
|
if (!shardTests.size) {
|
|
|
|
|
// Filtering with "only semantics" does not work when we have zero tests - it leaves all the tests.
|
|
|
|
|
// We need an empty suite in this case.
|
|
|
|
|
rootSuite._entries = [];
|
|
|
|
|
rootSuite.suites = [];
|
|
|
|
|
rootSuite.tests = [];
|
|
|
|
|
} else {
|
2023-01-18 12:56:03 -08:00
|
|
|
|
filterSuiteWithOnlySemantics(rootSuite, () => false, test => shardTests.has(test));
|
2022-11-29 16:02:11 -08:00
|
|
|
|
}
|
2022-10-12 14:34:22 -07:00
|
|
|
|
}
|
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
private async _run(options: RunOptions): Promise<FullResult> {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const config = this._configLoader.fullConfig();
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// 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.
|
2023-01-18 12:56:03 -08:00
|
|
|
|
const { rootSuite, testGroups } = await this._collectTestGroups(options);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// Fail when no tests.
|
2022-10-12 14:34:22 -07:00
|
|
|
|
if (!rootSuite.allTests().length && !options.passWithNoTests)
|
2022-12-22 17:31:02 -08:00
|
|
|
|
this._fatalErrors.push(createNoTestsError());
|
2022-01-31 17:09:04 -08:00
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
this._filterForCurrentShard(rootSuite, testGroups);
|
2022-09-23 20:01:27 -07:00
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
config._maxConcurrentTestGroups = testGroups.length;
|
2021-07-27 11:04:38 -07:00
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// Report begin
|
2022-01-31 17:09:04 -08:00
|
|
|
|
this._reporter.onBegin?.(config, rootSuite);
|
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// Bail out on errors prior to running global setup.
|
2022-12-22 17:31:02 -08:00
|
|
|
|
if (this._fatalErrors.length) {
|
|
|
|
|
for (const error of this._fatalErrors)
|
2022-01-31 17:09:04 -08:00
|
|
|
|
this._reporter.onError?.(error);
|
|
|
|
|
return { status: 'failed' };
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// Bail out if list mode only, don't do any work.
|
2022-08-04 08:09:54 -07:00
|
|
|
|
if (options.listOnly)
|
2022-01-31 17:09:04 -08:00
|
|
|
|
return { status: 'passed' };
|
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// Remove output directores.
|
2022-12-31 20:05:10 +01:00
|
|
|
|
if (!await this._removeOutputDirs(options))
|
2022-02-22 12:50:26 -08:00
|
|
|
|
return { status: 'failed' };
|
2022-02-07 10:41:56 -08:00
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// Run Global setup.
|
2022-01-31 17:09:04 -08:00
|
|
|
|
const result: FullResult = { status: 'passed' };
|
2022-09-23 20:01:27 -07:00
|
|
|
|
const globalTearDown = await this._performGlobalSetup(config, rootSuite, result);
|
2022-06-12 12:06:00 -08:00
|
|
|
|
if (result.status !== 'passed')
|
|
|
|
|
return result;
|
2022-01-31 17:09:04 -08:00
|
|
|
|
|
2022-09-14 09:16:41 -07:00
|
|
|
|
if (config._ignoreSnapshots) {
|
|
|
|
|
this._reporter.onStdOut?.(colors.dim([
|
|
|
|
|
'NOTE: running with "ignoreSnapshots" option. All of the following asserts are silently ignored:',
|
|
|
|
|
'- expect().toMatchSnapshot()',
|
|
|
|
|
'- expect().toHaveScreenshot()',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n')));
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-30 09:12:06 -07:00
|
|
|
|
// Run tests.
|
2022-01-31 17:09:04 -08:00
|
|
|
|
try {
|
2023-01-18 12:56:03 -08:00
|
|
|
|
const dispatchResult = await this._dispatchToWorkers(testGroups);
|
2022-11-01 23:44:30 -07:00
|
|
|
|
if (dispatchResult === 'signal') {
|
2022-09-23 20:01:27 -07:00
|
|
|
|
result.status = 'interrupted';
|
|
|
|
|
} else {
|
2022-11-01 23:44:30 -07:00
|
|
|
|
const failed = dispatchResult === 'workererror' || rootSuite.allTests().some(test => !test.ok());
|
2022-01-31 17:09:04 -08:00
|
|
|
|
result.status = failed ? 'failed' : 'passed';
|
2021-06-29 10:55:46 -07:00
|
|
|
|
}
|
2022-01-31 17:09:04 -08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
this._reporter.onError?.(serializeError(e));
|
|
|
|
|
return { status: 'failed' };
|
2021-06-06 17:09:53 -07:00
|
|
|
|
} finally {
|
2022-02-07 10:41:56 -08:00
|
|
|
|
await globalTearDown?.();
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-01 23:44:30 -07:00
|
|
|
|
private async _dispatchToWorkers(stageGroups: TestGroup[]): Promise<'success'|'signal'|'workererror'> {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const dispatcher = new Dispatcher(this._configLoader, [...stageGroups], this._reporter);
|
2022-11-01 23:44:30 -07:00
|
|
|
|
const sigintWatcher = new SigIntWatcher();
|
|
|
|
|
await Promise.race([dispatcher.run(), sigintWatcher.promise()]);
|
|
|
|
|
if (!sigintWatcher.hadSignal()) {
|
|
|
|
|
// We know for sure there was no Ctrl+C, so we remove custom SIGINT handler
|
|
|
|
|
// as soon as we can.
|
|
|
|
|
sigintWatcher.disarm();
|
|
|
|
|
}
|
|
|
|
|
await dispatcher.stop();
|
|
|
|
|
if (sigintWatcher.hadSignal())
|
|
|
|
|
return 'signal';
|
|
|
|
|
if (dispatcher.hasWorkerErrors())
|
|
|
|
|
return 'workererror';
|
|
|
|
|
return 'success';
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:02:54 -08:00
|
|
|
|
private _skipTestsFromMatchingGroups(testGroups: TestGroup[], groupFilter: (g: TestGroup) => boolean): TestGroup[] {
|
2022-11-01 23:44:30 -07:00
|
|
|
|
const result = [];
|
2022-10-18 17:18:45 -07:00
|
|
|
|
for (const group of testGroups) {
|
2023-01-12 13:02:54 -08:00
|
|
|
|
if (groupFilter(group)) {
|
2022-10-18 17:18:45 -07:00
|
|
|
|
for (const test of group.tests) {
|
|
|
|
|
const result = test._appendTestResult();
|
|
|
|
|
this._reporter.onTestBegin?.(test, result);
|
|
|
|
|
result.status = 'skipped';
|
|
|
|
|
this._reporter.onTestEnd?.(test, result);
|
|
|
|
|
}
|
2022-11-01 23:44:30 -07:00
|
|
|
|
} else {
|
|
|
|
|
result.push(group);
|
2022-10-18 17:18:45 -07:00
|
|
|
|
}
|
|
|
|
|
}
|
2022-11-01 23:44:30 -07:00
|
|
|
|
return result;
|
2022-10-18 17:18:45 -07:00
|
|
|
|
}
|
|
|
|
|
|
2022-08-04 08:09:54 -07:00
|
|
|
|
private async _removeOutputDirs(options: RunOptions): Promise<boolean> {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const config = this._configLoader.fullConfig();
|
2022-08-04 08:09:54 -07:00
|
|
|
|
const outputDirs = new Set<string>();
|
|
|
|
|
for (const p of config.projects) {
|
|
|
|
|
if (!options.projectFilter || options.projectFilter.includes(p.name))
|
|
|
|
|
outputDirs.add(p.outputDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all(Array.from(outputDirs).map(outputDir => removeFolderAsync(outputDir).catch(async (error: any) => {
|
|
|
|
|
if ((error as any).code === 'EBUSY') {
|
|
|
|
|
// We failed to remove folder, might be due to the whole folder being mounted inside a container:
|
|
|
|
|
// https://github.com/microsoft/playwright/issues/12106
|
|
|
|
|
// Do a best-effort to remove all files inside of it instead.
|
|
|
|
|
const entries = await readDirAsync(outputDir).catch(e => []);
|
|
|
|
|
await Promise.all(entries.map(entry => removeFolderAsync(path.join(outputDir, entry))));
|
|
|
|
|
} else {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
})));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this._reporter.onError?.(serializeError(e));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-23 20:01:27 -07:00
|
|
|
|
private async _performGlobalSetup(config: FullConfigInternal, rootSuite: Suite, result: FullResult): Promise<(() => Promise<void>) | undefined> {
|
2022-08-05 15:24:30 -07:00
|
|
|
|
let globalSetupResult: any = undefined;
|
2022-08-01 09:01:23 -07:00
|
|
|
|
|
2022-06-12 12:06:00 -08:00
|
|
|
|
const pluginsThatWereSetUp: TestRunnerPlugin[] = [];
|
|
|
|
|
const sigintWatcher = new SigIntWatcher();
|
2022-01-31 17:09:04 -08:00
|
|
|
|
|
2022-02-07 10:41:56 -08:00
|
|
|
|
const tearDown = async () => {
|
2022-08-05 15:24:30 -07:00
|
|
|
|
await this._runAndReportError(async () => {
|
|
|
|
|
if (globalSetupResult && typeof globalSetupResult === 'function')
|
2023-01-17 17:16:36 -08:00
|
|
|
|
await globalSetupResult(this._configLoader.fullConfig());
|
2022-08-05 15:24:30 -07:00
|
|
|
|
}, result);
|
2022-01-31 17:09:04 -08:00
|
|
|
|
|
2022-08-05 15:24:30 -07:00
|
|
|
|
await this._runAndReportError(async () => {
|
|
|
|
|
if (globalSetupResult && config.globalTeardown)
|
2023-01-17 17:16:36 -08:00
|
|
|
|
await (await this._configLoader.loadGlobalHook(config.globalTeardown))(this._configLoader.fullConfig());
|
2022-08-05 15:24:30 -07:00
|
|
|
|
}, result);
|
2022-01-31 17:09:04 -08:00
|
|
|
|
|
2022-06-12 12:06:00 -08:00
|
|
|
|
for (const plugin of pluginsThatWereSetUp.reverse()) {
|
2022-04-25 09:40:58 -08:00
|
|
|
|
await this._runAndReportError(async () => {
|
2022-05-05 09:14:00 -08:00
|
|
|
|
await plugin.teardown?.();
|
2022-04-25 09:40:58 -08:00
|
|
|
|
}, result);
|
|
|
|
|
}
|
2022-02-07 10:41:56 -08:00
|
|
|
|
};
|
|
|
|
|
|
2022-06-12 12:06:00 -08:00
|
|
|
|
// Legacy webServer support.
|
2022-09-13 17:05:37 -07:00
|
|
|
|
this._plugins.push(...webServerPluginsForConfig(config));
|
|
|
|
|
|
|
|
|
|
// Docker support.
|
|
|
|
|
this._plugins.push(dockerPlugin);
|
2022-05-03 13:25:56 -08:00
|
|
|
|
|
2022-06-12 12:06:00 -08:00
|
|
|
|
await this._runAndReportError(async () => {
|
2022-04-25 09:40:58 -08:00
|
|
|
|
// First run the plugins, if plugin is a web server we want it to run before the
|
|
|
|
|
// config's global setup.
|
2022-06-12 12:06:00 -08:00
|
|
|
|
for (const plugin of this._plugins) {
|
|
|
|
|
await Promise.race([
|
2022-09-13 17:05:37 -07:00
|
|
|
|
plugin.setup?.(config, config._configDir, rootSuite, this._reporter),
|
2022-06-12 12:06:00 -08:00
|
|
|
|
sigintWatcher.promise(),
|
|
|
|
|
]);
|
|
|
|
|
if (sigintWatcher.hadSignal())
|
|
|
|
|
break;
|
|
|
|
|
pluginsThatWereSetUp.push(plugin);
|
|
|
|
|
}
|
2022-04-25 09:40:58 -08:00
|
|
|
|
|
2022-08-05 15:24:30 -07:00
|
|
|
|
// Then do global setup.
|
|
|
|
|
if (!sigintWatcher.hadSignal()) {
|
|
|
|
|
if (config.globalSetup) {
|
2023-01-17 17:16:36 -08:00
|
|
|
|
const hook = await this._configLoader.loadGlobalHook(config.globalSetup);
|
2022-08-05 15:24:30 -07:00
|
|
|
|
await Promise.race([
|
2023-01-17 17:16:36 -08:00
|
|
|
|
Promise.resolve().then(() => hook(this._configLoader.fullConfig())).then((r: any) => globalSetupResult = r || '<noop>'),
|
2022-08-05 15:24:30 -07:00
|
|
|
|
sigintWatcher.promise(),
|
|
|
|
|
]);
|
|
|
|
|
} else {
|
|
|
|
|
// Make sure we run the teardown.
|
|
|
|
|
globalSetupResult = '<noop>';
|
2022-07-20 12:41:35 -07:00
|
|
|
|
}
|
2022-06-12 12:06:00 -08:00
|
|
|
|
}
|
2022-02-07 10:41:56 -08:00
|
|
|
|
}, result);
|
|
|
|
|
|
2022-06-12 12:06:00 -08:00
|
|
|
|
sigintWatcher.disarm();
|
|
|
|
|
|
|
|
|
|
if (result.status !== 'passed' || sigintWatcher.hadSignal()) {
|
2022-02-10 12:44:42 -08:00
|
|
|
|
await tearDown();
|
2022-06-12 12:06:00 -08:00
|
|
|
|
result.status = sigintWatcher.hadSignal() ? 'interrupted' : 'failed';
|
2022-02-07 10:41:56 -08:00
|
|
|
|
return;
|
2022-01-31 17:09:04 -08:00
|
|
|
|
}
|
2022-02-07 10:41:56 -08:00
|
|
|
|
|
|
|
|
|
return tearDown;
|
2022-01-31 17:09:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
2022-02-07 10:41:56 -08:00
|
|
|
|
private async _runAndReportError(callback: () => Promise<void>, result: FullResult) {
|
2022-01-31 17:09:04 -08:00
|
|
|
|
try {
|
|
|
|
|
await callback();
|
|
|
|
|
} catch (e) {
|
2022-02-07 10:41:56 -08:00
|
|
|
|
result.status = 'failed';
|
2022-01-31 17:09:04 -08:00
|
|
|
|
this._reporter.onError?.(serializeError(e));
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
function filterOnly(suite: Suite) {
|
2022-11-22 16:22:48 -08:00
|
|
|
|
if (!suite._getOnlyItems().length)
|
|
|
|
|
return;
|
2023-01-18 12:56:03 -08:00
|
|
|
|
const suiteFilter = (suite: Suite) => suite._only;
|
|
|
|
|
const testFilter = (test: TestCase) => test._only;
|
2022-01-16 08:47:09 -08:00
|
|
|
|
return filterSuiteWithOnlySemantics(suite, suiteFilter, testFilter);
|
2021-06-24 10:02:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
2022-11-22 16:22:48 -08:00
|
|
|
|
function createFileMatcherFromFilter(filter: TestFileFilter) {
|
|
|
|
|
const fileMatcher = createFileMatcher(filter.re || filter.exact || '');
|
|
|
|
|
return (testFileName: string, testLine: number, testColumn: number) =>
|
|
|
|
|
fileMatcher(testFileName) && (filter.line === testLine || filter.line === null) && (filter.column === testColumn || filter.column === null);
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-18 12:56:03 -08:00
|
|
|
|
function filterByFocusedLine(suite: Suite, focusedTestFileLines: TestFileFilter[]) {
|
2022-11-22 16:22:48 -08:00
|
|
|
|
if (!focusedTestFileLines.length)
|
2022-01-16 08:47:09 -08:00
|
|
|
|
return;
|
2022-11-22 16:22:48 -08:00
|
|
|
|
const matchers = focusedTestFileLines.map(createFileMatcherFromFilter);
|
|
|
|
|
const testFileLineMatches = (testFileName: string, testLine: number, testColumn: number) => matchers.some(m => m(testFileName, testLine, testColumn));
|
2023-01-18 12:56:03 -08:00
|
|
|
|
const suiteFilter = (suite: Suite) => !!suite.location && testFileLineMatches(suite.location.file, suite.location.line, suite.location.column);
|
|
|
|
|
const testFilter = (test: TestCase) => testFileLineMatches(test.location.file, test.location.line, test.location.column);
|
2021-07-15 22:02:10 -07:00
|
|
|
|
return filterSuite(suite, suiteFilter, testFilter);
|
2021-06-24 10:02:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
2022-01-16 08:47:09 -08:00
|
|
|
|
function filterSuiteWithOnlySemantics(suite: Suite, suiteFilter: (suites: Suite) => boolean, testFilter: (test: TestCase) => boolean) {
|
|
|
|
|
const onlySuites = suite.suites.filter(child => filterSuiteWithOnlySemantics(child, suiteFilter, testFilter) || suiteFilter(child));
|
2021-07-15 22:02:10 -07:00
|
|
|
|
const onlyTests = suite.tests.filter(testFilter);
|
2021-06-06 17:09:53 -07:00
|
|
|
|
const onlyEntries = new Set([...onlySuites, ...onlyTests]);
|
|
|
|
|
if (onlyEntries.size) {
|
|
|
|
|
suite.suites = onlySuites;
|
2021-07-15 22:02:10 -07:00
|
|
|
|
suite.tests = onlyTests;
|
2021-06-06 17:09:53 -07:00
|
|
|
|
suite._entries = suite._entries.filter(e => onlyEntries.has(e)); // Preserve the order.
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-16 08:47:09 -08:00
|
|
|
|
function filterSuite(suite: Suite, suiteFilter: (suites: Suite) => boolean, testFilter: (test: TestCase) => boolean) {
|
|
|
|
|
for (const child of suite.suites) {
|
|
|
|
|
if (!suiteFilter(child))
|
|
|
|
|
filterSuite(child, suiteFilter, testFilter);
|
|
|
|
|
}
|
|
|
|
|
suite.tests = suite.tests.filter(testFilter);
|
|
|
|
|
const entries = new Set([...suite.suites, ...suite.tests]);
|
|
|
|
|
suite._entries = suite._entries.filter(e => entries.has(e)); // Preserve the order.
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-26 14:39:51 -07:00
|
|
|
|
async function collectFiles(testDir: string, respectGitIgnore: boolean): Promise<string[]> {
|
2022-02-08 15:27:05 -08:00
|
|
|
|
if (!fs.existsSync(testDir))
|
|
|
|
|
return [];
|
|
|
|
|
if (!fs.statSync(testDir).isDirectory())
|
|
|
|
|
return [];
|
|
|
|
|
|
2021-06-06 17:09:53 -07:00
|
|
|
|
type Rule = {
|
|
|
|
|
dir: string;
|
|
|
|
|
negate: boolean;
|
|
|
|
|
match: (s: string, partial?: boolean) => boolean
|
|
|
|
|
};
|
|
|
|
|
type IgnoreStatus = 'ignored' | 'included' | 'ignored-but-recurse';
|
|
|
|
|
|
|
|
|
|
const checkIgnores = (entryPath: string, rules: Rule[], isDirectory: boolean, parentStatus: IgnoreStatus) => {
|
|
|
|
|
let status = parentStatus;
|
|
|
|
|
for (const rule of rules) {
|
|
|
|
|
const ruleIncludes = rule.negate;
|
|
|
|
|
if ((status === 'included') === ruleIncludes)
|
|
|
|
|
continue;
|
|
|
|
|
const relative = path.relative(rule.dir, entryPath);
|
|
|
|
|
if (rule.match('/' + relative) || rule.match(relative)) {
|
|
|
|
|
// Matches "/dir/file" or "dir/file"
|
|
|
|
|
status = ruleIncludes ? 'included' : 'ignored';
|
|
|
|
|
} else if (isDirectory && (rule.match('/' + relative + '/') || rule.match(relative + '/'))) {
|
|
|
|
|
// Matches "/dir/subdir/" or "dir/subdir/" for directories.
|
|
|
|
|
status = ruleIncludes ? 'included' : 'ignored';
|
|
|
|
|
} else if (isDirectory && ruleIncludes && (rule.match('/' + relative, true) || rule.match(relative, true))) {
|
|
|
|
|
// Matches "/dir/donotskip/" when "/dir" is excluded, but "!/dir/donotskip/file" is included.
|
|
|
|
|
status = 'ignored-but-recurse';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return status;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const files: string[] = [];
|
|
|
|
|
|
|
|
|
|
const visit = async (dir: string, rules: Rule[], status: IgnoreStatus) => {
|
|
|
|
|
const entries = await readDirAsync(dir, { withFileTypes: true });
|
|
|
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
2022-05-26 14:39:51 -07:00
|
|
|
|
if (respectGitIgnore) {
|
|
|
|
|
const gitignore = entries.find(e => e.isFile() && e.name === '.gitignore');
|
|
|
|
|
if (gitignore) {
|
|
|
|
|
const content = await readFileAsync(path.join(dir, gitignore.name), 'utf8');
|
|
|
|
|
const newRules: Rule[] = content.split(/\r?\n/).map(s => {
|
|
|
|
|
s = s.trim();
|
|
|
|
|
if (!s)
|
|
|
|
|
return;
|
|
|
|
|
// Use flipNegate, because we handle negation ourselves.
|
|
|
|
|
const rule = new minimatch.Minimatch(s, { matchBase: true, dot: true, flipNegate: true }) as any;
|
|
|
|
|
if (rule.comment)
|
|
|
|
|
return;
|
|
|
|
|
rule.dir = dir;
|
|
|
|
|
return rule;
|
|
|
|
|
}).filter(rule => !!rule);
|
|
|
|
|
rules = [...rules, ...newRules];
|
|
|
|
|
}
|
2021-06-06 17:09:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
2022-05-26 14:39:51 -07:00
|
|
|
|
if (entry.name === '.' || entry.name === '..')
|
|
|
|
|
continue;
|
|
|
|
|
if (entry.isFile() && entry.name === '.gitignore')
|
2021-06-06 17:09:53 -07:00
|
|
|
|
continue;
|
|
|
|
|
if (entry.isDirectory() && entry.name === 'node_modules')
|
|
|
|
|
continue;
|
|
|
|
|
const entryPath = path.join(dir, entry.name);
|
|
|
|
|
const entryStatus = checkIgnores(entryPath, rules, entry.isDirectory(), status);
|
|
|
|
|
if (entry.isDirectory() && entryStatus !== 'ignored')
|
|
|
|
|
await visit(entryPath, rules, entryStatus);
|
|
|
|
|
else if (entry.isFile() && entryStatus === 'included')
|
|
|
|
|
files.push(entryPath);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
await visit(testDir, [], 'included');
|
|
|
|
|
return files;
|
|
|
|
|
}
|
2021-06-28 22:13:35 +02:00
|
|
|
|
|
2021-07-19 14:54:18 -07:00
|
|
|
|
function buildItemLocation(rootDir: string, testOrSuite: Suite | TestCase) {
|
2021-07-18 17:40:59 -07:00
|
|
|
|
if (!testOrSuite.location)
|
|
|
|
|
return '';
|
2021-07-16 12:40:33 -07:00
|
|
|
|
return `${path.relative(rootDir, testOrSuite.location.file)}:${testOrSuite.location.line}`;
|
2021-06-28 22:13:35 +02:00
|
|
|
|
}
|
2021-07-16 22:34:55 -07:00
|
|
|
|
|
2022-09-23 20:01:27 -07:00
|
|
|
|
function createTestGroups(projectSuites: Suite[], workers: number): TestGroup[] {
|
2021-08-04 21:11:02 -07:00
|
|
|
|
// This function groups tests that can be run together.
|
|
|
|
|
// Tests cannot be run together when:
|
|
|
|
|
// - They belong to different projects - requires different workers.
|
|
|
|
|
// - They have a different repeatEachIndex - requires different workers.
|
|
|
|
|
// - They have a different set of worker fixtures in the pool - requires different workers.
|
|
|
|
|
// - They have a different requireFile - reuses the worker, but runs each requireFile separately.
|
2021-09-02 15:42:07 -07:00
|
|
|
|
// - They belong to a parallel suite.
|
|
|
|
|
|
|
|
|
|
// Using the map "workerHash -> requireFile -> group" makes us preserve the natural order
|
|
|
|
|
// of worker hashes and require files for the simple cases.
|
2022-03-28 16:10:32 -07:00
|
|
|
|
const groups = new Map<string, Map<string, {
|
|
|
|
|
// Tests that must be run in order are in the same group.
|
|
|
|
|
general: TestGroup,
|
2022-07-26 13:38:25 -07:00
|
|
|
|
|
|
|
|
|
// There are 3 kinds of parallel tests:
|
|
|
|
|
// - Tests belonging to parallel suites, without beforeAll/afterAll hooks.
|
|
|
|
|
// These can be run independently, they are put into their own group, key === test.
|
|
|
|
|
// - Tests belonging to parallel suites, with beforeAll/afterAll hooks.
|
|
|
|
|
// These should share the worker as much as possible, put into single parallelWithHooks group.
|
|
|
|
|
// We'll divide them into equally-sized groups later.
|
|
|
|
|
// - Tests belonging to serial suites inside parallel suites.
|
|
|
|
|
// These should run as a serial group, each group is independent, key === serial suite.
|
|
|
|
|
parallel: Map<Suite | TestCase, TestGroup>,
|
2022-03-28 16:10:32 -07:00
|
|
|
|
parallelWithHooks: TestGroup,
|
|
|
|
|
}>>();
|
2021-09-02 15:42:07 -07:00
|
|
|
|
|
|
|
|
|
const createGroup = (test: TestCase): TestGroup => {
|
|
|
|
|
return {
|
|
|
|
|
workerHash: test._workerHash,
|
|
|
|
|
requireFile: test._requireFile,
|
2022-01-03 17:29:54 -08:00
|
|
|
|
repeatEachIndex: test.repeatEachIndex,
|
2022-07-27 20:17:19 -07:00
|
|
|
|
projectId: test._projectId,
|
2021-09-02 15:42:07 -07:00
|
|
|
|
tests: [],
|
|
|
|
|
};
|
|
|
|
|
};
|
2021-08-04 21:11:02 -07:00
|
|
|
|
|
2022-09-23 20:01:27 -07:00
|
|
|
|
for (const projectSuite of projectSuites) {
|
2021-07-27 11:04:38 -07:00
|
|
|
|
for (const test of projectSuite.allTests()) {
|
2021-09-02 15:42:07 -07:00
|
|
|
|
let withWorkerHash = groups.get(test._workerHash);
|
|
|
|
|
if (!withWorkerHash) {
|
|
|
|
|
withWorkerHash = new Map();
|
|
|
|
|
groups.set(test._workerHash, withWorkerHash);
|
2021-07-29 18:27:47 -07:00
|
|
|
|
}
|
2021-09-02 15:42:07 -07:00
|
|
|
|
let withRequireFile = withWorkerHash.get(test._requireFile);
|
|
|
|
|
if (!withRequireFile) {
|
|
|
|
|
withRequireFile = {
|
|
|
|
|
general: createGroup(test),
|
2022-07-26 13:38:25 -07:00
|
|
|
|
parallel: new Map(),
|
2022-03-28 16:10:32 -07:00
|
|
|
|
parallelWithHooks: createGroup(test),
|
2021-09-02 15:42:07 -07:00
|
|
|
|
};
|
|
|
|
|
withWorkerHash.set(test._requireFile, withRequireFile);
|
2021-07-29 18:27:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
2022-07-26 13:38:25 -07:00
|
|
|
|
// Note that a parallel suite cannot be inside a serial suite. This is enforced in TestType.
|
2021-09-02 15:42:07 -07:00
|
|
|
|
let insideParallel = false;
|
2022-07-26 13:38:25 -07:00
|
|
|
|
let outerMostSerialSuite: Suite | undefined;
|
2022-03-28 16:10:32 -07:00
|
|
|
|
let hasAllHooks = false;
|
|
|
|
|
for (let parent: Suite | undefined = test.parent; parent; parent = parent.parent) {
|
2022-07-26 13:38:25 -07:00
|
|
|
|
if (parent._parallelMode === 'serial')
|
|
|
|
|
outerMostSerialSuite = parent;
|
|
|
|
|
insideParallel = insideParallel || parent._parallelMode === 'parallel';
|
2022-03-28 16:10:32 -07:00
|
|
|
|
hasAllHooks = hasAllHooks || parent._hooks.some(hook => hook.type === 'beforeAll' || hook.type === 'afterAll');
|
|
|
|
|
}
|
2021-09-02 15:42:07 -07:00
|
|
|
|
|
|
|
|
|
if (insideParallel) {
|
2022-07-26 13:38:25 -07:00
|
|
|
|
if (hasAllHooks && !outerMostSerialSuite) {
|
2022-03-28 16:10:32 -07:00
|
|
|
|
withRequireFile.parallelWithHooks.tests.push(test);
|
|
|
|
|
} else {
|
2022-07-26 13:38:25 -07:00
|
|
|
|
const key = outerMostSerialSuite || test;
|
|
|
|
|
let group = withRequireFile.parallel.get(key);
|
|
|
|
|
if (!group) {
|
|
|
|
|
group = createGroup(test);
|
|
|
|
|
withRequireFile.parallel.set(key, group);
|
|
|
|
|
}
|
2022-03-28 16:10:32 -07:00
|
|
|
|
group.tests.push(test);
|
|
|
|
|
}
|
2021-09-02 15:42:07 -07:00
|
|
|
|
} else {
|
|
|
|
|
withRequireFile.general.tests.push(test);
|
2021-07-27 11:04:38 -07:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-07-29 18:27:47 -07:00
|
|
|
|
|
2021-09-02 15:42:07 -07:00
|
|
|
|
const result: TestGroup[] = [];
|
|
|
|
|
for (const withWorkerHash of groups.values()) {
|
|
|
|
|
for (const withRequireFile of withWorkerHash.values()) {
|
2022-07-26 13:38:25 -07:00
|
|
|
|
// Tests without parallel mode should run serially as a single group.
|
2021-09-02 15:42:07 -07:00
|
|
|
|
if (withRequireFile.general.tests.length)
|
|
|
|
|
result.push(withRequireFile.general);
|
2022-03-28 16:10:32 -07:00
|
|
|
|
|
2022-07-26 13:38:25 -07:00
|
|
|
|
// Parallel test groups without beforeAll/afterAll can be run independently.
|
|
|
|
|
result.push(...withRequireFile.parallel.values());
|
|
|
|
|
|
|
|
|
|
// Tests with beforeAll/afterAll should try to share workers as much as possible.
|
2022-03-28 16:10:32 -07:00
|
|
|
|
const parallelWithHooksGroupSize = Math.ceil(withRequireFile.parallelWithHooks.tests.length / workers);
|
|
|
|
|
let lastGroup: TestGroup | undefined;
|
|
|
|
|
for (const test of withRequireFile.parallelWithHooks.tests) {
|
|
|
|
|
if (!lastGroup || lastGroup.tests.length >= parallelWithHooksGroupSize) {
|
|
|
|
|
lastGroup = createGroup(test);
|
|
|
|
|
result.push(lastGroup);
|
|
|
|
|
}
|
|
|
|
|
lastGroup.tests.push(test);
|
|
|
|
|
}
|
2021-09-02 15:42:07 -07:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result;
|
2021-07-27 11:04:38 -07:00
|
|
|
|
}
|
|
|
|
|
|
2021-07-16 22:34:55 -07:00
|
|
|
|
class ListModeReporter implements Reporter {
|
2022-03-28 15:53:42 -07:00
|
|
|
|
private config!: FullConfigInternal;
|
2022-03-24 07:33:33 -07:00
|
|
|
|
|
2022-03-28 15:53:42 -07:00
|
|
|
|
onBegin(config: FullConfigInternal, suite: Suite): void {
|
2022-03-24 07:33:33 -07:00
|
|
|
|
this.config = config;
|
2021-11-11 16:48:08 -08:00
|
|
|
|
// eslint-disable-next-line no-console
|
2021-07-16 22:34:55 -07:00
|
|
|
|
console.log(`Listing tests:`);
|
|
|
|
|
const tests = suite.allTests();
|
|
|
|
|
const files = new Set<string>();
|
|
|
|
|
for (const test of tests) {
|
|
|
|
|
// root, project, file, ...describes, test
|
|
|
|
|
const [, projectName, , ...titles] = test.titlePath();
|
|
|
|
|
const location = `${path.relative(config.rootDir, test.location.file)}:${test.location.line}:${test.location.column}`;
|
|
|
|
|
const projectTitle = projectName ? `[${projectName}] › ` : '';
|
2021-11-11 16:48:08 -08:00
|
|
|
|
// eslint-disable-next-line no-console
|
2021-07-16 22:34:55 -07:00
|
|
|
|
console.log(` ${projectTitle}${location} › ${titles.join(' ')}`);
|
|
|
|
|
files.add(test.location.file);
|
|
|
|
|
}
|
2021-11-11 16:48:08 -08:00
|
|
|
|
// eslint-disable-next-line no-console
|
2021-07-16 22:34:55 -07:00
|
|
|
|
console.log(`Total: ${tests.length} ${tests.length === 1 ? 'test' : 'tests'} in ${files.size} ${files.size === 1 ? 'file' : 'files'}`);
|
|
|
|
|
}
|
2022-03-24 07:33:33 -07:00
|
|
|
|
|
|
|
|
|
onError(error: TestError) {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.error('\n' + formatError(this.config, error, false).message);
|
|
|
|
|
}
|
2021-07-16 22:34:55 -07:00
|
|
|
|
}
|
2021-07-20 15:03:01 -05:00
|
|
|
|
|
2022-12-21 09:36:59 -08:00
|
|
|
|
function createForbidOnlyErrors(config: FullConfigInternal, onlyTestsAndSuites: (TestCase | Suite)[]): TestError[] {
|
|
|
|
|
const errors: TestError[] = [];
|
2021-11-11 16:48:08 -08:00
|
|
|
|
for (const testOrSuite of onlyTestsAndSuites) {
|
|
|
|
|
// Skip root and file.
|
|
|
|
|
const title = testOrSuite.titlePath().slice(2).join(' ');
|
2022-12-21 09:36:59 -08:00
|
|
|
|
const error: TestError = {
|
|
|
|
|
message: `Error: focused item found in the --forbid-only mode: "${title}"`,
|
|
|
|
|
location: testOrSuite.location!,
|
|
|
|
|
};
|
|
|
|
|
errors.push(error);
|
2021-11-11 16:48:08 -08:00
|
|
|
|
}
|
2022-12-21 09:36:59 -08:00
|
|
|
|
return errors;
|
2021-11-11 16:48:08 -08:00
|
|
|
|
}
|
|
|
|
|
|
2022-12-21 09:36:59 -08:00
|
|
|
|
function createDuplicateTitlesErrors(config: FullConfigInternal, rootSuite: Suite): TestError[] {
|
|
|
|
|
const errors: TestError[] = [];
|
2022-05-11 11:53:16 +01:00
|
|
|
|
for (const fileSuite of rootSuite.suites) {
|
2022-12-21 09:36:59 -08:00
|
|
|
|
const testsByFullTitle = new Map<string, TestCase>();
|
2022-05-11 11:53:16 +01:00
|
|
|
|
for (const test of fileSuite.allTests()) {
|
2023-01-09 09:33:09 -08:00
|
|
|
|
const fullTitle = test.titlePath().slice(2).join(' › ');
|
2022-12-21 09:36:59 -08:00
|
|
|
|
const existingTest = testsByFullTitle.get(fullTitle);
|
|
|
|
|
if (existingTest) {
|
|
|
|
|
const error: TestError = {
|
|
|
|
|
message: `Error: duplicate test title "${fullTitle}", first declared in ${buildItemLocation(config.rootDir, existingTest)}`,
|
|
|
|
|
location: test.location,
|
|
|
|
|
};
|
|
|
|
|
errors.push(error);
|
2022-05-11 11:53:16 +01:00
|
|
|
|
}
|
2022-12-21 09:36:59 -08:00
|
|
|
|
testsByFullTitle.set(fullTitle, test);
|
2022-05-11 11:53:16 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
2022-12-21 09:36:59 -08:00
|
|
|
|
return errors;
|
2021-11-11 16:48:08 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createNoTestsError(): TestError {
|
|
|
|
|
return createStacklessError(`=================\n no tests found.\n=================`);
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-21 09:36:59 -08:00
|
|
|
|
function createStacklessError(message: string, location?: TestError['location']): TestError {
|
|
|
|
|
return { message, location };
|
2021-11-11 16:48:08 -08:00
|
|
|
|
}
|
|
|
|
|
|
2022-11-08 12:05:00 -08:00
|
|
|
|
function sanitizeConfigForJSON(object: any, visited: Set<any>): any {
|
|
|
|
|
const type = typeof object;
|
|
|
|
|
if (type === 'function' || type === 'symbol')
|
|
|
|
|
return undefined;
|
|
|
|
|
if (!object || type !== 'object')
|
|
|
|
|
return object;
|
|
|
|
|
|
|
|
|
|
if (object instanceof RegExp)
|
|
|
|
|
return String(object);
|
|
|
|
|
if (object instanceof Date)
|
|
|
|
|
return object.toISOString();
|
|
|
|
|
|
|
|
|
|
if (visited.has(object))
|
|
|
|
|
return undefined;
|
|
|
|
|
visited.add(object);
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(object))
|
|
|
|
|
return object.map(a => sanitizeConfigForJSON(a, visited));
|
|
|
|
|
|
|
|
|
|
const result: any = {};
|
|
|
|
|
const keys = Object.keys(object).slice(0, 100);
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
if (key.startsWith('_'))
|
|
|
|
|
continue;
|
|
|
|
|
result[key] = sanitizeConfigForJSON(object[key], visited);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-14 10:17:35 -08:00
|
|
|
|
export const builtInReporters = ['list', 'line', 'dot', 'json', 'junit', 'null', 'github', 'html'] as const;
|
2021-07-20 15:03:01 -05:00
|
|
|
|
export type BuiltInReporter = typeof builtInReporters[number];
|