chore: watch mode first cut (#20647)

This commit is contained in:
Pavel Feldman 2023-02-06 15:52:14 -08:00 committed by GitHub
parent b6df48758d
commit 430d08f4fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 489 additions and 99 deletions

45
package-lock.json generated
View File

@ -2037,7 +2037,6 @@
},
"node_modules/braces": {
"version": "3.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.0.1"
@ -2169,14 +2168,14 @@
},
"node_modules/chokidar": {
"version": "3.5.3",
"dev": true,
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -2195,7 +2194,6 @@
},
"node_modules/chokidar/node_modules/anymatch": {
"version": "3.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@ -2207,7 +2205,6 @@
},
"node_modules/chokidar/node_modules/binary-extensions": {
"version": "2.2.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -2215,7 +2212,6 @@
},
"node_modules/chokidar/node_modules/is-binary-path": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@ -3521,7 +3517,6 @@
},
"node_modules/fill-range": {
"version": "7.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@ -3676,7 +3671,6 @@
},
"node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -3943,7 +3937,6 @@
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -3959,7 +3952,6 @@
},
"node_modules/is-glob": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@ -3970,7 +3962,6 @@
},
"node_modules/is-number": {
"version": "7.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@ -4419,7 +4410,6 @@
},
"node_modules/normalize-path": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -4602,7 +4592,6 @@
},
"node_modules/picomatch": {
"version": "2.2.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -4876,7 +4865,6 @@
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@ -5397,7 +5385,6 @@
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@ -6091,6 +6078,7 @@
"license": "Apache-2.0",
"dependencies": {
"@types/node": "*",
"chokidar": "3.5.3",
"playwright-core": "1.31.0-next"
},
"bin": {
@ -6935,6 +6923,7 @@
"version": "file:packages/playwright-test",
"requires": {
"@types/node": "*",
"chokidar": "3.5.3",
"playwright-core": "1.31.0-next"
}
},
@ -7427,7 +7416,6 @@
},
"braces": {
"version": "3.0.2",
"dev": true,
"requires": {
"fill-range": "^7.0.1"
}
@ -7503,7 +7491,8 @@
},
"chokidar": {
"version": "3.5.3",
"dev": true,
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -7517,19 +7506,16 @@
"dependencies": {
"anymatch": {
"version": "3.1.2",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"binary-extensions": {
"version": "2.2.0",
"dev": true
"version": "2.2.0"
},
"is-binary-path": {
"version": "2.1.0",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
@ -8328,7 +8314,6 @@
},
"fill-range": {
"version": "7.0.1",
"dev": true,
"requires": {
"to-regex-range": "^5.0.1"
}
@ -8430,7 +8415,6 @@
},
"glob-parent": {
"version": "5.1.2",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
@ -8609,8 +8593,7 @@
}
},
"is-extglob": {
"version": "2.1.1",
"dev": true
"version": "2.1.1"
},
"is-fullwidth-code-point": {
"version": "3.0.0",
@ -8618,14 +8601,12 @@
},
"is-glob": {
"version": "4.0.3",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"dev": true
"version": "7.0.0"
},
"is-what": {
"version": "4.1.8",
@ -8916,8 +8897,7 @@
}
},
"normalize-path": {
"version": "3.0.0",
"dev": true
"version": "3.0.0"
},
"normalize-url": {
"version": "4.5.1",
@ -9033,8 +9013,7 @@
"version": "1.0.0"
},
"picomatch": {
"version": "2.2.3",
"dev": true
"version": "2.2.3"
},
"pify": {
"version": "4.0.1",
@ -9220,7 +9199,6 @@
},
"readdirp": {
"version": "3.6.0",
"dev": true,
"requires": {
"picomatch": "^2.2.1"
}
@ -9548,7 +9526,6 @@
},
"to-regex-range": {
"version": "5.0.1",
"dev": true,
"requires": {
"is-number": "^7.0.0"
}

View File

@ -74,6 +74,7 @@ This project incorporates components from the projects listed below. The origina
- @types/stack-utils@2.0.1 (https://github.com/DefinitelyTyped/DefinitelyTyped)
- @types/yargs-parser@21.0.0 (https://github.com/DefinitelyTyped/DefinitelyTyped)
- @types/yargs@16.0.4 (https://github.com/DefinitelyTyped/DefinitelyTyped)
- ansi-colors@4.1.3 (https://github.com/doowb/ansi-colors)
- ansi-regex@5.0.1 (https://github.com/chalk/ansi-regex)
- ansi-styles@3.2.1 (https://github.com/chalk/ansi-styles)
- ansi-styles@4.3.0 (https://github.com/chalk/ansi-styles)
@ -94,6 +95,7 @@ This project incorporates components from the projects listed below. The origina
- define-lazy-prop@2.0.0 (https://github.com/sindresorhus/define-lazy-prop)
- diff-sequences@27.5.1 (https://github.com/facebook/jest)
- electron-to-chromium@1.4.284 (https://github.com/kilian/electron-to-chromium)
- enquirer@2.3.6 (https://github.com/enquirer/enquirer)
- escalade@3.1.1 (https://github.com/lukeed/escalade)
- escape-string-regexp@1.0.5 (https://github.com/sindresorhus/escape-string-regexp)
- escape-string-regexp@2.0.0 (https://github.com/sindresorhus/escape-string-regexp)
@ -2179,6 +2181,32 @@ MIT License
=========================================
END OF @types/yargs@16.0.4 AND INFORMATION
%% ansi-colors@4.1.3 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2015-present, Brian Woodward.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF ansi-colors@4.1.3 AND INFORMATION
%% ansi-regex@5.0.1 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
@ -2944,6 +2972,32 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH RE
=========================================
END OF electron-to-chromium@1.4.284 AND INFORMATION
%% enquirer@2.3.6 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2016-present, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF enquirer@2.3.6 AND INFORMATION
%% escalade@3.1.1 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
@ -3869,6 +3923,6 @@ END OF update-browserslist-db@1.0.10 AND INFORMATION
SUMMARY BEGIN HERE
=========================================
Total Packages: 132
Total Packages: 134
=========================================
END OF SUMMARY

View File

@ -8,6 +8,7 @@
"name": "utils-bundle",
"version": "0.0.1",
"dependencies": {
"enquirer": "^2.3.6",
"json5": "2.2.3",
"open": "8.4.0",
"pirates": "4.0.4",
@ -43,6 +44,14 @@
"@types/node": "*"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"engines": {
"node": ">=6"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -56,6 +65,17 @@
"node": ">=8"
}
},
"node_modules/enquirer": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
"integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
"dependencies": {
"ansi-colors": "^4.1.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
@ -168,6 +188,11 @@
"@types/node": "*"
}
},
"ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="
},
"buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -178,6 +203,14 @@
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="
},
"enquirer": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
"integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
"requires": {
"ansi-colors": "^4.1.1"
}
},
"is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",

View File

@ -9,6 +9,7 @@
"generate-license": "node ../../../../utils/generate_third_party_notice.js"
},
"dependencies": {
"enquirer": "^2.3.6",
"json5": "2.2.3",
"open": "8.4.0",
"pirates": "4.0.4",

View File

@ -28,3 +28,6 @@ export const sourceMapSupport = sourceMapSupportLibrary;
import stoppableLibrary from 'stoppable';
export const stoppable = stoppableLibrary;
import enquirerLibrary from 'enquirer';
export const enquirer = enquirerLibrary;

View File

@ -34,6 +34,7 @@
"license": "Apache-2.0",
"dependencies": {
"@types/node": "*",
"chokidar": "3.5.3",
"playwright-core": "1.31.0-next"
}
}

View File

@ -21,7 +21,7 @@ import fs from 'fs';
import path from 'path';
import { Runner } from './runner/runner';
import { stopProfiling, startProfiling } from './common/profiler';
import { experimentalLoaderOption, fileIsModule } from './util';
import { createFileFilterForArg, experimentalLoaderOption, fileIsModule, forceRegExp } from './util';
import { createTitleMatcher } from './util';
import { showHTMLReport } from './reporters/html';
import { baseFullConfig, builtInReporters, ConfigLoader, defaultTimeout, kDefaultConfigFiles, resolveConfigFile } from './common/configLoader';
@ -61,6 +61,7 @@ function addTestCommand(program: Command) {
command.option('--project <project-name...>', `Only run tests from the specified list of projects (default: run all projects)`);
command.option('--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`);
command.option('--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`);
command.option('--watch', `Run watch mode`);
command.option('-u, --update-snapshots', `Update snapshots with actual results (default: only create missing snapshots)`);
command.option('-x', `Stop after the first failure`);
command.action(async (args, opts) => {
@ -159,14 +160,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
configLoader.ignoreProjectDependencies();
const config = configLoader.fullConfig();
config._internal.cliFileFilters = args.map(arg => {
const match = /^(.*?):(\d+):?(\d+)?$/.exec(arg);
return {
re: forceRegExp(match ? match[1] : arg),
line: match ? parseInt(match[2], 10) : null,
column: match?.[3] ? parseInt(match[3], 10) : null,
};
});
config._internal.cliFileFilters = args.map(arg => createFileFilterForArg(arg));
const grepMatcher = opts.grep ? createTitleMatcher(forceRegExp(opts.grep)) : () => true;
const grepInvertMatcher = opts.grepInvert ? createTitleMatcher(forceRegExp(opts.grepInvert)) : () => false;
config._internal.cliTitleMatcher = (title: string) => !grepInvertMatcher(title) && grepMatcher(title);
@ -175,7 +169,9 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
config._internal.passWithNoTests = !!opts.passWithNoTests;
const runner = new Runner(config);
const status = await runner.runAllTests();
if (opts.watch)
process.stdout.write('\x1Bc');
const status = await runner.runAllTests(!!opts.watch);
await stopProfiling(undefined);
if (status === 'interrupted')
@ -201,13 +197,6 @@ async function listTestFiles(opts: { [key: string]: any }) {
});
}
function forceRegExp(pattern: string): RegExp {
const match = pattern.match(/^\/(.*)\/([gi]*)$/);
if (match)
return new RegExp(match[1], match[2]);
return new RegExp(pattern, 'gi');
}
function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrides {
const shardPair = options.shard ? options.shard.split('/').map((t: string) => parseInt(t, 10)) : undefined;
return {

View File

@ -18,7 +18,7 @@ import path from 'path';
import { calculateSha1 } from 'playwright-core/lib/utils';
import type { Suite, TestCase } from './test';
import type { FullProjectInternal } from './types';
import type { TestFileFilter } from '../util';
import type { Matcher, TestFileFilter } from '../util';
import { createFileMatcher } from '../util';
@ -114,6 +114,12 @@ export function filterByFocusedLine(suite: Suite, focusedTestFileLines: TestFile
return filterSuite(suite, suiteFilter, testFilter);
}
export function filterByTestIds(suite: Suite, testIdMatcher: Matcher | undefined) {
if (!testIdMatcher)
return;
filterTestsRemoveEmptySuites(suite, test => testIdMatcher(test.id));
}
function createFileMatcherFromFilter(filter: TestFileFilter) {
const fileMatcher = createFileMatcher(filter.re || filter.exact || '');
return (testFileName: string, testLine: number, testColumn: number) =>

View File

@ -53,6 +53,7 @@ type ConfigInternal = {
cliFileFilters: TestFileFilter[];
cliTitleMatcher: Matcher;
cliProjectFilter?: string[];
testIdMatcher?: Matcher;
passWithNoTests?: boolean;
};

View File

@ -21,6 +21,7 @@ export interface TestRunnerPlugin {
name: string;
setup?(config: FullConfig, configDir: string, reporter: Reporter): Promise<void>;
begin?(suite: Suite): Promise<void>;
end?(): Promise<void>;
teardown?(): Promise<void>;
}

View File

@ -163,7 +163,7 @@ export function createPlugin(
}
},
teardown: async () => {
end: async () => {
await new Promise(f => stoppableServer.stop(f));
},
};

View File

@ -112,7 +112,7 @@ class HtmlReporter implements Reporter {
this._buildResult = await builder.build({ ...this.config.metadata, duration }, reports);
}
async onExit() {
async _onExit() {
if (process.env.CI || !this._buildResult)
return;

View File

@ -102,7 +102,7 @@ export class Multiplexer implements Reporter {
await Promise.resolve().then(() => reporter.onEnd?.(result)).catch(e => console.error('Error in reporter', e));
for (const reporter of this._reporters)
await Promise.resolve().then(() => (reporter as any).onExit?.()).catch(e => console.error('Error in reporter', e));
await Promise.resolve().then(() => (reporter as any)._onExit?.()).catch(e => console.error('Error in reporter', e));
}
onError(error: TestError) {

View File

@ -6,3 +6,4 @@
../third_party/
../plugins/
../util.ts
../utilsBundle.ts

View File

@ -25,10 +25,10 @@ import { createTitleMatcher, errorWithFile } from '../util';
import type { Matcher, TestFileFilter } from '../util';
import { buildProjectsClosure, collectFilesForProject, filterProjects } from './projectUtils';
import { requireOrImport } from '../common/transform';
import { buildFileSuiteForProject, filterByFocusedLine, filterOnly, filterTestsRemoveEmptySuites } from '../common/suiteUtils';
import { buildFileSuiteForProject, filterByFocusedLine, filterByTestIds, filterOnly, filterTestsRemoveEmptySuites } from '../common/suiteUtils';
import { filterForShard } from './testGroups';
export async function loadAllTests(config: FullConfigInternal, projectsToIgnore: Set<FullProjectInternal>, fileMatcher: Matcher, errors: TestError[]): Promise<Suite> {
export async function loadAllTests(mode: 'out-of-process' | 'in-process', config: FullConfigInternal, projectsToIgnore: Set<FullProjectInternal>, fileMatcher: Matcher, errors: TestError[]): Promise<Suite> {
const projects = filterProjects(config.projects, config._internal.cliProjectFilter);
let filesToRunByProject = new Map<FullProjectInternal, string[]>();
@ -81,7 +81,7 @@ export async function loadAllTests(config: FullConfigInternal, projectsToIgnore:
// Load all test files and create a preprocessed root. Child suites are files there.
const fileSuits: Suite[] = [];
{
const loaderHost: LoaderHost = process.env.PW_TEST_OOP_LOADER ? new OutOfProcessLoaderHost(config) : new InProcessLoaderHost(config);
const loaderHost: LoaderHost = mode === 'out-of-process' ? new OutOfProcessLoaderHost(config) : new InProcessLoaderHost(config);
const allTestFiles = new Set<string>();
for (const files of filesToRunByProject.values())
files.forEach(file => allTestFiles.add(file));
@ -125,7 +125,7 @@ export async function loadAllTests(config: FullConfigInternal, projectsToIgnore:
return rootSuite;
}
async function createProjectSuite(fileSuits: Suite[], project: FullProjectInternal, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher }, files: string[]): Promise<Suite | null> {
async function createProjectSuite(fileSuits: Suite[], project: FullProjectInternal, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher, testIdMatcher?: Matcher }, files: string[]): Promise<Suite | null> {
const fileSuitesMap = new Map<string, Suite>();
for (const fileSuite of fileSuits)
fileSuitesMap.set(fileSuite._requireFile, fileSuite);
@ -143,8 +143,9 @@ async function createProjectSuite(fileSuits: Suite[], project: FullProjectIntern
projectSuite._addSuite(builtSuite);
}
}
// Filter tests to respect line/column filter.
filterByFocusedLine(projectSuite, options.cliFileFilters);
filterByTestIds(projectSuite, options.testIdMatcher);
const grepMatcher = createTitleMatcher(project.grep);
const grepInvertMatcher = project.grepInvert ? createTitleMatcher(project.grepInvert) : null;

View File

@ -69,6 +69,7 @@ export class OutOfProcessLoaderHost extends LoaderHost {
}
override async stop() {
await this._startPromise;
const result = await this._processHost.sendMessage({ method: 'serializeCompilationCache' }) as any;
addToCompilationCache(result);
await this._processHost.stop();

View File

@ -31,11 +31,11 @@ import type { FullConfigInternal } from '../common/types';
import { loadReporter } from './loadUtils';
import type { BuiltInReporter } from '../common/configLoader';
export async function createReporter(config: FullConfigInternal, list: boolean) {
export async function createReporter(config: FullConfigInternal, mode: 'list' | 'watch' | 'run') {
const defaultReporters: {[key in BuiltInReporter]: new(arg: any) => Reporter} = {
dot: list ? ListModeReporter : DotReporter,
line: list ? ListModeReporter : LineReporter,
list: list ? ListModeReporter : ListReporter,
dot: mode === 'list' ? ListModeReporter : DotReporter,
line: mode === 'list' ? ListModeReporter : LineReporter,
list: mode === 'list' ? ListModeReporter : ListReporter,
github: GitHubReporter,
json: JSONReporter,
junit: JUnitReporter,
@ -43,6 +43,9 @@ export async function createReporter(config: FullConfigInternal, list: boolean)
html: HtmlReporter,
};
const reporters: Reporter[] = [];
if (mode === 'watch') {
reporters.push(new WatchModeReporter());
} else {
for (const r of config.reporter) {
const [name, arg] = r;
if (name in defaultReporters) {
@ -56,6 +59,7 @@ export async function createReporter(config: FullConfigInternal, list: boolean)
const reporterConstructor = await loadReporter(config, process.env.PW_TEST_REPORTER);
reporters.push(new reporterConstructor());
}
}
const someReporterPrintsToStdio = reporters.some(r => {
const prints = r.printsToStdio ? r.printsToStdio() : true;
@ -64,7 +68,7 @@ export async function createReporter(config: FullConfigInternal, list: boolean)
if (reporters.length && !someReporterPrintsToStdio) {
// Add a line/dot/list-mode reporter for convenience.
// Important to put it first, jsut in case some other reporter stalls onEnd.
if (list)
if (mode === 'list')
reporters.unshift(new ListModeReporter());
else
reporters.unshift(!process.env.CI ? new LineReporter({ omitFailures: true }) : new DotReporter());
@ -99,3 +103,6 @@ export class ListModeReporter implements Reporter {
console.error('\n' + formatError(this.config, error, false).message);
}
}
export class WatchModeReporter extends LineReporter {
}

View File

@ -24,6 +24,7 @@ import { createTaskRunner, createTaskRunnerForList } from './tasks';
import type { TaskRunnerState } from './tasks';
import type { FullConfigInternal } from '../common/types';
import { colors } from 'playwright-core/lib/utilsBundle';
import { runWatchModeLoop } from './watchMode';
export class Runner {
private _config: FullConfigInternal;
@ -46,7 +47,7 @@ export class Runner {
return report;
}
async runAllTests(): Promise<FullResult['status']> {
async runAllTests(watchMode: boolean): Promise<FullResult['status']> {
const config = this._config;
const listOnly = config._internal.listOnly;
const deadline = config.globalTimeout ? monotonicTime() + config.globalTimeout : 0;
@ -54,9 +55,9 @@ export class Runner {
// Legacy webServer support.
webServerPluginsForConfig(config).forEach(p => config._internal.plugins.push({ factory: p }));
const reporter = await createReporter(config, listOnly);
const reporter = await createReporter(config, listOnly ? 'list' : watchMode ? 'watch' : 'run');
const taskRunner = listOnly ? createTaskRunnerForList(config, reporter)
: createTaskRunner(config, reporter);
: createTaskRunner(config, reporter, watchMode);
const context: TaskRunnerState = {
config,
@ -77,12 +78,16 @@ export class Runner {
const taskStatus = await taskRunner.run(context, deadline);
let status: FullResult['status'] = 'passed';
if (context.phases.find(p => p.dispatcher.hasWorkerErrors()) || context.rootSuite?.allTests().some(test => !test.ok()))
const failedTests = context.rootSuite?.allTests().filter(test => !test.ok()) || [];
if (context.phases.find(p => p.dispatcher.hasWorkerErrors()) || failedTests.length)
status = 'failed';
if (status === 'passed' && taskStatus !== 'passed')
status = taskStatus;
await reporter.onExit({ status });
if (watchMode)
await runWatchModeLoop(config, failedTests);
// Calling process.exit() might truncate large stdout/stderr output.
// See https://github.com/nodejs/node/issues/6456.
// See https://github.com/nodejs/node/issues/12921

View File

@ -50,29 +50,42 @@ export type TaskRunnerState = {
}[];
};
export function createTaskRunner(config: FullConfigInternal, reporter: Multiplexer): TaskRunner<TaskRunnerState> {
export function createTaskRunner(config: FullConfigInternal, reporter: Multiplexer, doNotTeardown: boolean): TaskRunner<TaskRunnerState> {
const taskRunner = new TaskRunner<TaskRunnerState>(reporter, config.globalTimeout);
for (const plugin of config._internal.plugins)
taskRunner.addTask('plugin setup', createPluginSetupTask(plugin));
taskRunner.addTask('load tests', createLoadTask());
taskRunner.addTask('plugin setup', createPluginSetupTask(plugin, doNotTeardown));
if (config.globalSetup || config.globalTeardown)
taskRunner.addTask('global setup', createGlobalSetupTask(doNotTeardown));
taskRunner.addTask('load tests', createLoadTask('in-process'));
taskRunner.addTask('clear output', createRemoveOutputDirsTask());
taskRunner.addTask('prepare workers', createTestGroupsTask());
for (const plugin of config._internal.plugins)
taskRunner.addTask('plugin begin', async ({ rootSuite }) => plugin.instance?.begin?.(rootSuite!));
addCommonTasks(taskRunner, config);
return taskRunner;
}
export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: Multiplexer, projectsToIgnore?: Set<FullProjectInternal>, additionalFileMatcher?: Matcher): TaskRunner<TaskRunnerState> {
const taskRunner = new TaskRunner<TaskRunnerState>(reporter, config.globalTimeout);
taskRunner.addTask('load tests', createLoadTask('out-of-process', projectsToIgnore, additionalFileMatcher));
addCommonTasks(taskRunner, config);
return taskRunner;
}
function addCommonTasks(taskRunner: TaskRunner<TaskRunnerState>, config: FullConfigInternal) {
taskRunner.addTask('create tasks', createTestGroupsTask());
taskRunner.addTask('report begin', async ({ reporter, rootSuite }) => {
reporter.onBegin?.(config, rootSuite!);
return () => reporter.onEnd();
});
if (config.globalSetup || config.globalTeardown)
taskRunner.addTask('global setup', createGlobalSetupTask());
for (const plugin of config._internal.plugins)
taskRunner.addTask('plugin begin', createPluginBeginTask(plugin));
taskRunner.addTask('start workers', createWorkersTask());
taskRunner.addTask('test suite', createRunTestsTask());
return taskRunner;
}
export function createTaskRunnerForList(config: FullConfigInternal, reporter: Multiplexer): TaskRunner<TaskRunnerState> {
const taskRunner = new TaskRunner<TaskRunnerState>(reporter, config.globalTimeout);
taskRunner.addTask('load tests', createLoadTask());
taskRunner.addTask('load tests', createLoadTask('in-process'));
taskRunner.addTask('report begin', async ({ reporter, rootSuite }) => {
reporter.onBegin?.(config, rootSuite!);
return () => reporter.onEnd();
@ -80,23 +93,30 @@ export function createTaskRunnerForList(config: FullConfigInternal, reporter: Mu
return taskRunner;
}
function createPluginSetupTask(plugin: TestRunnerPluginRegistration): Task<TaskRunnerState> {
function createPluginSetupTask(plugin: TestRunnerPluginRegistration, doNotTeardown: boolean): Task<TaskRunnerState> {
return async ({ config, reporter }) => {
if (typeof plugin.factory === 'function')
plugin.instance = await plugin.factory();
else
plugin.instance = plugin.factory;
await plugin.instance?.setup?.(config, config._internal.configDir, reporter);
return () => plugin.instance?.teardown?.();
return doNotTeardown ? undefined : () => plugin.instance?.teardown?.();
};
}
function createGlobalSetupTask(): Task<TaskRunnerState> {
function createPluginBeginTask(plugin: TestRunnerPluginRegistration): Task<TaskRunnerState> {
return async ({ rootSuite }) => {
await plugin.instance?.begin?.(rootSuite!);
return () => plugin.instance?.end?.();
};
}
function createGlobalSetupTask(doNotTeardown: boolean): Task<TaskRunnerState> {
return async ({ config }) => {
const setupHook = config.globalSetup ? await loadGlobalHook(config, config.globalSetup) : undefined;
const teardownHook = config.globalTeardown ? await loadGlobalHook(config, config.globalTeardown) : undefined;
const globalSetupResult = setupHook ? await setupHook(config) : undefined;
return async () => {
return doNotTeardown ? undefined : async () => {
if (typeof globalSetupResult === 'function')
await globalSetupResult();
await teardownHook?.(config);
@ -126,12 +146,12 @@ function createRemoveOutputDirsTask(): Task<TaskRunnerState> {
};
}
function createLoadTask(projectsToIgnore = new Set<FullProjectInternal>(), additionalFileMatcher?: Matcher): Task<TaskRunnerState> {
function createLoadTask(mode: 'out-of-process' | 'in-process', projectsToIgnore = new Set<FullProjectInternal>(), additionalFileMatcher?: Matcher): Task<TaskRunnerState> {
return async (context, errors) => {
const { config } = context;
const cliMatcher = config._internal.cliFileFilters.length ? createFileMatcherFromFilters(config._internal.cliFileFilters) : () => true;
const fileMatcher = (value: string) => cliMatcher(value) && (additionalFileMatcher ? additionalFileMatcher(value) : true);
context.rootSuite = await loadAllTests(config, projectsToIgnore, fileMatcher, errors);
context.rootSuite = await loadAllTests(mode, config, projectsToIgnore, fileMatcher, errors);
// Fail when no tests.
if (!context.rootSuite.allTests().length && !config._internal.passWithNoTests && !config.shard)
throw new Error(`No tests found`);
@ -158,9 +178,13 @@ function createTestGroupsTask(): Task<TaskRunnerState> {
context.phases.push({ dispatcher: new Dispatcher(config, reporter), projects });
context.config._internal.maxConcurrentTestGroups = Math.max(context.config._internal.maxConcurrentTestGroups, testGroupsInPhase);
}
};
}
function createWorkersTask(): Task<TaskRunnerState> {
return async ({ phases }) => {
return async () => {
for (const { dispatcher } of context.phases.reverse())
for (const { dispatcher } of phases.reverse())
await dispatcher.stop();
};
};

View File

@ -0,0 +1,275 @@
/**
* 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 readline from 'readline';
import { ManualPromise } from 'playwright-core/lib/utils';
import type { FullConfigInternal, FullProjectInternal } from '../common/types';
import { Multiplexer } from '../reporters/multiplexer';
import { createFileFilterForArg, createFileMatcherFromFilters, createTitleMatcher, forceRegExp } from '../util';
import type { Matcher } from '../util';
import { createTaskRunnerForWatch } from './tasks';
import type { TaskRunnerState } from './tasks';
import { buildProjectsClosure, filterProjects } from './projectUtils';
import { clearCompilationCache } from '../common/compilationCache';
import type { FullResult, TestCase } from 'packages/playwright-test/reporter';
import chokidar from 'chokidar';
import { WatchModeReporter } from './reporters';
import { colors } from 'playwright-core/lib/utilsBundle';
import { enquirer } from '../utilsBundle';
class FSWatcher {
private _dirtyFiles = new Set<string>();
private _notifyDirtyFiles: (() => void) | undefined;
constructor(dirs: string[]) {
let timer: NodeJS.Timer;
chokidar.watch(dirs, { ignoreInitial: true }).on('all', async (event, file) => {
if (event !== 'add' && event !== 'change')
return;
this._dirtyFiles.add(file);
if (timer)
clearTimeout(timer);
timer = setTimeout(() => {
this._notifyDirtyFiles?.();
}, 250);
});
}
async onDirtyFiles(): Promise<void> {
if (this._dirtyFiles.size)
return;
await new Promise<void>(f => this._notifyDirtyFiles = f);
}
takeDirtyFiles(): Set<string> {
const result = this._dirtyFiles;
this._dirtyFiles = new Set();
return result;
}
}
export async function runWatchModeLoop(config: FullConfigInternal, failedTests: TestCase[]) {
const projects = filterProjects(config.projects, config._internal.cliProjectFilter);
const projectClosure = buildProjectsClosure(projects);
config._internal.passWithNoTests = true;
const failedTestIdCollector = new Set(failedTests.map(t => t.id));
const originalTitleMatcher = config._internal.cliTitleMatcher;
const originalFileFilters = config._internal.cliFileFilters;
const fsWatcher = new FSWatcher(projectClosure.map(p => p.testDir));
let lastFilePattern: string | undefined;
let lastTestPattern: string | undefined;
while (true) {
process.stdout.write(`
Waiting for file changes...
${colors.dim('press')} ${colors.bold('h')} ${colors.dim('to show help, press')} ${colors.bold('q')} ${colors.dim('to quit')}
`);
const readCommandPromise = readCommand();
await Promise.race([
fsWatcher.onDirtyFiles(),
readCommandPromise,
]);
if (!readCommandPromise.isDone())
readCommandPromise.resolve('changed');
const command = await readCommandPromise;
if (command === 'changed') {
process.stdout.write('\x1Bc');
await runChangedTests(config, failedTestIdCollector, projectClosure, fsWatcher.takeDirtyFiles());
continue;
}
if (command === 'all') {
process.stdout.write('\x1Bc');
// All means reset filters.
config._internal.cliTitleMatcher = originalTitleMatcher;
config._internal.cliFileFilters = originalFileFilters;
lastFilePattern = undefined;
lastTestPattern = undefined;
await runTests(config, failedTestIdCollector);
continue;
}
if (command === 'file') {
const { filePattern } = await enquirer.prompt<{ filePattern: string }>({
type: 'text',
name: 'filePattern',
message: 'Input filename pattern (regex)',
initial: lastFilePattern,
});
if (filePattern.trim()) {
lastFilePattern = filePattern;
config._internal.cliFileFilters = [createFileFilterForArg(filePattern)];
} else {
lastFilePattern = undefined;
config._internal.cliFileFilters = originalFileFilters;
}
await runTests(config, failedTestIdCollector);
continue;
}
if (command === 'grep') {
const { testPattern } = await enquirer.prompt<{ testPattern: string }>({
type: 'text',
name: 'testPattern',
message: 'Input test name pattern (regex)',
initial: lastTestPattern,
});
if (testPattern.trim()) {
lastTestPattern = testPattern;
config._internal.cliTitleMatcher = createTitleMatcher(forceRegExp(testPattern));
} else {
lastTestPattern = undefined;
config._internal.cliTitleMatcher = originalTitleMatcher;
}
await runTests(config, failedTestIdCollector);
continue;
}
if (command === 'failed') {
process.stdout.write('\x1Bc');
config._internal.testIdMatcher = id => failedTestIdCollector.has(id);
try {
await runTests(config, failedTestIdCollector);
} finally {
config._internal.testIdMatcher = undefined;
}
continue;
}
}
}
async function runChangedTests(config: FullConfigInternal, failedTestIds: Set<string>, projectClosure: FullProjectInternal[], files: Set<string>) {
const commandLineFileMatcher = config._internal.cliFileFilters.length ? createFileMatcherFromFilters(config._internal.cliFileFilters) : () => true;
// Collect projects with changes.
const filesByProject = new Map<FullProjectInternal, string[]>();
for (const project of projectClosure) {
const projectFiles: string[] = [];
for (const file of files) {
if (!file.startsWith(project.testDir))
continue;
if (project._internal.type === 'dependency' || commandLineFileMatcher(file))
projectFiles.push(file);
}
if (projectFiles.length)
filesByProject.set(project, projectFiles);
}
// Collect all the affected projects, follow project dependencies.
// Prepare to exclude all the projects that do not depend on this file, as if they did not exist.
const affectedProjects = affectedProjectsClosure(projectClosure, [...filesByProject.keys()]);
const affectsAnyDependency = [...affectedProjects].some(p => p._internal.type === 'dependency');
const projectsToIgnore = new Set(projectClosure.filter(p => !affectedProjects.has(p)));
// If there are affected dependency projects, do the full run, respect the original CLI.
// if there are no affected dependency projects, intersect CLI with dirty files
const additionalFileMatcher = affectsAnyDependency ? () => true : (file: string) => files.has(file);
return await runTests(config, failedTestIds, projectsToIgnore, additionalFileMatcher);
}
async function runTests(config: FullConfigInternal, failedTestIds: Set<string>, projectsToIgnore?: Set<FullProjectInternal>, additionalFileMatcher?: Matcher) {
const reporter = new Multiplexer([new WatchModeReporter()]);
const taskRunner = createTaskRunnerForWatch(config, reporter, projectsToIgnore, additionalFileMatcher);
const context: TaskRunnerState = {
config,
reporter,
phases: [],
};
clearCompilationCache();
reporter.onConfigure(config);
const taskStatus = await taskRunner.run(context, 0);
let status: FullResult['status'] = 'passed';
let hasFailedTests = false;
for (const test of context.rootSuite?.allTests() || []) {
if (test.outcome() === 'expected') {
failedTestIds.delete(test.id);
} else {
failedTestIds.add(test.id);
hasFailedTests = true;
}
}
if (context.phases.find(p => p.dispatcher.hasWorkerErrors()) || hasFailedTests)
status = 'failed';
if (status === 'passed' && taskStatus !== 'passed')
status = taskStatus;
await reporter.onExit({ status });
}
function affectedProjectsClosure(projectClosure: FullProjectInternal[], affected: FullProjectInternal[]): Set<FullProjectInternal> {
const result = new Set<FullProjectInternal>(affected);
for (let i = 0; i < projectClosure.length; ++i) {
for (const p of projectClosure) {
for (const dep of p._internal.deps) {
if (result.has(dep))
result.add(p);
}
}
}
return result;
}
function readCommand(): ManualPromise<Command> {
const result = new ManualPromise<Command>();
const rl = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 });
readline.emitKeypressEvents(process.stdin, rl);
if (process.stdin.isTTY)
process.stdin.setRawMode(true);
const handler = (text: string, key: any) => {
if (text === '\x03' || text === '\x1B' || (key && key.name === 'escape') || (key && key.ctrl && key.name === 'c'))
return process.exit(130);
if (process.platform !== 'win32' && key && key.ctrl && key.name === 'z') {
process.kill(process.ppid, 'SIGTSTP');
process.kill(process.pid, 'SIGTSTP');
}
const name = key?.name;
if (name === 'q')
process.exit(0);
if (name === 'h') {
process.stdout.write(`
Watch Usage
${commands.map(i => colors.dim(' press ') + colors.reset(colors.bold(i[0])) + colors.dim(` to ${i[1]}`)).join('\n')}
`);
return;
}
switch (name) {
case 'a': result.resolve('all'); break;
case 'p': result.resolve('file'); break;
case 't': result.resolve('grep'); break;
case 'f': result.resolve('failed'); break;
}
};
process.stdin.on('keypress', handler);
result.finally(() => {
process.stdin.off('keypress', handler);
rl.close();
if (process.stdin.isTTY)
process.stdin.setRawMode(false);
});
return result;
}
type Command = 'all' | 'failed' | 'changed' | 'file' | 'grep';
const commands = [
['a', 'rerun all tests'],
['f', 'rerun only failed tests'],
['p', 'filter by a filename'],
['t', 'filter by a test name regex pattern'],
['q', 'quit'],
];

View File

@ -106,6 +106,15 @@ export type TestFileFilter = {
column: number | null;
};
export function createFileFilterForArg(arg: string): TestFileFilter {
const match = /^(.*?):(\d+):?(\d+)?$/.exec(arg);
return {
re: forceRegExp(match ? match[1] : arg),
line: match ? parseInt(match[2], 10) : null,
column: match?.[3] ? parseInt(match[3], 10) : null,
};
}
export function createFileMatcherFromFilters(filters: TestFileFilter[]): Matcher {
return createFileMatcher(filters.map(filter => filter.re || filter.exact || ''));
}
@ -174,7 +183,7 @@ export function forceRegExp(pattern: string): RegExp {
const match = pattern.match(/^\/(.*)\/([gi]*)$/);
if (match)
return new RegExp(match[1], match[2]);
return new RegExp(pattern, 'g');
return new RegExp(pattern, 'gi');
}
export function relativeFilePath(file: string): string {

View File

@ -19,3 +19,4 @@ export const open: typeof import('../bundles/utils/node_modules/open') = require
export const pirates: typeof import('../bundles/utils/node_modules/pirates') = require('./utilsBundleImpl').pirates;
export const sourceMapSupport: typeof import('../bundles/utils/node_modules/@types/source-map-support') = require('./utilsBundleImpl').sourceMapSupport;
export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable;
export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer;