Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (?<my_server_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 (?<my_server_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.

20 changes: 1 addition & 19 deletions docs/src/test-api/class-testconfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (?<my_server_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 (?<my_server_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.

Expand Down
42 changes: 42 additions & 0 deletions docs/src/test-api/class-testproject.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]>
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright/src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -161,6 +162,7 @@ export class FullProjectInternal {
readonly respectGitIgnore: boolean;
readonly snapshotPathTemplate: string | undefined;
readonly workers: number | undefined;
readonly webServers: NonNullable<FullConfig['webServer']>[];
id = '';
deps: FullProjectInternal[] = [];
teardown: FullProjectInternal | undefined;
Expand All @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions packages/playwright/src/common/configLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export interface TestRunnerPlugin {
export type TestRunnerPluginRegistration = {
factory: TestRunnerPlugin | (() => TestRunnerPlugin | Promise<TestRunnerPlugin>);
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<string>;
Comment thread
yury-s marked this conversation as resolved.
Outdated
};

export { webServer } from './webServerPlugin';
42 changes: 25 additions & 17 deletions packages/playwright/src/plugins/webServerPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
36 changes: 26 additions & 10 deletions packages/playwright/src/runner/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -95,12 +95,28 @@ export class TestRun {
readonly loadFileFilters: Matcher[] = [];
readonly preOnlyTestFilters: TestCaseFilter[] = [];
readonly postShardTestFilters: TestCaseFilter[] = [];
readonly projectClosureIds: Set<string>;

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[] {
Comment thread
yury-s marked this conversation as resolved.
Outdated
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) {
Expand Down Expand Up @@ -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(),
];
}
Expand All @@ -187,15 +203,15 @@ export function createReportBeginTask(): Task<TestRun> {
};
}

export function createPluginSetupTasks(config: FullConfigInternal): Task<TestRun>[] {
return config.plugins.map(plugin => ({
export function createPluginSetupTasks(config: FullConfigInternal, testRun: TestRun): Task<TestRun>[] {
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?.();
Expand Down Expand Up @@ -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?.();
}

Expand Down
Loading
Loading