diff --git a/packages/playwright-test/src/configLoader.ts b/packages/playwright-test/src/configLoader.ts index b7cb512a1aaf8..496f4d1718e41 100644 --- a/packages/playwright-test/src/configLoader.ts +++ b/packages/playwright-test/src/configLoader.ts @@ -19,7 +19,7 @@ import * as os from 'os'; import * as path from 'path'; import { isRegExp } from 'playwright-core/lib/utils'; import type { Reporter } from '../types/testReporter'; -import type { SerializedLoaderData } from './ipc'; +import type { SerializedConfig } from './ipc'; import type { BuiltInReporter, ConfigCLIOverrides } from './runner'; import { builtInReporters } from './runner'; import { requireOrImport } from './transform'; @@ -39,7 +39,7 @@ export class ConfigLoader { this._fullConfig = { ...baseFullConfig }; } - static async deserialize(data: SerializedLoaderData): Promise { + static async deserialize(data: SerializedConfig): Promise { const loader = new ConfigLoader(data.configCLIOverrides); if (data.configFile) await loader.loadConfigFile(data.configFile); @@ -182,8 +182,8 @@ export class ConfigLoader { return this._fullConfig; } - serialize(): SerializedLoaderData { - const result: SerializedLoaderData = { + serializedConfig(): SerializedConfig { + const result: SerializedConfig = { configFile: this._configFile, configDir: this._configDir, configCLIOverrides: this._configCLIOverrides, diff --git a/packages/playwright-test/src/dispatcher.ts b/packages/playwright-test/src/dispatcher.ts index ac04eb8d650f0..2730d04bee3a1 100644 --- a/packages/playwright-test/src/dispatcher.ts +++ b/packages/playwright-test/src/dispatcher.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, StepBeginPayload, StepEndPayload, TeardownErrorsPayload, RunPayload, SerializedLoaderData } from './ipc'; +import type { TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, StepBeginPayload, StepEndPayload, TeardownErrorsPayload, RunPayload, SerializedConfig } from './ipc'; import type { TestResult, Reporter, TestStep, TestError } from '../types/testReporter'; import type { Suite } from './test'; import type { ConfigLoader } from './configLoader'; @@ -105,7 +105,7 @@ export class Dispatcher { // 2. Start the worker if it is down. if (!worker) { - worker = this._createWorker(job, index, this._configLoader.serialize()); + worker = this._createWorker(job, index, this._configLoader.serializedConfig()); this._workerSlots[index].worker = worker; worker.on('exit', () => this._workerSlots[index].worker = undefined); await worker.start(); @@ -426,7 +426,7 @@ export class Dispatcher { return result; } - _createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: SerializedLoaderData) { + _createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: SerializedConfig) { const worker = new WorkerHost(testGroup, parallelIndex, loaderData); const handleOutput = (params: TestOutputPayload) => { const chunk = chunkFromParams(params); diff --git a/packages/playwright-test/src/ipc.ts b/packages/playwright-test/src/ipc.ts index cf09c10d5d6c3..b142f9ca38f17 100644 --- a/packages/playwright-test/src/ipc.ts +++ b/packages/playwright-test/src/ipc.ts @@ -17,7 +17,7 @@ import type { ConfigCLIOverrides } from './runner'; import type { TestInfoError, TestStatus } from './types'; -export type SerializedLoaderData = { +export type SerializedConfig = { configFile: string | undefined; configDir: string; configCLIOverrides: ConfigCLIOverrides; @@ -40,7 +40,7 @@ export type WorkerInitParams = { parallelIndex: number; repeatEachIndex: number; projectId: string; - loader: SerializedLoaderData; + config: SerializedConfig; }; export type TestBeginPayload = { diff --git a/packages/playwright-test/src/poolBuilder.ts b/packages/playwright-test/src/poolBuilder.ts new file mode 100644 index 0000000000000..f3ab69c11df4d --- /dev/null +++ b/packages/playwright-test/src/poolBuilder.ts @@ -0,0 +1,88 @@ +/** + * 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 { FixturePool, isFixtureOption } from './fixtures'; +import type { Suite, TestCase } from './test'; +import type { TestTypeImpl } from './testType'; +import type { Fixtures, FixturesWithLocation, FullProjectInternal } from './types'; + +export class PoolBuilder { + private _project: FullProjectInternal; + private _testTypePools = new Map(); + + constructor(project: FullProjectInternal) { + this._project = project; + } + + buildPools(suite: Suite, repeatEachIndex: number) { + suite.forEachTest(test => { + const pool = this._buildPoolForTest(test); + test._workerHash = `run${this._project._id}-${pool.digest}-repeat${repeatEachIndex}`; + test._pool = pool; + }); + } + + private _buildPoolForTest(test: TestCase): FixturePool { + 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); + return pool; + } + + 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)!; + } + + 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/runner.ts b/packages/playwright-test/src/runner.ts index c5e061225838a..dbf17d28da8c2 100644 --- a/packages/playwright-test/src/runner.ts +++ b/packages/playwright-test/src/runner.ts @@ -46,6 +46,8 @@ import { createFileMatcher, createFileMatcherFromFilters, createTitleMatcher, se import type { Matcher, TestFileFilter } from './util'; import { setFatalErrorSink } from './globals'; import { TestLoader } from './testLoader'; +import { buildFileSuiteForProject, filterTests } from './suiteUtils'; +import { PoolBuilder } from './poolBuilder'; const removeFolderAsync = promisify(rimraf); const readDirAsync = promisify(fs.readdir); @@ -319,6 +321,7 @@ export class Runner { const rootSuite = new Suite('', 'root'); for (const [project, files] of filesByProject) { + const poolBuilder = new PoolBuilder(project); const grepMatcher = createTitleMatcher(project.grep); const grepInvertMatcher = project.grepInvert ? createTitleMatcher(project.grepInvert) : null; @@ -339,9 +342,11 @@ export class Runner { if (!fileSuite) continue; for (let repeatEachIndex = 0; repeatEachIndex < project.repeatEach; repeatEachIndex++) { - const builtSuite = testLoader.buildFileSuiteForProject(project, fileSuite, repeatEachIndex, titleMatcher); - if (builtSuite) - projectSuite._addSuite(builtSuite); + const builtSuite = buildFileSuiteForProject(project, fileSuite, repeatEachIndex); + if (!filterTests(builtSuite, titleMatcher)) + continue; + projectSuite._addSuite(builtSuite); + poolBuilder.buildPools(builtSuite, repeatEachIndex); } } } diff --git a/packages/playwright-test/src/suiteUtils.ts b/packages/playwright-test/src/suiteUtils.ts new file mode 100644 index 0000000000000..9ac2ec62a732c --- /dev/null +++ b/packages/playwright-test/src/suiteUtils.ts @@ -0,0 +1,58 @@ +/** +* 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 path from 'path'; +import { calculateSha1 } from 'playwright-core/lib/utils'; +import type { Suite, TestCase } from './test'; +import type { FullProjectInternal } from './types'; + +export function filterTests(suite: Suite, filter: (test: TestCase) => boolean): boolean { + suite.suites = suite.suites.filter(child => filterTests(child, filter)); + suite.tests = suite.tests.filter(filter); + const entries = new Set([...suite.suites, ...suite.tests]); + suite._entries = suite._entries.filter(e => entries.has(e)); // Preserve the order. + return !!suite._entries.length; +} + +export function buildFileSuiteForProject(project: FullProjectInternal, suite: Suite, repeatEachIndex: number): Suite { + const relativeFile = path.relative(project.testDir, suite.location!.file).split(path.sep).join('/'); + const fileId = calculateSha1(relativeFile).slice(0, 20); + + // Clone suite. + const result = suite._deepClone(); + result._fileId = fileId; + + // Assign test properties with project-specific values. + result.forEachTest((test, suite) => { + suite._fileId = fileId; + 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=${project._id}]${test.titlePath().join('\x1e')}${repeatEachIndexSuffix}`; + const testId = fileId + '-' + calculateSha1(testIdExpression).slice(0, 20); + test.id = testId; + test.repeatEachIndex = repeatEachIndex; + test._projectId = project._id; + test.retries = project.retries; + for (let parentSuite: Suite | undefined = suite; parentSuite; parentSuite = parentSuite.parent) { + if (parentSuite._retries !== undefined) { + test.retries = parentSuite._retries; + break; + } + } + }); + + return result; +} diff --git a/packages/playwright-test/src/test.ts b/packages/playwright-test/src/test.ts index 731c6569399eb..442272c9edaa5 100644 --- a/packages/playwright-test/src/test.ts +++ b/packages/playwright-test/src/test.ts @@ -115,6 +115,15 @@ export class Suite extends Base implements reporterTypes.Suite { return suite; } + forEachTest(visitor: (test: TestCase, suite: Suite) => void) { + for (const entry of this._entries) { + if (entry instanceof Suite) + entry.forEachTest(visitor); + else + visitor(entry, this); + } + } + _clone(): Suite { const suite = new Suite(this.title, this._type); suite._only = this._only; diff --git a/packages/playwright-test/src/testLoader.ts b/packages/playwright-test/src/testLoader.ts index 139a699c1e37a..a8ac9683f57a1 100644 --- a/packages/playwright-test/src/testLoader.ts +++ b/packages/playwright-test/src/testLoader.ts @@ -15,13 +15,10 @@ */ import * as path from 'path'; -import { calculateSha1 } from 'playwright-core/lib/utils'; -import { FixturePool, isFixtureOption } from './fixtures'; import { setCurrentlyLoadingFileSuite } from './globals'; -import { Suite, type TestCase } from './test'; -import type { TestTypeImpl } from './testType'; +import { Suite } from './test'; import { requireOrImport } from './transform'; -import type { Fixtures, FixturesWithLocation, FullConfigInternal, FullProjectInternal } from './types'; +import type { FullConfigInternal } from './types'; import { serializeError } from './util'; export const defaultTimeout = 30000; @@ -31,14 +28,13 @@ export const defaultTimeout = 30000; 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') { + async loadTestFile(file: string, environment: 'runner' | 'worker'): Promise { if (cachedFileSuites.has(file)) return cachedFileSuites.get(file)!; const suite = new Suite(path.relative(this._fullConfig.rootDir, file) || path.basename(file), 'file'); @@ -79,126 +75,4 @@ export class TestLoader { 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, 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, 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, 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); - test._workerHash = `run${this._project._id}-${pool.digest}-repeat${repeatEachIndex}`; - test._pool = pool; - } - } - } - if (!to._entries.length) - return false; - return true; - } - - cloneFileSuite(suite: Suite, 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, 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/workerHost.ts b/packages/playwright-test/src/workerHost.ts index ccc8756726411..24ba0cbd022ac 100644 --- a/packages/playwright-test/src/workerHost.ts +++ b/packages/playwright-test/src/workerHost.ts @@ -15,7 +15,7 @@ */ import type { TestGroup } from './dispatcher'; -import type { RunPayload, SerializedLoaderData, WorkerInitParams } from './ipc'; +import type { RunPayload, SerializedConfig, WorkerInitParams } from './ipc'; import { ProcessHost } from './processHost'; let lastWorkerIndex = 0; @@ -27,7 +27,7 @@ export class WorkerHost extends ProcessHost { currentTestId: string | null = null; private _params: WorkerInitParams; - constructor(testGroup: TestGroup, parallelIndex: number, loader: SerializedLoaderData) { + constructor(testGroup: TestGroup, parallelIndex: number, config: SerializedConfig) { const workerIndex = lastWorkerIndex++; super(require.resolve('./workerRunner.js'), `worker-${workerIndex}`); this.workerIndex = workerIndex; @@ -39,7 +39,7 @@ export class WorkerHost extends ProcessHost { parallelIndex, repeatEachIndex: testGroup.repeatEachIndex, projectId: testGroup.projectId, - loader, + config, }; } diff --git a/packages/playwright-test/src/workerRunner.ts b/packages/playwright-test/src/workerRunner.ts index ec6ef140f33df..2e29b2eea8a1a 100644 --- a/packages/playwright-test/src/workerRunner.ts +++ b/packages/playwright-test/src/workerRunner.ts @@ -29,6 +29,8 @@ import type { TimeSlot } from './timeoutManager'; import { TimeoutManager } from './timeoutManager'; import { ProcessRunner } from './process'; import { TestLoader } from './testLoader'; +import { buildFileSuiteForProject, filterTests } from './suiteUtils'; +import { PoolBuilder } from './poolBuilder'; const removeFolderAsync = util.promisify(rimraf); @@ -37,6 +39,7 @@ export class WorkerRunner extends ProcessRunner { private _configLoader!: ConfigLoader; private _testLoader!: TestLoader; private _project!: FullProjectInternal; + private _poolBuilder!: PoolBuilder; private _fixtureRunner: FixtureRunner; // Accumulated fatal errors that cannot be attributed to a test. @@ -169,9 +172,10 @@ export class WorkerRunner extends ProcessRunner { if (this._configLoader) return; - this._configLoader = await ConfigLoader.deserialize(this._params.loader); + this._configLoader = await ConfigLoader.deserialize(this._params.config); this._testLoader = new TestLoader(this._configLoader.fullConfig()); this._project = this._configLoader.fullConfig().projects.find(p => p._id === this._params.projectId)!; + this._poolBuilder = new PoolBuilder(this._project); } async runTestGroup(runPayload: RunPayload) { @@ -181,12 +185,10 @@ export class WorkerRunner extends ProcessRunner { try { await this._loadIfNeeded(); const fileSuite = await this._testLoader.loadTestFile(runPayload.file, 'worker'); - const suite = this._testLoader.buildFileSuiteForProject(this._project, fileSuite, this._params.repeatEachIndex, test => { - if (!entries.has(test.id)) - return false; - return true; - }); - if (suite) { + const suite = buildFileSuiteForProject(this._project, fileSuite, this._params.repeatEachIndex); + const hasEntries = filterTests(suite, test => entries.has(test.id)); + if (hasEntries) { + this._poolBuilder.buildPools(suite, this._params.repeatEachIndex); this._extraSuiteAnnotations = new Map(); this._activeSuites = new Set(); this._didRunFullCleanup = false;