mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-15 06:02:57 +03:00
chore: split config and test loaders (#20175)
This commit is contained in:
parent
020dcd89fa
commit
d9d4070520
@ -23,10 +23,11 @@ import path from 'path';
|
||||
import { Runner, builtInReporters, kDefaultConfigFiles } from './runner';
|
||||
import type { ConfigCLIOverrides } from './runner';
|
||||
import { stopProfiling, startProfiling } from './profiler';
|
||||
import { fileIsModule } from './util';
|
||||
import type { TestFileFilter } from './util';
|
||||
import { createTitleMatcher } from './util';
|
||||
import { showHTMLReport } from './reporters/html';
|
||||
import { baseFullConfig, defaultTimeout, fileIsModule } from './loader';
|
||||
import { baseFullConfig, defaultTimeout } from './configLoader';
|
||||
import type { TraceMode } from './types';
|
||||
|
||||
export function addTestCommands(program: Command) {
|
||||
|
@ -17,40 +17,30 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { calculateSha1, isRegExp } from 'playwright-core/lib/utils';
|
||||
import * as url from 'url';
|
||||
import { isRegExp } from 'playwright-core/lib/utils';
|
||||
import type { Reporter } from '../types/testReporter';
|
||||
import { FixturePool, isFixtureOption } from './fixtures';
|
||||
import { setCurrentlyLoadingFileSuite } from './globals';
|
||||
import type { SerializedLoaderData, WorkerIsolation } from './ipc';
|
||||
import type { SerializedLoaderData } from './ipc';
|
||||
import type { BuiltInReporter, ConfigCLIOverrides } from './runner';
|
||||
import { builtInReporters } from './runner';
|
||||
import { Suite, type TestCase } from './test';
|
||||
import type { TestTypeImpl } from './testType';
|
||||
import { installTransform } from './transform';
|
||||
import type { Config, Fixtures, FixturesWithLocation, FullConfigInternal, FullProjectInternal, Project, ReporterDescription } from './types';
|
||||
import { errorWithFile, getPackageJsonPath, mergeObjects, serializeError } from './util';
|
||||
import { requireOrImport } from './transform';
|
||||
import type { Config, FullConfigInternal, FullProjectInternal, Project, ReporterDescription } from './types';
|
||||
import { errorWithFile, getPackageJsonPath, mergeObjects } from './util';
|
||||
|
||||
export const defaultTimeout = 30000;
|
||||
|
||||
// To allow multiple loaders in the same process without clearing require cache,
|
||||
// we make these maps global.
|
||||
const cachedFileSuites = new Map<string, Suite>();
|
||||
|
||||
export class Loader {
|
||||
export class ConfigLoader {
|
||||
private _configCLIOverrides: ConfigCLIOverrides;
|
||||
private _fullConfig: FullConfigInternal;
|
||||
private _configDir: string = '';
|
||||
private _configFile: string | undefined;
|
||||
private _projectSuiteBuilders = new Map<FullProjectInternal, ProjectSuiteBuilder>();
|
||||
|
||||
constructor(configCLIOverrides?: ConfigCLIOverrides) {
|
||||
this._configCLIOverrides = configCLIOverrides || {};
|
||||
this._fullConfig = { ...baseFullConfig };
|
||||
}
|
||||
|
||||
static async deserialize(data: SerializedLoaderData): Promise<Loader> {
|
||||
const loader = new Loader(data.configCLIOverrides);
|
||||
static async deserialize(data: SerializedLoaderData): Promise<ConfigLoader> {
|
||||
const loader = new ConfigLoader(data.configCLIOverrides);
|
||||
if (data.configFile)
|
||||
await loader.loadConfigFile(data.configFile);
|
||||
else
|
||||
@ -182,49 +172,6 @@ export class Loader {
|
||||
}
|
||||
}
|
||||
|
||||
async loadTestFile(file: string, environment: 'runner' | 'worker', phase: 'test' | 'projectSetup' | 'globalSetup') {
|
||||
if (cachedFileSuites.has(file))
|
||||
return cachedFileSuites.get(file)!;
|
||||
const suite = new Suite(path.relative(this._fullConfig.rootDir, file) || path.basename(file), 'file');
|
||||
suite._requireFile = file;
|
||||
suite._phase = phase;
|
||||
suite.location = { file, line: 0, column: 0 };
|
||||
|
||||
setCurrentlyLoadingFileSuite(suite);
|
||||
try {
|
||||
await this._requireOrImport(file);
|
||||
cachedFileSuites.set(file, suite);
|
||||
} catch (e) {
|
||||
if (environment === 'worker')
|
||||
throw e;
|
||||
suite._loadError = serializeError(e);
|
||||
} finally {
|
||||
setCurrentlyLoadingFileSuite(undefined);
|
||||
}
|
||||
|
||||
{
|
||||
// Test locations that we discover potentially have different file name.
|
||||
// This could be due to either
|
||||
// a) use of source maps or due to
|
||||
// b) require of one file from another.
|
||||
// Try fixing (a) w/o regressing (b).
|
||||
|
||||
const files = new Set<string>();
|
||||
suite.allTests().map(t => files.add(t.location.file));
|
||||
if (files.size === 1) {
|
||||
// All tests point to one file.
|
||||
const mappedFile = files.values().next().value;
|
||||
if (suite.location.file !== mappedFile) {
|
||||
// The file is different, check for a likely source map case.
|
||||
if (path.extname(mappedFile) !== path.extname(suite.location.file))
|
||||
suite.location.file = mappedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suite;
|
||||
}
|
||||
|
||||
async loadGlobalHook(file: string): Promise<(config: FullConfigInternal) => any> {
|
||||
return this._requireOrImportDefaultFunction(path.resolve(this._fullConfig.rootDir, file), false);
|
||||
}
|
||||
@ -237,13 +184,6 @@ export class Loader {
|
||||
return this._fullConfig;
|
||||
}
|
||||
|
||||
buildFileSuiteForProject(project: FullProjectInternal, suite: Suite, repeatEachIndex: number, filter: (test: TestCase) => boolean): Suite | undefined {
|
||||
if (!this._projectSuiteBuilders.has(project))
|
||||
this._projectSuiteBuilders.set(project, new ProjectSuiteBuilder(project));
|
||||
const builder = this._projectSuiteBuilders.get(project)!;
|
||||
return builder.cloneFileSuite(suite, 'isolate-pools', repeatEachIndex, filter);
|
||||
}
|
||||
|
||||
serialize(): SerializedLoaderData {
|
||||
const result: SerializedLoaderData = {
|
||||
configFile: this._configFile,
|
||||
@ -305,21 +245,8 @@ export class Loader {
|
||||
};
|
||||
}
|
||||
|
||||
private async _requireOrImport(file: string) {
|
||||
const revertBabelRequire = installTransform();
|
||||
const isModule = fileIsModule(file);
|
||||
try {
|
||||
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
|
||||
if (isModule)
|
||||
return await esmImport();
|
||||
return require(file);
|
||||
} finally {
|
||||
revertBabelRequire();
|
||||
}
|
||||
}
|
||||
|
||||
private async _requireOrImportDefaultFunction(file: string, expectConstructor: boolean) {
|
||||
let func = await this._requireOrImport(file);
|
||||
let func = await requireOrImport(file);
|
||||
if (func && typeof func === 'object' && ('default' in func))
|
||||
func = func['default'];
|
||||
if (typeof func !== 'function')
|
||||
@ -328,131 +255,13 @@ export class Loader {
|
||||
}
|
||||
|
||||
private async _requireOrImportDefaultObject(file: string) {
|
||||
let object = await this._requireOrImport(file);
|
||||
let object = await requireOrImport(file);
|
||||
if (object && typeof object === 'object' && ('default' in object))
|
||||
object = object['default'];
|
||||
return object;
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectSuiteBuilder {
|
||||
private _project: FullProjectInternal;
|
||||
private _testTypePools = new Map<TestTypeImpl, FixturePool>();
|
||||
private _testPools = new Map<TestCase, FixturePool>();
|
||||
|
||||
constructor(project: FullProjectInternal) {
|
||||
this._project = project;
|
||||
}
|
||||
|
||||
private _buildTestTypePool(testType: TestTypeImpl): FixturePool {
|
||||
if (!this._testTypePools.has(testType)) {
|
||||
const fixtures = this._applyConfigUseOptions(testType, this._project.use || {});
|
||||
const pool = new FixturePool(fixtures);
|
||||
this._testTypePools.set(testType, pool);
|
||||
}
|
||||
return this._testTypePools.get(testType)!;
|
||||
}
|
||||
|
||||
// TODO: we can optimize this function by building the pool inline in cloneSuite
|
||||
private _buildPool(test: TestCase): FixturePool {
|
||||
if (!this._testPools.has(test)) {
|
||||
let pool = this._buildTestTypePool(test._testType);
|
||||
|
||||
const parents: Suite[] = [];
|
||||
for (let parent: Suite | undefined = test.parent; parent; parent = parent.parent)
|
||||
parents.push(parent);
|
||||
parents.reverse();
|
||||
|
||||
for (const parent of parents) {
|
||||
if (parent._use.length)
|
||||
pool = new FixturePool(parent._use, pool, parent._type === 'describe');
|
||||
for (const hook of parent._hooks)
|
||||
pool.validateFunction(hook.fn, hook.type + ' hook', hook.location);
|
||||
for (const modifier of parent._modifiers)
|
||||
pool.validateFunction(modifier.fn, modifier.type + ' modifier', modifier.location);
|
||||
}
|
||||
|
||||
pool.validateFunction(test.fn, 'Test', test.location);
|
||||
this._testPools.set(test, pool);
|
||||
}
|
||||
return this._testPools.get(test)!;
|
||||
}
|
||||
|
||||
private _cloneEntries(from: Suite, to: Suite, workerIsolation: WorkerIsolation, repeatEachIndex: number, filter: (test: TestCase) => boolean): boolean {
|
||||
for (const entry of from._entries) {
|
||||
if (entry instanceof Suite) {
|
||||
const suite = entry._clone();
|
||||
suite._fileId = to._fileId;
|
||||
to._addSuite(suite);
|
||||
// Ignore empty titles, similar to Suite.titlePath().
|
||||
if (!this._cloneEntries(entry, suite, workerIsolation, repeatEachIndex, filter)) {
|
||||
to._entries.pop();
|
||||
to.suites.pop();
|
||||
}
|
||||
} else {
|
||||
const test = entry._clone();
|
||||
to._addTest(test);
|
||||
test.retries = this._project.retries;
|
||||
for (let parentSuite: Suite | undefined = to; parentSuite; parentSuite = parentSuite.parent) {
|
||||
if (parentSuite._retries !== undefined) {
|
||||
test.retries = parentSuite._retries;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const repeatEachIndexSuffix = repeatEachIndex ? ` (repeat:${repeatEachIndex})` : '';
|
||||
// At the point of the query, suite is not yet attached to the project, so we only get file, describe and test titles.
|
||||
const testIdExpression = `[project=${this._project._id}]${test.titlePath().join('\x1e')}${repeatEachIndexSuffix}`;
|
||||
const testId = to._fileId + '-' + calculateSha1(testIdExpression).slice(0, 20);
|
||||
test.id = testId;
|
||||
test.repeatEachIndex = repeatEachIndex;
|
||||
test._projectId = this._project._id;
|
||||
if (!filter(test)) {
|
||||
to._entries.pop();
|
||||
to.tests.pop();
|
||||
} else {
|
||||
const pool = this._buildPool(entry);
|
||||
if (this._project._fullConfig._workerIsolation === 'isolate-pools')
|
||||
test._workerHash = `run${this._project._id}-${pool.digest}-repeat${repeatEachIndex}`;
|
||||
else
|
||||
test._workerHash = `run${this._project._id}-repeat${repeatEachIndex}`;
|
||||
test._pool = pool;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!to._entries.length)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
cloneFileSuite(suite: Suite, workerIsolation: WorkerIsolation, repeatEachIndex: number, filter: (test: TestCase) => boolean): Suite | undefined {
|
||||
const result = suite._clone();
|
||||
const relativeFile = path.relative(this._project.testDir, suite.location!.file).split(path.sep).join('/');
|
||||
result._fileId = calculateSha1(relativeFile).slice(0, 20);
|
||||
return this._cloneEntries(suite, result, workerIsolation, repeatEachIndex, filter) ? result : undefined;
|
||||
}
|
||||
|
||||
private _applyConfigUseOptions(testType: TestTypeImpl, configUse: Fixtures): FixturesWithLocation[] {
|
||||
const configKeys = new Set(Object.keys(configUse));
|
||||
if (!configKeys.size)
|
||||
return testType.fixtures;
|
||||
const result: FixturesWithLocation[] = [];
|
||||
for (const f of testType.fixtures) {
|
||||
result.push(f);
|
||||
const optionsFromConfig: Fixtures = {};
|
||||
for (const [key, value] of Object.entries(f.fixtures)) {
|
||||
if (isFixtureOption(value) && configKeys.has(key))
|
||||
(optionsFromConfig as any)[key] = [(configUse as any)[key], value[1]];
|
||||
}
|
||||
if (Object.entries(optionsFromConfig).length) {
|
||||
// Add config options immediately after original option definition,
|
||||
// so that any test.use() override it.
|
||||
result.push({ fixtures: optionsFromConfig, location: { file: `project#${this._project._id}`, line: 1, column: 1 }, fromConfig: true });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function takeFirst<T>(...args: (T | undefined)[]): T {
|
||||
for (const arg of args) {
|
||||
if (arg !== undefined)
|
||||
@ -693,19 +502,3 @@ function resolveScript(id: string, rootDir: string) {
|
||||
return localPath;
|
||||
return require.resolve(id, { paths: [rootDir] });
|
||||
}
|
||||
|
||||
export function fileIsModule(file: string): boolean {
|
||||
if (file.endsWith('.mjs'))
|
||||
return true;
|
||||
|
||||
const folder = path.dirname(file);
|
||||
return folderIsModule(folder);
|
||||
}
|
||||
|
||||
export function folderIsModule(folder: string): boolean {
|
||||
const packageJsonPath = getPackageJsonPath(folder);
|
||||
if (!packageJsonPath)
|
||||
return false;
|
||||
// Rely on `require` internal caching logic.
|
||||
return require(packageJsonPath).type === 'module';
|
||||
}
|
@ -17,7 +17,7 @@
|
||||
import type { TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, StepBeginPayload, StepEndPayload, TeardownErrorsPayload, WatchTestResolvedPayload, RunPayload, SerializedLoaderData } from './ipc';
|
||||
import type { TestResult, Reporter, TestStep, TestError } from '../types/testReporter';
|
||||
import type { Suite } from './test';
|
||||
import type { Loader } from './loader';
|
||||
import type { ConfigLoader } from './configLoader';
|
||||
import type { ProcessExitData } from './processHost';
|
||||
import { TestCase } from './test';
|
||||
import { ManualPromise } from 'playwright-core/lib/utils';
|
||||
@ -52,13 +52,13 @@ export class Dispatcher {
|
||||
private _isStopped = false;
|
||||
|
||||
private _testById = new Map<string, TestData>();
|
||||
private _loader: Loader;
|
||||
private _configLoader: ConfigLoader;
|
||||
private _reporter: Reporter;
|
||||
private _hasWorkerErrors = false;
|
||||
private _failureCount = 0;
|
||||
|
||||
constructor(loader: Loader, testGroups: TestGroup[], reporter: Reporter) {
|
||||
this._loader = loader;
|
||||
constructor(configLoader: ConfigLoader, testGroups: TestGroup[], reporter: Reporter) {
|
||||
this._configLoader = configLoader;
|
||||
this._reporter = reporter;
|
||||
this._queue = testGroups;
|
||||
for (const group of testGroups) {
|
||||
@ -108,7 +108,7 @@ export class Dispatcher {
|
||||
|
||||
// 2. Start the worker if it is down.
|
||||
if (!worker) {
|
||||
worker = this._createWorker(job, index, this._loader.serialize());
|
||||
worker = this._createWorker(job, index, this._configLoader.serialize());
|
||||
this._workerSlots[index].worker = worker;
|
||||
worker.on('exit', () => this._workerSlots[index].worker = undefined);
|
||||
await worker.start();
|
||||
@ -152,7 +152,7 @@ export class Dispatcher {
|
||||
async run() {
|
||||
this._workerSlots = [];
|
||||
// 1. Allocate workers.
|
||||
for (let i = 0; i < this._loader.fullConfig().workers; i++)
|
||||
for (let i = 0; i < this._configLoader.fullConfig().workers; i++)
|
||||
this._workerSlots.push({ busy: false });
|
||||
// 2. Schedule enough jobs.
|
||||
for (let i = 0; i < this._workerSlots.length; i++)
|
||||
@ -439,7 +439,7 @@ export class Dispatcher {
|
||||
}
|
||||
|
||||
_createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: SerializedLoaderData) {
|
||||
const worker = new WorkerHost(testGroup, parallelIndex, this._loader.fullConfig()._workerIsolation, loaderData);
|
||||
const worker = new WorkerHost(testGroup, parallelIndex, this._configLoader.fullConfig()._workerIsolation, loaderData);
|
||||
const handleOutput = (params: TestOutputPayload) => {
|
||||
const chunk = chunkFromParams(params);
|
||||
if (worker.didFail()) {
|
||||
@ -480,7 +480,7 @@ export class Dispatcher {
|
||||
}
|
||||
|
||||
private _hasReachedMaxFailures() {
|
||||
const maxFailures = this._loader.fullConfig().maxFailures;
|
||||
const maxFailures = this._configLoader.fullConfig().maxFailures;
|
||||
return maxFailures > 0 && this._failureCount >= maxFailures;
|
||||
}
|
||||
|
||||
@ -488,7 +488,7 @@ export class Dispatcher {
|
||||
if (result.status !== 'skipped' && result.status !== test.expectedStatus)
|
||||
++this._failureCount;
|
||||
this._reporter.onTestEnd?.(test, result);
|
||||
const maxFailures = this._loader.fullConfig().maxFailures;
|
||||
const maxFailures = this._configLoader.fullConfig().maxFailures;
|
||||
if (maxFailures && this._failureCount === maxFailures)
|
||||
this.stop().catch(e => {});
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import { promisify } from 'util';
|
||||
import type { FullResult, Reporter, TestError } from '../types/testReporter';
|
||||
import type { TestGroup } from './dispatcher';
|
||||
import { Dispatcher } from './dispatcher';
|
||||
import { Loader } from './loader';
|
||||
import { ConfigLoader } from './configLoader';
|
||||
import type { TestRunnerPlugin } from './plugins';
|
||||
import { setRunnerToAddPluginsTo } from './plugins';
|
||||
import { dockerPlugin } from './plugins/dockerPlugin';
|
||||
@ -45,6 +45,7 @@ import type { Config, FullConfigInternal, FullProjectInternal, ReporterInternal
|
||||
import { createFileMatcher, createFileMatcherFromFilters, createTitleMatcher, serializeError } from './util';
|
||||
import type { Matcher, TestFileFilter } from './util';
|
||||
import { setFatalErrorSink } from './globals';
|
||||
import { TestLoader } from './testLoader';
|
||||
|
||||
const removeFolderAsync = promisify(rimraf);
|
||||
const readDirAsync = promisify(fs.readdir);
|
||||
@ -79,13 +80,13 @@ export type ConfigCLIOverrides = {
|
||||
};
|
||||
|
||||
export class Runner {
|
||||
private _loader: Loader;
|
||||
private _configLoader: ConfigLoader;
|
||||
private _reporter!: ReporterInternal;
|
||||
private _plugins: TestRunnerPlugin[] = [];
|
||||
private _fatalErrors: TestError[] = [];
|
||||
|
||||
constructor(configCLIOverrides?: ConfigCLIOverrides) {
|
||||
this._loader = new Loader(configCLIOverrides);
|
||||
this._configLoader = new ConfigLoader(configCLIOverrides);
|
||||
setRunnerToAddPluginsTo(this);
|
||||
setFatalErrorSink(this._fatalErrors);
|
||||
}
|
||||
@ -95,11 +96,11 @@ export class Runner {
|
||||
}
|
||||
|
||||
async loadConfigFromResolvedFile(resolvedConfigFile: string): Promise<FullConfigInternal> {
|
||||
return await this._loader.loadConfigFile(resolvedConfigFile);
|
||||
return await this._configLoader.loadConfigFile(resolvedConfigFile);
|
||||
}
|
||||
|
||||
loadEmptyConfig(configFileOrDirectory: string): Promise<Config> {
|
||||
return this._loader.loadEmptyConfig(configFileOrDirectory);
|
||||
return this._configLoader.loadEmptyConfig(configFileOrDirectory);
|
||||
}
|
||||
|
||||
static resolveConfigFile(configFileOrDirectory: string): string | null {
|
||||
@ -144,17 +145,17 @@ export class Runner {
|
||||
html: HtmlReporter,
|
||||
};
|
||||
const reporters: Reporter[] = [];
|
||||
for (const r of this._loader.fullConfig().reporter) {
|
||||
for (const r of this._configLoader.fullConfig().reporter) {
|
||||
const [name, arg] = r;
|
||||
if (name in defaultReporters) {
|
||||
reporters.push(new defaultReporters[name as keyof typeof defaultReporters](arg));
|
||||
} else {
|
||||
const reporterConstructor = await this._loader.loadReporter(name);
|
||||
const reporterConstructor = await this._configLoader.loadReporter(name);
|
||||
reporters.push(new reporterConstructor(arg));
|
||||
}
|
||||
}
|
||||
if (process.env.PW_TEST_REPORTER) {
|
||||
const reporterConstructor = await this._loader.loadReporter(process.env.PW_TEST_REPORTER);
|
||||
const reporterConstructor = await this._configLoader.loadReporter(process.env.PW_TEST_REPORTER);
|
||||
reporters.push(new reporterConstructor());
|
||||
}
|
||||
|
||||
@ -175,7 +176,7 @@ export class Runner {
|
||||
|
||||
async runAllTests(options: RunOptions): Promise<FullResult> {
|
||||
this._reporter = await this._createReporter(!!options.listOnly);
|
||||
const config = this._loader.fullConfig();
|
||||
const config = this._configLoader.fullConfig();
|
||||
const result = await raceAgainstTimeout(() => this._run(options), config.globalTimeout);
|
||||
let fullResult: FullResult;
|
||||
if (result.timedOut) {
|
||||
@ -213,7 +214,7 @@ export class Runner {
|
||||
}
|
||||
|
||||
private _collectProjects(projectNames?: string[]): FullProjectInternal[] {
|
||||
const fullConfig = this._loader.fullConfig();
|
||||
const fullConfig = this._configLoader.fullConfig();
|
||||
if (!projectNames)
|
||||
return [...fullConfig.projects];
|
||||
const projectsToFind = new Set<string>();
|
||||
@ -246,7 +247,7 @@ export class Runner {
|
||||
const fileToProjectName = new Map<string, string>();
|
||||
const commandLineFileMatcher = commandLineFileFilters.length ? createFileMatcherFromFilters(commandLineFileFilters) : () => true;
|
||||
|
||||
const config = this._loader.fullConfig();
|
||||
const config = this._configLoader.fullConfig();
|
||||
const globalSetupFiles = new Set<string>();
|
||||
if (config._globalScripts) {
|
||||
const allFiles = await collectFiles(config.rootDir, true);
|
||||
@ -295,7 +296,7 @@ export class Runner {
|
||||
}
|
||||
|
||||
private async _collectTestGroups(options: RunOptions): Promise<{ rootSuite: Suite, globalSetupGroups: TestGroup[], projectSetupGroups: TestGroup[], testGroups: TestGroup[] }> {
|
||||
const config = this._loader.fullConfig();
|
||||
const config = this._configLoader.fullConfig();
|
||||
const projects = this._collectProjects(options.projectFilter);
|
||||
const { filesByProject, setupFiles, globalSetupFiles } = await this._collectFiles(projects, options.testFileFilters);
|
||||
|
||||
@ -330,9 +331,10 @@ export class Runner {
|
||||
}
|
||||
|
||||
private async _createFilteredRootSuite(options: RunOptions, filesByProject: Map<FullProjectInternal, string[]>, doNotFilterFiles: Set<string>, shouldCloneTests: boolean, setupFiles: Set<string>, globalSetupFiles: Set<string>): Promise<{rootSuite: Suite, fatalErrors: TestError[]}> {
|
||||
const config = this._loader.fullConfig();
|
||||
const config = this._configLoader.fullConfig();
|
||||
const fatalErrors: TestError[] = [];
|
||||
const allTestFiles = new Set<string>();
|
||||
const testLoader = new TestLoader(config);
|
||||
for (const files of filesByProject.values())
|
||||
files.forEach(file => allTestFiles.add(file));
|
||||
|
||||
@ -344,7 +346,7 @@ export class Runner {
|
||||
type = 'globalSetup';
|
||||
else if (setupFiles.has(file))
|
||||
type = 'projectSetup';
|
||||
const fileSuite = await this._loader.loadTestFile(file, 'runner', type);
|
||||
const fileSuite = await testLoader.loadTestFile(file, 'runner', type);
|
||||
if (fileSuite._loadError)
|
||||
fatalErrors.push(fileSuite._loadError);
|
||||
// We have to clone only if there maybe subsequent calls of this method.
|
||||
@ -397,7 +399,7 @@ export class Runner {
|
||||
if (!fileSuite)
|
||||
continue;
|
||||
for (let repeatEachIndex = 0; repeatEachIndex < project.repeatEach; repeatEachIndex++) {
|
||||
const builtSuite = this._loader.buildFileSuiteForProject(project, fileSuite, repeatEachIndex, titleMatcher);
|
||||
const builtSuite = testLoader.buildFileSuiteForProject(project, fileSuite, repeatEachIndex, titleMatcher);
|
||||
if (builtSuite)
|
||||
projectSuite._addSuite(builtSuite);
|
||||
}
|
||||
@ -407,7 +409,7 @@ export class Runner {
|
||||
}
|
||||
|
||||
private _filterForCurrentShard(rootSuite: Suite, projectSetupGroups: TestGroup[], testGroups: TestGroup[]) {
|
||||
const shard = this._loader.fullConfig().shard;
|
||||
const shard = this._configLoader.fullConfig().shard;
|
||||
if (!shard)
|
||||
return;
|
||||
|
||||
@ -471,7 +473,7 @@ export class Runner {
|
||||
}
|
||||
|
||||
private async _run(options: RunOptions): Promise<FullResult> {
|
||||
const config = this._loader.fullConfig();
|
||||
const config = this._configLoader.fullConfig();
|
||||
// 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, globalSetupGroups, projectSetupGroups, testGroups } = await this._collectTestGroups(options);
|
||||
@ -553,7 +555,7 @@ export class Runner {
|
||||
}
|
||||
|
||||
private async _dispatchToWorkers(stageGroups: TestGroup[]): Promise<'success'|'signal'|'workererror'> {
|
||||
const dispatcher = new Dispatcher(this._loader, [...stageGroups], this._reporter);
|
||||
const dispatcher = new Dispatcher(this._configLoader, [...stageGroups], this._reporter);
|
||||
const sigintWatcher = new SigIntWatcher();
|
||||
await Promise.race([dispatcher.run(), sigintWatcher.promise()]);
|
||||
if (!sigintWatcher.hadSignal()) {
|
||||
@ -587,7 +589,7 @@ export class Runner {
|
||||
}
|
||||
|
||||
private async _removeOutputDirs(options: RunOptions): Promise<boolean> {
|
||||
const config = this._loader.fullConfig();
|
||||
const config = this._configLoader.fullConfig();
|
||||
const outputDirs = new Set<string>();
|
||||
for (const p of config.projects) {
|
||||
if (!options.projectFilter || options.projectFilter.includes(p.name))
|
||||
@ -622,12 +624,12 @@ export class Runner {
|
||||
const tearDown = async () => {
|
||||
await this._runAndReportError(async () => {
|
||||
if (globalSetupResult && typeof globalSetupResult === 'function')
|
||||
await globalSetupResult(this._loader.fullConfig());
|
||||
await globalSetupResult(this._configLoader.fullConfig());
|
||||
}, result);
|
||||
|
||||
await this._runAndReportError(async () => {
|
||||
if (globalSetupResult && config.globalTeardown)
|
||||
await (await this._loader.loadGlobalHook(config.globalTeardown))(this._loader.fullConfig());
|
||||
await (await this._configLoader.loadGlobalHook(config.globalTeardown))(this._configLoader.fullConfig());
|
||||
}, result);
|
||||
|
||||
for (const plugin of pluginsThatWereSetUp.reverse()) {
|
||||
@ -659,9 +661,9 @@ export class Runner {
|
||||
// Then do global setup.
|
||||
if (!sigintWatcher.hadSignal()) {
|
||||
if (config.globalSetup) {
|
||||
const hook = await this._loader.loadGlobalHook(config.globalSetup);
|
||||
const hook = await this._configLoader.loadGlobalHook(config.globalSetup);
|
||||
await Promise.race([
|
||||
Promise.resolve().then(() => hook(this._loader.fullConfig())).then((r: any) => globalSetupResult = r || '<noop>'),
|
||||
Promise.resolve().then(() => hook(this._configLoader.fullConfig())).then((r: any) => globalSetupResult = r || '<noop>'),
|
||||
sigintWatcher.promise(),
|
||||
]);
|
||||
} else {
|
||||
|
@ -19,7 +19,6 @@ import path from 'path';
|
||||
import { monotonicTime } from 'playwright-core/lib/utils';
|
||||
import type { TestInfoError, TestInfo, TestStatus } from '../types/test';
|
||||
import type { StepBeginPayload, StepEndPayload, WorkerInitParams } from './ipc';
|
||||
import type { Loader } from './loader';
|
||||
import type { TestCase } from './test';
|
||||
import { TimeoutManager } from './timeoutManager';
|
||||
import type { Annotation, FullConfigInternal, FullProjectInternal, TestStepInternal } from './types';
|
||||
@ -82,7 +81,7 @@ export class TestInfoImpl implements TestInfo {
|
||||
}
|
||||
|
||||
constructor(
|
||||
loader: Loader,
|
||||
config: FullConfigInternal,
|
||||
project: FullProjectInternal,
|
||||
workerParams: WorkerInitParams,
|
||||
test: TestCase,
|
||||
@ -101,7 +100,7 @@ export class TestInfoImpl implements TestInfo {
|
||||
this.workerIndex = workerParams.workerIndex;
|
||||
this.parallelIndex = workerParams.parallelIndex;
|
||||
this.project = project;
|
||||
this.config = loader.fullConfig();
|
||||
this.config = config;
|
||||
this.title = test.title;
|
||||
this.titlePath = test.titlePath();
|
||||
this.file = test.location.file;
|
||||
|
209
packages/playwright-test/src/testLoader.ts
Normal file
209
packages/playwright-test/src/testLoader.ts
Normal file
@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright Microsoft Corporation. All rights reserved.
|
||||
*
|
||||
* 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 path from 'path';
|
||||
import { calculateSha1 } from 'playwright-core/lib/utils';
|
||||
import { FixturePool, isFixtureOption } from './fixtures';
|
||||
import { setCurrentlyLoadingFileSuite } from './globals';
|
||||
import type { WorkerIsolation } from './ipc';
|
||||
import { Suite, type TestCase } from './test';
|
||||
import type { TestTypeImpl } from './testType';
|
||||
import { requireOrImport } from './transform';
|
||||
import type { Fixtures, FixturesWithLocation, FullConfigInternal, FullProjectInternal } from './types';
|
||||
import { serializeError } from './util';
|
||||
|
||||
export const defaultTimeout = 30000;
|
||||
|
||||
// To allow multiple loaders in the same process without clearing require cache,
|
||||
// we make these maps global.
|
||||
const cachedFileSuites = new Map<string, Suite>();
|
||||
|
||||
export class TestLoader {
|
||||
private _projectSuiteBuilders = new Map<FullProjectInternal, ProjectSuiteBuilder>();
|
||||
private _fullConfig: FullConfigInternal;
|
||||
|
||||
constructor(fullConfig: FullConfigInternal) {
|
||||
this._fullConfig = fullConfig;
|
||||
}
|
||||
|
||||
async loadTestFile(file: string, environment: 'runner' | 'worker', phase: 'test' | 'projectSetup' | 'globalSetup') {
|
||||
if (cachedFileSuites.has(file))
|
||||
return cachedFileSuites.get(file)!;
|
||||
const suite = new Suite(path.relative(this._fullConfig.rootDir, file) || path.basename(file), 'file');
|
||||
suite._requireFile = file;
|
||||
suite._phase = phase;
|
||||
suite.location = { file, line: 0, column: 0 };
|
||||
|
||||
setCurrentlyLoadingFileSuite(suite);
|
||||
try {
|
||||
await requireOrImport(file);
|
||||
cachedFileSuites.set(file, suite);
|
||||
} catch (e) {
|
||||
if (environment === 'worker')
|
||||
throw e;
|
||||
suite._loadError = serializeError(e);
|
||||
} finally {
|
||||
setCurrentlyLoadingFileSuite(undefined);
|
||||
}
|
||||
|
||||
{
|
||||
// Test locations that we discover potentially have different file name.
|
||||
// This could be due to either
|
||||
// a) use of source maps or due to
|
||||
// b) require of one file from another.
|
||||
// Try fixing (a) w/o regressing (b).
|
||||
|
||||
const files = new Set<string>();
|
||||
suite.allTests().map(t => files.add(t.location.file));
|
||||
if (files.size === 1) {
|
||||
// All tests point to one file.
|
||||
const mappedFile = files.values().next().value;
|
||||
if (suite.location.file !== mappedFile) {
|
||||
// The file is different, check for a likely source map case.
|
||||
if (path.extname(mappedFile) !== path.extname(suite.location.file))
|
||||
suite.location.file = mappedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suite;
|
||||
}
|
||||
|
||||
buildFileSuiteForProject(project: FullProjectInternal, suite: Suite, repeatEachIndex: number, filter: (test: TestCase) => boolean): Suite | undefined {
|
||||
if (!this._projectSuiteBuilders.has(project))
|
||||
this._projectSuiteBuilders.set(project, new ProjectSuiteBuilder(project));
|
||||
const builder = this._projectSuiteBuilders.get(project)!;
|
||||
return builder.cloneFileSuite(suite, 'isolate-pools', repeatEachIndex, filter);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectSuiteBuilder {
|
||||
private _project: FullProjectInternal;
|
||||
private _testTypePools = new Map<TestTypeImpl, FixturePool>();
|
||||
private _testPools = new Map<TestCase, FixturePool>();
|
||||
|
||||
constructor(project: FullProjectInternal) {
|
||||
this._project = project;
|
||||
}
|
||||
|
||||
private _buildTestTypePool(testType: TestTypeImpl): FixturePool {
|
||||
if (!this._testTypePools.has(testType)) {
|
||||
const fixtures = this._applyConfigUseOptions(testType, this._project.use || {});
|
||||
const pool = new FixturePool(fixtures);
|
||||
this._testTypePools.set(testType, pool);
|
||||
}
|
||||
return this._testTypePools.get(testType)!;
|
||||
}
|
||||
|
||||
// TODO: we can optimize this function by building the pool inline in cloneSuite
|
||||
private _buildPool(test: TestCase): FixturePool {
|
||||
if (!this._testPools.has(test)) {
|
||||
let pool = this._buildTestTypePool(test._testType);
|
||||
|
||||
const parents: Suite[] = [];
|
||||
for (let parent: Suite | undefined = test.parent; parent; parent = parent.parent)
|
||||
parents.push(parent);
|
||||
parents.reverse();
|
||||
|
||||
for (const parent of parents) {
|
||||
if (parent._use.length)
|
||||
pool = new FixturePool(parent._use, pool, parent._type === 'describe');
|
||||
for (const hook of parent._hooks)
|
||||
pool.validateFunction(hook.fn, hook.type + ' hook', hook.location);
|
||||
for (const modifier of parent._modifiers)
|
||||
pool.validateFunction(modifier.fn, modifier.type + ' modifier', modifier.location);
|
||||
}
|
||||
|
||||
pool.validateFunction(test.fn, 'Test', test.location);
|
||||
this._testPools.set(test, pool);
|
||||
}
|
||||
return this._testPools.get(test)!;
|
||||
}
|
||||
|
||||
private _cloneEntries(from: Suite, to: Suite, workerIsolation: WorkerIsolation, repeatEachIndex: number, filter: (test: TestCase) => boolean): boolean {
|
||||
for (const entry of from._entries) {
|
||||
if (entry instanceof Suite) {
|
||||
const suite = entry._clone();
|
||||
suite._fileId = to._fileId;
|
||||
to._addSuite(suite);
|
||||
// Ignore empty titles, similar to Suite.titlePath().
|
||||
if (!this._cloneEntries(entry, suite, workerIsolation, repeatEachIndex, filter)) {
|
||||
to._entries.pop();
|
||||
to.suites.pop();
|
||||
}
|
||||
} else {
|
||||
const test = entry._clone();
|
||||
to._addTest(test);
|
||||
test.retries = this._project.retries;
|
||||
for (let parentSuite: Suite | undefined = to; parentSuite; parentSuite = parentSuite.parent) {
|
||||
if (parentSuite._retries !== undefined) {
|
||||
test.retries = parentSuite._retries;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const repeatEachIndexSuffix = repeatEachIndex ? ` (repeat:${repeatEachIndex})` : '';
|
||||
// At the point of the query, suite is not yet attached to the project, so we only get file, describe and test titles.
|
||||
const testIdExpression = `[project=${this._project._id}]${test.titlePath().join('\x1e')}${repeatEachIndexSuffix}`;
|
||||
const testId = to._fileId + '-' + calculateSha1(testIdExpression).slice(0, 20);
|
||||
test.id = testId;
|
||||
test.repeatEachIndex = repeatEachIndex;
|
||||
test._projectId = this._project._id;
|
||||
if (!filter(test)) {
|
||||
to._entries.pop();
|
||||
to.tests.pop();
|
||||
} else {
|
||||
const pool = this._buildPool(entry);
|
||||
if (this._project._fullConfig._workerIsolation === 'isolate-pools')
|
||||
test._workerHash = `run${this._project._id}-${pool.digest}-repeat${repeatEachIndex}`;
|
||||
else
|
||||
test._workerHash = `run${this._project._id}-repeat${repeatEachIndex}`;
|
||||
test._pool = pool;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!to._entries.length)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
cloneFileSuite(suite: Suite, workerIsolation: WorkerIsolation, repeatEachIndex: number, filter: (test: TestCase) => boolean): Suite | undefined {
|
||||
const result = suite._clone();
|
||||
const relativeFile = path.relative(this._project.testDir, suite.location!.file).split(path.sep).join('/');
|
||||
result._fileId = calculateSha1(relativeFile).slice(0, 20);
|
||||
return this._cloneEntries(suite, result, workerIsolation, repeatEachIndex, filter) ? result : undefined;
|
||||
}
|
||||
|
||||
private _applyConfigUseOptions(testType: TestTypeImpl, configUse: Fixtures): FixturesWithLocation[] {
|
||||
const configKeys = new Set(Object.keys(configUse));
|
||||
if (!configKeys.size)
|
||||
return testType.fixtures;
|
||||
const result: FixturesWithLocation[] = [];
|
||||
for (const f of testType.fixtures) {
|
||||
result.push(f);
|
||||
const optionsFromConfig: Fixtures = {};
|
||||
for (const [key, value] of Object.entries(f.fixtures)) {
|
||||
if (isFixtureOption(value) && configKeys.has(key))
|
||||
(optionsFromConfig as any)[key] = [(configUse as any)[key], value[1]];
|
||||
}
|
||||
if (Object.entries(optionsFromConfig).length) {
|
||||
// Add config options immediately after original option definition,
|
||||
// so that any test.use() override it.
|
||||
result.push({ fixtures: optionsFromConfig, location: { file: `project#${this._project._id}`, line: 1, column: 1 }, fromConfig: true });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
@ -25,6 +25,7 @@ import type { TsConfigLoaderResult } from './third_party/tsconfig-loader';
|
||||
import { tsConfigLoader } from './third_party/tsconfig-loader';
|
||||
import Module from 'module';
|
||||
import type { BabelTransformFunction } from './babelBundle';
|
||||
import { fileIsModule } from './util';
|
||||
|
||||
const version = 13;
|
||||
const cacheDir = process.env.PWTEST_CACHE_DIR || path.join(os.tmpdir(), 'playwright-transform-cache');
|
||||
@ -215,6 +216,19 @@ export function transformHook(code: string, filename: string, moduleUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireOrImport(file: string) {
|
||||
const revertBabelRequire = installTransform();
|
||||
const isModule = fileIsModule(file);
|
||||
try {
|
||||
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
|
||||
if (isModule)
|
||||
return await esmImport();
|
||||
return require(file);
|
||||
} finally {
|
||||
revertBabelRequire();
|
||||
}
|
||||
}
|
||||
|
||||
export function installTransform(): () => void {
|
||||
let reverted = false;
|
||||
|
||||
|
@ -296,4 +296,20 @@ export async function normalizeAndSaveAttachment(outputPath: string, name: strin
|
||||
const contentType = options.contentType ?? (typeof options.body === 'string' ? 'text/plain' : 'application/octet-stream');
|
||||
return { name, contentType, body: typeof options.body === 'string' ? Buffer.from(options.body) : options.body };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function fileIsModule(file: string): boolean {
|
||||
if (file.endsWith('.mjs'))
|
||||
return true;
|
||||
|
||||
const folder = path.dirname(file);
|
||||
return folderIsModule(folder);
|
||||
}
|
||||
|
||||
export function folderIsModule(folder: string): boolean {
|
||||
const packageJsonPath = getPackageJsonPath(folder);
|
||||
if (!packageJsonPath)
|
||||
return false;
|
||||
// Rely on `require` internal caching logic.
|
||||
return require(packageJsonPath).type === 'module';
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ import util from 'util';
|
||||
import { debugTest, formatLocation, relativeFilePath, serializeError } from './util';
|
||||
import type { TestBeginPayload, TestEndPayload, RunPayload, DonePayload, WorkerInitParams, TeardownErrorsPayload, WatchTestResolvedPayload } from './ipc';
|
||||
import { setCurrentTestInfo } from './globals';
|
||||
import { Loader } from './loader';
|
||||
import { ConfigLoader } from './configLoader';
|
||||
import type { Suite, TestCase } from './test';
|
||||
import type { Annotation, FullProjectInternal, TestInfoError } from './types';
|
||||
import { FixtureRunner } from './fixtures';
|
||||
@ -28,12 +28,14 @@ import { TestInfoImpl } from './testInfo';
|
||||
import type { TimeSlot } from './timeoutManager';
|
||||
import { TimeoutManager } from './timeoutManager';
|
||||
import { ProcessRunner } from './process';
|
||||
import { TestLoader } from './testLoader';
|
||||
|
||||
const removeFolderAsync = util.promisify(rimraf);
|
||||
|
||||
export class WorkerRunner extends ProcessRunner {
|
||||
private _params: WorkerInitParams;
|
||||
private _loader!: Loader;
|
||||
private _configLoader!: ConfigLoader;
|
||||
private _testLoader!: TestLoader;
|
||||
private _project!: FullProjectInternal;
|
||||
private _fixtureRunner: FixtureRunner;
|
||||
|
||||
@ -164,15 +166,16 @@ export class WorkerRunner extends ProcessRunner {
|
||||
}
|
||||
|
||||
private async _loadIfNeeded() {
|
||||
if (this._loader)
|
||||
if (this._configLoader)
|
||||
return;
|
||||
|
||||
this._loader = await Loader.deserialize(this._params.loader);
|
||||
const globalProject = this._loader.fullConfig()._globalProject;
|
||||
this._configLoader = await ConfigLoader.deserialize(this._params.loader);
|
||||
this._testLoader = new TestLoader(this._configLoader.fullConfig());
|
||||
const globalProject = this._configLoader.fullConfig()._globalProject;
|
||||
if (this._params.projectId === globalProject._id)
|
||||
this._project = globalProject;
|
||||
else
|
||||
this._project = this._loader.fullConfig().projects.find(p => p._id === this._params.projectId)!;
|
||||
this._project = this._configLoader.fullConfig().projects.find(p => p._id === this._params.projectId)!;
|
||||
}
|
||||
|
||||
async runTestGroup(runPayload: RunPayload) {
|
||||
@ -181,8 +184,8 @@ export class WorkerRunner extends ProcessRunner {
|
||||
let fatalUnknownTestIds;
|
||||
try {
|
||||
await this._loadIfNeeded();
|
||||
const fileSuite = await this._loader.loadTestFile(runPayload.file, 'worker', runPayload.phase);
|
||||
const suite = this._loader.buildFileSuiteForProject(this._project, fileSuite, this._params.repeatEachIndex, test => {
|
||||
const fileSuite = await this._testLoader.loadTestFile(runPayload.file, 'worker', runPayload.phase);
|
||||
const suite = this._testLoader.buildFileSuiteForProject(this._project, fileSuite, this._params.repeatEachIndex, test => {
|
||||
if (runPayload.watchMode) {
|
||||
const testResolvedPayload: WatchTestResolvedPayload = {
|
||||
testId: test.id,
|
||||
@ -238,7 +241,7 @@ export class WorkerRunner extends ProcessRunner {
|
||||
}
|
||||
|
||||
private async _runTest(test: TestCase, retry: number, nextTest: TestCase | undefined) {
|
||||
const testInfo = new TestInfoImpl(this._loader, this._project, this._params, test, retry,
|
||||
const testInfo = new TestInfoImpl(this._configLoader.fullConfig(), this._project, this._params, test, retry,
|
||||
stepBeginPayload => this.dispatchEvent('stepBegin', stepBeginPayload),
|
||||
stepEndPayload => this.dispatchEvent('stepEnd', stepEndPayload));
|
||||
|
||||
@ -484,8 +487,8 @@ export class WorkerRunner extends ProcessRunner {
|
||||
setCurrentTestInfo(null);
|
||||
this.dispatchEvent('testEnd', buildTestEndPayload(testInfo));
|
||||
|
||||
const preserveOutput = this._loader.fullConfig().preserveOutput === 'always' ||
|
||||
(this._loader.fullConfig().preserveOutput === 'failures-only' && testInfo._isFailure());
|
||||
const preserveOutput = this._configLoader.fullConfig().preserveOutput === 'always' ||
|
||||
(this._configLoader.fullConfig().preserveOutput === 'failures-only' && testInfo._isFailure());
|
||||
if (!preserveOutput)
|
||||
await removeFolderAsync(testInfo.outputDir).catch(e => {});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user