test: introduce some common test fixtures (#9060)

This commit is contained in:
Dmitry Gozman 2021-09-21 16:24:48 -07:00 committed by GitHub
parent d4073e8214
commit 018467911b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 192 additions and 184 deletions

View File

@ -25,6 +25,7 @@ import { start } from '../../lib/outofprocess';
import { PlaywrightClient } from '../../lib/remote/playwrightClient';
import type { LaunchOptions } from '../../index';
import { TestProxy } from './proxy';
import { commonFixtures, CommonFixtures } from './commonFixtures';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
type Mode = 'default' | 'driver' | 'service';
@ -242,4 +243,4 @@ const coverageFixtures: Fixtures<{}, CoverageOptions & { __collectCoverage: void
export type CommonOptions = BaseOptions & ServerOptions & CoverageOptions;
export type CommonWorkerFixtures = CommonOptions & BaseFixtures;
export const baseTest = _baseTest.extend<{}, CoverageOptions>(coverageFixtures).extend<ServerFixtures>(serverFixtures).extend<{}, BaseOptions & BaseFixtures>(baseFixtures);
export const baseTest = _baseTest.extend<CommonFixtures>(commonFixtures).extend<{}, CoverageOptions>(coverageFixtures).extend<ServerFixtures>(serverFixtures as any).extend<{}, BaseOptions & BaseFixtures>(baseFixtures);

View File

@ -22,6 +22,7 @@ import * as fs from 'fs';
import * as os from 'os';
import { RemoteServer, RemoteServerOptions } from './remoteServer';
import { baseTest, CommonWorkerFixtures } from './baseTest';
import { CommonFixtures } from './commonFixtures';
type PlaywrightWorkerOptions = {
executablePath: LaunchOptions['executablePath'];
@ -48,7 +49,7 @@ type PlaywrightTestFixtures = {
};
export type PlaywrightOptions = PlaywrightWorkerOptions & PlaywrightTestOptions;
export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTestFixtures, PlaywrightWorkerOptions & PlaywrightWorkerFixtures, {}, CommonWorkerFixtures> = {
export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTestFixtures, PlaywrightWorkerOptions & PlaywrightWorkerFixtures, CommonFixtures, CommonWorkerFixtures> = {
executablePath: [ undefined, { scope: 'worker' } ],
proxy: [ undefined, { scope: 'worker' } ],
args: [ undefined, { scope: 'worker' } ],
@ -109,13 +110,13 @@ export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTest
await persistentContext.close();
},
startRemoteServer: async ({ browserType, browserOptions }, run) => {
startRemoteServer: async ({ childProcess, browserType, browserOptions }, run) => {
let remoteServer: RemoteServer | undefined;
await run(async options => {
if (remoteServer)
throw new Error('can only start one remote server');
remoteServer = new RemoteServer();
await remoteServer._start(browserType, browserOptions, options);
await remoteServer._start(childProcess, browserType, browserOptions, options);
return remoteServer;
});
if (remoteServer)

View File

@ -0,0 +1,138 @@
/**
* 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.
*/
import type { Fixtures } from './test-runner';
import { spawn, ChildProcess, execSync } from 'child_process';
import net from 'net';
type TestChildParams = {
command: string[],
cwd?: string,
env?: { [key: string]: string | number | boolean | undefined },
shell?: boolean,
onOutput?: () => void;
};
export class TestChildProcess {
params: TestChildParams;
process: ChildProcess;
output = '';
onOutput?: () => void;
exited: Promise<{ exitCode: number | null, signal: string | null }>;
exitCode: Promise<number>;
constructor(params: TestChildParams) {
this.params = params;
this.process = spawn(params.command[0], params.command.slice(1), {
env: {
...process.env,
...params.env,
} as any,
cwd: params.cwd,
shell: params.shell,
});
if (process.env.PWTEST_DEBUG)
process.stdout.write(`\n\nLaunching ${params.command.join(' ')}\n`);
this.onOutput = params.onOutput;
const appendChunk = (chunk: string | Buffer) => {
this.output += String(chunk);
if (process.env.PWTEST_DEBUG)
process.stdout.write(String(chunk));
this.onOutput?.();
};
this.process.stderr.on('data', appendChunk);
this.process.stdout.on('data', appendChunk);
const onExit = () => {
if (!this.process.pid || this.process.killed)
return;
try {
if (process.platform === 'win32')
execSync(`taskkill /pid ${this.process.pid} /T /F /FI "MEMUSAGE gt 0"`);
else
process.kill(-this.process.pid, 'SIGKILL');
} catch (e) {
// the process might have already stopped
}
};
process.on('exit', onExit);
this.exited = new Promise(f => {
this.process.on('exit', (exitCode, signal) => f({ exitCode, signal }));
process.off('exit', onExit);
});
this.exitCode = this.exited.then(r => r.exitCode);
}
async close() {
if (!this.process.killed)
this.process.kill();
return this.exited;
}
async cleanExit() {
const r = await this.exited;
if (r.exitCode)
throw new Error(`Process failed with exit code ${r.exitCode}`);
if (r.signal)
throw new Error(`Process recieved signal: ${r.signal}`);
}
}
export type CommonFixtures = {
childProcess: (params: TestChildParams) => TestChildProcess;
waitForPort: (port: number) => Promise<void>;
};
export const commonFixtures: Fixtures<CommonFixtures, {}> = {
childProcess: async ({}, use, testInfo) => {
const processes: TestChildProcess[] = [];
await use(params => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map(child => child.close()));
if (testInfo.status !== 'passed' && !process.env.PW_RUNNER_DEBUG) {
for (const process of processes) {
console.log('====== ' + process.params.command.join(' '));
console.log(process.output);
console.log('=========================================');
}
}
},
waitForPort: async ({}, use) => {
const token = { canceled: false };
await use(async port => {
while (!token.canceled) {
const promise = new Promise<boolean>(resolve => {
const conn = net.connect(port)
.on('error', () => resolve(false))
.on('connect', () => {
conn.end();
resolve(true);
});
});
if (await promise)
return;
await new Promise(x => setTimeout(x, 100));
}
});
token.canceled = true;
},
};

View File

@ -15,8 +15,8 @@
*/
import path from 'path';
import { spawn } from 'child_process';
import type { BrowserType, Browser, LaunchOptions } from '../../index';
import { CommonFixtures, TestChildProcess } from './commonFixtures';
const playwrightPath = path.join(__dirname, '..', '..');
@ -28,20 +28,17 @@ export type RemoteServerOptions = {
};
export class RemoteServer {
_output: Map<any, any>;
_outputCallback: Map<any, any>;
private _process: TestChildProcess;
_output: Map<string, string>;
_outputCallback: Map<string, () => void>;
_browserType: BrowserType;
_child: import('child_process').ChildProcess;
_exitPromise: Promise<unknown>;
_exitAndDisconnectPromise: Promise<any>;
_browser: Browser;
_didExit: boolean;
_wsEndpoint: string;
async _start(browserType: BrowserType, browserOptions: LaunchOptions, remoteServerOptions: RemoteServerOptions = {}) {
async _start(childProcess: CommonFixtures['childProcess'], browserType: BrowserType, browserOptions: LaunchOptions, remoteServerOptions: RemoteServerOptions = {}) {
this._output = new Map();
this._outputCallback = new Map();
this._didExit = false;
this._browserType = browserType;
// Copy options to prevent a large JSON string when launching subprocess.
@ -62,29 +59,20 @@ export class RemoteServer {
launchOptions,
...remoteServerOptions,
};
this._child = spawn('node', [path.join(__dirname, 'remote-server-impl.js'), JSON.stringify(options)], { env: process.env });
this._child.on('error', (...args) => console.log('ERROR', ...args));
this._exitPromise = new Promise(resolve => this._child.on('exit', (exitCode, signal) => {
this._didExit = true;
resolve(exitCode);
}));
this._process = childProcess({
command: ['node', path.join(__dirname, 'remote-server-impl.js'), JSON.stringify(options)],
});
let outputString = '';
this._child.stdout.on('data', data => {
outputString += data.toString();
// Uncomment to debug.
// console.log(data.toString());
let index = 0;
this._process.onOutput = () => {
let match;
while ((match = outputString.match(/\(([^()]+)=>([^()]+)\)/))) {
while ((match = this._process.output.substring(index).match(/\(([^()]+)=>([^()]+)\)/))) {
const key = match[1];
const value = match[2];
this._addOutput(key, value);
outputString = outputString.substring(match.index + match[0].length);
index += match.index + match[0].length;
}
});
this._child.stderr.on('data', data => {
console.log(data.toString());
});
};
this._wsEndpoint = await this.out('wsEndpoint');
@ -95,7 +83,7 @@ export class RemoteServer {
}
}
_addOutput(key, value) {
_addOutput(key: string, value: string) {
this._output.set(key, value);
const cb = this._outputCallback.get(key);
this._outputCallback.delete(key);
@ -103,9 +91,9 @@ export class RemoteServer {
cb();
}
async out(key) {
async out(key: string) {
if (!this._output.has(key))
await new Promise(f => this._outputCallback.set(key, f));
await new Promise<void>(f => this._outputCallback.set(key, f));
return this._output.get(key);
}
@ -114,11 +102,11 @@ export class RemoteServer {
}
child() {
return this._child;
return this._process.process;
}
async childExitCode() {
return await this._exitPromise;
return await this._process.exitCode;
}
async close() {
@ -126,8 +114,7 @@ export class RemoteServer {
await this._browser.close();
this._browser = undefined;
}
if (!this._didExit)
this._child.kill();
await this._process.close();
return await this.childExitCode();
}
}

View File

@ -18,8 +18,8 @@ import { contextTest } from '../config/browserTest';
import type { Page } from '../../index';
import * as path from 'path';
import type { Source } from '../../src/server/supplements/recorder/recorderTypes';
import { ChildProcess, spawn } from 'child_process';
import { chromium } from '../../index';
import { CommonFixtures, TestChildProcess } from '../config/commonFixtures';
export { expect } from '../config/test-runner';
type CLITestArgs = {
@ -49,13 +49,13 @@ export const test = contextTest.extend<CLITestArgs>({
});
},
runCLI: async ({ browserName, channel, headless, mode, executablePath }, run, testInfo) => {
runCLI: async ({ childProcess, browserName, channel, headless, mode, executablePath }, run, testInfo) => {
process.env.PWTEST_RECORDER_PORT = String(10907 + testInfo.workerIndex);
testInfo.skip(mode === 'service');
let cli: CLIMock | undefined;
await run(cliArgs => {
cli = new CLIMock(browserName, channel, headless, cliArgs, executablePath);
cli = new CLIMock(childProcess, browserName, channel, headless, cliArgs, executablePath);
return cli;
});
if (cli)
@ -177,15 +177,14 @@ class Recorder {
}
class CLIMock {
private process: ChildProcess;
private data: string;
private process: TestChildProcess;
private waitForText: string;
private waitForCallback: () => void;
exited: Promise<void>;
constructor(browserName: string, channel: string | undefined, headless: boolean | undefined, args: string[], executablePath: string | undefined) {
this.data = '';
constructor(childProcess: CommonFixtures['childProcess'], browserName: string, channel: string | undefined, headless: boolean | undefined, args: string[], executablePath: string | undefined) {
const nodeArgs = [
'node',
path.join(__dirname, '..', '..', 'lib', 'cli', 'cli.js'),
'codegen',
...args,
@ -193,36 +192,23 @@ class CLIMock {
];
if (channel)
nodeArgs.push(`--channel=${channel}`);
this.process = spawn('node', nodeArgs, {
this.process = childProcess({
command: nodeArgs,
env: {
...process.env,
PWTEST_CLI_EXIT: '1',
PWTEST_CLI_HEADLESS: headless ? '1' : undefined,
PWTEST_CLI_EXECUTABLE_PATH: executablePath,
},
stdio: 'pipe'
});
this.process.stdout.on('data', data => {
this.data = data.toString();
if (this.waitForCallback && this.data.includes(this.waitForText))
this.process.onOutput = () => {
if (this.waitForCallback && this.process.output.includes(this.waitForText))
this.waitForCallback();
});
this.exited = new Promise((f, r) => {
this.process.stderr.on('data', data => {
console.error(data.toString());
});
this.process.on('exit', (exitCode, signal) => {
if (exitCode)
r(new Error(`Process failed with exit code ${exitCode}`));
if (signal)
r(new Error(`Process recieved signal: ${signal}`));
f();
});
});
};
this.exited = this.process.cleanExit();
}
async waitFor(text: string, timeout = 10_000): Promise<void> {
if (this.data.includes(text))
if (this.process.output.includes(text))
return Promise.resolve();
this.waitForText = text;
return new Promise((f, r) => {
@ -236,7 +222,7 @@ class CLIMock {
}
text() {
return removeAnsiColors(this.data);
return removeAnsiColors(this.process.output);
}
}

View File

@ -15,7 +15,7 @@
*/
import { TestInfo, test as base } from './stable-test-runner';
import { spawn, ChildProcess, execSync } from 'child_process';
import { CommonFixtures, commonFixtures } from '../config/commonFixtures';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@ -23,7 +23,6 @@ import type { JSONReport, JSONReportSuite } from '../../src/test/reporters/json'
import rimraf from 'rimraf';
import { promisify } from 'util';
import * as url from 'url';
import net from 'net';
const removeFolderAsync = promisify(rimraf);
@ -47,76 +46,6 @@ type Files = { [key: string]: string | Buffer };
type Params = { [key: string]: string | number | boolean | string[] };
type Env = { [key: string]: string | number | boolean | undefined };
type ChildParams = {
command: string[],
cwd?: string,
env?: Env,
shell?: boolean,
sendSIGINTAfter?: number,
};
class Child {
params: ChildParams;
process: ChildProcess;
output = '';
exited: Promise<number>;
constructor(params: ChildParams) {
this.params = params;
this.process = spawn(params.command[0], params.command.slice(1), {
env: {
...process.env,
...params.env,
} as any,
cwd: params.cwd,
shell: params.shell,
});
if (process.env.PW_RUNNER_DEBUG)
process.stdout.write(`\n\nLaunching ${params.command.join(' ')}\n`);
this.process.stderr.on('data', chunk => {
this.output += String(chunk);
if (process.env.PW_RUNNER_DEBUG)
process.stdout.write(String(chunk));
});
let didSendSigint = false;
this.process.stdout.on('data', chunk => {
this.output += String(chunk);
if (params.sendSIGINTAfter && !didSendSigint && countTimes(this.output, '%%SEND-SIGINT%%') >= params.sendSIGINTAfter) {
didSendSigint = true;
process.kill(this.process.pid, 'SIGINT');
}
if (process.env.PW_RUNNER_DEBUG)
process.stdout.write(String(chunk));
});
const onExit = () => {
if (!this.process.pid || this.process.killed)
return;
try {
if (process.platform === 'win32')
execSync(`taskkill /pid ${this.process.pid} /T /F /FI "MEMUSAGE gt 0"`);
else
process.kill(-this.process.pid, 'SIGKILL');
} catch (e) {
// the process might have already stopped
}
};
process.on('exit', onExit);
this.exited = new Promise(f => {
this.process.on('exit', (code, signal) => f(code));
process.off('exit', onExit);
});
}
async close() {
if (!this.process.killed)
this.process.kill();
return this.exited;
}
}
async function writeFiles(testInfo: TestInfo, files: Files) {
const baseDir = testInfo.outputPath();
@ -162,7 +91,7 @@ async function writeFiles(testInfo: TestInfo, files: Files) {
return baseDir;
}
async function runPlaywrightTest(childProcess: (params: ChildParams) => Promise<Child>, baseDir: string, params: any, env: Env, options: RunOptions): Promise<RunResult> {
async function runPlaywrightTest(childProcess: CommonFixtures['childProcess'], baseDir: string, params: any, env: Env, options: RunOptions): Promise<RunResult> {
const paramList = [];
for (const key of Object.keys(params)) {
for (const value of Array.isArray(params[key]) ? params[key] : [params[key]]) {
@ -183,7 +112,7 @@ async function runPlaywrightTest(childProcess: (params: ChildParams) => Promise<
if (options.additionalArgs)
args.push(...options.additionalArgs);
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-test-cache-'));
const testProcess = await childProcess({
const testProcess = childProcess({
command: args,
env: {
...process.env,
@ -194,9 +123,15 @@ async function runPlaywrightTest(childProcess: (params: ChildParams) => Promise<
...env,
},
cwd: baseDir,
sendSIGINTAfter: options.sendSIGINTAfter,
});
const exitCode = await testProcess.exited;
let didSendSigint = false;
testProcess.onOutput = () => {
if (options.sendSIGINTAfter && !didSendSigint && countTimes(testProcess.output, '%%SEND-SIGINT%%') >= options.sendSIGINTAfter) {
didSendSigint = true;
process.kill(testProcess.process.pid, 'SIGINT');
}
};
const { exitCode } = await testProcess.exited;
await removeFolderAsync(cacheDir);
const outputString = testProcess.output.toString();
@ -256,11 +191,10 @@ type Fixtures = {
writeFiles: (files: Files) => Promise<string>;
runInlineTest: (files: Files, params?: Params, env?: Env, options?: RunOptions) => Promise<RunResult>;
runTSC: (files: Files) => Promise<TSCResult>;
childProcess: (params: ChildParams) => Promise<Child>;
waitForPort: (port: number) => Promise<void>;
};
export const test = base.extend<Fixtures>({
const common = base.extend<CommonFixtures>(commonFixtures as any);
export const test = common.extend<Fixtures>({
writeFiles: async ({}, use, testInfo) => {
await use(files => writeFiles(testInfo, files));
},
@ -275,54 +209,15 @@ export const test = base.extend<Fixtures>({
runTSC: async ({ childProcess }, use, testInfo) => {
await use(async files => {
const baseDir = await writeFiles(testInfo, { 'tsconfig.json': JSON.stringify(TSCONFIG), ...files });
const tsc = await childProcess({
const tsc = childProcess({
command: ['npx', 'tsc', '-p', baseDir],
cwd: baseDir,
shell: true,
});
const exitCode = await tsc.exited;
const { exitCode } = await tsc.exited;
return { exitCode, output: tsc.output };
});
},
childProcess: async ({}, use, testInfo) => {
const children: Child[] = [];
await use(async params => {
const child = new Child(params);
children.push(child);
return child;
});
await Promise.all(children.map(child => child.close()));
if (testInfo.status !== 'passed' && !process.env.PW_RUNNER_DEBUG) {
for (const child of children) {
console.log('====== ' + child.params.command.join(' '));
console.log(child.output);
console.log('=========================================');
}
}
},
waitForPort: async ({}, use) => {
const token = { canceled: false };
await use(async port => {
await test.step(`waiting for port ${port}`, async () => {
while (!token.canceled) {
const promise = new Promise<boolean>(resolve => {
const conn = net.connect(port)
.on('error', () => resolve(false))
.on('connect', () => {
conn.end();
resolve(true);
});
});
if (await promise)
return;
await new Promise(x => setTimeout(x, 100));
}
});
});
token.canceled = true;
},
});
const TSCONFIG = {