diff --git a/packages/playwright-test/src/common/ipc.ts b/packages/playwright-test/src/common/ipc.ts index 67c1c72c90..98574c4318 100644 --- a/packages/playwright-test/src/common/ipc.ts +++ b/packages/playwright-test/src/common/ipc.ts @@ -123,6 +123,8 @@ export type TeardownErrorsPayload = { fatalErrors: TestInfoError[]; }; +export type EnvProducedPayload = [string, string | null][]; + export function serializeConfig(config: FullConfigInternal): SerializedConfig { const result: SerializedConfig = { configFile: config.configFile, diff --git a/packages/playwright-test/src/common/process.ts b/packages/playwright-test/src/common/process.ts index 9061be3125..8119555202 100644 --- a/packages/playwright-test/src/common/process.ts +++ b/packages/playwright-test/src/common/process.ts @@ -15,7 +15,7 @@ */ import type { WriteStream } from 'tty'; -import type { ProcessInitParams, TtyParams } from './ipc'; +import type { EnvProducedPayload, ProcessInitParams, TtyParams } from './ipc'; import { startProfiling, stopProfiling } from 'playwright-core/lib/utils'; import type { TestInfoError } from './types'; import { serializeError } from '../util'; @@ -53,6 +53,8 @@ process.on('SIGTERM', () => {}); let processRunner: ProcessRunner; let processName: string; +const startingEnv = { ...process.env }; + process.on('message', async message => { if (message.method === '__init__') { const { processParams, runnerParams, runnerScript } = message.params as { processParams: ProcessInitParams, runnerParams: any, runnerScript: string }; @@ -65,6 +67,9 @@ process.on('message', async message => { return; } if (message.method === '__stop__') { + const keys = new Set([...Object.keys(process.env), ...Object.keys(startingEnv)]); + const producedEnv: EnvProducedPayload = [...keys].filter(key => startingEnv[key] !== process.env[key]).map(key => [key, process.env[key] ?? null]); + sendMessageToParent({ method: '__env_produced__', params: producedEnv }); await gracefullyCloseAndExit(); return; } diff --git a/packages/playwright-test/src/runner/dispatcher.ts b/packages/playwright-test/src/runner/dispatcher.ts index f8076c8b76..74a8632499 100644 --- a/packages/playwright-test/src/runner/dispatcher.ts +++ b/packages/playwright-test/src/runner/dispatcher.ts @@ -35,6 +35,8 @@ type TestData = { resultByWorkerIndex: Map; }; +export type EnvByProjectId = Map>; + export class Dispatcher { private _workerSlots: { busy: boolean, worker?: WorkerHost }[] = []; private _queue: TestGroup[] = []; @@ -48,6 +50,9 @@ export class Dispatcher { private _hasWorkerErrors = false; private _failureCount = 0; + private _extraEnvByProjectId: EnvByProjectId = new Map(); + private _producedEnvByProjectId: EnvByProjectId = new Map(); + constructor(config: FullConfigInternal, reporter: Reporter) { this._config = config; this._reporter = reporter; @@ -164,7 +169,8 @@ export class Dispatcher { return workersWithSameHash > this._queuedOrRunningHashCount.get(worker.hash())!; } - async run(testGroups: TestGroup[]) { + async run(testGroups: TestGroup[], extraEnvByProjectId: EnvByProjectId) { + this._extraEnvByProjectId = extraEnvByProjectId; this._queue = testGroups; for (const group of testGroups) { this._queuedOrRunningHashCount.set(group.workerHash, 1 + (this._queuedOrRunningHashCount.get(group.workerHash) || 0)); @@ -452,7 +458,7 @@ export class Dispatcher { } _createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: SerializedConfig) { - const worker = new WorkerHost(testGroup, parallelIndex, loaderData); + const worker = new WorkerHost(testGroup, parallelIndex, loaderData, this._extraEnvByProjectId.get(testGroup.projectId) || {}); const handleOutput = (params: TestOutputPayload) => { const chunk = chunkFromParams(params); if (worker.didFail()) { @@ -481,9 +487,17 @@ export class Dispatcher { for (const error of params.fatalErrors) this._reporter.onError?.(error); }); + worker.on('exit', () => { + const producedEnv = this._producedEnvByProjectId.get(testGroup.projectId) || {}; + this._producedEnvByProjectId.set(testGroup.projectId, { ...producedEnv, ...worker.producedEnv() }); + }); return worker; } + producedEnvByProjectId() { + return this._producedEnvByProjectId; + } + async stop() { if (this._isStopped) return; diff --git a/packages/playwright-test/src/runner/loaderHost.ts b/packages/playwright-test/src/runner/loaderHost.ts index d54197b88c..86f13cc785 100644 --- a/packages/playwright-test/src/runner/loaderHost.ts +++ b/packages/playwright-test/src/runner/loaderHost.ts @@ -46,8 +46,8 @@ export class OutOfProcessLoaderHost { private _processHost: ProcessHost; constructor(config: FullConfigInternal) { - this._processHost = new ProcessHost(require.resolve('../loader/loaderMain.js'), 'loader'); - this._startPromise = this._processHost.startRunner(serializeConfig(config), true, {}); + this._processHost = new ProcessHost(require.resolve('../loader/loaderMain.js'), 'loader', {}); + this._startPromise = this._processHost.startRunner(serializeConfig(config), true); } async loadTestFile(file: string, testErrors: TestError[]): Promise { diff --git a/packages/playwright-test/src/runner/processHost.ts b/packages/playwright-test/src/runner/processHost.ts index c9a99da529..72af3f64a7 100644 --- a/packages/playwright-test/src/runner/processHost.ts +++ b/packages/playwright-test/src/runner/processHost.ts @@ -17,7 +17,7 @@ import child_process from 'child_process'; import { EventEmitter } from 'events'; import { debug } from 'playwright-core/lib/utilsBundle'; -import type { ProcessInitParams } from '../common/ipc'; +import type { EnvProducedPayload, ProcessInitParams } from '../common/ipc'; import type { ProtocolResponse } from '../common/process'; export type ProcessExitData = { @@ -35,17 +35,20 @@ export class ProcessHost extends EventEmitter { private _lastMessageId = 0; private _callbacks = new Map void, reject: (error: Error) => void }>(); private _processName: string; + private _producedEnv: Record = {}; + private _extraEnv: Record; - constructor(runnerScript: string, processName: string) { + constructor(runnerScript: string, processName: string, env: Record) { super(); this._runnerScript = runnerScript; this._processName = processName; + this._extraEnv = env; } - async startRunner(runnerParams: any, inheritStdio: boolean, env: NodeJS.ProcessEnv) { + async startRunner(runnerParams: any, inheritStdio: boolean) { this.process = child_process.fork(require.resolve('../common/process'), { detached: false, - env: { ...process.env, ...env }, + env: { ...process.env, ...this._extraEnv }, stdio: inheritStdio ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'ignore', process.env.PW_RUNNER_DEBUG ? 'inherit' : 'ignore', 'ipc'], }); this.process.on('exit', (code, signal) => { @@ -56,7 +59,10 @@ export class ProcessHost extends EventEmitter { this.process.on('message', (message: any) => { if (debug.enabled('pw:test:protocol')) debug('pw:test:protocol')('◀ RECV ' + JSON.stringify(message)); - if (message.method === '__dispatch__') { + if (message.method === '__env_produced__') { + const producedEnv: EnvProducedPayload = message.params; + this._producedEnv = Object.fromEntries(producedEnv.map(e => [e[0], e[1] ?? undefined])); + } else if (message.method === '__dispatch__') { const { id, error, method, params, result } = message.params as ProtocolResponse; if (id && this._callbacks.has(id)) { const { resolve, reject } = this._callbacks.get(id)!; @@ -139,6 +145,10 @@ export class ProcessHost extends EventEmitter { return this._didSendStop; } + producedEnv() { + return this._producedEnv; + } + private send(message: { method: string, params?: any }) { if (debug.enabled('pw:test:protocol')) debug('pw:test:protocol')('SEND ► ' + JSON.stringify(message)); diff --git a/packages/playwright-test/src/runner/tasks.ts b/packages/playwright-test/src/runner/tasks.ts index c77862a3c8..63396e1347 100644 --- a/packages/playwright-test/src/runner/tasks.ts +++ b/packages/playwright-test/src/runner/tasks.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import path from 'path'; import { promisify } from 'util'; import { debug, rimraf } from 'playwright-core/lib/utilsBundle'; -import { Dispatcher } from './dispatcher'; +import { Dispatcher, type EnvByProjectId } from './dispatcher'; import type { TestRunnerPluginRegistration } from '../plugins'; import type { Multiplexer } from '../reporters/multiplexer'; import { createTestGroups, type TestGroup } from '../runner/testGroups'; @@ -217,6 +217,7 @@ function createRunTestsTask(): Task { return async context => { const { phases } = context; const successfulProjects = new Set(); + const extraEnvByProjectId: EnvByProjectId = new Map(); for (const { dispatcher, projects } of phases) { // Each phase contains dispatcher and a set of test groups. @@ -224,6 +225,12 @@ function createRunTestsTask(): Task { // that depend on the projects that failed previously. const phaseTestGroups: TestGroup[] = []; for (const { project, testGroups } of projects) { + // Inherit extra enviroment variables from dependencies. + let extraEnv: Record = {}; + for (const dep of project._internal.deps) + extraEnv = { ...extraEnv, ...extraEnvByProjectId.get(dep._internal.id) }; + extraEnvByProjectId.set(project._internal.id, extraEnv); + const hasFailedDeps = project._internal.deps.some(p => !successfulProjects.has(p)); if (!hasFailedDeps) { phaseTestGroups.push(...testGroups); @@ -236,8 +243,12 @@ function createRunTestsTask(): Task { } if (phaseTestGroups.length) { - await dispatcher!.run(phaseTestGroups); + await dispatcher!.run(phaseTestGroups, extraEnvByProjectId); await dispatcher.stop(); + for (const [projectId, envProduced] of dispatcher.producedEnvByProjectId()) { + const extraEnv = extraEnvByProjectId.get(projectId) || {}; + extraEnvByProjectId.set(projectId, { ...extraEnv, ...envProduced }); + } } // If the worker broke, fail everything, we have no way of knowing which diff --git a/packages/playwright-test/src/runner/workerHost.ts b/packages/playwright-test/src/runner/workerHost.ts index 96eeaa4c6d..14f09d7852 100644 --- a/packages/playwright-test/src/runner/workerHost.ts +++ b/packages/playwright-test/src/runner/workerHost.ts @@ -27,9 +27,13 @@ export class WorkerHost extends ProcessHost { currentTestId: string | null = null; private _params: WorkerInitParams; - constructor(testGroup: TestGroup, parallelIndex: number, config: SerializedConfig) { + constructor(testGroup: TestGroup, parallelIndex: number, config: SerializedConfig, extraEnv: Record) { const workerIndex = lastWorkerIndex++; - super(require.resolve('../worker/workerMain.js'), `worker-${workerIndex}`); + super(require.resolve('../worker/workerMain.js'), `worker-${workerIndex}`, { + ...extraEnv, + FORCE_COLOR: '1', + DEBUG_COLORS: '1', + }); this.workerIndex = workerIndex; this.parallelIndex = parallelIndex; this._hash = testGroup.workerHash; @@ -44,10 +48,7 @@ export class WorkerHost extends ProcessHost { } async start() { - await this.startRunner(this._params, false, { - FORCE_COLOR: '1', - DEBUG_COLORS: '1', - }); + await this.startRunner(this._params, false); } runTestGroup(runPayload: RunPayload) { diff --git a/tests/playwright-test/deps.spec.ts b/tests/playwright-test/deps.spec.ts index 0f53316b29..3adabefab6 100644 --- a/tests/playwright-test/deps.spec.ts +++ b/tests/playwright-test/deps.spec.ts @@ -38,6 +38,50 @@ test('should run projects with dependencies', async ({ runInlineTest }) => { expect(result.outputLines).toEqual(['A', 'B', 'C']); }); +test('should inherit env changes from dependencies', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { projects: [ + { name: 'A', testMatch: '**/a.spec.ts' }, + { name: 'B', testMatch: '**/b.spec.ts' }, + { name: 'C', testMatch: '**/c.spec.ts', dependencies: ['A'] }, + { name: 'D', testMatch: '**/d.spec.ts', dependencies: ['B'] }, + ] }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}, testInfo) => { + process.env.SET_IN_A = 'valuea'; + delete process.env.SET_OUTSIDE; + console.log('\\n%%A'); + }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}, testInfo) => { + process.env.SET_IN_B = 'valueb'; + console.log('\\n%%B'); + }); + `, + 'c.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}, testInfo) => { + console.log('\\n%%C-' + process.env.SET_IN_A + '-' + process.env.SET_IN_B + '-' + process.env.SET_OUTSIDE); + }); + `, + 'd.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}, testInfo) => { + console.log('\\n%%D-' + process.env.SET_IN_A + '-' + process.env.SET_IN_B + '-' + process.env.SET_OUTSIDE); + }); + `, + }, {}, { SET_OUTSIDE: 'outside' }); + expect(result.passed).toBe(4); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + expect(result.outputLines.sort()).toEqual(['A', 'B', 'C-valuea-undefined-undefined', 'D-undefined-valueb-outside']); +}); + test('should not run projects with dependencies when --no-deps is passed', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.ts': `