chore(watch): print current filters (#20696)

This commit is contained in:
Pavel Feldman 2023-02-07 09:48:46 -08:00 committed by GitHub
parent 303c5998f8
commit 98e348d16a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 97 additions and 68 deletions

View File

@ -21,8 +21,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { Runner } from './runner/runner'; import { Runner } from './runner/runner';
import { stopProfiling, startProfiling } from './common/profiler'; import { stopProfiling, startProfiling } from './common/profiler';
import { createFileFilterForArg, experimentalLoaderOption, fileIsModule, forceRegExp } from './util'; import { experimentalLoaderOption, fileIsModule } from './util';
import { createTitleMatcher } from './util';
import { showHTMLReport } from './reporters/html'; import { showHTMLReport } from './reporters/html';
import { baseFullConfig, builtInReporters, ConfigLoader, defaultTimeout, kDefaultConfigFiles, resolveConfigFile } from './common/configLoader'; import { baseFullConfig, builtInReporters, ConfigLoader, defaultTimeout, kDefaultConfigFiles, resolveConfigFile } from './common/configLoader';
import type { TraceMode } from './common/types'; import type { TraceMode } from './common/types';
@ -160,10 +159,9 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
configLoader.ignoreProjectDependencies(); configLoader.ignoreProjectDependencies();
const config = configLoader.fullConfig(); const config = configLoader.fullConfig();
config._internal.cliFileFilters = args.map(arg => createFileFilterForArg(arg)); config._internal.cliArgs = args;
const grepMatcher = opts.grep ? createTitleMatcher(forceRegExp(opts.grep)) : () => true; config._internal.cliGrep = opts.grep as string | undefined;
const grepInvertMatcher = opts.grepInvert ? createTitleMatcher(forceRegExp(opts.grepInvert)) : () => false; config._internal.cliGrepInvert = opts.grepInvert as string | undefined;
config._internal.cliTitleMatcher = (title: string) => !grepInvertMatcher(title) && grepMatcher(title);
config._internal.listOnly = !!opts.list; config._internal.listOnly = !!opts.list;
config._internal.cliProjectFilter = opts.project || undefined; config._internal.cliProjectFilter = opts.project || undefined;
config._internal.passWithNoTests = !!opts.passWithNoTests; config._internal.passWithNoTests = !!opts.passWithNoTests;

View File

@ -450,8 +450,9 @@ export const baseFullConfig: FullConfigInternal = {
maxConcurrentTestGroups: 0, maxConcurrentTestGroups: 0,
ignoreSnapshots: false, ignoreSnapshots: false,
plugins: [], plugins: [],
cliTitleMatcher: () => true, cliArgs: [],
cliFileFilters: [], cliGrep: undefined,
cliGrepInvert: undefined,
listOnly: false, listOnly: false,
} }
}; };

View File

