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

View File

@ -108,9 +108,8 @@ function addFindRelatedTestFilesCommand(program: Command) {
function addTestServerCommand(program: Command) {
const command = program.command('test-server', { hidden: true });
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(options => {
void runTestServer(options.config);
command.action(() => {
void runTestServer();
});
}

View File

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