mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: watch mode straw man (#16127)
This commit is contained in:
parent
35bcecc5c3
commit
7645ac25a0
@ -23,7 +23,7 @@ import path from 'path';
|
|||||||
import { Runner, builtInReporters, kDefaultConfigFiles } from './runner';
|
import { Runner, builtInReporters, kDefaultConfigFiles } from './runner';
|
||||||
import type { ConfigCLIOverrides } from './runner';
|
import type { ConfigCLIOverrides } from './runner';
|
||||||
import { stopProfiling, startProfiling } from './profiler';
|
import { stopProfiling, startProfiling } from './profiler';
|
||||||
import type { FilePatternFilter } from './util';
|
import type { TestFileFilter } from './util';
|
||||||
import { showHTMLReport } from './reporters/html';
|
import { showHTMLReport } from './reporters/html';
|
||||||
import { baseFullConfig, defaultTimeout, fileIsModule } from './loader';
|
import { baseFullConfig, defaultTimeout, fileIsModule } from './loader';
|
||||||
|
|
||||||
@ -31,7 +31,6 @@ export function addTestCommands(program: Command) {
|
|||||||
addTestCommand(program);
|
addTestCommand(program);
|
||||||
addShowReportCommand(program);
|
addShowReportCommand(program);
|
||||||
addListFilesCommand(program);
|
addListFilesCommand(program);
|
||||||
addTestServerCommand(program);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTestCommand(program: Command) {
|
function addTestCommand(program: Command) {
|
||||||
@ -93,20 +92,6 @@ function addListFilesCommand(program: Command) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTestServerCommand(program: Command) {
|
|
||||||
const command = program.command('test-server', { hidden: true });
|
|
||||||
command.option('-c, --config <file>', `Configuration file, or a test directory with optional ${kDefaultConfigFiles.map(file => `"${file}"`).join('/')}`);
|
|
||||||
command.option('--reporter <reporter>', `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${baseFullConfig.reporter[0]}")`);
|
|
||||||
command.action(async opts => {
|
|
||||||
try {
|
|
||||||
await runTestServer(opts);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addShowReportCommand(program: Command) {
|
function addShowReportCommand(program: Command) {
|
||||||
const command = program.command('show-report [report]');
|
const command = program.command('show-report [report]');
|
||||||
command.description('show HTML report');
|
command.description('show HTML report');
|
||||||
@ -158,7 +143,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
|
|||||||
else
|
else
|
||||||
await runner.loadEmptyConfig(configFileOrDirectory);
|
await runner.loadEmptyConfig(configFileOrDirectory);
|
||||||
|
|
||||||
const filePatternFilter: FilePatternFilter[] = args.map(arg => {
|
const testFileFilters: TestFileFilter[] = args.map(arg => {
|
||||||
const match = /^(.*?):(\d+):?(\d+)?$/.exec(arg);
|
const match = /^(.*?):(\d+):?(\d+)?$/.exec(arg);
|
||||||
return {
|
return {
|
||||||
re: forceRegExp(match ? match[1] : arg),
|
re: forceRegExp(match ? match[1] : arg),
|
||||||
@ -169,8 +154,9 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
|
|||||||
|
|
||||||
const result = await runner.runAllTests({
|
const result = await runner.runAllTests({
|
||||||
listOnly: !!opts.list,
|
listOnly: !!opts.list,
|
||||||
filePatternFilter,
|
testFileFilters,
|
||||||
projectFilter: opts.project || undefined,
|
projectFilter: opts.project || undefined,
|
||||||
|
watchMode: !!process.env.PW_TEST_WATCH,
|
||||||
});
|
});
|
||||||
await stopProfiling(undefined);
|
await stopProfiling(undefined);
|
||||||
|
|
||||||
@ -197,25 +183,6 @@ async function listTestFiles(opts: { [key: string]: any }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTestServer(opts: { [key: string]: any }) {
|
|
||||||
const overrides = overridesFromOptions(opts);
|
|
||||||
|
|
||||||
overrides.use = { headless: false };
|
|
||||||
overrides.maxFailures = 1;
|
|
||||||
overrides.timeout = 0;
|
|
||||||
overrides.workers = 1;
|
|
||||||
|
|
||||||
// When no --config option is passed, let's look for the config file in the current directory.
|
|
||||||
const configFileOrDirectory = opts.config ? path.resolve(process.cwd(), opts.config) : process.cwd();
|
|
||||||
const resolvedConfigFile = Runner.resolveConfigFile(configFileOrDirectory)!;
|
|
||||||
if (restartWithExperimentalTsEsm(resolvedConfigFile))
|
|
||||||
return;
|
|
||||||
|
|
||||||
const runner = new Runner(overrides);
|
|
||||||
await runner.loadConfigFromResolvedFile(resolvedConfigFile);
|
|
||||||
await runner.runTestServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceRegExp(pattern: string): RegExp {
|
function forceRegExp(pattern: string): RegExp {
|
||||||
const match = pattern.match(/^\/(.*)\/([gi]*)$/);
|
const match = pattern.match(/^\/(.*)\/([gi]*)$/);
|
||||||
if (match)
|
if (match)
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
import child_process from 'child_process';
|
import child_process from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import type { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams, StepBeginPayload, StepEndPayload, SerializedLoaderData, TeardownErrorsPayload, TestServerTestResolvedPayload, WorkerIsolation } from './ipc';
|
import type { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams, StepBeginPayload, StepEndPayload, SerializedLoaderData, TeardownErrorsPayload, WatchTestResolvedPayload, WorkerIsolation } from './ipc';
|
||||||
import type { TestResult, Reporter, TestStep, TestError } from '../types/testReporter';
|
import type { TestResult, Reporter, TestStep, TestError } from '../types/testReporter';
|
||||||
import type { Suite } from './test';
|
import type { Suite } from './test';
|
||||||
import type { Loader } from './loader';
|
import type { Loader } from './loader';
|
||||||
@ -31,7 +31,7 @@ export type TestGroup = {
|
|||||||
repeatEachIndex: number;
|
repeatEachIndex: number;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
tests: TestCase[];
|
tests: TestCase[];
|
||||||
testServerTestLine?: number;
|
watchMode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestResultData = {
|
type TestResultData = {
|
||||||
@ -175,7 +175,7 @@ export class Dispatcher {
|
|||||||
let doneCallback = () => {};
|
let doneCallback = () => {};
|
||||||
const result = new Promise<void>(f => doneCallback = f);
|
const result = new Promise<void>(f => doneCallback = f);
|
||||||
const doneWithJob = () => {
|
const doneWithJob = () => {
|
||||||
worker.removeListener('testServer:testResolved', onTestServerTestResolved);
|
worker.removeListener('watchTestResolved', onWatchTestResolved);
|
||||||
worker.removeListener('testBegin', onTestBegin);
|
worker.removeListener('testBegin', onTestBegin);
|
||||||
worker.removeListener('testEnd', onTestEnd);
|
worker.removeListener('testEnd', onTestEnd);
|
||||||
worker.removeListener('stepBegin', onStepBegin);
|
worker.removeListener('stepBegin', onStepBegin);
|
||||||
@ -188,11 +188,11 @@ export class Dispatcher {
|
|||||||
const remainingByTestId = new Map(testGroup.tests.map(e => [ e.id, e ]));
|
const remainingByTestId = new Map(testGroup.tests.map(e => [ e.id, e ]));
|
||||||
const failedTestIds = new Set<string>();
|
const failedTestIds = new Set<string>();
|
||||||
|
|
||||||
const onTestServerTestResolved = (params: TestServerTestResolvedPayload) => {
|
const onWatchTestResolved = (params: WatchTestResolvedPayload) => {
|
||||||
const test = new TestCase(params.title, () => {}, new TestTypeImpl([]), params.location);
|
const test = new TestCase(params.title, () => {}, new TestTypeImpl([]), params.location);
|
||||||
this._testById.set(params.testId, { test, resultByWorkerIndex: new Map() });
|
this._testById.set(params.testId, { test, resultByWorkerIndex: new Map() });
|
||||||
};
|
};
|
||||||
worker.addListener('testServer:testResolved', onTestServerTestResolved);
|
worker.addListener('watchTestResolved', onWatchTestResolved);
|
||||||
|
|
||||||
const onTestBegin = (params: TestBeginPayload) => {
|
const onTestBegin = (params: TestBeginPayload) => {
|
||||||
const data = this._testById.get(params.testId)!;
|
const data = this._testById.get(params.testId)!;
|
||||||
@ -463,6 +463,8 @@ export class Dispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
|
if (this._isStopped)
|
||||||
|
return;
|
||||||
this._isStopped = true;
|
this._isStopped = true;
|
||||||
await Promise.all(this._workerSlots.map(({ worker }) => worker?.stop()));
|
await Promise.all(this._workerSlots.map(({ worker }) => worker?.stop()));
|
||||||
this._checkFinished();
|
this._checkFinished();
|
||||||
@ -564,7 +566,7 @@ class Worker extends EventEmitter {
|
|||||||
entries: testGroup.tests.map(test => {
|
entries: testGroup.tests.map(test => {
|
||||||
return { testId: test.id, retry: test.results.length };
|
return { testId: test.id, retry: test.results.length };
|
||||||
}),
|
}),
|
||||||
testServerTestLine: testGroup.testServerTestLine,
|
watchMode: testGroup.watchMode,
|
||||||
};
|
};
|
||||||
this.send({ method: 'run', params: runPayload });
|
this.send({ method: 'run', params: runPayload });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export type WorkerInitParams = {
|
|||||||
stderrParams: TtyParams;
|
stderrParams: TtyParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestServerTestResolvedPayload = {
|
export type WatchTestResolvedPayload = {
|
||||||
testId: string;
|
testId: string;
|
||||||
title: string;
|
title: string;
|
||||||
location: { file: string, line: number, column: number };
|
location: { file: string, line: number, column: number };
|
||||||
@ -95,7 +95,7 @@ export type TestEntry = {
|
|||||||
export type RunPayload = {
|
export type RunPayload = {
|
||||||
file: string;
|
file: string;
|
||||||
entries: TestEntry[];
|
entries: TestEntry[];
|
||||||
testServerTestLine?: number;
|
watchMode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DonePayload = {
|
export type DonePayload = {
|
||||||
|
|||||||
@ -646,6 +646,7 @@ export const baseFullConfig: FullConfigInternal = {
|
|||||||
version: require('../package.json').version,
|
version: require('../package.json').version,
|
||||||
workers,
|
workers,
|
||||||
webServer: null,
|
webServer: null,
|
||||||
|
_watchMode: false,
|
||||||
_webServers: [],
|
_webServers: [],
|
||||||
_globalOutputDir: path.resolve(process.cwd()),
|
_globalOutputDir: path.resolve(process.cwd()),
|
||||||
_configDir: '',
|
_configDir: '',
|
||||||
|
|||||||
@ -118,6 +118,9 @@ export class BaseReporter implements Reporter {
|
|||||||
protected generateStartingMessage() {
|
protected generateStartingMessage() {
|
||||||
const jobs = Math.min(this.config.workers, this.config._testGroupsCount);
|
const jobs = Math.min(this.config.workers, this.config._testGroupsCount);
|
||||||
const shardDetails = this.config.shard ? `, shard ${this.config.shard.current} of ${this.config.shard.total}` : '';
|
const shardDetails = this.config.shard ? `, shard ${this.config.shard.current} of ${this.config.shard.total}` : '';
|
||||||
|
if (this.config._watchMode)
|
||||||
|
return `\nRunning tests in the --watch mode`;
|
||||||
|
else
|
||||||
return `\nRunning ${this.totalTestCount} test${this.totalTestCount !== 1 ? 's' : ''} using ${jobs} worker${jobs !== 1 ? 's' : ''}${shardDetails}`;
|
return `\nRunning ${this.totalTestCount} test${this.totalTestCount !== 1 ? 's' : ''} using ${jobs} worker${jobs !== 1 ? 's' : ''}${shardDetails}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import * as path from 'path';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import type { TestGroup } from './dispatcher';
|
import type { TestGroup } from './dispatcher';
|
||||||
import { Dispatcher } from './dispatcher';
|
import { Dispatcher } from './dispatcher';
|
||||||
import type { FilePatternFilter } from './util';
|
import type { TestFileFilter } from './util';
|
||||||
import { createFileMatcher, createTitleMatcher, serializeError } from './util';
|
import { createFileMatcher, createTitleMatcher, serializeError } from './util';
|
||||||
import type { TestCase } from './test';
|
import type { TestCase } from './test';
|
||||||
import { Suite } from './test';
|
import { Suite } from './test';
|
||||||
@ -45,7 +45,6 @@ import type { TestRunnerPlugin } from './plugins';
|
|||||||
import { setRunnerToAddPluginsTo } from './plugins';
|
import { setRunnerToAddPluginsTo } from './plugins';
|
||||||
import { webServerPluginsForConfig } from './plugins/webServerPlugin';
|
import { webServerPluginsForConfig } from './plugins/webServerPlugin';
|
||||||
import { MultiMap } from 'playwright-core/lib/utils/multimap';
|
import { MultiMap } from 'playwright-core/lib/utils/multimap';
|
||||||
import { createGuid } from 'playwright-core/lib/utils';
|
|
||||||
|
|
||||||
const removeFolderAsync = promisify(rimraf);
|
const removeFolderAsync = promisify(rimraf);
|
||||||
const readDirAsync = promisify(fs.readdir);
|
const readDirAsync = promisify(fs.readdir);
|
||||||
@ -54,8 +53,9 @@ export const kDefaultConfigFiles = ['playwright.config.ts', 'playwright.config.j
|
|||||||
|
|
||||||
type RunOptions = {
|
type RunOptions = {
|
||||||
listOnly?: boolean;
|
listOnly?: boolean;
|
||||||
filePatternFilter?: FilePatternFilter[];
|
testFileFilters?: TestFileFilter[];
|
||||||
projectFilter?: string[];
|
projectFilter?: string[];
|
||||||
|
watchMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConfigCLIOverrides = {
|
export type ConfigCLIOverrides = {
|
||||||
@ -78,10 +78,17 @@ export type ConfigCLIOverrides = {
|
|||||||
use?: any;
|
use?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WatchProgress = {
|
||||||
|
canceled: boolean;
|
||||||
|
dispatcher: Dispatcher | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export class Runner {
|
export class Runner {
|
||||||
private _loader: Loader;
|
private _loader: Loader;
|
||||||
private _reporter!: Reporter;
|
private _reporter!: Reporter;
|
||||||
private _plugins: TestRunnerPlugin[] = [];
|
private _plugins: TestRunnerPlugin[] = [];
|
||||||
|
private _watchRepeatEachIndex = 0;
|
||||||
|
private _watchJobsQueue = Promise.resolve();
|
||||||
|
|
||||||
constructor(configCLIOverrides?: ConfigCLIOverrides) {
|
constructor(configCLIOverrides?: ConfigCLIOverrides) {
|
||||||
this._loader = new Loader(configCLIOverrides);
|
this._loader = new Loader(configCLIOverrides);
|
||||||
@ -174,8 +181,13 @@ export class Runner {
|
|||||||
async runAllTests(options: RunOptions = {}): Promise<FullResult> {
|
async runAllTests(options: RunOptions = {}): Promise<FullResult> {
|
||||||
this._reporter = await this._createReporter(!!options.listOnly);
|
this._reporter = await this._createReporter(!!options.listOnly);
|
||||||
const config = this._loader.fullConfig();
|
const config = this._loader.fullConfig();
|
||||||
|
if (options.watchMode) {
|
||||||
|
config._watchMode = true;
|
||||||
|
config._workerIsolation = 'isolate-projects';
|
||||||
|
return await this._watch(options);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await raceAgainstTimeout(() => this._run(!!options.listOnly, options.filePatternFilter || [], options.projectFilter), config.globalTimeout);
|
const result = await raceAgainstTimeout(() => this._run(options), config.globalTimeout);
|
||||||
let fullResult: FullResult;
|
let fullResult: FullResult;
|
||||||
if (result.timedOut) {
|
if (result.timedOut) {
|
||||||
this._reporter.onError?.(createStacklessError(`Timed out waiting ${config.globalTimeout / 1000}s for the entire test run`));
|
this._reporter.onError?.(createStacklessError(`Timed out waiting ${config.globalTimeout / 1000}s for the entire test run`));
|
||||||
@ -210,44 +222,8 @@ export class Runner {
|
|||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
async runTestServer(): Promise<void> {
|
private async _collectFiles(testFileFilters: TestFileFilter[], projectNames?: string[]): Promise<Map<FullProjectInternal, string[]>> {
|
||||||
const config = this._loader.fullConfig();
|
const testFileFilter = testFileFilters.length ? createFileMatcher(testFileFilters.map(e => e.re || e.exact || '')) : () => true;
|
||||||
this._reporter = await this._createReporter(false);
|
|
||||||
const rootSuite = new Suite('', 'root');
|
|
||||||
this._reporter.onBegin?.(config, rootSuite);
|
|
||||||
const result: FullResult = { status: 'passed' };
|
|
||||||
const globalTearDown = await this._performGlobalAndProjectSetup(config, rootSuite, config.projects, result);
|
|
||||||
if (result.status !== 'passed')
|
|
||||||
return;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const nextTest = await (this._reporter as any)._nextTest!();
|
|
||||||
if (!nextTest)
|
|
||||||
break;
|
|
||||||
const { projectId, file, line } = nextTest;
|
|
||||||
const testGroup: TestGroup = {
|
|
||||||
workerHash: createGuid(), // Create new worker for each test.
|
|
||||||
requireFile: file,
|
|
||||||
repeatEachIndex: 0,
|
|
||||||
projectId,
|
|
||||||
tests: [],
|
|
||||||
testServerTestLine: line,
|
|
||||||
};
|
|
||||||
const dispatcher = new Dispatcher(this._loader, [testGroup], this._reporter);
|
|
||||||
await dispatcher.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
await globalTearDown?.();
|
|
||||||
await this._reporter.onEnd?.(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _run(list: boolean, testFileReFilters: FilePatternFilter[], projectNames?: string[]): Promise<FullResult> {
|
|
||||||
const filesByProject = await this._collectFiles(testFileReFilters, projectNames);
|
|
||||||
return await this._runFiles(list, filesByProject, testFileReFilters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _collectFiles(testFileReFilters: FilePatternFilter[], projectNames?: string[]): Promise<Map<FullProjectInternal, string[]>> {
|
|
||||||
const testFileFilter = testFileReFilters.length ? createFileMatcher(testFileReFilters.map(e => e.re)) : () => true;
|
|
||||||
let projectsToFind: Set<string> | undefined;
|
let projectsToFind: Set<string> | undefined;
|
||||||
let unknownProjects: Map<string, string> | undefined;
|
let unknownProjects: Map<string, string> | undefined;
|
||||||
if (projectNames) {
|
if (projectNames) {
|
||||||
@ -288,7 +264,10 @@ export class Runner {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _runFiles(list: boolean, filesByProject: Map<FullProjectInternal, string[]>, testFileReFilters: FilePatternFilter[]): Promise<FullResult> {
|
private async _run(options: RunOptions): Promise<FullResult> {
|
||||||
|
const testFileFilters = options.testFileFilters || [];
|
||||||
|
const filesByProject = await this._collectFiles(testFileFilters, options.projectFilter);
|
||||||
|
|
||||||
const allTestFiles = new Set<string>();
|
const allTestFiles = new Set<string>();
|
||||||
for (const files of filesByProject.values())
|
for (const files of filesByProject.values())
|
||||||
files.forEach(file => allTestFiles.add(file));
|
files.forEach(file => allTestFiles.add(file));
|
||||||
@ -312,7 +291,7 @@ export class Runner {
|
|||||||
fatalErrors.push(duplicateTitlesError);
|
fatalErrors.push(duplicateTitlesError);
|
||||||
|
|
||||||
// 3. Filter tests to respect line/column filter.
|
// 3. Filter tests to respect line/column filter.
|
||||||
filterByFocusedLine(preprocessRoot, testFileReFilters);
|
filterByFocusedLine(preprocessRoot, testFileFilters);
|
||||||
|
|
||||||
// 4. Complain about only.
|
// 4. Complain about only.
|
||||||
if (config.forbidOnly) {
|
if (config.forbidOnly) {
|
||||||
@ -322,7 +301,7 @@ export class Runner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Filter only.
|
// 5. Filter only.
|
||||||
if (!list)
|
if (!options.listOnly)
|
||||||
filterOnly(preprocessRoot);
|
filterOnly(preprocessRoot);
|
||||||
|
|
||||||
// 6. Generate projects.
|
// 6. Generate projects.
|
||||||
@ -406,27 +385,12 @@ export class Runner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 11. Bail out if list mode only, don't do any work.
|
// 11. Bail out if list mode only, don't do any work.
|
||||||
if (list)
|
if (options.listOnly)
|
||||||
return { status: 'passed' };
|
return { status: 'passed' };
|
||||||
|
|
||||||
// 12. Remove output directores.
|
// 12. Remove output directores.
|
||||||
try {
|
if (!this._removeOutputDirs(options))
|
||||||
const outputDirs = new Set([...filesByProject.keys()].map(project => project.outputDir));
|
|
||||||
await Promise.all(Array.from(outputDirs).map(outputDir => removeFolderAsync(outputDir).catch(async error => {
|
|
||||||
if ((error as any).code === 'EBUSY') {
|
|
||||||
// We failed to remove folder, might be due to the whole folder being mounted inside a container:
|
|
||||||
// https://github.com/microsoft/playwright/issues/12106
|
|
||||||
// Do a best-effort to remove all files inside of it instead.
|
|
||||||
const entries = await readDirAsync(outputDir).catch(e => []);
|
|
||||||
await Promise.all(entries.map(entry => removeFolderAsync(path.join(outputDir, entry))));
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
} catch (e) {
|
|
||||||
this._reporter.onError?.(serializeError(e));
|
|
||||||
return { status: 'failed' };
|
return { status: 'failed' };
|
||||||
}
|
|
||||||
|
|
||||||
// 13. Run Global setup.
|
// 13. Run Global setup.
|
||||||
const result: FullResult = { status: 'passed' };
|
const result: FullResult = { status: 'passed' };
|
||||||
@ -464,6 +428,154 @@ export class Runner {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _watch(options: RunOptions): Promise<FullResult> {
|
||||||
|
const config = this._loader.fullConfig();
|
||||||
|
|
||||||
|
// 1. Create empty suite.
|
||||||
|
const rootSuite = new Suite('', 'root');
|
||||||
|
|
||||||
|
// 2. Report begin.
|
||||||
|
this._reporter.onBegin?.(config, rootSuite);
|
||||||
|
|
||||||
|
// 3. Remove output directores.
|
||||||
|
if (!this._removeOutputDirs(options))
|
||||||
|
return { status: 'failed' };
|
||||||
|
|
||||||
|
// 4. Run Global setup.
|
||||||
|
const result: FullResult = { status: 'passed' };
|
||||||
|
const globalTearDown = await this._performGlobalAndProjectSetup(config, rootSuite, config.projects.filter(p => !options.projectFilter || options.projectFilter.includes(p.name)), result);
|
||||||
|
if (result.status !== 'passed')
|
||||||
|
return result;
|
||||||
|
|
||||||
|
const progress: WatchProgress = { canceled: false, dispatcher: undefined };
|
||||||
|
|
||||||
|
const runAndWatch = async () => {
|
||||||
|
// 5. Collect all files.
|
||||||
|
const testFileFilters = options.testFileFilters || [];
|
||||||
|
const filesByProject = await this._collectFiles(testFileFilters, options.projectFilter);
|
||||||
|
|
||||||
|
const allTestFiles = new Set<string>();
|
||||||
|
for (const files of filesByProject.values())
|
||||||
|
files.forEach(file => allTestFiles.add(file));
|
||||||
|
|
||||||
|
// 6. Trigger 'all files changed'.
|
||||||
|
await this._runAndReportError(async () => {
|
||||||
|
await this._runModifiedTestFilesForWatch(progress, options, allTestFiles);
|
||||||
|
}, result);
|
||||||
|
|
||||||
|
// 7. Start watching the filesystem for modifications.
|
||||||
|
await this._watchTestFiles(progress, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sigintWatcher = new SigIntWatcher();
|
||||||
|
await Promise.race([runAndWatch(), sigintWatcher.promise()]);
|
||||||
|
if (!sigintWatcher.hadSignal())
|
||||||
|
sigintWatcher.disarm();
|
||||||
|
progress.canceled = true;
|
||||||
|
await progress.dispatcher?.stop();
|
||||||
|
} finally {
|
||||||
|
await globalTearDown?.();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _runModifiedTestFilesForWatch(progress: WatchProgress, options: RunOptions, testFiles: Set<string>): Promise<void> {
|
||||||
|
if (progress.canceled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const testFileFilters: TestFileFilter[] = [...testFiles].map(f => ({
|
||||||
|
exact: f,
|
||||||
|
line: null,
|
||||||
|
column: null,
|
||||||
|
}));
|
||||||
|
const filesByProject = await this._collectFiles(testFileFilters, options.projectFilter);
|
||||||
|
|
||||||
|
if (progress.canceled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const testGroups: TestGroup[] = [];
|
||||||
|
const repeatEachIndex = ++this._watchRepeatEachIndex;
|
||||||
|
for (const [project, files] of filesByProject) {
|
||||||
|
for (const file of files) {
|
||||||
|
const group: TestGroup = {
|
||||||
|
workerHash: `run${project._id}-repeat${repeatEachIndex}`,
|
||||||
|
requireFile: file,
|
||||||
|
repeatEachIndex,
|
||||||
|
projectId: project._id,
|
||||||
|
tests: [],
|
||||||
|
watchMode: true,
|
||||||
|
};
|
||||||
|
testGroups.push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatcher = new Dispatcher(this._loader, testGroups, this._reporter);
|
||||||
|
progress.dispatcher = dispatcher;
|
||||||
|
await dispatcher.run();
|
||||||
|
await dispatcher.stop();
|
||||||
|
progress.dispatcher = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _watchTestFiles(progress: WatchProgress, options: RunOptions): Promise<void> {
|
||||||
|
const folders = new Set<string>();
|
||||||
|
const config = this._loader.fullConfig();
|
||||||
|
for (const project of config.projects) {
|
||||||
|
if (options.projectFilter && !options.projectFilter.includes(project.name))
|
||||||
|
continue;
|
||||||
|
folders.add(project.testDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
const changedFiles = new Set<string>();
|
||||||
|
let throttleTimer: NodeJS.Timeout | undefined;
|
||||||
|
fs.watch(folder, (event, filename) => {
|
||||||
|
if (event !== 'change')
|
||||||
|
return;
|
||||||
|
|
||||||
|
const fullName = path.join(folder, filename);
|
||||||
|
changedFiles.add(fullName);
|
||||||
|
if (throttleTimer)
|
||||||
|
clearTimeout(throttleTimer);
|
||||||
|
|
||||||
|
throttleTimer = setTimeout(() => {
|
||||||
|
const copy = new Set(changedFiles);
|
||||||
|
changedFiles.clear();
|
||||||
|
this._watchJobsQueue = this._watchJobsQueue.then(() => this._runModifiedTestFilesForWatch(progress, options, copy));
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _removeOutputDirs(options: RunOptions): Promise<boolean> {
|
||||||
|
const config = this._loader.fullConfig();
|
||||||
|
const outputDirs = new Set<string>();
|
||||||
|
for (const p of config.projects) {
|
||||||
|
if (!options.projectFilter || options.projectFilter.includes(p.name))
|
||||||
|
outputDirs.add(p.outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(Array.from(outputDirs).map(outputDir => removeFolderAsync(outputDir).catch(async (error: any) => {
|
||||||
|
if ((error as any).code === 'EBUSY') {
|
||||||
|
// We failed to remove folder, might be due to the whole folder being mounted inside a container:
|
||||||
|
// https://github.com/microsoft/playwright/issues/12106
|
||||||
|
// Do a best-effort to remove all files inside of it instead.
|
||||||
|
const entries = await readDirAsync(outputDir).catch(e => []);
|
||||||
|
await Promise.all(entries.map(entry => removeFolderAsync(path.join(outputDir, entry))));
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
} catch (e) {
|
||||||
|
this._reporter.onError?.(serializeError(e));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async _performGlobalAndProjectSetup(config: FullConfigInternal, rootSuite: Suite, projects: FullProjectInternal[], result: FullResult): Promise<(() => Promise<void>) | undefined> {
|
private async _performGlobalAndProjectSetup(config: FullConfigInternal, rootSuite: Suite, projects: FullProjectInternal[], result: FullResult): Promise<(() => Promise<void>) | undefined> {
|
||||||
type SetupData = {
|
type SetupData = {
|
||||||
setupFile?: string | null;
|
setupFile?: string | null;
|
||||||
@ -569,14 +681,20 @@ function filterOnly(suite: Suite) {
|
|||||||
return filterSuiteWithOnlySemantics(suite, suiteFilter, testFilter);
|
return filterSuiteWithOnlySemantics(suite, suiteFilter, testFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByFocusedLine(suite: Suite, focusedTestFileLines: FilePatternFilter[]) {
|
function filterByFocusedLine(suite: Suite, focusedTestFileLines: TestFileFilter[]) {
|
||||||
const filterWithLine = !!focusedTestFileLines.find(f => f.line !== null);
|
const filterWithLine = !!focusedTestFileLines.find(f => f.line !== null);
|
||||||
if (!filterWithLine)
|
if (!filterWithLine)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const testFileLineMatches = (testFileName: string, testLine: number, testColumn: number) => focusedTestFileLines.some(({ re, line, column }) => {
|
const testFileLineMatches = (testFileName: string, testLine: number, testColumn: number) => focusedTestFileLines.some(({ re, exact, line, column }) => {
|
||||||
|
const lineColumnOk = (line === testLine || line === null) && (column === testColumn || column === null);
|
||||||
|
if (!lineColumnOk)
|
||||||
|
return false;
|
||||||
|
if (re) {
|
||||||
re.lastIndex = 0;
|
re.lastIndex = 0;
|
||||||
return re.test(testFileName) && (line === testLine || line === null) && (column === testColumn || column === null);
|
return re.test(testFileName);
|
||||||
|
}
|
||||||
|
return testFileName === exact;
|
||||||
});
|
});
|
||||||
const suiteFilter = (suite: Suite) => {
|
const suiteFilter = (suite: Suite) => {
|
||||||
return !!suite.location && testFileLineMatches(suite.location.file, suite.location.line, suite.location.column);
|
return !!suite.location && testFileLineMatches(suite.location.file, suite.location.line, suite.location.column);
|
||||||
@ -727,6 +845,7 @@ function createTestGroups(rootSuite: Suite, workers: number): TestGroup[] {
|
|||||||
repeatEachIndex: test.repeatEachIndex,
|
repeatEachIndex: test.repeatEachIndex,
|
||||||
projectId: test._projectId,
|
projectId: test._projectId,
|
||||||
tests: [],
|
tests: [],
|
||||||
|
watchMode: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export interface FullConfigInternal extends FullConfigPublic {
|
|||||||
_globalOutputDir: string;
|
_globalOutputDir: string;
|
||||||
_configDir: string;
|
_configDir: string;
|
||||||
_testGroupsCount: number;
|
_testGroupsCount: number;
|
||||||
|
_watchMode: boolean;
|
||||||
_workerIsolation: WorkerIsolation;
|
_workerIsolation: WorkerIsolation;
|
||||||
/**
|
/**
|
||||||
* If populated, this should also be the first/only entry in _webServers. Legacy singleton `webServer` as well as those provided via an array in the user-facing playwright.config.{ts,js} will be in `_webServers`. The legacy field (`webServer`) field additionally stores the backwards-compatible singleton `webServer` since it had been showing up in globalSetup to the user.
|
* If populated, this should also be the first/only entry in _webServers. Legacy singleton `webServer` as well as those provided via an array in the user-facing playwright.config.{ts,js} will be in `_webServers`. The legacy field (`webServer`) field additionally stores the backwards-compatible singleton `webServer` since it had been showing up in globalSetup to the user.
|
||||||
|
|||||||
@ -101,8 +101,9 @@ export function serializeError(error: Error | any): TestError {
|
|||||||
|
|
||||||
export type Matcher = (value: string) => boolean;
|
export type Matcher = (value: string) => boolean;
|
||||||
|
|
||||||
export type FilePatternFilter = {
|
export type TestFileFilter = {
|
||||||
re: RegExp;
|
re?: RegExp;
|
||||||
|
exact?: string;
|
||||||
line: number | null;
|
line: number | null;
|
||||||
column: number | null;
|
column: number | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -69,7 +69,7 @@ process.on('message', async message => {
|
|||||||
initConsoleParameters(initParams);
|
initConsoleParameters(initParams);
|
||||||
startProfiling();
|
startProfiling();
|
||||||
workerRunner = new WorkerRunner(initParams);
|
workerRunner = new WorkerRunner(initParams);
|
||||||
for (const event of ['testServer:testResolved', 'testBegin', 'testEnd', 'stepBegin', 'stepEnd', 'done', 'teardownErrors'])
|
for (const event of ['watchTestResolved', 'testBegin', 'testEnd', 'stepBegin', 'stepEnd', 'done', 'teardownErrors'])
|
||||||
workerRunner.on(event, sendMessageToParent.bind(null, event));
|
workerRunner.on(event, sendMessageToParent.bind(null, event));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import { colors, rimraf } from 'playwright-core/lib/utilsBundle';
|
|||||||
import util from 'util';
|
import util from 'util';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { relativeFilePath, serializeError } from './util';
|
import { relativeFilePath, serializeError } from './util';
|
||||||
import type { TestBeginPayload, TestEndPayload, RunPayload, DonePayload, WorkerInitParams, StepBeginPayload, StepEndPayload, TeardownErrorsPayload, TestServerTestResolvedPayload } from './ipc';
|
import type { TestBeginPayload, TestEndPayload, RunPayload, DonePayload, WorkerInitParams, StepBeginPayload, StepEndPayload, TeardownErrorsPayload, WatchTestResolvedPayload } from './ipc';
|
||||||
import { setCurrentTestInfo } from './globals';
|
import { setCurrentTestInfo } from './globals';
|
||||||
import { Loader } from './loader';
|
import { Loader } from './loader';
|
||||||
import type { Suite, TestCase } from './test';
|
import type { Suite, TestCase } from './test';
|
||||||
@ -171,13 +171,13 @@ export class WorkerRunner extends EventEmitter {
|
|||||||
await this._loadIfNeeded();
|
await this._loadIfNeeded();
|
||||||
const fileSuite = await this._loader.loadTestFile(runPayload.file, 'worker');
|
const fileSuite = await this._loader.loadTestFile(runPayload.file, 'worker');
|
||||||
const suite = this._loader.buildFileSuiteForProject(this._project, fileSuite, this._params.repeatEachIndex, test => {
|
const suite = this._loader.buildFileSuiteForProject(this._project, fileSuite, this._params.repeatEachIndex, test => {
|
||||||
if (test.location.line === runPayload.testServerTestLine) {
|
if (runPayload.watchMode) {
|
||||||
const testResolvedPayload: TestServerTestResolvedPayload = {
|
const testResolvedPayload: WatchTestResolvedPayload = {
|
||||||
testId: test.id,
|
testId: test.id,
|
||||||
title: test.title,
|
title: test.title,
|
||||||
location: test.location
|
location: test.location
|
||||||
};
|
};
|
||||||
this.emit('testServer:testResolved', testResolvedPayload);
|
this.emit('watchTestResolved', testResolvedPayload);
|
||||||
entries.set(test.id, { testId: test.id, retry: 0 });
|
entries.set(test.id, { testId: test.id, retry: 0 });
|
||||||
}
|
}
|
||||||
if (!entries.has(test.id))
|
if (!entries.has(test.id))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user