@ -17,7 +17,7 @@
import type { Fixtures, TestInfoError, Project } from '../../types/test'; import type { Fixtures, TestInfoError, Project } from '../../types/test';
import type { Location } from '../../types/testReporter'; import type { Location } from '../../types/testReporter';
import type { TestRunnerPluginRegistration } from '../plugins'; import type { TestRunnerPluginRegistration } from '../plugins';
import type { Matcher, TestFileFilter } from '../util'; import type { Matcher } from '../util';
import type { ConfigCLIOverrides } from './ipc'; import type { ConfigCLIOverrides } from './ipc';
import type { FullConfig as FullConfigPublic, FullProject as FullProjectPublic } from './types'; import type { FullConfig as FullConfigPublic, FullProject as FullProjectPublic } from './types';
export * from '../../types/test'; export * from '../../types/test';
@ -50,8 +50,9 @@ type ConfigInternal = {
webServers: Exclude<FullConfigPublic['webServer'], null>[]; webServers: Exclude<FullConfigPublic['webServer'], null>[];
plugins: TestRunnerPluginRegistration[]; plugins: TestRunnerPluginRegistration[];
listOnly: boolean; listOnly: boolean;
cliFileFilters: TestFileFilter[]; cliArgs: string[];
cliTitleMatcher: Matcher; cliGrep: string | undefined;
cliGrepInvert: string | undefined;
cliProjectFilter?: string[]; cliProjectFilter?: string[];
testIdMatcher?: Matcher; testIdMatcher?: Matcher;
passWithNoTests?: boolean; passWithNoTests?: boolean;

View File

@ -231,11 +231,9 @@ export class BaseReporter implements Reporter {
} }
private _printSummary(summary: string) { private _printSummary(summary: string) {
if (summary.trim()) { if (summary.trim())
console.log('');
console.log(summary); console.log(summary);
} }
}
willRetry(test: TestCase): boolean { willRetry(test: TestCase): boolean {
return test.outcome() === 'unexpected' && test.results.length <= test.retries; return test.outcome() === 'unexpected' && test.results.length <= test.retries;
@ -487,3 +485,8 @@ function fitToWidth(line: string, width: number, prefix?: string): string {
function belongsToNodeModules(file: string) { function belongsToNodeModules(file: string) {
return file.includes(`${path.sep}node_modules${path.sep}`); return file.includes(`${path.sep}node_modules${path.sep}`);
} }
export function separator(): string {
const columns = process.stdout?.columns || 30;
return colors.dim('⎯'.repeat(Math.min(100, columns)));
}

View File

@ -21,7 +21,7 @@ import type { LoaderHost } from './loaderHost';
import { Suite } from '../common/test'; import { Suite } from '../common/test';
import type { TestCase } from '../common/test'; import type { TestCase } from '../common/test';
import type { FullConfigInternal, FullProjectInternal } from '../common/types'; import type { FullConfigInternal, FullProjectInternal } from '../common/types';
import { createTitleMatcher, errorWithFile } from '../util'; import { createFileFiltersFromArguments, createTitleMatcher, errorWithFile, forceRegExp } from '../util';
import type { Matcher, TestFileFilter } from '../util'; import type { Matcher, TestFileFilter } from '../util';
import { buildProjectsClosure, collectFilesForProject, filterProjects } from './projectUtils'; import { buildProjectsClosure, collectFilesForProject, filterProjects } from './projectUtils';
import { requireOrImport } from '../common/transform'; import { requireOrImport } from '../common/transform';
@ -98,9 +98,15 @@ export async function loadAllTests(mode: 'out-of-process' | 'in-process', config
// Create root suites with clones for the projects. // Create root suites with clones for the projects.
const rootSuite = new Suite('', 'root'); const rootSuite = new Suite('', 'root');
// Interpret cli parameters.
const cliFileFilters = createFileFiltersFromArguments(config._internal.cliArgs);
const grepMatcher = config._internal.cliGrep ? createTitleMatcher(forceRegExp(config._internal.cliGrep)) : () => true;
const grepInvertMatcher = config._internal.cliGrepInvert ? createTitleMatcher(forceRegExp(config._internal.cliGrepInvert)) : () => false;
const cliTitleMatcher = (title: string) => !grepInvertMatcher(title) && grepMatcher(title);
// First iterate leaf projects to focus only, then add all other projects. // First iterate leaf projects to focus only, then add all other projects.
for (const project of topLevelProjects) { for (const project of topLevelProjects) {
const projectSuite = await createProjectSuite(fileSuits, project, config._internal, filesToRunByProject.get(project)!); const projectSuite = await createProjectSuite(fileSuits, project, { cliFileFilters, cliTitleMatcher, testIdMatcher: config._internal.testIdMatcher }, filesToRunByProject.get(project)!);
if (projectSuite) if (projectSuite)
rootSuite._addSuite(projectSuite); rootSuite._addSuite(projectSuite);
} }

View File

@ -16,7 +16,7 @@
import path from 'path'; import path from 'path';
import type { Reporter, TestError } from '../../types/testReporter'; import type { Reporter, TestError } from '../../types/testReporter';
import { formatError } from '../reporters/base'; import { separator, formatError } from '../reporters/base';
import DotReporter from '../reporters/dot'; import DotReporter from '../reporters/dot';
import EmptyReporter from '../reporters/empty'; import EmptyReporter from '../reporters/empty';
import GitHubReporter from '../reporters/github'; import GitHubReporter from '../reporters/github';
@ -30,6 +30,7 @@ import type { Suite } from '../common/test';
import type { FullConfigInternal } from '../common/types'; import type { FullConfigInternal } from '../common/types';
import { loadReporter } from './loadUtils'; import { loadReporter } from './loadUtils';
import type { BuiltInReporter } from '../common/configLoader'; import type { BuiltInReporter } from '../common/configLoader';
import { colors } from 'playwright-core/lib/utilsBundle';
export async function createReporter(config: FullConfigInternal, mode: 'list' | 'watch' | 'run') { export async function createReporter(config: FullConfigInternal, mode: 'list' | 'watch' | 'run') {
const defaultReporters: {[key in BuiltInReporter]: new(arg: any) => Reporter} = { const defaultReporters: {[key in BuiltInReporter]: new(arg: any) => Reporter} = {
@ -104,5 +105,23 @@ export class ListModeReporter implements Reporter {
} }
} }
export class WatchModeReporter extends LineReporter { let seq = 0;
export class WatchModeReporter extends ListReporter {
override generateStartingMessage(): string {
const tokens: string[] = [];
tokens.push('npx playwright test');
tokens.push(...(this.config._internal.cliProjectFilter || [])?.map(p => colors.blue(`--project ${p}`)));
if (this.config._internal.cliGrep)
tokens.push(colors.red(`--grep ${this.config._internal.cliGrep}`));
if (this.config._internal.cliArgs)
tokens.push(...this.config._internal.cliArgs.map(a => colors.bold(a)));
tokens.push(colors.dim(`#${++seq}`));
const lines: string[] = [];
const sep = separator();
lines.push('\x1Bc' + sep);
lines.push(`${tokens.join(' ')}`);
lines.push(sep + super.generateStartingMessage());
return lines.join('\n');
}
} }

View File

@ -86,7 +86,7 @@ export class Runner {
await reporter.onExit({ status }); await reporter.onExit({ status });
if (watchMode) if (watchMode)
await runWatchModeLoop(config, failedTests); status = await runWatchModeLoop(config, failedTests);
// Calling process.exit() might truncate large stdout/stderr output. // Calling process.exit() might truncate large stdout/stderr output.
// See https://github.com/nodejs/node/issues/6456. // See https://github.com/nodejs/node/issues/6456.

View File

@ -28,7 +28,7 @@ import { TaskRunner } from './taskRunner';
import type { Suite } from '../common/test'; import type { Suite } from '../common/test';
import type { FullConfigInternal, FullProjectInternal } from '../common/types'; import type { FullConfigInternal, FullProjectInternal } from '../common/types';
import { loadAllTests, loadGlobalHook } from './loadUtils'; import { loadAllTests, loadGlobalHook } from './loadUtils';
import { createFileMatcherFromFilters } from '../util'; import { createFileMatcherFromArguments } from '../util';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
const removeFolderAsync = promisify(rimraf); const removeFolderAsync = promisify(rimraf);
@ -149,7 +149,7 @@ function createRemoveOutputDirsTask(): Task<TaskRunnerState> {
function createLoadTask(mode: 'out-of-process' | 'in-process', 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) => { return async (context, errors) => {
const { config } = context; const { config } = context;
const cliMatcher = config._internal.cliFileFilters.length ? createFileMatcherFromFilters(config._internal.cliFileFilters) : () => true; const cliMatcher = config._internal.cliArgs.length ? createFileMatcherFromArguments(config._internal.cliArgs) : () => true;
const fileMatcher = (value: string) => cliMatcher(value) && (additionalFileMatcher ? additionalFileMatcher(value) : true); const fileMatcher = (value: string) => cliMatcher(value) && (additionalFileMatcher ? additionalFileMatcher(value) : true);
context.rootSuite = await loadAllTests(mode, config, projectsToIgnore, fileMatcher, errors); context.rootSuite = await loadAllTests(mode, config, projectsToIgnore, fileMatcher, errors);
// Fail when no tests. // Fail when no tests.

View File

@ -18,7 +18,7 @@ import readline from 'readline';
import { ManualPromise } from 'playwright-core/lib/utils'; import { ManualPromise } from 'playwright-core/lib/utils';
import type { FullConfigInternal, FullProjectInternal } from '../common/types'; import type { FullConfigInternal, FullProjectInternal } from '../common/types';
import { Multiplexer } from '../reporters/multiplexer'; import { Multiplexer } from '../reporters/multiplexer';
import { createFileFilterForArg, createFileMatcherFromFilters, createTitleMatcher, forceRegExp } from '../util'; import { createFileMatcherFromArguments } from '../util';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
import { createTaskRunnerForWatch } from './tasks'; import { createTaskRunnerForWatch } from './tasks';
import type { TaskRunnerState } from './tasks'; import type { TaskRunnerState } from './tasks';
@ -29,6 +29,7 @@ import chokidar from 'chokidar';
import { WatchModeReporter } from './reporters'; import { WatchModeReporter } from './reporters';
import { colors } from 'playwright-core/lib/utilsBundle'; import { colors } from 'playwright-core/lib/utilsBundle';
import { enquirer } from '../utilsBundle'; import { enquirer } from '../utilsBundle';
import { separator } from '../reporters/base';
class FSWatcher { class FSWatcher {
private _dirtyFiles = new Set<string>(); private _dirtyFiles = new Set<string>();
@ -61,22 +62,21 @@ class FSWatcher {
} }
} }
export async function runWatchModeLoop(config: FullConfigInternal, failedTests: TestCase[]) { export async function runWatchModeLoop(config: FullConfigInternal, failedTests: TestCase[]): Promise<FullResult['status']> {
const projects = filterProjects(config.projects, config._internal.cliProjectFilter); const projects = filterProjects(config.projects, config._internal.cliProjectFilter);
const projectClosure = buildProjectsClosure(projects); const projectClosure = buildProjectsClosure(projects);
config._internal.passWithNoTests = true; config._internal.passWithNoTests = true;
const failedTestIdCollector = new Set(failedTests.map(t => t.id)); const failedTestIdCollector = new Set(failedTests.map(t => t.id));
const originalTitleMatcher = config._internal.cliTitleMatcher; const originalCliArgs = config._internal.cliArgs;
const originalFileFilters = config._internal.cliFileFilters; const originalCliGrep = config._internal.cliGrep;
const fsWatcher = new FSWatcher(projectClosure.map(p => p.testDir)); const fsWatcher = new FSWatcher(projectClosure.map(p => p.testDir));
let lastFilePattern: string | undefined;
let lastTestPattern: string | undefined;
while (true) { while (true) {
const sep = separator();
process.stdout.write(` process.stdout.write(`
Waiting for file changes... ${sep}
${colors.dim('press')} ${colors.bold('h')} ${colors.dim('to show help, press')} ${colors.bold('q')} ${colors.dim('to quit')} Waiting for file changes. Press ${colors.bold('h')} for help or ${colors.bold('q')} to quit.
`); `);
const readCommandPromise = readCommand(); const readCommandPromise = readCommand();
await Promise.race([ await Promise.race([
@ -88,17 +88,13 @@ ${colors.dim('press')} ${colors.bold('h')} ${colors.dim('to show help, press')}
const command = await readCommandPromise; const command = await readCommandPromise;
if (command === 'changed') { if (command === 'changed') {
process.stdout.write('\x1Bc');
await runChangedTests(config, failedTestIdCollector, projectClosure, fsWatcher.takeDirtyFiles()); await runChangedTests(config, failedTestIdCollector, projectClosure, fsWatcher.takeDirtyFiles());
continue; continue;
} }
if (command === 'all') { if (command === 'all') {
process.stdout.write('\x1Bc');
// All means reset filters. // All means reset filters.
config._internal.cliTitleMatcher = originalTitleMatcher; config._internal.cliArgs = originalCliArgs;
config._internal.cliFileFilters = originalFileFilters; config._internal.cliGrep = originalCliGrep;
lastFilePattern = undefined;
lastTestPattern = undefined;
await runTests(config, failedTestIdCollector); await runTests(config, failedTestIdCollector);
continue; continue;
} }
@ -107,15 +103,12 @@ ${colors.dim('press')} ${colors.bold('h')} ${colors.dim('to show help, press')}
type: 'text', type: 'text',
name: 'filePattern', name: 'filePattern',
message: 'Input filename pattern (regex)', message: 'Input filename pattern (regex)',
initial: lastFilePattern, initial: config._internal.cliArgs.join(' '),
}); });
if (filePattern.trim()) { if (filePattern.trim())
lastFilePattern = filePattern; config._internal.cliArgs = [filePattern];
config._internal.cliFileFilters = [createFileFilterForArg(filePattern)]; else
} else { config._internal.cliArgs = [];
lastFilePattern = undefined;
config._internal.cliFileFilters = originalFileFilters;
}
await runTests(config, failedTestIdCollector); await runTests(config, failedTestIdCollector);
continue; continue;
} }
@ -124,20 +117,16 @@ ${colors.dim('press')} ${colors.bold('h')} ${colors.dim('to show help, press')}
type: 'text', type: 'text',
name: 'testPattern', name: 'testPattern',
message: 'Input test name pattern (regex)', message: 'Input test name pattern (regex)',
initial: lastTestPattern, initial: config._internal.cliGrep,
}); });
if (testPattern.trim()) { if (testPattern.trim())
lastTestPattern = testPattern; config._internal.cliGrep = testPattern;
config._internal.cliTitleMatcher = createTitleMatcher(forceRegExp(testPattern)); else
} else { config._internal.cliGrep = undefined;
lastTestPattern = undefined;
config._internal.cliTitleMatcher = originalTitleMatcher;
}
await runTests(config, failedTestIdCollector); await runTests(config, failedTestIdCollector);
continue; continue;
} }
if (command === 'failed') { if (command === 'failed') {
process.stdout.write('\x1Bc');
config._internal.testIdMatcher = id => failedTestIdCollector.has(id); config._internal.testIdMatcher = id => failedTestIdCollector.has(id);
try { try {
await runTests(config, failedTestIdCollector); await runTests(config, failedTestIdCollector);
@ -146,11 +135,15 @@ ${colors.dim('press')} ${colors.bold('h')} ${colors.dim('to show help, press')}
} }
continue; continue;
} }
if (command === 'exit')
return 'passed';
if (command === 'interrupted')
return 'interrupted';
} }
} }
async function runChangedTests(config: FullConfigInternal, failedTestIdCollector: Set<string>, projectClosure: FullProjectInternal[], changedFiles: Set<string>) { async function runChangedTests(config: FullConfigInternal, failedTestIdCollector: Set<string>, projectClosure: FullProjectInternal[], changedFiles: Set<string>) {
const commandLineFileMatcher = config._internal.cliFileFilters.length ? createFileMatcherFromFilters(config._internal.cliFileFilters) : () => true; const commandLineFileMatcher = config._internal.cliArgs.length ? createFileMatcherFromArguments(config._internal.cliArgs) : () => true;
// Resolve files that depend on the changed files. // Resolve files that depend on the changed files.
const testFiles = new Set<string>(); const testFiles = new Set<string>();
@ -234,19 +227,24 @@ function readCommand(): ManualPromise<Command> {
process.stdin.setRawMode(true); process.stdin.setRawMode(true);
const handler = (text: string, key: any) => { const handler = (text: string, key: any) => {
if (text === '\x03' || text === '\x1B' || (key && key.name === 'escape') || (key && key.ctrl && key.name === 'c')) if (text === '\x03' || text === '\x1B' || (key && key.name === 'escape') || (key && key.ctrl && key.name === 'c')) {
return process.exit(130); result.resolve('interrupted');
return;
}
if (process.platform !== 'win32' && key && key.ctrl && key.name === 'z') { if (process.platform !== 'win32' && key && key.ctrl && key.name === 'z') {
process.kill(process.ppid, 'SIGTSTP'); process.kill(process.ppid, 'SIGTSTP');
process.kill(process.pid, 'SIGTSTP'); process.kill(process.pid, 'SIGTSTP');
} }
const name = key?.name; const name = key?.name;
if (name === 'q') if (name === 'q') {
process.exit(0); result.resolve('exit');
return;
}
if (name === 'h') { if (name === 'h') {
process.stdout.write(` process.stdout.write(`${separator()}
Watch Usage Watch Usage
${commands.map(i => colors.dim(' press ') + colors.reset(colors.bold(i[0])) + colors.dim(` to ${i[1]}`)).join('\n')} ${commands.map(i => ' ' + colors.bold(i[0]) + `: ${i[1]}`).join('\n')}
`); `);
return; return;
} }
@ -269,7 +267,7 @@ ${commands.map(i => colors.dim(' press ') + colors.reset(colors.bold(i[0])) + c
return result; return result;
} }
type Command = 'all' | 'failed' | 'changed' | 'file' | 'grep'; type Command = 'all' | 'failed' | 'changed' | 'file' | 'grep' | 'exit' | 'interrupted';
const commands = [ const commands = [
['a', 'rerun all tests'], ['a', 'rerun all tests'],

View File

@ -106,16 +106,19 @@ export type TestFileFilter = {
column: number | null; column: number | null;
}; };
export function createFileFilterForArg(arg: string): TestFileFilter { export function createFileFiltersFromArguments(args: string[]): TestFileFilter[] {
return 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),
line: match ? parseInt(match[2], 10) : null, line: match ? parseInt(match[2], 10) : null,
column: match?.[3] ? parseInt(match[3], 10) : null, column: match?.[3] ? parseInt(match[3], 10) : null,
}; };
});
} }
export function createFileMatcherFromFilters(filters: TestFileFilter[]): Matcher { export function createFileMatcherFromArguments(args: string[]): Matcher {
const filters = createFileFiltersFromArguments(args);
return createFileMatcher(filters.map(filter => filter.re || filter.exact || '')); return createFileMatcher(filters.map(filter => filter.re || filter.exact || ''));
} }