diff --git a/packages/playwright-test/package.json b/packages/playwright-test/package.json index d12a44a4fd..798e24989d 100644 --- a/packages/playwright-test/package.json +++ b/packages/playwright-test/package.json @@ -19,6 +19,7 @@ "./lib/cli": "./lib/cli.js", "./lib/experimentalLoader": "./lib/experimentalLoader.js", "./lib/mount": "./lib/mount.js", + "./lib/plugins": "./lib/plugins/index.js", "./lib/plugins/vitePlugin": "./lib/plugins/vitePlugin.js", "./reporter": "./reporter.js" }, diff --git a/packages/playwright-test/src/DEPS.list b/packages/playwright-test/src/DEPS.list index 5408da9b82..a27fa8b5fd 100644 --- a/packages/playwright-test/src/DEPS.list +++ b/packages/playwright-test/src/DEPS.list @@ -3,3 +3,4 @@ matchers/ reporters/ third_party/ +plugins/webServerPlugin.ts diff --git a/packages/playwright-test/src/loader.ts b/packages/playwright-test/src/loader.ts index 2344d8c971..588e4d38ae 100644 --- a/packages/playwright-test/src/loader.ts +++ b/packages/playwright-test/src/loader.ts @@ -30,6 +30,7 @@ import type { Reporter } from '../types/testReporter'; import { builtInReporters } from './runner'; import { isRegExp } from 'playwright-core/lib/utils'; import { serializeError } from './util'; +import { _legacyWebServer } from './plugins/webServerPlugin'; // To allow multiple loaders in the same process without clearing require cache, // we make these maps global. @@ -76,6 +77,11 @@ export class Loader { } private _processConfigObject(config: Config, configDir: string) { + if (config.webServer) { + config.plugins = config.plugins || []; + config.plugins.push(_legacyWebServer(config.webServer)); + } + for (const plugin of config.plugins || []) plugin.configure?.(config, configDir); diff --git a/packages/playwright-test/src/plugins/index.ts b/packages/playwright-test/src/plugins/index.ts new file mode 100644 index 0000000000..a337ddc086 --- /dev/null +++ b/packages/playwright-test/src/plugins/index.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { webServer } from './webServerPlugin'; diff --git a/packages/playwright-test/src/webServer.ts b/packages/playwright-test/src/plugins/webServerPlugin.ts similarity index 64% rename from packages/playwright-test/src/webServer.ts rename to packages/playwright-test/src/plugins/webServerPlugin.ts index 816e3a69a9..d7710923c3 100644 --- a/packages/playwright-test/src/webServer.ts +++ b/packages/playwright-test/src/plugins/webServerPlugin.ts @@ -13,15 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import http from 'http'; import https from 'https'; +import path from 'path'; import net from 'net'; + import { debug } from 'playwright-core/lib/utilsBundle'; import { raceAgainstTimeout } from 'playwright-core/lib/utils/timeoutRunner'; -import type { FullConfig } from './types'; import { launchProcess } from 'playwright-core/lib/utils/processLauncher'; -import type { Reporter } from '../types/testReporter'; + +import type { PlaywrightTestConfig, TestPlugin } from '../types'; +import type { Reporter } from '../../types/testReporter'; + +export type WebServerPluginOptions = { + command: string; + url: string; + ignoreHTTPSErrors?: boolean; + timeout?: number; + reuseExistingServer?: boolean; + cwd?: string; + env?: { [key: string]: string; }; +}; + +interface InternalWebServerConfigOptions extends Omit { + setBaseURL: boolean; + url?: string; + port?: number; +} const DEFAULT_ENVIRONMENT_VARIABLES = { 'BROWSER': 'none', // Disable that create-react-app will open the page in the browser @@ -29,29 +47,47 @@ const DEFAULT_ENVIRONMENT_VARIABLES = { const debugWebServer = debug('pw:webserver'); -type WebServerConfig = NonNullable; - -export class WebServer { +export class InternalWebServerPlugin implements TestPlugin { private _isAvailable: () => Promise; private _killProcess?: () => Promise; private _processExitedPromise!: Promise; + private _config: InternalWebServerConfigOptions; + private _reporter: Reporter; - constructor(private readonly config: WebServerConfig, private readonly reporter: Reporter) { - this._isAvailable = getIsAvailableFunction(config, reporter.onStdErr?.bind(reporter)); + constructor(config: InternalWebServerConfigOptions, reporter: Reporter) { + this._reporter = reporter; + this._config = { ...config }; + this._config.setBaseURL = this._config.setBaseURL ?? true; + this._isAvailable = getIsAvailableFunction(config, this._reporter.onStdErr?.bind(this._reporter)); } - public static async create(config: WebServerConfig, reporter: Reporter): Promise { - const webServer = new WebServer(config, reporter); + get name() { + const target = this._config.url || `http://localhost:${this._config.port}`; + return `playwright-webserver-plugin [${target}]`; + } + + public async configure(config: PlaywrightTestConfig, configDir: string) { + this._config.cwd = this._config.cwd ? path.resolve(configDir, this._config.cwd) : configDir; + if (this._config.setBaseURL && this._config.port !== undefined && !config.use?.baseURL) { + config.use = (config.use || {}); + config.use.baseURL = `http://localhost:${this._config.port}`; + } + } + + public async setup() { try { - await webServer._startProcess(); - await webServer._waitForProcess(); - return webServer; + await this._startProcess(); + await this._waitForProcess(); } catch (error) { - await webServer.kill(); + await this.teardown(); throw error; } } + public async teardown() { + await this._killProcess?.(); + } + private async _startProcess(): Promise { let processExitedReject = (error: Error) => { }; this._processExitedPromise = new Promise((_, reject) => processExitedReject = reject); @@ -59,20 +95,20 @@ export class WebServer { const isAlreadyAvailable = await this._isAvailable(); if (isAlreadyAvailable) { debugWebServer(`WebServer is already available`); - if (this.config.reuseExistingServer) + if (this._config.reuseExistingServer) return; - throw new Error(`${this.config.url ?? `http://localhost:${this.config.port}`} is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.`); + throw new Error(`${this._config.url ?? `http://localhost:${this._config.port}`} is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.`); } - debugWebServer(`Starting WebServer process ${this.config.command}...`); + debugWebServer(`Starting WebServer process ${this._config.command}...`); const { launchedProcess, kill } = await launchProcess({ - command: this.config.command, + command: this._config.command, env: { ...DEFAULT_ENVIRONMENT_VARIABLES, ...process.env, - ...this.config.env, + ...this._config.env, }, - cwd: this.config.cwd, + cwd: this._config.cwd, stdio: 'stdin', shell: true, attemptToGracefullyClose: async () => {}, @@ -84,10 +120,10 @@ export class WebServer { debugWebServer(`Process started`); - launchedProcess.stderr!.on('data', line => this.reporter.onStdErr?.('[WebServer] ' + line.toString())); + launchedProcess.stderr!.on('data', line => this._reporter.onStdErr?.('[WebServer] ' + line.toString())); launchedProcess.stdout!.on('data', line => { if (debugWebServer.enabled) - this.reporter.onStdOut?.('[WebServer] ' + line.toString()); + this._reporter.onStdOut?.('[WebServer] ' + line.toString()); }); } @@ -95,12 +131,10 @@ export class WebServer { debugWebServer(`Waiting for availability...`); await this._waitForAvailability(); debugWebServer(`WebServer available`); - if (this.config.port !== undefined) - process.env.PLAYWRIGHT_TEST_BASE_URL = `http://localhost:${this.config.port}`; } private async _waitForAvailability() { - const launchTimeout = this.config.timeout || 60 * 1000; + const launchTimeout = this._config.timeout || 60 * 1000; const cancellationToken = { canceled: false }; const { timedOut } = (await Promise.race([ raceAgainstTimeout(() => waitFor(this._isAvailable, cancellationToken), launchTimeout), @@ -110,9 +144,6 @@ export class WebServer { if (timedOut) throw new Error(`Timed out waiting ${launchTimeout}ms from config.webServer.`); } - public async kill() { - await this._killProcess?.(); - } } async function isPortUsed(port: number): Promise { @@ -173,7 +204,7 @@ async function waitFor(waitFn: () => Promise, cancellationToken: { canc } } -function getIsAvailableFunction({ url, port, ignoreHTTPSErrors }: Pick, onStdErr: Reporter['onStdErr']) { +function getIsAvailableFunction({ url, port, ignoreHTTPSErrors }: Pick, onStdErr: Reporter['onStdErr']) { if (url !== undefined && port === undefined) { const urlObject = new URL(url); return () => isURLAvailable(urlObject, ignoreHTTPSErrors, onStdErr); @@ -183,3 +214,13 @@ function getIsAvailableFunction({ url, port, ignoreHTTPSErrors }: Pick { + // eslint-disable-next-line no-console + return new InternalWebServerPlugin({ ...config, setBaseURL: false }, { onStdOut: d => console.log(d.toString()), onStdErr: d => console.error(d.toString()) }); +}; + +export const _legacyWebServer = (config: Omit): TestPlugin => { + // eslint-disable-next-line no-console + return new InternalWebServerPlugin({ ...config, setBaseURL: true }, { onStdOut: d => console.log(d.toString()), onStdErr: d => console.error(d.toString()) }); +}; diff --git a/packages/playwright-test/src/runner.ts b/packages/playwright-test/src/runner.ts index 690a450324..0c978db0e2 100644 --- a/packages/playwright-test/src/runner.ts +++ b/packages/playwright-test/src/runner.ts @@ -40,7 +40,6 @@ import HtmlReporter from './reporters/html'; import type { ProjectImpl } from './project'; import type { Config } from './types'; import type { FullConfigInternal } from './types'; -import { WebServer } from './webServer'; import { raceAgainstTimeout } from 'playwright-core/lib/utils/timeoutRunner'; import { SigIntWatcher } from './sigIntWatcher'; import { GlobalInfoImpl } from './globalInfo'; @@ -414,7 +413,6 @@ export class Runner { const result: FullResult = { status: 'passed' }; const pluginTeardowns: (() => Promise)[] = []; let globalSetupResult: any; - let webServer: WebServer | undefined; const tearDown = async () => { // Reverse to setup. @@ -428,10 +426,6 @@ export class Runner { await (await this._loader.loadGlobalHook(config.globalTeardown, 'globalTeardown'))(this._loader.fullConfig()); }, result); - await this._runAndReportError(async () => { - await webServer?.kill(); - }, result); - for (const teardown of pluginTeardowns) { await this._runAndReportError(async () => { await teardown(); @@ -448,9 +442,6 @@ export class Runner { pluginTeardowns.unshift(plugin.teardown); } - // Then do legacy web server. - webServer = config.webServer ? await WebServer.create(config.webServer, this._reporter) : undefined; - // The do global setup. if (config.globalSetup) globalSetupResult = await (await this._loader.loadGlobalHook(config.globalSetup, 'globalSetup'))(this._loader.fullConfig(), this._globalInfo); diff --git a/tests/playwright-test/assets/simple-server.js b/tests/playwright-test/assets/simple-server.js index 3772aa6490..bea8268a74 100644 --- a/tests/playwright-test/assets/simple-server.js +++ b/tests/playwright-test/assets/simple-server.js @@ -10,5 +10,8 @@ setTimeout(() => { server.setRoute('/env-FOO', (message, response) => { response.end(process.env.FOO); }); + server.setRoute('/port', (_, response) => { + response.end('' + server.PORT); + }); }); }, process.argv[3] ? +process.argv[3] : 0); diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index b334bfe347..6b0791d205 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -426,3 +426,131 @@ test(`should suport self signed certificate`, async ({ runInlineTest, httpsServe }); expect(result.exitCode).toBe(0); }); + +test('should create multiple servers', async ({ runInlineTest }, { workerIndex }) => { + const port = workerIndex + 10500; + const result = await runInlineTest({ + 'test.spec.ts': ` + const { test } = pwt; + test('connect to the server', async ({page}) => { + await page.goto('http://localhost:${port}/port'); + await page.locator('text=${port}'); + + await page.goto('http://localhost:${port + 1}/port'); + await page.locator('text=${port + 1}'); + }); + `, + 'playwright.config.ts': ` + import { webServer } from '@playwright/test/lib/plugins'; + module.exports = { + plugins: [ + webServer({ + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port}', + url: 'http://localhost:${port}/port', + }), + webServer({ + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port + 1}', + url: 'http://localhost:${port + 1}/port', + }), + ], + globalSetup: 'globalSetup.ts', + globalTeardown: 'globalTeardown.ts', + }; + `, + 'globalSetup.ts': ` + module.exports = async () => { + const http = require("http"); + const response = await new Promise(resolve => { + const request = http.request("http://localhost:${port}/hello", resolve); + request.end(); + }) + console.log('globalSetup-status-'+response.statusCode) + return async () => { + const response = await new Promise(resolve => { + const request = http.request("http://localhost:${port}/hello", resolve); + request.end(); + }) + console.log('globalSetup-teardown-status-'+response.statusCode) + }; + }; + `, + 'globalTeardown.ts': ` + module.exports = async () => { + const http = require("http"); + const response = await new Promise(resolve => { + const request = http.request("http://localhost:${port}/hello", resolve); + request.end(); + }) + console.log('globalTeardown-status-'+response.statusCode) + }; + `, + }, undefined, { DEBUG: 'pw:webserver' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('[WebServer] listening'); + expect(result.output).toContain('[WebServer] error from server'); + expect(result.output).toContain('passed'); + + const expectedLogMessages = ['globalSetup-status-200', 'globalSetup-teardown-status', 'globalTeardown-status-200']; + const actualLogMessages = expectedLogMessages.map(log => ({ + log, + index: result.output.indexOf(log), + })).sort((a, b) => a.index - b.index).filter(l => l.index !== -1).map(l => l.log); + expect(actualLogMessages).toStrictEqual(expectedLogMessages); +}); + +test.describe('baseURL with plugins', () => { + test('plugins do not set it', async ({ runInlineTest }, { workerIndex }) => { + const port = workerIndex + 10500; + const result = await runInlineTest({ + 'test.spec.ts': ` + const { test } = pwt; + test('connect to the server', async ({baseURL, page}) => { + expect(baseURL).toBeUndefined(); + }); + `, + 'playwright.config.ts': ` + import { webServer } from '@playwright/test/lib/plugins'; + module.exports = { + plugins: [ + webServer({ + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port}', + url: 'http://localhost:${port}/port', + }), + ], + }; + `, + }, undefined, { DEBUG: 'pw:webserver' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + }); + + test('legacy config sets it alongside plugin', async ({ runInlineTest }, { workerIndex }) => { + const port = workerIndex + 10500; + const result = await runInlineTest({ + 'test.spec.ts': ` + const { test } = pwt; + test('connect to the server', async ({baseURL, page}) => { + expect(baseURL).toBe('http://localhost:${port}'); + }); + `, + 'playwright.config.ts': ` + import { webServer } from '@playwright/test/lib/plugins'; + module.exports = { + plugins: [ + webServer({ + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port + 1}', + url: 'http://localhost:${port + 1}/port' + }), + ], + webServer: { + command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port}', + port: ${port}, + } + }; + `, + }, undefined, { DEBUG: 'pw:webserver' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + }); +});