diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index d321cf6621a90..24ab745cd5056 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -265,6 +265,24 @@ export default defineConfig({ }); ``` +## property: TestConfig.importConditions +* since: v1.57 +- type: ?<[Array]<[string]>> + +Additional Node.js import conditions to use when resolving package exports. This is useful for packages that use [conditional exports](https://nodejs.org/api/packages.html#conditional-exports) to expose different entry points based on conditions. + +These conditions are added to Node.js's default conditions (`node`, `import`, `require`, etc.) and apply only when running tests, not when loading the configuration file. + +**Usage** + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + importConditions: ['custom', 'development'], +}); +``` + ## property: TestConfig.maxFailures * since: v1.10 - type: ?<[int]> diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 463ac131337e9..26a6539b2cc3c 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -47,6 +47,7 @@ export class FullConfigInternal { readonly plugins: TestRunnerPluginRegistration[]; readonly projects: FullProjectInternal[] = []; readonly singleTSConfigPath?: string; + readonly importConditions?: string[]; readonly captureGitInfo: Config['captureGitInfo']; readonly failOnFlakyTests: boolean; cliArgs: string[] = []; @@ -79,6 +80,7 @@ export class FullConfigInternal { const privateConfiguration = (userConfig as any)['@playwright/test']; this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p })); this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig); + this.importConditions = userConfig.importConditions; this.captureGitInfo = userConfig.captureGitInfo; this.failOnFlakyTests = takeFirst(configCLIOverrides.failOnFlakyTests, userConfig.failOnFlakyTests, false); diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 4d35f4087a4e2..f73315c87040e 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -261,6 +261,15 @@ function validateConfig(file: string, config: Config) { if (!fs.existsSync(path.resolve(file, '..', config.tsconfig))) throw errorWithFile(file, `config.tsconfig does not exist`); } + + if ('importConditions' in config && config.importConditions !== undefined) { + if (!Array.isArray(config.importConditions)) + throw errorWithFile(file, `config.importConditions must be an array`); + config.importConditions.forEach((condition, index) => { + if (typeof condition !== 'string') + throw errorWithFile(file, `config.importConditions[${index}] must be a string`); + }); + } } function validateProject(file: string, project: Project, title: string) { diff --git a/packages/playwright/src/runner/dispatcher.ts b/packages/playwright/src/runner/dispatcher.ts index d9db512cd98a9..79fe200587c75 100644 --- a/packages/playwright/src/runner/dispatcher.ts +++ b/packages/playwright/src/runner/dispatcher.ts @@ -218,10 +218,12 @@ export class Dispatcher { _createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: SerializedConfig) { const projectConfig = this._config.projects.find(p => p.id === testGroup.projectId)!; const outputDir = projectConfig.project.outputDir; + const execArgv = this._config.importConditions?.map(condition => `--conditions=${condition}`); const worker = new WorkerHost(testGroup, { parallelIndex, config: loaderData, extraEnv: this._extraEnvByProjectId.get(testGroup.projectId) || {}, + execArgv, outputDir, pauseOnError: this._failureTracker.pauseOnError(), pauseAtEnd: this._failureTracker.pauseAtEnd(projectConfig), diff --git a/packages/playwright/src/runner/processHost.ts b/packages/playwright/src/runner/processHost.ts index 06882a4d1b2fa..9640b181136d1 100644 --- a/packages/playwright/src/runner/processHost.ts +++ b/packages/playwright/src/runner/processHost.ts @@ -48,7 +48,7 @@ export class ProcessHost extends EventEmitter { this._extraEnv = env; } - async startRunner(runnerParams: any, options: { onStdOut?: (chunk: Buffer | string) => void, onStdErr?: (chunk: Buffer | string) => void } = {}): Promise { + async startRunner(runnerParams: any, options: { onStdOut?: (chunk: Buffer | string) => void, onStdErr?: (chunk: Buffer | string) => void, execArgv?: string[] } = {}): Promise { assert(!this.process, 'Internal error: starting the same process twice'); this.process = child_process.fork(require.resolve('../common/process'), { detached: false, @@ -56,6 +56,7 @@ export class ProcessHost extends EventEmitter { ...process.env, ...this._extraEnv, }, + ...(options.execArgv ? { execArgv: options.execArgv } : {}), stdio: [ 'ignore', options.onStdOut ? 'pipe' : 'inherit', diff --git a/packages/playwright/src/runner/workerHost.ts b/packages/playwright/src/runner/workerHost.ts index 979f56f3a979d..db9c64c418a86 100644 --- a/packages/playwright/src/runner/workerHost.ts +++ b/packages/playwright/src/runner/workerHost.ts @@ -33,6 +33,7 @@ type WorkerHostOptions = { parallelIndex: number; config: SerializedConfig; extraEnv: Record; + execArgv?: string[]; outputDir: string; pauseOnError: boolean; pauseAtEnd: boolean; @@ -43,6 +44,7 @@ export class WorkerHost extends ProcessHost { readonly workerIndex: number; private _hash: string; private _params: WorkerInitParams; + private _execArgv?: string[]; private _didFail = false; constructor(testGroup: TestGroup, options: WorkerHostOptions) { @@ -55,6 +57,7 @@ export class WorkerHost extends ProcessHost { this.workerIndex = workerIndex; this.parallelIndex = options.parallelIndex; this._hash = testGroup.workerHash; + this._execArgv = options.execArgv; this._params = { workerIndex: this.workerIndex, @@ -73,6 +76,7 @@ export class WorkerHost extends ProcessHost { return await this.startRunner(this._params, { onStdOut: chunk => this.emit('stdOut', stdioChunkToParams(chunk)), onStdErr: chunk => this.emit('stdErr', stdioChunkToParams(chunk)), + execArgv: this._execArgv, }); } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index b5babaa8d908e..114bea4d25f79 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1376,6 +1376,28 @@ interface TestConfig { */ ignoreSnapshots?: boolean; + /** + * Additional Node.js import conditions to use when resolving package exports. This is useful for packages that use + * [conditional exports](https://nodejs.org/api/packages.html#conditional-exports) to expose different entry points + * based on conditions. + * + * These conditions are added to Node.js's default conditions (`node`, `import`, `require`, etc.) and apply only when + * running tests, not when loading the configuration file. + * + * **Usage** + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * importConditions: ['custom', 'development'], + * }); + * ``` + * + */ + importConditions?: Array; + /** * The maximum number of test failures for the whole test suite run. After reaching this number, testing will stop and * exit with an error. Setting to zero (default) disables this behavior. diff --git a/tests/playwright-test/importConditions.spec.ts b/tests/playwright-test/importConditions.spec.ts new file mode 100644 index 0000000000000..1886c5b053545 --- /dev/null +++ b/tests/playwright-test/importConditions.spec.ts @@ -0,0 +1,223 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './playwright-test-fixtures'; + +// Config Validation Tests +test('should validate importConditions is an array', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: 'invalid', + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('dummy', () => {}); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('config.importConditions must be an array'); +}); + +test('should validate importConditions array elements are strings', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: ['valid', 123, 'another'], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('dummy', () => {}); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('config.importConditions[1] must be a string'); +}); + +test('should allow empty importConditions array', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: [], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('passes', () => {}); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should allow undefined importConditions', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: undefined, + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('passes', () => {}); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +// execArgv Passing Tests +test('should pass importConditions to worker process.execArgv', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: ['custom', 'development'], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('verify execArgv', () => { + console.log('%%execArgv=' + JSON.stringify(process.execArgv)); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('--conditions=custom'); + expect(result.output).toContain('--conditions=development'); +}); + +test('should pass single importCondition to worker', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: ['test-condition'], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('verify single condition', () => { + const hasCondition = process.execArgv.some(arg => arg === '--conditions=test-condition'); + console.log('%%hasCondition=' + hasCondition); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('hasCondition=true'); +}); + +test('should not pass conditions when importConditions is empty', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: [], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('verify no conditions', () => { + const hasConditions = process.execArgv.some(arg => arg.startsWith('--conditions=')); + console.log('%%hasConditions=' + hasConditions); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('hasConditions=false'); +}); + +// Integration Tests +test('should run tests successfully with importConditions', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: ['custom'], + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('should pass', () => { + expect(1 + 1).toBe(2); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should work with multiple workers', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: ['test'], + workers: 2, + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('test 1', () => { + console.log('%%worker-' + process.env.TEST_WORKER_INDEX); + }); + `, + 'b.test.ts': ` + import { test } from '@playwright/test'; + test('test 2', () => { + console.log('%%worker-' + process.env.TEST_WORKER_INDEX); + }); + ` + }, { workers: 2 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); +}); + +// Edge Cases +test('should handle many conditions', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + importConditions: ['cond1', 'cond2', 'cond3', 'cond4', 'cond5'], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('verify all conditions', () => { + const conditions = process.execArgv.filter(arg => arg.startsWith('--conditions=')); + console.log('%%conditionCount=' + conditions.length); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('conditionCount=5'); +}); + +test('should not interfere with existing tests when omitted', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + workers: 1, + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('normal test', () => { + expect(true).toBe(true); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +});