chore: use test server as a singleton (#29630)

This commit is contained in:
Pavel Feldman 2024-02-22 15:56:38 -08:00 committed by GitHub
parent ee93136132
commit 2ca45ff948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 118 additions and 78 deletions

View File

@ -93,7 +93,7 @@ export async function deserializeConfig(data: SerializedConfig): Promise<FullCon
return config; return config;
} }
export async function loadUserConfig(location: ConfigLocation): Promise<Config> { async function loadUserConfig(location: ConfigLocation): Promise<Config> {
let object = location.resolvedConfigFile ? await requireOrImport(location.resolvedConfigFile) : {}; let object = location.resolvedConfigFile ? await requireOrImport(location.resolvedConfigFile) : {};
if (object && typeof object === 'object' && ('default' in object)) if (object && typeof object === 'object' && ('default' in object))
object = object['default']; object = object['default'];
@ -333,12 +333,12 @@ export async function loadEmptyConfigForMergeReports() {
return await loadConfig({ configDir: process.cwd() }); return await loadConfig({ configDir: process.cwd() });
} }
export function restartWithExperimentalTsEsm(configFile: string | undefined): boolean { export function restartWithExperimentalTsEsm(configFile: string | undefined, force: boolean = false): boolean {
const nodeVersion = +process.versions.node.split('.')[0]; const nodeVersion = +process.versions.node.split('.')[0];
// New experimental loader is only supported on Node 16+. // New experimental loader is only supported on Node 16+.
if (nodeVersion < 16) if (nodeVersion < 16)
return false; return false;
if (!configFile) if (!configFile && !force)
return false; return false;
if (process.env.PW_DISABLE_TS_ESM) if (process.env.PW_DISABLE_TS_ESM)
return false; return false;
@ -348,9 +348,10 @@ export function restartWithExperimentalTsEsm(configFile: string | undefined): bo
process.execArgv = execArgvWithoutExperimentalLoaderOptions(); process.execArgv = execArgvWithoutExperimentalLoaderOptions();
return false; return false;
} }
if (!fileIsModule(configFile)) if (!force && !fileIsModule(configFile!))
return false; return false;
// Node.js < 20
// Node.js < 20
if (!require('node:module').register) { if (!require('node:module').register) {
const innerProcess = (require('child_process') as typeof import('child_process')).fork(require.resolve('../../cli'), process.argv.slice(2), { const innerProcess = (require('child_process') as typeof import('child_process')).fork(require.resolve('../../cli'), process.argv.slice(2), {
env: { env: {

View File

@ -108,9 +108,8 @@ function addFindRelatedTestFilesCommand(program: Command) {
function addTestServerCommand(program: Command) { function addTestServerCommand(program: Command) {
const command = program.command('test-server', { hidden: true }); const command = program.command('test-server', { hidden: true });
command.description('start test server'); command.description('start test server');
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.action(() => {
command.action(options => { void runTestServer();
void runTestServer(options.config);
}); });
} }

View File

@ -19,8 +19,8 @@ import path from 'path';
import { ManualPromise, createGuid, gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils'; import { ManualPromise, createGuid, gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils';
import { WSServer } from 'playwright-core/lib/utils'; import { WSServer } from 'playwright-core/lib/utils';
import type { WebSocket } from 'playwright-core/lib/utilsBundle'; import type { WebSocket } from 'playwright-core/lib/utilsBundle';
import type { FullResult } from 'playwright/types/testReporter'; import type { FullResult, TestError } from 'playwright/types/testReporter';
import { loadConfig, resolveConfigFile, restartWithExperimentalTsEsm } from '../common/configLoader'; import { loadConfig, restartWithExperimentalTsEsm } from '../common/configLoader';
import { InternalReporter } from '../reporters/internalReporter'; import { InternalReporter } from '../reporters/internalReporter';
import { Multiplexer } from '../reporters/multiplexer'; import { Multiplexer } from '../reporters/multiplexer';
import { createReporters } from './reporters'; import { createReporters } from './reporters';
@ -28,33 +28,15 @@ import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer } from
import type { ConfigCLIOverrides } from '../common/ipc'; import type { ConfigCLIOverrides } from '../common/ipc';
import { Runner } from './runner'; import { Runner } from './runner';
import type { FindRelatedTestFilesReport } from './runner'; import type { FindRelatedTestFilesReport } from './runner';
import type { ConfigLocation } from '../common/config'; import type { FullConfigInternal } from '../common/config';
type PlaywrightTestOptions = { export async function runTestServer() {
headed?: boolean, if (restartWithExperimentalTsEsm(undefined, true))
oneWorker?: boolean,
trace?: 'on' | 'off',
projects?: string[];
grep?: string;
reuseContext?: boolean,
connectWsEndpoint?: string;
};
export async function runTestServer(configFile: string | undefined) {
process.env.PW_TEST_HTML_REPORT_OPEN = 'never';
const configFileOrDirectory = configFile ? path.resolve(process.cwd(), configFile) : process.cwd();
const resolvedConfigFile = resolveConfigFile(configFileOrDirectory);
if (restartWithExperimentalTsEsm(resolvedConfigFile))
return null; return null;
const configPaths: ConfigLocation = { process.env.PW_TEST_HTML_REPORT_OPEN = 'never';
resolvedConfigFile,
configDir: resolvedConfigFile ? path.dirname(resolvedConfigFile) : configFileOrDirectory
};
const wss = new WSServer({ const wss = new WSServer({
onConnection(request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) { onConnection(request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) {
const dispatcher = new Dispatcher(configPaths, ws); const dispatcher = new Dispatcher(ws);
ws.on('message', async message => { ws.on('message', async message => {
const { id, method, params } = JSON.parse(message.toString()); const { id, method, params } = JSON.parse(message.toString());
try { try {
@ -78,13 +60,49 @@ export async function runTestServer(configFile: string | undefined) {
process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0)); process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0));
} }
class Dispatcher { export interface TestServerInterface {
list(params: {
configFile: string;
locations: string[];
reporter: string;
env: NodeJS.ProcessEnv;
}): Promise<void>;
test(params: {
configFile: string;
locations: string[];
reporter: string;
env: NodeJS.ProcessEnv;
headed?: boolean;
oneWorker?: boolean;
trace?: 'on' | 'off';
projects?: string[];
grep?: string;
reuseContext?: boolean;
connectWsEndpoint?: string;
}): Promise<void>;
findRelatedTestFiles(params: {
configFile: string;
files: string[];
}): Promise<{ testFiles: string[]; errors?: TestError[]; }>;
stop(params: {
configFile: string;
}): Promise<void>;
closeGracefully(): Promise<void>;
}
export interface TestServerEvents {
on(event: 'stdio', listener: (params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }) => void): void;
}
class Dispatcher implements TestServerInterface {
private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined; private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined;
private _ws: WebSocket; private _ws: WebSocket;
private _configLocation: ConfigLocation;
constructor(configLocation: ConfigLocation, ws: WebSocket) { constructor(ws: WebSocket) {
this._configLocation = configLocation;
this._ws = ws; this._ws = ws;
process.stdout.write = ((chunk: string | Buffer, cb?: Buffer | Function, cb2?: Function) => { process.stdout.write = ((chunk: string | Buffer, cb?: Buffer | Function, cb2?: Function) => {
@ -105,32 +123,16 @@ class Dispatcher {
}) as any; }) as any;
} }
async list(params: { locations: string[], reporter: string, env: NodeJS.ProcessEnv }) { async list(params: {
for (const name in params.env) configFile: string;
process.env[name] = params.env[name]; locations: string[];
await this._listTests(params.reporter, params.locations); reporter: string;
} env: NodeJS.ProcessEnv;
}) {
async test(params: { locations: string[], options: PlaywrightTestOptions, reporter: string, env: NodeJS.ProcessEnv }) { this._syncEnv(params.env);
for (const name in params.env) const config = await this._loadConfig(params.configFile);
process.env[name] = params.env[name]; config.cliArgs = params.locations || [];
await this._runTests(params.reporter, params.locations, params.options); const reporter = new InternalReporter(new Multiplexer(await createReporters(config, 'list', [[params.reporter]])));
}
async findRelatedTestFiles(params: { files: string[] }): Promise<FindRelatedTestFilesReport> {
const config = await this._loadConfig({});
const runner = new Runner(config);
return runner.findRelatedTestFiles('out-of-process', params.files);
}
async stop() {
await this._stopTests();
}
private async _listTests(reporterPath: string, locations: string[] | undefined) {
const config = await this._loadConfig({});
config.cliArgs = [...(locations || []), '--reporter=null'];
const reporter = new InternalReporter(new Multiplexer(await createReporters(config, 'list', [[reporterPath]])));
const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: true }); const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: true });
const testRun = new TestRun(config, reporter); const testRun = new TestRun(config, reporter);
reporter.onConfigure(config.config); reporter.onConfigure(config.config);
@ -145,27 +147,41 @@ class Dispatcher {
await reporter.onExit(); await reporter.onExit();
} }
private async _runTests(reporterPath: string, locations: string[] | undefined, options: PlaywrightTestOptions) { async test(params: {
configFile: string;
locations: string[];
reporter: string;
env: NodeJS.ProcessEnv;
headed?: boolean;
oneWorker?: boolean;
trace?: 'on' | 'off';
projects?: string[];
grep?: string;
reuseContext?: boolean;
connectWsEndpoint?: string;
}) {
this._syncEnv(params.env);
await this._stopTests(); await this._stopTests();
const overrides: ConfigCLIOverrides = { const overrides: ConfigCLIOverrides = {
additionalReporters: [[reporterPath]], additionalReporters: [[params.reporter]],
repeatEach: 1, repeatEach: 1,
retries: 0, retries: 0,
preserveOutputDir: true, preserveOutputDir: true,
use: { use: {
trace: options.trace, trace: params.trace,
headless: options.headed ? false : undefined, headless: params.headed ? false : undefined,
_optionContextReuseMode: options.reuseContext ? 'when-possible' : undefined, _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: options.connectWsEndpoint ? { wsEndpoint: options.connectWsEndpoint } : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
}, },
workers: options.oneWorker ? 1 : undefined, workers: params.oneWorker ? 1 : undefined,
}; };
const config = await this._loadConfig(overrides); const config = await this._loadConfig(params.configFile, overrides);
config.cliListOnly = false; config.cliListOnly = false;
config.cliArgs = locations || []; config.cliArgs = params.locations || [];
config.cliGrep = options.grep; config.cliGrep = params.grep;
config.cliProjectFilter = options.projects?.length ? options.projects : undefined; config.cliProjectFilter = params.projects?.length ? params.projects : undefined;
const reporter = new InternalReporter(new Multiplexer(await createReporters(config, 'run'))); const reporter = new InternalReporter(new Multiplexer(await createReporters(config, 'run')));
const taskRunner = createTaskRunnerForTestServer(config, reporter); const taskRunner = createTaskRunnerForTestServer(config, reporter);
@ -182,18 +198,42 @@ class Dispatcher {
await run; await run;
} }
async findRelatedTestFiles(params: {
configFile: string;
files: string[];
}): Promise<FindRelatedTestFilesReport> {
const config = await this._loadConfig(params.configFile);
const runner = new Runner(config);
return runner.findRelatedTestFiles('out-of-process', params.files);
}
async stop(params: {
configFile: string;
}) {
await this._stopTests();
}
async closeGracefully() {
gracefullyProcessExitDoNotHang(0);
}
private async _stopTests() { private async _stopTests() {
this._testRun?.stop?.resolve(); this._testRun?.stop?.resolve();
await this._testRun?.run; await this._testRun?.run;
} }
private async _loadConfig(overrides: ConfigCLIOverrides) {
return await loadConfig(this._configLocation, overrides);
}
private _dispatchEvent(method: string, params: any) { private _dispatchEvent(method: string, params: any) {
this._ws.send(JSON.stringify({ method, params })); this._ws.send(JSON.stringify({ method, params }));
} }
private async _loadConfig(configFile: string, overrides?: ConfigCLIOverrides): Promise<FullConfigInternal> {
return loadConfig({ resolvedConfigFile: configFile, configDir: path.dirname(configFile) }, overrides);
}
private _syncEnv(env: NodeJS.ProcessEnv) {
for (const name in env)
process.env[name] = env[name];
}
} }
function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string) { function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string) {