diff --git a/packages/playwright-test/src/cli.ts b/packages/playwright-test/src/cli.ts index 6e4f52dbaf..17a9d7bc4c 100644 --- a/packages/playwright-test/src/cli.ts +++ b/packages/playwright-test/src/cli.ts @@ -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) { diff --git a/packages/playwright-test/src/loader.ts b/packages/playwright-test/src/configLoader.ts similarity index 71% rename from packages/playwright-test/src/loader.ts rename to packages/playwright-test/src/configLoader.ts index dfd37d7ddd..6fdff3319d 100644 --- a/packages/playwright-test/src/loader.ts +++ b/packages/playwright-test/src/configLoader.ts @@ -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(); - -export class Loader { +export class ConfigLoader { private _configCLIOverrides: ConfigCLIOverrides; private _fullConfig: FullConfigInternal; private _configDir: string = ''; private _configFile: string | undefined; - private _projectSuiteBuilders = new Map(); constructor(configCLIOverrides?: ConfigCLIOverrides) { this._configCLIOverrides = configCLIOverrides || {}; this._fullConfig = { ...baseFullConfig }; } - static async deserialize(data: SerializedLoaderData): Promise { - const loader = new Loader(data.configCLIOverrides); + static async deserialize(data: SerializedLoaderData): Promise { + 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(); - 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(); - private _testPools = new Map(); - - 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(...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'; -} diff --git a/packages/playwright-test/src/dispatcher.ts b/packages/playwright-test/src/dispatcher.ts index f9296ec437..d8fffed935 100644 --- a/packages/playwright-test/src/dispatcher.ts +++ b/packages/playwright-test/src/dispatcher.ts @@ -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(); - 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 => {}); } diff --git a/packages/playwright-test/src/runner.ts b/packages/playwright-test/src/runner.ts index 3d6597cb1e..ba934322a0 100644 --- a/packages/playwright-test/src/runner.ts +++ b/packages/playwright-test/src/runner.ts @@ -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 { - return await this._loader.loadConfigFile(resolvedConfigFile); + return await this._configLoader.loadConfigFile(resolvedConfigFile); } loadEmptyConfig(configFileOrDirectory: string): Promise { - 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 { 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(); @@ -246,7 +247,7 @@ export class Runner { const fileToProjectName = new Map(); const commandLineFileMatcher = commandLineFileFilters.length ? createFileMatcherFromFilters(commandLineFileFilters) : () => true; - const config = this._loader.fullConfig(); + const config = this._configLoader.fullConfig(); const globalSetupFiles = new Set(); 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, doNotFilterFiles: Set, shouldCloneTests: boolean, setupFiles: Set, globalSetupFiles: Set): Promise<{rootSuite: Suite, fatalErrors: TestError[]}> { - const config = this._loader.fullConfig(); + const config = this._configLoader.fullConfig(); const fatalErrors: TestError[] = []; const allTestFiles = new Set(); + 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 { - 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 { - const config = this._loader.fullConfig(); + const config = this._configLoader.fullConfig(); const outputDirs = new Set(); 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 || ''), + Promise.resolve().then(() => hook(this._configLoader.fullConfig())).then((r: any) => globalSetupResult = r || ''), sigintWatcher.promise(), ]); } else { diff --git a/packages/playwright-test/src/testInfo.ts b/packages/playwright-test/src/testInfo.ts index f892d119b1..0d7ff691a7 100644 --- a/packages/playwright-test/src/testInfo.ts +++ b/packages/playwright-test/src/testInfo.ts @@ -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; diff --git a/packages/playwright-test/src/testLoader.ts b/packages/playwright-test/src/testLoader.ts new file mode 100644 index 0000000000..8da1314ecc --- /dev/null +++ b/packages/playwright-test/src/testLoader.ts @@ -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(); + +export class TestLoader { + private _projectSuiteBuilders = new Map(); + 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(); + 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(); + private _testPools = new Map(); + + 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; + } +} diff --git a/packages/playwright-test/src/transform.ts b/packages/playwright-test/src/transform.ts index 6c1505653c..cd2d782445 100644 --- a/packages/playwright-test/src/transform.ts +++ b/packages/playwright-test/src/transform.ts @@ -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; diff --git a/packages/playwright-test/src/util.ts b/packages/playwright-test/src/util.ts index 9a59bcc28b..720e58f2fd 100644 --- a/packages/playwright-test/src/util.ts +++ b/packages/playwright-test/src/util.ts @@ -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 }; } -} \ No newline at end of file +} + +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'; +} diff --git a/packages/playwright-test/src/workerRunner.ts b/packages/playwright-test/src/workerRunner.ts index 8ae4fba87f..7ab89a5aa2 100644 --- a/packages/playwright-test/src/workerRunner.ts +++ b/packages/playwright-test/src/workerRunner.ts @@ -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 => {}); }