From 1890ddae552e7f9f05bd96bc3d90e9f05ae041da Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 May 2026 16:48:19 -0700 Subject: [PATCH 1/4] feat(test): support per-project webServer configuration Add `webServer` to TestProject so each project can declare its own dev server(s). A per-project server is launched only when the project is selected (directly via --project or through the dependency closure), in addition to any top-level `webServer`. Fixes https://github.com/microsoft/playwright/issues/22496 --- docs/src/api/params.md | 21 ++ docs/src/test-api/class-testconfig.md | 20 +- docs/src/test-api/class-testproject.md | 42 ++++ packages/playwright/src/common/config.ts | 6 +- .../playwright/src/common/configLoader.ts | 14 ++ packages/playwright/src/plugins/index.ts | 3 + .../playwright/src/plugins/webServerPlugin.ts | 42 ++-- packages/playwright/src/runner/tasks.ts | 36 +++- packages/playwright/src/runner/testRunner.ts | 27 +-- packages/playwright/types/test.d.ts | 48 +++++ tests/playwright-test/web-server.spec.ts | 187 ++++++++++++++++++ utils/generate_types/overrides-test.d.ts | 1 + 12 files changed, 388 insertions(+), 59 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index eae785f34737e..491395efd7673 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1965,3 +1965,24 @@ In this config: 1. Since `snapshotPathTemplate` resolves to relative path, it will be resolved relative to `configDir`. 1. Forward slashes `"/"` can be used as path separators on any platform. +## test-config-web-server-options +* langs: js +- type: ?<[Object]|[Array]<[Object]>> + - `command` <[string]> Shell command to start. For example `npm run start`.. + - `cwd` ?<[string]> Current working directory of the spawned process, defaults to the directory of the configuration file. + - `env` ?<[Object]<[string], [string]>> Environment variables to set for the command, `process.env` by default. + - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`. + - `signal` <["SIGINT"|"SIGTERM"]> + - `timeout` <[int]> + - `ignoreHTTPSErrors` ?<[boolean]> Whether to ignore HTTPS errors when fetching the `url`. Defaults to `false`. + - `name` ?<[string]> Specifies a custom name for the web server. This name will be prefixed to log messages. Defaults to `[WebServer]`. + - `port` ?<[int]> The port that your http server is expected to appear on. It does wait until it accepts connections. Either `port` or `url` should be specified. + - `reuseExistingServer` ?<[boolean]> If true, it will re-use an existing server on the `port` or `url` when available. If no server is running on that `port` or `url`, it will run the command to start a new server. If `false`, it will throw if an existing process is listening on the `port` or `url`. This should be commonly set to `!process.env.CI` to allow the local dev server when running tests locally. + - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. + - `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. + - `wait` ?<[Object]> Consider command started only when given output has been produced. + - `stdout` ?<[RegExp]> Regular expression to wait for in the `stdout` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. + - `stderr` ?<[RegExp]> Regular expression to wait for in the `stderr` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. + - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. + - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. + diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 88b059a35a145..83cae0624a263 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -690,26 +690,8 @@ export default defineConfig({ }); ``` -## property: TestConfig.webServer +## property: TestConfig.webServer = %%-test-config-web-server-options-%% * since: v1.10 -- type: ?<[Object]|[Array]<[Object]>> - - `command` <[string]> Shell command to start. For example `npm run start`.. - - `cwd` ?<[string]> Current working directory of the spawned process, defaults to the directory of the configuration file. - - `env` ?<[Object]<[string], [string]>> Environment variables to set for the command, `process.env` by default. - - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`. - - `signal` <["SIGINT"|"SIGTERM"]> - - `timeout` <[int]> - - `ignoreHTTPSErrors` ?<[boolean]> Whether to ignore HTTPS errors when fetching the `url`. Defaults to `false`. - - `name` ?<[string]> Specifies a custom name for the web server. This name will be prefixed to log messages. Defaults to `[WebServer]`. - - `port` ?<[int]> The port that your http server is expected to appear on. It does wait until it accepts connections. Either `port` or `url` should be specified. - - `reuseExistingServer` ?<[boolean]> If true, it will re-use an existing server on the `port` or `url` when available. If no server is running on that `port` or `url`, it will run the command to start a new server. If `false`, it will throw if an existing process is listening on the `port` or `url`. This should be commonly set to `!process.env.CI` to allow the local dev server when running tests locally. - - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. - - `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. - - `wait` ?<[Object]> Consider command started only when given output has been produced. - - `stdout` ?<[RegExp]> Regular expression to wait for in the `stdout` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. - - `stderr` ?<[RegExp]> Regular expression to wait for in the `stderr` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. - - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. - - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. Launch a development web server (or multiple) during the tests. diff --git a/docs/src/test-api/class-testproject.md b/docs/src/test-api/class-testproject.md index ce55beb069a40..6cb2a98e147c7 100644 --- a/docs/src/test-api/class-testproject.md +++ b/docs/src/test-api/class-testproject.md @@ -395,6 +395,48 @@ export default defineConfig({ Use [`property: TestConfig.use`] to change this option for all projects. +## property: TestProject.webServer = %%-test-config-web-server-options-%% +* since: v1.61 + +Launch a development web server (or multiple) before running tests in this project. See [`property: TestConfig.webServer`] for the shape of each entry. + +A per-project `webServer` is only launched when the project is selected (either directly via `--project` or indirectly through dependencies). This is useful when only a subset of your projects need a local backend, while others run against a deployed environment. + +Per-project web servers are launched in addition to any top-level [`property: TestConfig.webServer`]. + +**Usage** + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + projects: [ + { + name: 'functional', + grepInvert: /@smoke/, + use: { baseURL: 'http://localhost:3000' }, + webServer: [ + { + command: 'npm run start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, + { + command: 'npm run mock-server', + port: 3001, + reuseExistingServer: !process.env.CI, + }, + ], + }, + { + name: 'smoke', + grep: /@smoke/, + use: { baseURL: 'https://production.app.com' }, + }, + ], +}); +``` + ## property: TestProject.workers * since: v1.52 - type: ?<[int]|[string]> diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index a1098ef09955e..475386f853c54 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -129,7 +129,8 @@ export class FullConfigInternal { } // When no projects are defined, do not use config.workers as a hard limit for project.workers. - const projectConfigs = configCLIOverrides.projects || userConfig.projects || [{ ...userConfig, workers: undefined }]; + // Strip webServer from the implicit default project — it is already accounted for at the top level. + const projectConfigs = configCLIOverrides.projects || userConfig.projects || [{ ...userConfig, workers: undefined, webServer: undefined }]; this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir)); resolveProjectDependencies(this.projects); this._assignUniqueProjectIds(this.projects); @@ -161,6 +162,7 @@ export class FullProjectInternal { readonly respectGitIgnore: boolean; readonly snapshotPathTemplate: string | undefined; readonly workers: number | undefined; + readonly webServers: NonNullable[]; id = ''; deps: FullProjectInternal[] = []; teardown: FullProjectInternal | undefined; @@ -169,6 +171,8 @@ export class FullProjectInternal { this.fullConfig = fullConfig; const testDir = takeFirst(pathResolve(configDir, projectConfig.testDir), pathResolve(configDir, config.testDir), fullConfig.configDir); this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate); + const webServer = projectConfig.webServer; + this.webServers = Array.isArray(webServer) ? webServer : webServer ? [webServer] : []; this.project = { grep: takeFirst(projectConfig.grep, config.grep, defaultGrep), diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 166313e06342b..626de4b42f8dd 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -328,6 +328,20 @@ function validateProject(file: string, project: Project, title: string) { else if (typeof project.workers === 'string' && !project.workers.endsWith('%')) throw errorWithFile(file, `${title}.workers must be a number or percentage`); } + + // Top-level webServer's command-empty case is reported at runtime instead. + if (title !== 'config' && 'webServer' in project && project.webServer !== undefined) { + const webServer = project.webServer; + const isArray = Array.isArray(webServer); + const items = isArray ? webServer : [webServer]; + items.forEach((item, index) => { + const itemTitle = isArray ? `${title}.webServer[${index}]` : `${title}.webServer`; + if (!item || typeof item !== 'object') + throw errorWithFile(file, `${itemTitle} must be an object`); + if (typeof item.command !== 'string' || !item.command) + throw errorWithFile(file, `${itemTitle}.command must be a non-empty string`); + }); + } } export function resolveConfigLocation(configFile: string | undefined): ConfigLocation { diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 4f2e878a4be6a..0f314af0863b9 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -30,6 +30,9 @@ export interface TestRunnerPlugin { export type TestRunnerPluginRegistration = { factory: TestRunnerPlugin | (() => TestRunnerPlugin | Promise); instance?: TestRunnerPlugin; + // When set, the plugin is only set up when one of these projects (or their + // transitive closure of dependencies/teardowns) is selected to run. + projectIds?: Set; }; export { webServer } from './webServerPlugin'; diff --git a/packages/playwright/src/plugins/webServerPlugin.ts b/packages/playwright/src/plugins/webServerPlugin.ts index 804bf96968262..3858a4c59f3c4 100644 --- a/packages/playwright/src/plugins/webServerPlugin.ts +++ b/packages/playwright/src/plugins/webServerPlugin.ts @@ -24,7 +24,7 @@ import { raceAgainstDeadline } from '@isomorphic/timeoutRunner'; import { isURLAvailable } from '@utils/network'; import { launchProcess } from '@utils/processLauncher'; -import type { TestRunnerPlugin } from '.'; +import type { TestRunnerPlugin, TestRunnerPluginRegistration } from '.'; import type { FullConfig } from '../../types/testReporter'; import type { FullConfigInternal } from '../common'; import type { ReporterV2 } from '../reporters/reporterV2'; @@ -258,27 +258,35 @@ export const webServer = (options: WebServerPluginOptions): TestRunnerPlugin => return new WebServerPlugin(options, false); }; -export const webServerPluginsForConfig = (config: FullConfigInternal): TestRunnerPlugin[] => { +export const webServerPluginsForConfig = (config: FullConfigInternal): TestRunnerPluginRegistration[] => { const shouldSetBaseUrl = !!config.config.webServer; - const webServerPlugins = []; - for (const webServerConfig of config.webServers) { - if (webServerConfig.port && webServerConfig.url) - throw new Error(`Either 'port' or 'url' should be specified in config.webServer.`); - - let url: string | undefined; - if (webServerConfig.port || webServerConfig.url) { - url = webServerConfig.url || `http://localhost:${webServerConfig.port}`; - - // We only set base url when only the port is given. That's a legacy mode we have regrets about. - if (shouldSetBaseUrl && !webServerConfig.url) - process.env.PLAYWRIGHT_TEST_BASE_URL = url; - } - webServerPlugins.push(new WebServerPlugin({ ...webServerConfig, url }, webServerConfig.port !== undefined)); + const plugins: TestRunnerPluginRegistration[] = []; + for (const webServerConfig of config.webServers) + plugins.push({ factory: createWebServerPlugin(webServerConfig, shouldSetBaseUrl) }); + + for (const project of config.projects) { + for (const webServerConfig of project.webServers) + plugins.push({ factory: createWebServerPlugin(webServerConfig, false), projectIds: new Set([project.id]) }); } - return webServerPlugins; + return plugins; }; +function createWebServerPlugin(webServerConfig: WebServerPluginOptions & { port?: number }, shouldSetBaseUrl: boolean): TestRunnerPlugin { + if (webServerConfig.port && webServerConfig.url) + throw new Error(`Either 'port' or 'url' should be specified in config.webServer.`); + + let url: string | undefined; + if (webServerConfig.port || webServerConfig.url) { + url = webServerConfig.url || `http://localhost:${webServerConfig.port}`; + + // We only set base url when only the port is given. That's a legacy mode we have regrets about. + if (shouldSetBaseUrl && !webServerConfig.url) + process.env.PLAYWRIGHT_TEST_BASE_URL = url; + } + return new WebServerPlugin({ ...webServerConfig, url }, webServerConfig.port !== undefined); +} + function prefixOutputLines(output: string, prefixName: string = 'WebServer'): string { const lastIsNewLine = output[output.length - 1] === '\n'; let lines = output.split('\n'); diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 6edd4e887c2b0..5017ffe359e0c 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -25,7 +25,7 @@ import { removeFolders } from '@utils/fileUtils'; import { Dispatcher } from './dispatcher'; import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook, loadTestList } from './loadUtils'; -import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; +import { buildDependentProjects, buildProjectsClosure, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase'; import { TaskRunner } from './taskRunner'; import { detectChangedTestFiles } from './vcs'; @@ -95,12 +95,28 @@ export class TestRun { readonly loadFileFilters: Matcher[] = []; readonly preOnlyTestFilters: TestCaseFilter[] = []; readonly postShardTestFilters: TestCaseFilter[] = []; + readonly projectClosureIds: Set; constructor(config: FullConfigInternal, reporter: InternalReporter, options?: TestRunOptions) { this.config = config; this.options = options ?? {}; this.reporter = reporter; this.filteredProjects = filterProjects(config.projects, this.options.projectFilter); + this.projectClosureIds = new Set(); + for (const project of buildProjectsClosure(this.filteredProjects).keys()) + this.projectClosureIds.add(project.id); + } + + activePlugins(): TestRunnerPluginRegistration[] { + return this.config.plugins.filter(plugin => { + if (!plugin.projectIds) + return true; + for (const id of plugin.projectIds) { + if (this.projectClosureIds.has(id)) + return true; + } + return false; + }); } onTestPaused(params: TestPausedParams) { @@ -148,20 +164,20 @@ async function finishTaskRun(testRun: TestRun, status: FullResult['status']) { return status; } -export function createGlobalSetupTasks(config: FullConfigInternal) { +export function createGlobalSetupTasks(config: FullConfigInternal, testRun: TestRun) { return [ createRemoveOutputDirsTask(), - ...createPluginSetupTasks(config), + ...createPluginSetupTasks(config, testRun), ...config.globalTeardowns.map(file => createGlobalTeardownTask(file, config)).reverse(), ...config.globalSetups.map(file => createGlobalSetupTask(file, config)), ]; } -export function createRunTestsTasks(config: FullConfigInternal) { +export function createRunTestsTasks(config: FullConfigInternal, testRun: TestRun) { return [ createPhasesTask(), createReportBeginTask(), - ...config.plugins.map(plugin => createPluginBeginTask(plugin)), + ...testRun.activePlugins().map(plugin => createPluginBeginTask(plugin)), createRunTestsTask(), ]; } @@ -187,15 +203,15 @@ export function createReportBeginTask(): Task { }; } -export function createPluginSetupTasks(config: FullConfigInternal): Task[] { - return config.plugins.map(plugin => ({ +export function createPluginSetupTasks(config: FullConfigInternal, testRun: TestRun): Task[] { + return testRun.activePlugins().map(plugin => ({ title: 'plugin setup', - setup: async ({ reporter }) => { + setup: async testRun => { if (typeof plugin.factory === 'function') plugin.instance = await plugin.factory(); else plugin.instance = plugin.factory; - await plugin.instance?.setup?.(config.config, config.configDir, reporter); + await plugin.instance?.setup?.(config.config, config.configDir, testRun.reporter); }, teardown: async () => { await plugin.instance?.teardown?.(); @@ -340,7 +356,7 @@ export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); if (testRun.options.onlyChanged || options.populateDependencies) { - for (const plugin of testRun.config.plugins) + for (const plugin of testRun.activePlugins()) await plugin.instance?.populateDependencies?.(); } diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index c7267c5264d97..1bfb15e074ac7 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -166,8 +166,9 @@ export class TestRunner extends EventEmitter { if (!config) return { status: 'failed', env: [] }; - const { status, cleanup } = await runTasksDeferCleanup(new TestRun(config, reporter), [ - ...createGlobalSetupTasks(config), + const testRun = new TestRun(config, reporter); + const { status, cleanup } = await runTasksDeferCleanup(testRun, [ + ...createGlobalSetupTasks(config, testRun), ]); const env: [string, string | null][] = []; @@ -195,8 +196,9 @@ export class TestRunner extends EventEmitter { const config = await this._loadConfigOrReportError(reporter); if (!config) return { status: 'failed' }; - const status = await runTasks(new TestRun(config, reporter), [ - ...createPluginSetupTasks(config), + const testRun = new TestRun(config, reporter); + const status = await runTasks(testRun, [ + ...createPluginSetupTasks(config, testRun), createClearCacheTask(config), ]); return { status }; @@ -340,7 +342,7 @@ export class TestRunner extends EventEmitter { const tasks = [ createApplyRebaselinesTask(), createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: !!params.failOnLoadErrors, doNotRunDepsOutsideProjectFilter: params.doNotRunDepsOutsideProjectFilter }), - ...createRunTestsTasks(config), + ...createRunTestsTasks(config, testRun), ]; const run = runTasks(testRun, tasks, 0, stop).then(async status => { this._testRun = undefined; @@ -365,8 +367,9 @@ export class TestRunner extends EventEmitter { const config = await this._loadConfigOrReportError(reporter); if (!config) return { errors: errorReporter.errors(), testFiles: [] }; - const status = await runTasks(new TestRun(config, reporter), [ - ...createPluginSetupTasks(config), + const testRun = new TestRun(config, reporter); + const status = await runTasks(testRun, [ + ...createPluginSetupTasks(config, testRun), createLoadTask('out-of-process', { failOnLoadErrors: true, filterOnly: false, populateDependencies: true }), ]); if (status !== 'passed') @@ -392,7 +395,7 @@ export class TestRunner extends EventEmitter { const config = await configLoader.loadConfig(this.configLocation, overrides); // Preserve plugin instances between setup and build. if (!this._plugins) { - webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); + config.plugins.push(...webServerPluginsForConfig(config)); addGitCommitInfoPlugin(config); this._plugins = config.plugins || []; } else { @@ -442,7 +445,7 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: addGitCommitInfoPlugin(config); // Legacy webServer support. - webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); + config.plugins.push(...webServerPluginsForConfig(config)); const filteredProjects = filterProjects(config.projects, options.projectFilter); const reporters = await createReporters(config, options.listMode ? 'list' : 'test', undefined, options); @@ -454,17 +457,17 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: } const reporter = new InternalReporter([...reporters, lastRun]); + const testRun = new TestRun(config, reporter, { ...options, pauseAtEnd: config.configCLIOverrides.pause, pauseOnError: config.configCLIOverrides.pause }); const tasks = options.listMode ? [ createLoadTask('in-process', { failOnLoadErrors: true, filterOnly: false }), createReportBeginTask(), ] : [ createApplyRebaselinesTask(), - ...createGlobalSetupTasks(config), + ...createGlobalSetupTasks(config, testRun), createLoadTask('in-process', { filterOnly: true, failOnLoadErrors: true }), - ...createRunTestsTasks(config), + ...createRunTestsTasks(config, testRun), ]; - const testRun = new TestRun(config, reporter, { ...options, pauseAtEnd: config.configCLIOverrides.pause, pauseOnError: config.configCLIOverrides.pause }); const status = await runTasks(testRun, tasks, config.config.globalTimeout); // Calling process.exit() might truncate large stdout/stderr output. diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index ce67a74db67ac..b13521083d267 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -129,6 +129,54 @@ interface TestProject { * all projects. */ use?: UseOptions; + /** + * Launch a development web server (or multiple) before running tests in this project. See + * [testConfig.webServer](https://playwright.dev/docs/api/class-testconfig#test-config-web-server) for the shape of + * each entry. + * + * A per-project `webServer` is only launched when the project is selected (either directly via `--project` or + * indirectly through dependencies). This is useful when only a subset of your projects need a local backend, while + * others run against a deployed environment. + * + * Per-project web servers are launched in addition to any top-level + * [testConfig.webServer](https://playwright.dev/docs/api/class-testconfig#test-config-web-server). + * + * **Usage** + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * projects: [ + * { + * name: 'functional', + * grepInvert: /@smoke/, + * use: { baseURL: 'http://localhost:3000' }, + * webServer: [ + * { + * command: 'npm run start', + * url: 'http://localhost:3000', + * reuseExistingServer: !process.env.CI, + * }, + * { + * command: 'npm run mock-server', + * port: 3001, + * reuseExistingServer: !process.env.CI, + * }, + * ], + * }, + * { + * name: 'smoke', + * grep: /@smoke/, + * use: { baseURL: 'https://production.app.com' }, + * }, + * ], + * }); + * ``` + * + */ + webServer?: TestConfigWebServer | TestConfigWebServer[]; /** * List of projects that need to run before any test in this project runs. Dependencies can be useful for configuring * the global setup actions in a way that every action is in a form of a test. Passing `--no-deps` argument ignores diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index c171637bad76c..1629c4145ab7c 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -990,3 +990,190 @@ for (const stdio of ['stdout', 'stderr']) { expect(result.output).toContain('My server port is 123'); }); } + +test.describe('per-project webServer', () => { + test('should launch only servers for the selected project', async ({ runInlineTest }, { workerIndex }) => { + const portA = workerIndex * 4 + 10600; + const portB = workerIndex * 4 + 10601; + const result = await runInlineTest({ + 'test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('connect', async ({ baseURL, page }) => { + await page.goto('/hello'); + expect(await page.textContent('body')).toBe('hello'); + }); + `, + 'playwright.config.ts': ` + module.exports = { + projects: [ + { + name: 'with-server', + use: { baseURL: 'http://localhost:${portA}' }, + webServer: { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${portA}', + url: 'http://localhost:${portA}/hello', + name: 'ServerA', + }, + }, + { + name: 'no-server', + use: { baseURL: 'http://localhost:${portB}' }, + webServer: { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${portB}', + url: 'http://localhost:${portB}/hello', + name: 'ServerB', + }, + }, + ], + }; + `, + }, { project: 'with-server' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('[ServerA]'); + expect(result.output).not.toContain('[ServerB]'); + }); + + test('should launch a per-project server for a project running as dependency', async ({ runInlineTest }, { workerIndex }) => { + const port = workerIndex * 2 + 10610; + const result = await runInlineTest({ + 'setup.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('warm up', async ({ baseURL, request }) => { + const r = await request.get('/hello'); + expect(await r.text()).toBe('hello'); + }); + `, + 'test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('use', async ({ baseURL, page }) => { + await page.goto('/hello'); + expect(await page.textContent('body')).toBe('hello'); + }); + `, + 'playwright.config.ts': ` + module.exports = { + use: { baseURL: 'http://localhost:${port}' }, + projects: [ + { + name: 'setup', + testMatch: /setup\\.spec\\.ts/, + webServer: { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port}', + url: 'http://localhost:${port}/hello', + name: 'SetupServer', + }, + }, + { + name: 'main', + testMatch: /test\\.spec\\.ts/, + dependencies: ['setup'], + }, + ], + }; + `, + }, { project: 'main' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + expect(result.output).toContain('[SetupServer]'); + }); + + test('should launch top-level webServer regardless of selected project', async ({ runInlineTest }, { workerIndex }) => { + const topPort = workerIndex * 2 + 10620; + const projPort = workerIndex * 2 + 10621; + const result = await runInlineTest({ + 'test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('check both', async ({ request }) => { + expect((await (await request.get('http://localhost:${topPort}/hello')).text())).toBe('hello'); + }); + `, + 'playwright.config.ts': ` + module.exports = { + webServer: { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${topPort}', + url: 'http://localhost:${topPort}/hello', + name: 'TopServer', + }, + projects: [ + { + name: 'A', + webServer: { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${projPort}', + url: 'http://localhost:${projPort}/hello', + name: 'ProjAServer', + }, + }, + { + name: 'B', + }, + ], + }; + `, + }, { project: 'B' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('[TopServer]'); + expect(result.output).not.toContain('[ProjAServer]'); + }); + + test('should accept an array of webServer per project', async ({ runInlineTest }, { workerIndex }) => { + const port1 = workerIndex * 2 + 10630; + const port2 = workerIndex * 2 + 10631; + const result = await runInlineTest({ + 'test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('connect', async ({ request }) => { + expect(await (await request.get('http://localhost:${port1}/hello')).text()).toBe('hello'); + expect(await (await request.get('http://localhost:${port2}/hello')).text()).toBe('hello'); + }); + `, + 'playwright.config.ts': ` + module.exports = { + projects: [ + { + name: 'A', + webServer: [ + { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port1}', + url: 'http://localhost:${port1}/hello', + name: 'ServerOne', + }, + { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port2}', + url: 'http://localhost:${port2}/hello', + name: 'ServerTwo', + }, + ], + }, + ], + }; + `, + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('[ServerOne]'); + expect(result.output).toContain('[ServerTwo]'); + }); + + test('should validate project.webServer.command', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'test.spec.ts': ` + import { test } from '@playwright/test'; + test('pass', async ({}) => {}); + `, + 'playwright.config.ts': ` + module.exports = { + projects: [ + { + name: 'A', + webServer: { command: '', url: 'http://localhost:1' }, + }, + ], + }; + `, + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('webServer.command must be a non-empty string'); + }); +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index f66f4c0039d53..2e5258f9933d2 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -50,6 +50,7 @@ type UseOptions = Partial & Partial; interface TestProject { use?: UseOptions; + webServer?: TestConfigWebServer | TestConfigWebServer[]; } export interface Project extends TestProject { From 42c02553a7172aa05a3c352d95b598fcd4346c28 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 May 2026 17:25:37 -0700 Subject: [PATCH 2/4] chore(runner): inline plugin-projectId gate in setup, drop activePlugins Plugin registration carries a single optional projectId. The task builders no longer take a TestRun and no longer compute an active-plugin list up front; each plugin task's setup gates itself with \`!plugin.projectId || testRun.projectClosureIds.has(plugin.projectId)\`. --- packages/playwright/src/plugins/index.ts | 4 +-- .../playwright/src/plugins/webServerPlugin.ts | 2 +- packages/playwright/src/runner/tasks.ts | 30 +++++++------------ packages/playwright/src/runner/testRunner.ts | 12 ++++---- 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 0f314af0863b9..509874f2e4934 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -30,9 +30,9 @@ export interface TestRunnerPlugin { export type TestRunnerPluginRegistration = { factory: TestRunnerPlugin | (() => TestRunnerPlugin | Promise); instance?: TestRunnerPlugin; - // When set, the plugin is only set up when one of these projects (or their + // When set, the plugin is only set up when the project (or their // transitive closure of dependencies/teardowns) is selected to run. - projectIds?: Set; + projectId?: string; }; export { webServer } from './webServerPlugin'; diff --git a/packages/playwright/src/plugins/webServerPlugin.ts b/packages/playwright/src/plugins/webServerPlugin.ts index 3858a4c59f3c4..777abd25dc1d1 100644 --- a/packages/playwright/src/plugins/webServerPlugin.ts +++ b/packages/playwright/src/plugins/webServerPlugin.ts @@ -266,7 +266,7 @@ export const webServerPluginsForConfig = (config: FullConfigInternal): TestRunne for (const project of config.projects) { for (const webServerConfig of project.webServers) - plugins.push({ factory: createWebServerPlugin(webServerConfig, false), projectIds: new Set([project.id]) }); + plugins.push({ factory: createWebServerPlugin(webServerConfig, false), projectId: project.id }); } return plugins; diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 5017ffe359e0c..db0b82166db43 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -107,18 +107,6 @@ export class TestRun { this.projectClosureIds.add(project.id); } - activePlugins(): TestRunnerPluginRegistration[] { - return this.config.plugins.filter(plugin => { - if (!plugin.projectIds) - return true; - for (const id of plugin.projectIds) { - if (this.projectClosureIds.has(id)) - return true; - } - return false; - }); - } - onTestPaused(params: TestPausedParams) { this.options.onTestPaused?.(params); } @@ -164,20 +152,20 @@ async function finishTaskRun(testRun: TestRun, status: FullResult['status']) { return status; } -export function createGlobalSetupTasks(config: FullConfigInternal, testRun: TestRun) { +export function createGlobalSetupTasks(config: FullConfigInternal) { return [ createRemoveOutputDirsTask(), - ...createPluginSetupTasks(config, testRun), + ...createPluginSetupTasks(config), ...config.globalTeardowns.map(file => createGlobalTeardownTask(file, config)).reverse(), ...config.globalSetups.map(file => createGlobalSetupTask(file, config)), ]; } -export function createRunTestsTasks(config: FullConfigInternal, testRun: TestRun) { +export function createRunTestsTasks(config: FullConfigInternal) { return [ createPhasesTask(), createReportBeginTask(), - ...testRun.activePlugins().map(plugin => createPluginBeginTask(plugin)), + ...config.plugins.map(plugin => createPluginBeginTask(plugin)), createRunTestsTask(), ]; } @@ -203,10 +191,12 @@ export function createReportBeginTask(): Task { }; } -export function createPluginSetupTasks(config: FullConfigInternal, testRun: TestRun): Task[] { - return testRun.activePlugins().map(plugin => ({ +export function createPluginSetupTasks(config: FullConfigInternal): Task[] { + return config.plugins.map(plugin => ({ title: 'plugin setup', setup: async testRun => { + if (plugin.projectId && !testRun.projectClosureIds.has(plugin.projectId)) + return; if (typeof plugin.factory === 'function') plugin.instance = await plugin.factory(); else @@ -223,6 +213,8 @@ function createPluginBeginTask(plugin: TestRunnerPluginRegistration): Task { + if (plugin.projectId && !testRun.projectClosureIds.has(plugin.projectId)) + return; await plugin.instance?.begin?.(testRun.rootSuite!); }, teardown: async () => { @@ -356,7 +348,7 @@ export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); if (testRun.options.onlyChanged || options.populateDependencies) { - for (const plugin of testRun.activePlugins()) + for (const plugin of testRun.config.plugins) await plugin.instance?.populateDependencies?.(); } diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 1bfb15e074ac7..3e538f29f9ef0 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -168,7 +168,7 @@ export class TestRunner extends EventEmitter { const testRun = new TestRun(config, reporter); const { status, cleanup } = await runTasksDeferCleanup(testRun, [ - ...createGlobalSetupTasks(config, testRun), + ...createGlobalSetupTasks(config), ]); const env: [string, string | null][] = []; @@ -198,7 +198,7 @@ export class TestRunner extends EventEmitter { return { status: 'failed' }; const testRun = new TestRun(config, reporter); const status = await runTasks(testRun, [ - ...createPluginSetupTasks(config, testRun), + ...createPluginSetupTasks(config), createClearCacheTask(config), ]); return { status }; @@ -342,7 +342,7 @@ export class TestRunner extends EventEmitter { const tasks = [ createApplyRebaselinesTask(), createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: !!params.failOnLoadErrors, doNotRunDepsOutsideProjectFilter: params.doNotRunDepsOutsideProjectFilter }), - ...createRunTestsTasks(config, testRun), + ...createRunTestsTasks(config), ]; const run = runTasks(testRun, tasks, 0, stop).then(async status => { this._testRun = undefined; @@ -369,7 +369,7 @@ export class TestRunner extends EventEmitter { return { errors: errorReporter.errors(), testFiles: [] }; const testRun = new TestRun(config, reporter); const status = await runTasks(testRun, [ - ...createPluginSetupTasks(config, testRun), + ...createPluginSetupTasks(config), createLoadTask('out-of-process', { failOnLoadErrors: true, filterOnly: false, populateDependencies: true }), ]); if (status !== 'passed') @@ -463,9 +463,9 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: createReportBeginTask(), ] : [ createApplyRebaselinesTask(), - ...createGlobalSetupTasks(config, testRun), + ...createGlobalSetupTasks(config), createLoadTask('in-process', { filterOnly: true, failOnLoadErrors: true }), - ...createRunTestsTasks(config, testRun), + ...createRunTestsTasks(config), ]; const status = await runTasks(testRun, tasks, config.config.globalTimeout); From 1d2252ed45803507400128e367bd14327161bf27 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 May 2026 17:38:50 -0700 Subject: [PATCH 3/4] chore(test): validate top-level webServer at config load Drop the title-based exclusion so config.webServer is shape- and command-non-empty-checked at load time, same as project.webServer. Also revert the testRunner.ts hoists from the previous follow-up commit; the task builders no longer need the TestRun up front. --- packages/playwright/src/common/configLoader.ts | 3 +-- packages/playwright/src/runner/testRunner.ts | 11 ++++------- tests/playwright-test/web-server.spec.ts | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 626de4b42f8dd..efdd40fcc11d5 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -329,8 +329,7 @@ function validateProject(file: string, project: Project, title: string) { throw errorWithFile(file, `${title}.workers must be a number or percentage`); } - // Top-level webServer's command-empty case is reported at runtime instead. - if (title !== 'config' && 'webServer' in project && project.webServer !== undefined) { + if ('webServer' in project && project.webServer !== undefined) { const webServer = project.webServer; const isArray = Array.isArray(webServer); const items = isArray ? webServer : [webServer]; diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 3e538f29f9ef0..1a30b7e38319e 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -166,8 +166,7 @@ export class TestRunner extends EventEmitter { if (!config) return { status: 'failed', env: [] }; - const testRun = new TestRun(config, reporter); - const { status, cleanup } = await runTasksDeferCleanup(testRun, [ + const { status, cleanup } = await runTasksDeferCleanup(new TestRun(config, reporter), [ ...createGlobalSetupTasks(config), ]); @@ -196,8 +195,7 @@ export class TestRunner extends EventEmitter { const config = await this._loadConfigOrReportError(reporter); if (!config) return { status: 'failed' }; - const testRun = new TestRun(config, reporter); - const status = await runTasks(testRun, [ + const status = await runTasks(new TestRun(config, reporter), [ ...createPluginSetupTasks(config), createClearCacheTask(config), ]); @@ -367,8 +365,7 @@ export class TestRunner extends EventEmitter { const config = await this._loadConfigOrReportError(reporter); if (!config) return { errors: errorReporter.errors(), testFiles: [] }; - const testRun = new TestRun(config, reporter); - const status = await runTasks(testRun, [ + const status = await runTasks(new TestRun(config, reporter), [ ...createPluginSetupTasks(config), createLoadTask('out-of-process', { failOnLoadErrors: true, filterOnly: false, populateDependencies: true }), ]); @@ -457,7 +454,6 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: } const reporter = new InternalReporter([...reporters, lastRun]); - const testRun = new TestRun(config, reporter, { ...options, pauseAtEnd: config.configCLIOverrides.pause, pauseOnError: config.configCLIOverrides.pause }); const tasks = options.listMode ? [ createLoadTask('in-process', { failOnLoadErrors: true, filterOnly: false }), createReportBeginTask(), @@ -468,6 +464,7 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: ...createRunTestsTasks(config), ]; + const testRun = new TestRun(config, reporter, { ...options, pauseAtEnd: config.configCLIOverrides.pause, pauseOnError: config.configCLIOverrides.pause }); const status = await runTasks(testRun, tasks, config.config.globalTimeout); // Calling process.exit() might truncate large stdout/stderr output. diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index 1629c4145ab7c..e8f491d415b38 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -931,7 +931,7 @@ test('should throw helpful error when command is empty', async ({ runInlineTest `, }, undefined); expect(result.exitCode).toBe(1); - expect(result.output).toContain('config.webServer.command cannot be empty'); + expect(result.output).toContain('webServer[0].command must be a non-empty string'); }); for (const stdio of ['stdout', 'stderr']) { From 4f5d90ddb749db6656d6839173f04b79397a1773 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 18 May 2026 08:25:24 -0700 Subject: [PATCH 4/4] fix(test): allow webServer without command when reuseExistingServer Validate config.webServer[*].command shape only when it is explicitly provided. Missing command is legitimate when reuseExistingServer is true and the server is already running; the runtime path still errors if a command is needed but absent. --- packages/playwright/src/common/configLoader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index efdd40fcc11d5..b7bd1c9b1bbe2 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -337,7 +337,7 @@ function validateProject(file: string, project: Project, title: string) { const itemTitle = isArray ? `${title}.webServer[${index}]` : `${title}.webServer`; if (!item || typeof item !== 'object') throw errorWithFile(file, `${itemTitle} must be an object`); - if (typeof item.command !== 'string' || !item.command) + if (item.command !== undefined && (typeof item.command !== 'string' || !item.command)) throw errorWithFile(file, `${itemTitle}.command must be a non-empty string`); }); }