chore: split config and test loaders (#20175)

This commit is contained in:
Pavel Feldman 2023-01-17 17:16:36 -08:00 committed by GitHub
parent 020dcd89fa
commit d9d4070520
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 302 additions and 265 deletions

View File

@ -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) {

View File

@ -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';
}

View File

@ -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 => {});
}

View File

@ -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 {

View File

@ -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;

View 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;
}
}

View File

@ -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;

View File

@ -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';
}

View File

@ -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 => {});
}