diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 37b9ca5f27..90425fcf4c 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -629,6 +629,9 @@ export default defineConfig({ - `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"`. - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. + - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. + - `signal` <["SIGINT"|"SIGTERM"]> + - `timeout` <[int]> - `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-webserver-js.md b/docs/src/test-webserver-js.md index bf01a5cd27..f4c86197c0 100644 --- a/docs/src/test-webserver-js.md +++ b/docs/src/test-webserver-js.md @@ -37,6 +37,7 @@ export default defineConfig({ | `stdout` | 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"`. | | `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. | | `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. | +| `gracefulShutdown` | How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. | ## Adding a server timeout diff --git a/packages/playwright-core/src/utils/processLauncher.ts b/packages/playwright-core/src/utils/processLauncher.ts index 4e6c1030b2..1310f95277 100644 --- a/packages/playwright-core/src/utils/processLauncher.ts +++ b/packages/playwright-core/src/utils/processLauncher.ts @@ -180,7 +180,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise {}; const waitForCleanup = new Promise(f => fulfillCleanup = f); - spawnedProcess.once('exit', (exitCode, signal) => { + spawnedProcess.once('close', (exitCode, signal) => { options.log(`[pid=${spawnedProcess.pid}] `); processClosed = true; gracefullyCloseSet.delete(gracefullyClose); diff --git a/packages/playwright/src/plugins/webServerPlugin.ts b/packages/playwright/src/plugins/webServerPlugin.ts index e2474f35f2..002ad235bd 100644 --- a/packages/playwright/src/plugins/webServerPlugin.ts +++ b/packages/playwright/src/plugins/webServerPlugin.ts @@ -30,6 +30,7 @@ export type WebServerPluginOptions = { url?: string; ignoreHTTPSErrors?: boolean; timeout?: number; + gracefulShutdown?: { signal: 'SIGINT' | 'SIGTERM', timeout?: number }; reuseExistingServer?: boolean; cwd?: string; env?: { [key: string]: string; }; @@ -92,7 +93,7 @@ export class WebServerPlugin implements TestRunnerPlugin { } debugWebServer(`Starting WebServer process ${this._options.command}...`); - const { launchedProcess, kill } = await launchProcess({ + const { launchedProcess, gracefullyClose } = await launchProcess({ command: this._options.command, env: { ...DEFAULT_ENVIRONMENT_VARIABLES, @@ -102,14 +103,33 @@ export class WebServerPlugin implements TestRunnerPlugin { cwd: this._options.cwd, stdio: 'stdin', shell: true, - // Reject to indicate that we cannot close the web server gracefully - // and should fallback to non-graceful shutdown. - attemptToGracefullyClose: () => Promise.reject(), + attemptToGracefullyClose: async () => { + if (process.platform === 'win32') + throw new Error('Graceful shutdown is not supported on Windows'); + if (!this._options.gracefulShutdown) + throw new Error('skip graceful shutdown'); + + const { signal, timeout = 0 } = this._options.gracefulShutdown; + + // proper usage of SIGINT is to send it to the entire process group, see https://www.cons.org/cracauer/sigint.html + // there's no such convention for SIGTERM, so we decide what we want. signaling the process group for consistency. + process.kill(-launchedProcess.pid!, signal); + + return new Promise((resolve, reject) => { + const timer = timeout !== 0 + ? setTimeout(() => reject(new Error(`process didn't close gracefully within timeout`)), timeout) + : undefined; + launchedProcess.once('close', (...args) => { + clearTimeout(timer); + resolve(); + }); + }); + }, log: () => {}, onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')), tempDirectories: [], }); - this._killProcess = kill; + this._killProcess = gracefullyClose; debugWebServer(`Process started`); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 7c9cae5415..cff8c8ca60 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -9657,6 +9657,18 @@ interface TestConfigWebServer { */ timeout?: number; + /** + * How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: + * 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit + * within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't + * support `SIGINT` and `SIGTERM` signals, so this option is ignored. + */ + gracefulShutdown?: { + signal: "SIGINT"|"SIGTERM"; + + timeout: number; + }; + /** * 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 diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index 04e3c1d328..6caed12a42 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -17,6 +17,7 @@ import type http from 'http'; import path from 'path'; import { test, expect, parseTestRunnerOutput } from './playwright-test-fixtures'; +import type { RunResult } from './playwright-test-fixtures'; import { createHttpServer } from '../../packages/playwright-core/lib/utils/network'; const SIMPLE_SERVER_PATH = path.join(__dirname, 'assets', 'simple-server.js'); @@ -744,3 +745,66 @@ test('should forward stdout when set to "pipe" before server is ready', async ({ expect(result.output).toContain('[WebServer] output from server'); expect(result.output).not.toContain('Timed out waiting 3000ms'); }); + +test.describe('gracefulShutdown option', () => { + test.skip(process.platform === 'win32', 'No sending SIGINT on Windows'); + + const files = (additionalOptions = {}) => { + const port = test.info().workerIndex * 2 + 10510; + return { + 'child.js': ` + process.on('SIGINT', () => { console.log('%%childprocess received SIGINT'); setTimeout(() => process.exit(), 10) }) + process.on('SIGTERM', () => { console.log('%%childprocess received SIGTERM'); setTimeout(() => process.exit(), 10) }) + setTimeout(() => {}, 100000) // prevent child from exiting + `, + 'web-server.js': ` + require("node:child_process").fork('./child.js', { silent: false }) + + process.on('SIGINT', () => { + console.log('%%webserver received SIGINT but stubbornly refuses to wind down') + }) + process.on('SIGTERM', () => { + console.log('%%webserver received SIGTERM but stubbornly refuses to wind down') + }) + + const server = require("node:http").createServer((req, res) => { res.end("ok"); }) + server.listen(process.argv[2]); + `, + 'test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}) => {}); + `, + 'playwright.config.ts': ` + module.exports = { + webServer: { + command: 'echo some-precondition && node web-server.js ${port}', + port: ${port}, + stdout: 'pipe', + timeout: 3000, + ...${JSON.stringify(additionalOptions)} + }, + }; + `, + }; + }; + + function parseOutputLines(result: RunResult): string[] { + const prefix = '[WebServer] %%'; + return result.output.split('\n').filter(line => line.startsWith(prefix)).map(line => line.substring(prefix.length)); + } + + test('sends SIGKILL by default', async ({ runInlineTest }) => { + const result = await runInlineTest(files(), { workers: 1 }); + expect(parseOutputLines(result)).toEqual([]); + }); + + test('can be configured to send SIGTERM', async ({ runInlineTest }) => { + const result = await runInlineTest(files({ gracefulShutdown: { signal: 'SIGTERM', timeout: 500 } }), { workers: 1 }); + expect(parseOutputLines(result).sort()).toEqual(['childprocess received SIGTERM', 'webserver received SIGTERM but stubbornly refuses to wind down']); + }); + + test('can be configured to send SIGINT', async ({ runInlineTest }) => { + const result = await runInlineTest(files({ gracefulShutdown: { signal: 'SIGINT', timeout: 500 } }), { workers: 1 }); + expect(parseOutputLines(result).sort()).toEqual(['childprocess received SIGINT', 'webserver received SIGINT but stubbornly refuses to wind down']); + }); +});