Skip to content

Commit

Permalink
chore: experimental oop loader (#20269)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Jan 21, 2023
1 parent eafa6fd commit 7ff2760
Show file tree
Hide file tree
Showing 12 changed files with 321 additions and 173 deletions.
36 changes: 36 additions & 0 deletions packages/playwright-test/src/loaderHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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 { TestError } from '../reporter';
import type { SerializedConfig } from './ipc';
import { ProcessHost } from './processHost';
import { Suite } from './test';

export class LoaderHost extends ProcessHost<SerializedConfig> {
constructor() {
super(require.resolve('./loaderRunner.js'), 'loader');
}

async start(config: SerializedConfig) {
await this.startRunner(config, true, {});
}

async loadTestFiles(files: string[], loadErrors: TestError[]): Promise<Suite> {
const result = await this.sendMessage({ method: 'loadTestFiles', params: { files } }) as any;
loadErrors.push(...result.loadErrors);
return Suite._deepParse(result.rootSuite);
}
}
48 changes: 48 additions & 0 deletions packages/playwright-test/src/loaderRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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 { SerializedConfig } from './ipc';
import type { TestError } from '../reporter';
import { ConfigLoader } from './configLoader';
import { ProcessRunner } from './process';
import { loadTestFilesInProcess } from './testLoader';
import { setFatalErrorSink } from './globals';

export class LoaderRunner extends ProcessRunner {
private _config: SerializedConfig;
private _configLoaderPromise: Promise<ConfigLoader> | undefined;

constructor(config: SerializedConfig) {
super();
this._config = config;
}

private _configLoader(): Promise<ConfigLoader> {
if (!this._configLoaderPromise)
this._configLoaderPromise = ConfigLoader.deserialize(this._config);
return this._configLoaderPromise;
}

async loadTestFiles(params: { files: string[] }) {
const loadErrors: TestError[] = [];
setFatalErrorSink(loadErrors);
const configLoader = await this._configLoader();
const rootSuite = await loadTestFilesInProcess(configLoader.fullConfig(), params.files, loadErrors);
return { rootSuite: rootSuite._deepSerialize(), loadErrors };
}
}

export const create = (config: SerializedConfig) => new LoaderRunner(config);
33 changes: 3 additions & 30 deletions packages/playwright-test/src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
*/

import type { WriteStream } from 'tty';
import * as util from 'util';
import type { ProcessInitParams, TeardownErrorsPayload, TestOutputPayload, TtyParams } from './ipc';
import type { ProcessInitParams, TeardownErrorsPayload, TtyParams } from './ipc';
import { startProfiling, stopProfiling } from './profiler';
import type { TestInfoError } from './types';
import { serializeError } from './util';
Expand All @@ -29,7 +28,7 @@ export type ProtocolRequest = {

export type ProtocolResponse = {
id?: number;
error?: string;
error?: TestInfoError;
method?: string;
params?: any;
result?: any;
Expand All @@ -49,24 +48,6 @@ let closed = false;

sendMessageToParent({ method: 'ready' });

process.stdout.write = (chunk: string | Buffer) => {
const outPayload: TestOutputPayload = {
...chunkToParams(chunk)
};
sendMessageToParent({ method: 'stdOut', params: outPayload });
return true;
};

if (!process.env.PW_RUNNER_DEBUG) {
process.stderr.write = (chunk: string | Buffer) => {
const outPayload: TestOutputPayload = {
...chunkToParams(chunk)
};
sendMessageToParent({ method: 'stdErr', params: outPayload });
return true;
};
}

process.on('disconnect', gracefullyCloseAndExit);
process.on('SIGINT', () => {});
process.on('SIGTERM', () => {});
Expand Down Expand Up @@ -94,7 +75,7 @@ process.on('message', async message => {
const response: ProtocolResponse = { id, result };
sendMessageToParent({ method: '__dispatch__', params: response });
} catch (e) {
const response: ProtocolResponse = { id, error: e.toString() };
const response: ProtocolResponse = { id, error: serializeError(e) };
sendMessageToParent({ method: '__dispatch__', params: response });
}
}
Expand Down Expand Up @@ -132,14 +113,6 @@ function sendMessageToParent(message: { method: string, params?: any }) {
}
}

function chunkToParams(chunk: Buffer | string): { text?: string, buffer?: string } {
if (chunk instanceof Buffer)
return { buffer: chunk.toString('base64') };
if (typeof chunk !== 'string')
return { text: util.inspect(chunk) };
return { text: chunk };
}

function setTtyParams(stream: WriteStream, params: TtyParams) {
stream.isTTY = true;
if (params.rows)
Expand Down
27 changes: 14 additions & 13 deletions packages/playwright-test/src/processHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import child_process from 'child_process';
import { EventEmitter } from 'events';
import { debug } from 'playwright-core/lib/utilsBundle';
import type { ProcessInitParams } from './ipc';
import type { ProtocolResponse } from './process';

Expand All @@ -41,33 +42,32 @@ export class ProcessHost<InitParams> extends EventEmitter {
this._processName = processName;
}

protected async startRunner(runnerParams: InitParams) {
protected async startRunner(runnerParams: InitParams, inheritStdio: boolean, env: NodeJS.ProcessEnv) {
this.process = child_process.fork(require.resolve('./process'), {
detached: false,
env: {
FORCE_COLOR: '1',
DEBUG_COLORS: '1',
PW_PROCESS_RUNNER_SCRIPT: this._runnerScript,
...process.env
},
// Can't pipe since piping slows down termination for some reason.
stdio: ['ignore', 'ignore', process.env.PW_RUNNER_DEBUG ? 'inherit' : 'ignore', 'ipc']
env: { ...process.env, ...env },
stdio: inheritStdio ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'ignore', process.env.PW_RUNNER_DEBUG ? 'inherit' : 'ignore', 'ipc'],
});
this.process.on('exit', (code, signal) => {
this.didExit = true;
this.emit('exit', { unexpectedly: !this._didSendStop, code, signal } as ProcessExitData);
});
this.process.on('error', e => {}); // do not yell at a send to dead process.
this.process.on('message', (message: any) => {
if (debug.enabled('pw:test:protocol'))
debug('pw:test:protocol')('◀ RECV ' + JSON.stringify(message));
if (message.method === '__dispatch__') {
const { id, error, method, params, result } = message.params as ProtocolResponse;
if (id && this._callbacks.has(id)) {
const { resolve, reject } = this._callbacks.get(id)!;
this._callbacks.delete(id);
if (error)
reject(new Error(error));
else
if (error) {
const errorObject = new Error(error.message);
errorObject.stack = error.stack;
reject(errorObject);
} else {
resolve(result);
}
} else {
this.emit(method!, params);
}
Expand Down Expand Up @@ -140,7 +140,8 @@ export class ProcessHost<InitParams> extends EventEmitter {
}

private send(message: { method: string, params?: any }) {
// This is a great place for debug logging.
if (debug.enabled('pw:test:protocol'))
debug('pw:test:protocol')('SEND ► ' + JSON.stringify(message));
this.process.send(message);
}
}
98 changes: 20 additions & 78 deletions packages/playwright-test/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ import type { Config, FullConfigInternal, FullProjectInternal, ReporterInternal
import { createFileMatcher, createFileMatcherFromFilters, createTitleMatcher, serializeError } from './util';
import type { Matcher, TestFileFilter } from './util';
import { setFatalErrorSink } from './globals';
import { TestLoader } from './testLoader';
import { buildFileSuiteForProject, filterTests } from './suiteUtils';
import { PoolBuilder } from './poolBuilder';
import { buildFileSuiteForProject, filterOnly, filterSuite, filterSuiteWithOnlySemantics, filterTestsRemoveEmptySuites } from './suiteUtils';
import { LoaderHost } from './loaderHost';
import { loadTestFilesInProcess } from './testLoader';

const removeFolderAsync = promisify(rimraf);
const readDirAsync = promisify(fs.readdir);
Expand Down Expand Up @@ -271,27 +271,23 @@ export class Runner {
const config = this._configLoader.fullConfig();
const projects = this._collectProjects(options.projectFilter);
const filesByProject = await this._collectFiles(projects, options.testFileFilters);
const result = await this._createFilteredRootSuite(options, filesByProject);
this._fatalErrors.push(...result.fatalErrors);
const { rootSuite } = result;
const rootSuite = await this._createFilteredRootSuite(options, filesByProject);

const testGroups = createTestGroups(rootSuite.suites, config.workers);
return { rootSuite, testGroups };
}

private async _createFilteredRootSuite(options: RunOptions, filesByProject: Map<FullProjectInternal, string[]>): Promise<{rootSuite: Suite, fatalErrors: TestError[]}> {
private async _createFilteredRootSuite(options: RunOptions, filesByProject: Map<FullProjectInternal, string[]>): Promise<Suite> {
const config = this._configLoader.fullConfig();
const fatalErrors: TestError[] = [];
const allTestFiles = new Set<string>();
for (const files of filesByProject.values())
files.forEach(file => allTestFiles.add(file));

// Load all tests.
const { rootSuite: preprocessRoot, loadErrors } = await this._loadTests(allTestFiles);
fatalErrors.push(...loadErrors);
const preprocessRoot = await this._loadTests(allTestFiles);

// Complain about duplicate titles.
fatalErrors.push(...createDuplicateTitlesErrors(config, preprocessRoot));
this._fatalErrors.push(...createDuplicateTitlesErrors(config, preprocessRoot));

// Filter tests to respect line/column filter.
filterByFocusedLine(preprocessRoot, options.testFileFilters);
Expand All @@ -300,7 +296,7 @@ export class Runner {
if (config.forbidOnly) {
const onlyTestsAndSuites = preprocessRoot._getOnlyItems();
if (onlyTestsAndSuites.length > 0)
fatalErrors.push(...createForbidOnlyErrors(config, onlyTestsAndSuites));
this._fatalErrors.push(...createForbidOnlyErrors(config, onlyTestsAndSuites));
}

// Filter only.
Expand Down Expand Up @@ -335,30 +331,26 @@ export class Runner {
continue;
for (let repeatEachIndex = 0; repeatEachIndex < project.repeatEach; repeatEachIndex++) {
const builtSuite = buildFileSuiteForProject(project, fileSuite, repeatEachIndex);
if (!filterTests(builtSuite, titleMatcher))
if (!filterTestsRemoveEmptySuites(builtSuite, titleMatcher))
continue;
projectSuite._addSuite(builtSuite);
}
}
}
return { rootSuite, fatalErrors };
return rootSuite;
}

private async _loadTests(testFiles: Set<string>): Promise<{ rootSuite: Suite, loadErrors: TestError[] }> {
const config = this._configLoader.fullConfig();
const testLoader = new TestLoader(config);
const loadErrors: TestError[] = [];
const rootSuite = new Suite('', 'root');
for (const file of testFiles) {
const fileSuite = await testLoader.loadTestFile(file, 'loader');
if (fileSuite._loadError)
loadErrors.push(fileSuite._loadError);
// We have to clone only if there maybe subsequent calls of this method.
rootSuite._addSuite(fileSuite);
private async _loadTests(testFiles: Set<string>): Promise<Suite> {
if (process.env.PWTEST_OOP_LOADER) {
const loaderHost = new LoaderHost();
await loaderHost.start(this._configLoader.serializedConfig());
try {
return await loaderHost.loadTestFiles([...testFiles], this._fatalErrors);
} finally {
await loaderHost.stop();
}
}
// Generate hashes.
PoolBuilder.buildForLoader(rootSuite);
return { rootSuite, loadErrors };
return loadTestFilesInProcess(this._configLoader.fullConfig(), [...testFiles], this._fatalErrors);
}

private _filterForCurrentShard(rootSuite: Suite, testGroups: TestGroup[]) {
Expand Down Expand Up @@ -404,8 +396,6 @@ export class Runner {
// Filtering with "only semantics" does not work when we have zero tests - it leaves all the tests.
// We need an empty suite in this case.
rootSuite._entries = [];
rootSuite.suites = [];
rootSuite.tests = [];
} else {
filterSuiteWithOnlySemantics(rootSuite, () => false, test => shardTests.has(test));
}
Expand Down Expand Up @@ -493,23 +483,6 @@ export class Runner {
return 'success';
}

private _skipTestsFromMatchingGroups(testGroups: TestGroup[], groupFilter: (g: TestGroup) => boolean): TestGroup[] {
const result = [];
for (const group of testGroups) {
if (groupFilter(group)) {
for (const test of group.tests) {
const result = test._appendTestResult();
this._reporter.onTestBegin?.(test, result);
result.status = 'skipped';
this._reporter.onTestEnd?.(test, result);
}
} else {
result.push(group);
}
}
return result;
}

private async _removeOutputDirs(options: RunOptions): Promise<boolean> {
const config = this._configLoader.fullConfig();
const outputDirs = new Set<string>();
Expand Down Expand Up @@ -616,14 +589,6 @@ export class Runner {
}
}

function filterOnly(suite: Suite) {
if (!suite._getOnlyItems().length)
return;
const suiteFilter = (suite: Suite) => suite._only;
const testFilter = (test: TestCase) => test._only;
return filterSuiteWithOnlySemantics(suite, suiteFilter, testFilter);
}

function createFileMatcherFromFilter(filter: TestFileFilter) {
const fileMatcher = createFileMatcher(filter.re || filter.exact || '');
return (testFileName: string, testLine: number, testColumn: number) =>
Expand All @@ -640,29 +605,6 @@ function filterByFocusedLine(suite: Suite, focusedTestFileLines: TestFileFilter[
return filterSuite(suite, suiteFilter, testFilter);
}

function filterSuiteWithOnlySemantics(suite: Suite, suiteFilter: (suites: Suite) => boolean, testFilter: (test: TestCase) => boolean) {
const onlySuites = suite.suites.filter(child => filterSuiteWithOnlySemantics(child, suiteFilter, testFilter) || suiteFilter(child));
const onlyTests = suite.tests.filter(testFilter);
const onlyEntries = new Set([...onlySuites, ...onlyTests]);
if (onlyEntries.size) {
suite.suites = onlySuites;
suite.tests = onlyTests;
suite._entries = suite._entries.filter(e => onlyEntries.has(e)); // Preserve the order.
return true;
}
return false;
}

function filterSuite(suite: Suite, suiteFilter: (suites: Suite) => boolean, testFilter: (test: TestCase) => boolean) {
for (const child of suite.suites) {
if (!suiteFilter(child))
filterSuite(child, suiteFilter, testFilter);
}
suite.tests = suite.tests.filter(testFilter);
const entries = new Set([...suite.suites, ...suite.tests]);
suite._entries = suite._entries.filter(e => entries.has(e)); // Preserve the order.
}

async function collectFiles(testDir: string, respectGitIgnore: boolean): Promise<string[]> {
if (!fs.existsSync(testDir))
return [];
Expand Down

0 comments on commit 7ff2760

Please sign in to comment.