Skip to content

Commit 143ab3c

Browse files
feat(test): support project-specific web servers (#43860)
Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com>
1 parent 17ec699 commit 143ab3c

16 files changed

Lines changed: 418 additions & 20 deletions

File tree

‎docs/src/test-api/class-fullconfig.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ Playwright version.
164164
- type: <[null]|[Object]>
165165

166166
See [`property: TestConfig.webServer`].
167+
This property is `null` in merged reports because server configuration is not serialized.
167168

168169
## property: FullConfig.workers
169170
* since: v1.10

‎docs/src/test-api/class-fullproject.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,11 @@ See [`property: TestProject.timeout`].
9999
- type: <[Fixtures]>
100100

101101
See [`property: TestProject.use`].
102+
103+
## property: FullProject.webServer
104+
* since: v1.66
105+
- type: <[Array]<[Object]>>
106+
107+
The servers configured in [`property: TestProject.webServer`], normalized to an array.
108+
Does not include top-level servers.
109+
This property is an empty array in merged reports because server configuration is not serialized.

‎docs/src/test-api/class-testconfig.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,9 @@ export default defineConfig({
736736

737737
Launch a development web server (or multiple) during the tests.
738738

739+
Top-level servers start before `globalSetup` and stop after `globalTeardown`, regardless of project
740+
selection. To start servers only for particular projects, use [`property: TestProject.webServer`].
741+
739742
**Details**
740743

741744
If the port is specified, Playwright Test will wait for it to be available on `127.0.0.1` or `::1`, before running the tests. If the url is specified, Playwright Test will wait for the URL to return a 2xx, 3xx, 400, 401, 402, or 403 status code before running the tests.

‎docs/src/test-api/class-testproject.md‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Playwright Test supports running multiple test projects at the same time. This is useful for running tests in multiple configurations. For example, consider running tests against multiple browsers. This type describes format of a project in the configuration file, to access resolved configuration parameters at run time use [FullProject].
66

7-
`TestProject` encapsulates configuration specific to a single project. Projects are configured in [`property: TestConfig.projects`] specified in the [configuration file](../test-configuration.md). Note that all properties of [TestProject] are available in the top-level [TestConfig], in which case they are shared between all projects.
7+
`TestProject` encapsulates configuration specific to a single project. Projects are configured in [`property: TestConfig.projects`] specified in the [configuration file](../test-configuration.md). Note that all properties of [TestProject] are available in the top-level [TestConfig], in which case they are shared between all projects. The exception is [`property: TestProject.webServer`]: top-level servers are started separately, for the whole run.
88

99
Here is an example configuration that runs every test in Chromium, Firefox and WebKit, both Desktop and Mobile versions.
1010

@@ -428,6 +428,53 @@ export default defineConfig({
428428

429429
Use [`property: TestConfig.use`] to change this option for all projects.
430430

431+
## property: TestProject.webServer
432+
* since: v1.66
433+
- type: ?<[Object]|[Array]<[Object]>>
434+
435+
Launch a development web server (or multiple) for this project. Accepts the same options as
436+
[`property: TestConfig.webServer`].
437+
438+
Project servers are added to the top-level servers, not inherited from them or used to replace them.
439+
They start after `globalSetup` and stop before its returned teardown function and `globalTeardown`.
440+
In the command line, project selection controls which servers start, including servers for
441+
dependencies and teardown projects. Test filters and sharding do not affect server startup.
442+
Listing tests does not start project servers. In UI mode and VS Code, all project servers start
443+
with global setup and stay alive across test runs until global teardown or configuration reload.
444+
445+
[`property: FullProject.webServer`] exposes these settings as an array, including in `globalSetup`.
446+
Project servers are not available during `globalSetup` or `globalTeardown`.
447+
448+
For a single server with `port` and no `url`, the project's [`property: TestOptions.baseURL`] defaults
449+
to `http://localhost:<port>`. An explicitly configured `baseURL` takes precedence. For arrays or
450+
servers with `url`, configure `baseURL` explicitly.
451+
452+
**Usage**
453+
454+
```js title="playwright.config.ts"
455+
import { defineConfig } from '@playwright/test';
456+
457+
export default defineConfig({
458+
projects: [
459+
{
460+
name: 'local',
461+
webServer: {
462+
command: 'npm run start',
463+
url: 'http://localhost:3000',
464+
reuseExistingServer: !process.env.CI,
465+
},
466+
use: { baseURL: 'http://localhost:3000' },
467+
},
468+
{
469+
name: 'production',
470+
use: { baseURL: 'https://production.example' },
471+
},
472+
],
473+
});
474+
```
475+
476+
Running `npx playwright test --project=production` does not start the local server.
477+
431478
## property: TestProject.workers
432479
* since: v1.52
433480
- type: ?<[int]|[string]>

‎docs/src/test-webserver-js.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ title: "Web server"
77

88
Playwright comes with a `webServer` option in the config file which gives you the ability to launch a local dev server before running your tests. This is ideal for when writing your tests during development and when you don't have a staging or production url to test against.
99

10+
To start a server only when a particular project runs, configure
11+
[`property: TestProject.webServer`] inside that project. Top-level servers start regardless of the
12+
selected projects. Put servers shared by multiple projects at the top level.
13+
1014
## Configuring a web server
1115

1216
Use the `webServer` property in your Playwright config to launch a development web server during the tests.

‎packages/playwright/src/common/config.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export class FullConfigInternal {
131131
}
132132

133133
// When no projects are defined, do not use config.workers as a hard limit for project.workers.
134-
const projectConfigs = configCLIOverrides.projects || userConfig.projects || [{ ...userConfig, workers: undefined }];
134+
const projectConfigs = configCLIOverrides.projects || userConfig.projects || [{ ...userConfig, workers: undefined, webServer: undefined }];
135135
this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir));
136136
resolveProjectDependencies(this.projects);
137137
this._assignUniqueProjectIds(this.projects);
@@ -175,6 +175,9 @@ export class FullProjectInternal {
175175
this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate);
176176

177177
const use = mergeObjects(config.use, projectConfig.use, configCLIOverrides.use);
178+
const webServer = projectConfig.webServer;
179+
if (use.baseURL === undefined && webServer && !Array.isArray(webServer) && webServer.port && !webServer.url)
180+
use.baseURL = `http://localhost:${webServer.port}`;
178181
// `--trace <mode>` only forces the tracing mode, preserving other trace options from the config.
179182
const configTrace = takeFirst((projectConfig.use as Partial<PlaywrightWorkerOptions> | undefined)?.trace, (config.use as Partial<PlaywrightWorkerOptions> | undefined)?.trace);
180183
if (typeof configCLIOverrides.use?.trace === 'string' && typeof configTrace === 'object' && configTrace)
@@ -199,7 +202,12 @@ export class FullProjectInternal {
199202
dependencies: projectConfig.dependencies || [],
200203
teardown: projectConfig.teardown,
201204
ignoreSnapshots: takeFirst(configCLIOverrides.ignoreSnapshots, projectConfig.ignoreSnapshots, config.ignoreSnapshots, false),
205+
webServer: webServer ? (Array.isArray(webServer) ? webServer : [webServer]) : [],
202206
};
207+
for (const server of this.project.webServer) {
208+
if (server.port !== undefined && server.url !== undefined)
209+
throw new Error(`Either 'port' or 'url' should be specified in projects[${this.project.name}].webServer.`);
210+
}
203211
this.fullyParallel = takeFirst(configCLIOverrides.fullyParallel, projectConfig.fullyParallel, config.fullyParallel, undefined);
204212
this.expect = takeFirst(projectConfig.expect, config.expect, {});
205213
if (this.expect.toHaveScreenshot?.stylePath) {

‎packages/playwright/src/isomorphic/teleReceiver.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@ export class TeleReporterReceiver {
486486
snapshotDir: this._absolutePath(project.snapshotDir),
487487
ignoreSnapshots: project.ignoreSnapshots ?? false,
488488
use: project.use,
489+
webServer: [],
489490
};
490491
}
491492

‎packages/playwright/src/plugins/webServerPlugin.ts‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { launchProcess } from '@utils/processLauncher';
2727

2828
import type { TestRunnerPlugin } from '.';
2929
import type { FullConfig } from '../../types/testReporter';
30+
import type { FullProject } from '../../types/test';
3031
import type { FullConfigInternal } from '../common';
3132
import type { ReporterV2 } from '../reporters/reporterV2';
3233

@@ -59,14 +60,16 @@ export class WebServerPlugin implements TestRunnerPlugin {
5960
private _processExitedPromise!: Promise<any>;
6061
private _options: WebServerPluginOptions;
6162
private _checkPortOnly: boolean;
63+
private _configLabel: string;
6264
private _reporter?: ReporterV2;
6365
private _waitForStdioPromise: ManualPromise | undefined;
6466

6567
name = 'playwright:webserver';
6668

67-
constructor(options: WebServerPluginOptions, checkPortOnly: boolean) {
69+
constructor(options: WebServerPluginOptions, checkPortOnly: boolean, configLabel = 'config.webServer') {
6870
this._options = options;
6971
this._checkPortOnly = checkPortOnly;
72+
this._configLabel = configLabel;
7073
}
7174

7275
public async setup(config: FullConfig, configDir: string, reporter: ReporterV2) {
@@ -99,11 +102,11 @@ export class WebServerPlugin implements TestRunnerPlugin {
99102
if (this._options.reuseExistingServer)
100103
return;
101104
const port = new URL(this._options.url!).port;
102-
throw new Error(`${this._options.url ?? `http://localhost${port ? ':' + port : ''}`} is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.`);
105+
throw new Error(`${this._options.url ?? `http://localhost${port ? ':' + port : ''}`} is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in ${this._configLabel}.`);
103106
}
104107

105108
if (!this._options.command)
106-
throw new Error('config.webServer.command cannot be empty');
109+
throw new Error(`${this._configLabel}.command cannot be empty`);
107110

108111
debugWebServer(`Starting WebServer process ${this._options.command}...`);
109112
const { launchedProcess, gracefullyClose } = await launchProcess({
@@ -139,7 +142,7 @@ export class WebServerPlugin implements TestRunnerPlugin {
139142
});
140143
},
141144
log: () => {},
142-
onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')),
145+
onExit: code => processExitedReject(new Error(code ? `Process from ${this._configLabel} was not able to start. Exit code: ${code}` : `Process from ${this._configLabel} exited early.`)),
143146
tempDirectories: [],
144147
});
145148
this._killProcess = gracefullyClose;
@@ -205,7 +208,7 @@ export class WebServerPlugin implements TestRunnerPlugin {
205208
const { timedOut } = await Promise.race(racingPromises);
206209
cancellationToken.canceled = true;
207210
if (timedOut)
208-
throw new Error(`Timed out waiting ${launchTimeout}ms from config.webServer.`);
211+
throw new Error(`Timed out waiting ${launchTimeout}ms from ${this._configLabel}.`);
209212
debugWebServer(`WebServer available`);
210213
}
211214
}
@@ -260,11 +263,18 @@ export const webServer = (options: WebServerPluginOptions): TestRunnerPlugin =>
260263
};
261264

262265
export const webServerPluginsForConfig = (config: FullConfigInternal): TestRunnerPlugin[] => {
263-
const shouldSetBaseUrl = !!config.config.webServer;
266+
return webServerPluginsForServers(config.webServers, !!config.config.webServer, 'config.webServer');
267+
};
268+
269+
export const webServerPluginsForProject = (project: FullProject): TestRunnerPlugin[] => {
270+
return webServerPluginsForServers(project.webServer, false, `projects[${project.name}].webServer`);
271+
};
272+
273+
function webServerPluginsForServers(webServers: FullProject['webServer'], shouldSetBaseUrl: boolean, configLabel: string): TestRunnerPlugin[] {
264274
const webServerPlugins = [];
265-
for (const webServerConfig of config.webServers) {
275+
for (const webServerConfig of webServers) {
266276
if (webServerConfig.port && webServerConfig.url)
267-
throw new Error(`Either 'port' or 'url' should be specified in config.webServer.`);
277+
throw new Error(`Either 'port' or 'url' should be specified in ${configLabel}.`);
268278

269279
let url: string | undefined;
270280
if (webServerConfig.port || webServerConfig.url) {
@@ -274,11 +284,11 @@ export const webServerPluginsForConfig = (config: FullConfigInternal): TestRunne
274284
if (shouldSetBaseUrl && !webServerConfig.url)
275285
process.env.PLAYWRIGHT_TEST_BASE_URL = url;
276286
}
277-
webServerPlugins.push(new WebServerPlugin({ ...webServerConfig, url }, webServerConfig.port !== undefined));
287+
webServerPlugins.push(new WebServerPlugin({ ...webServerConfig, url }, webServerConfig.port !== undefined, configLabel));
278288
}
279289

280290
return webServerPlugins;
281-
};
291+
}
282292

283293
function prefixOutputLines(output: string, prefixName: string = 'WebServer'): string {
284294
const lastIsNewLine = output[output.length - 1] === '\n';

‎packages/playwright/src/reporters/teleEmitter.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,6 @@ export class TeleReporterEmitter implements ReporterV2 {
182182
globalSetup: config.globalSetup,
183183
globalTeardown: config.globalTeardown,
184184
tags: config.tags,
185-
webServer: config.webServer,
186185
failOnFlakyTests: config.failOnFlakyTests,
187186
};
188187
}

‎packages/playwright/src/runner/tasks.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import { removeFolders } from '@utils/fileUtils';
2525

2626
import { Dispatcher } from './dispatcher';
2727
import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook, loadTestList } from './loadUtils';
28-
import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils';
28+
import { buildDependentProjects, buildProjectsClosure, buildTeardownToSetupsMap, filterProjects } from './projectUtils';
2929
import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase';
3030
import { TaskRunner } from './taskRunner';
3131
import { detectChangedTestFiles } from './vcs';
3232
import { cc, config as commonConfig, FullConfigInternal, suiteUtils, test as testNs } from '../common';
3333
import { createTestGroups, shuffleTestGroups } from '../runner/testGroups';
3434
import { createTitleMatcher, forceRegExp, removeDirAndLogToConsole } from '../util';
35+
import { webServerPluginsForProject } from '../plugins/webServerPlugin';
3536

3637
import type { TestGroup } from '../runner/testGroups';
3738
import type { EnvByProjectId } from './dispatcher';
@@ -159,6 +160,7 @@ export function createGlobalSetupTasks(config: FullConfigInternal) {
159160
...createPluginSetupTasks(config),
160161
...config.globalTeardowns.map(file => createGlobalTeardownTask(file, config)).reverse(),
161162
...config.globalSetups.map(file => createGlobalSetupTask(file, config)),
163+
...createProjectWebServerSetupTasks(config),
162164
];
163165
}
164166

@@ -170,6 +172,26 @@ export function createRunTestsTasks(config: FullConfigInternal) {
170172
];
171173
}
172174

175+
function createProjectWebServerSetupTasks(config: FullConfigInternal): Task<TestRun>[] {
176+
return config.projects.flatMap(project => webServerPluginsForProject(project.project).map(plugin => {
177+
let started = false;
178+
return {
179+
title: 'project web server setup',
180+
setup: async ({ filteredProjects, reporter }) => {
181+
const projects = buildProjectsClosure(filteredProjects);
182+
if (!projects.has(project))
183+
return;
184+
started = true;
185+
await plugin.setup?.(config.config, config.configDir, reporter);
186+
},
187+
teardown: async () => {
188+
if (started)
189+
await plugin.teardown?.();
190+
},
191+
};
192+
}));
193+
}
194+
173195
export function createClearCacheTask(config: FullConfigInternal): Task<TestRun> {
174196
return {
175197
title: 'clear cache',

0 commit comments

Comments
 (0)