2023-02-07 00:52:14 +01:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2023-02-07 18:48:46 +01:00
|
|
|
import { createFileMatcherFromArguments } from '../util';
|
2023-02-07 00:52:14 +01:00
|
|
|
import type { Matcher } from '../util';
|
|
|
|
|
import { createTaskRunnerForWatch } from './tasks';
|
|
|
|
|
import type { TaskRunnerState } from './tasks';
|
|
|
|
|
import { buildProjectsClosure, filterProjects } from './projectUtils';
|
2023-02-07 02:09:16 +01:00
|
|
|
import { clearCompilationCache, collectAffectedTestFiles } from '../common/compilationCache';
|
2023-02-07 00:52:14 +01:00
|
|
|
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';
|
2023-02-07 18:48:46 +01:00
|
|
|
import { separator } from '../reporters/base';
|
2023-02-07 00:52:14 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-07 18:48:46 +01:00
|
|
|
export async function runWatchModeLoop(config: FullConfigInternal, failedTests: TestCase[]): Promise<FullResult['status']> {
|
2023-02-07 00:52:14 +01:00
|
|
|
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));
|
|
|
|
|
|
2023-02-07 18:48:46 +01:00
|
|
|
const originalCliArgs = config._internal.cliArgs;
|
|
|
|
|
const originalCliGrep = config._internal.cliGrep;
|
2023-02-08 00:56:39 +01:00
|
|
|
let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: Set<string>, dirtyFiles?: Set<string> } = { type: 'regular' };
|
2023-02-07 00:52:14 +01:00
|
|
|
|
|
|
|
|
const fsWatcher = new FSWatcher(projectClosure.map(p => p.testDir));
|
|
|
|
|
while (true) {
|
2023-02-07 18:48:46 +01:00
|
|
|
const sep = separator();
|
2023-02-07 00:52:14 +01:00
|
|
|
process.stdout.write(`
|
2023-02-07 18:48:46 +01:00
|
|
|
${sep}
|
|
|
|
|
Waiting for file changes. Press ${colors.bold('h')} for help or ${colors.bold('q')} to quit.
|
2023-02-07 00:52:14 +01:00
|
|
|
`);
|
|
|
|
|
const readCommandPromise = readCommand();
|
|
|
|
|
await Promise.race([
|
|
|
|
|
fsWatcher.onDirtyFiles(),
|
|
|
|
|
readCommandPromise,
|
|
|
|
|
]);
|
|
|
|
|
if (!readCommandPromise.isDone())
|
|
|
|
|
readCommandPromise.resolve('changed');
|
|
|
|
|
|
|
|
|
|
const command = await readCommandPromise;
|
2023-02-08 00:56:39 +01:00
|
|
|
|
2023-02-07 00:52:14 +01:00
|
|
|
if (command === 'changed') {
|
2023-02-08 00:56:39 +01:00
|
|
|
const dirtyFiles = fsWatcher.takeDirtyFiles();
|
|
|
|
|
await runChangedTests(config, failedTestIdCollector, projectClosure, dirtyFiles);
|
|
|
|
|
lastRun = { type: 'changed', dirtyFiles };
|
2023-02-07 00:52:14 +01:00
|
|
|
continue;
|
|
|
|
|
}
|
2023-02-08 00:56:39 +01:00
|
|
|
|
2023-02-07 00:52:14 +01:00
|
|
|
if (command === 'all') {
|
|
|
|
|
// All means reset filters.
|
2023-02-07 18:48:46 +01:00
|
|
|
config._internal.cliArgs = originalCliArgs;
|
|
|
|
|
config._internal.cliGrep = originalCliGrep;
|
2023-02-07 00:52:14 +01:00
|
|
|
await runTests(config, failedTestIdCollector);
|
2023-02-08 00:56:39 +01:00
|
|
|
lastRun = { type: 'regular' };
|
2023-02-07 00:52:14 +01:00
|
|
|
continue;
|
|
|
|
|
}
|
2023-02-08 00:56:39 +01:00
|
|
|
|
2023-02-07 00:52:14 +01:00
|
|
|
if (command === 'file') {
|
|
|
|
|
const { filePattern } = await enquirer.prompt<{ filePattern: string }>({
|
|
|
|
|
type: 'text',
|
|
|
|
|
name: 'filePattern',
|
|
|
|
|
message: 'Input filename pattern (regex)',
|
2023-02-07 18:48:46 +01:00
|
|
|
initial: config._internal.cliArgs.join(' '),
|
2023-02-07 00:52:14 +01:00
|
|
|
});
|
2023-02-07 18:48:46 +01:00
|
|
|
if (filePattern.trim())
|
|
|
|
|
config._internal.cliArgs = [filePattern];
|
|
|
|
|
else
|
|
|
|
|
config._internal.cliArgs = [];
|
2023-02-07 00:52:14 +01:00
|
|
|
await runTests(config, failedTestIdCollector);
|
2023-02-08 00:56:39 +01:00
|
|
|
lastRun = { type: 'regular' };
|
2023-02-07 00:52:14 +01:00
|
|
|
continue;
|
|
|
|
|
}
|
2023-02-08 00:56:39 +01:00
|
|
|
|
2023-02-07 00:52:14 +01:00
|
|
|
if (command === 'grep') {
|
|
|
|
|
const { testPattern } = await enquirer.prompt<{ testPattern: string }>({
|
|
|
|
|
type: 'text',
|
|
|
|
|
name: 'testPattern',
|
|
|
|
|
message: 'Input test name pattern (regex)',
|
2023-02-07 18:48:46 +01:00
|
|
|
initial: config._internal.cliGrep,
|
2023-02-07 00:52:14 +01:00
|
|
|
});
|
2023-02-07 18:48:46 +01:00
|
|
|
if (testPattern.trim())
|
|
|
|
|
config._internal.cliGrep = testPattern;
|
|
|
|
|
else
|
|
|
|
|
config._internal.cliGrep = undefined;
|
2023-02-07 00:52:14 +01:00
|
|
|
await runTests(config, failedTestIdCollector);
|
2023-02-08 00:56:39 +01:00
|
|
|
lastRun = { type: 'regular' };
|
2023-02-07 00:52:14 +01:00
|
|
|
continue;
|
|
|
|
|
}
|
2023-02-08 00:56:39 +01:00
|
|
|
|
2023-02-07 00:52:14 +01:00
|
|
|
if (command === 'failed') {
|
|
|
|
|
config._internal.testIdMatcher = id => failedTestIdCollector.has(id);
|
2023-02-08 00:56:39 +01:00
|
|
|
const failedTestIds = new Set(failedTestIdCollector);
|
|
|
|
|
await runTests(config, failedTestIdCollector);
|
|
|
|
|
config._internal.testIdMatcher = undefined;
|
|
|
|
|
lastRun = { type: 'failed', failedTestIds };
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (command === 'repeat') {
|
|
|
|
|
if (lastRun.type === 'regular') {
|
|
|
|
|
await runTests(config, failedTestIdCollector);
|
|
|
|
|
continue;
|
|
|
|
|
} else if (lastRun.type === 'changed') {
|
|
|
|
|
await runChangedTests(config, failedTestIdCollector, projectClosure, lastRun.dirtyFiles!);
|
|
|
|
|
} else if (lastRun.type === 'failed') {
|
|
|
|
|
config._internal.testIdMatcher = id => lastRun.failedTestIds!.has(id);
|
2023-02-07 00:52:14 +01:00
|
|
|
await runTests(config, failedTestIdCollector);
|
|
|
|
|
config._internal.testIdMatcher = undefined;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2023-02-08 00:56:39 +01:00
|
|
|
|
2023-02-07 18:48:46 +01:00
|
|
|
if (command === 'exit')
|
|
|
|
|
return 'passed';
|
2023-02-08 00:56:39 +01:00
|
|
|
|
2023-02-07 18:48:46 +01:00
|
|
|
if (command === 'interrupted')
|
|
|
|
|
return 'interrupted';
|
2023-02-07 00:52:14 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-07 02:09:16 +01:00
|
|
|
async function runChangedTests(config: FullConfigInternal, failedTestIdCollector: Set<string>, projectClosure: FullProjectInternal[], changedFiles: Set<string>) {
|
2023-02-07 18:48:46 +01:00
|
|
|
const commandLineFileMatcher = config._internal.cliArgs.length ? createFileMatcherFromArguments(config._internal.cliArgs) : () => true;
|
2023-02-07 00:52:14 +01:00
|
|
|
|
2023-02-07 02:09:16 +01:00
|
|
|
// Resolve files that depend on the changed files.
|
|
|
|
|
const testFiles = new Set<string>();
|
|
|
|
|
for (const file of changedFiles)
|
|
|
|
|
collectAffectedTestFiles(file, testFiles);
|
|
|
|
|
|
2023-02-07 00:52:14 +01:00
|
|
|
// Collect projects with changes.
|
|
|
|
|
const filesByProject = new Map<FullProjectInternal, string[]>();
|
|
|
|
|
for (const project of projectClosure) {
|
|
|
|
|
const projectFiles: string[] = [];
|
2023-02-07 02:09:16 +01:00
|
|
|
for (const file of testFiles) {
|
2023-02-07 00:52:14 +01:00
|
|
|
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
|
2023-02-07 02:09:16 +01:00
|
|
|
const additionalFileMatcher = affectsAnyDependency ? () => true : (file: string) => testFiles.has(file);
|
|
|
|
|
return await runTests(config, failedTestIdCollector, projectsToIgnore, additionalFileMatcher);
|
2023-02-07 00:52:14 +01:00
|
|
|
}
|
|
|
|
|
|
2023-02-07 02:09:16 +01:00
|
|
|
async function runTests(config: FullConfigInternal, failedTestIdCollector: Set<string>, projectsToIgnore?: Set<FullProjectInternal>, additionalFileMatcher?: Matcher) {
|
2023-02-07 00:52:14 +01:00
|
|
|
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') {
|
2023-02-07 02:09:16 +01:00
|
|
|
failedTestIdCollector.delete(test.id);
|
2023-02-07 00:52:14 +01:00
|
|
|
} else {
|
2023-02-07 02:09:16 +01:00
|
|
|
failedTestIdCollector.add(test.id);
|
2023-02-07 00:52:14 +01:00
|
|
|
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) => {
|
2023-02-07 18:48:46 +01:00
|
|
|
if (text === '\x03' || text === '\x1B' || (key && key.name === 'escape') || (key && key.ctrl && key.name === 'c')) {
|
|
|
|
|
result.resolve('interrupted');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2023-02-07 00:52:14 +01:00
|
|
|
if (process.platform !== 'win32' && key && key.ctrl && key.name === 'z') {
|
|
|
|
|
process.kill(process.ppid, 'SIGTSTP');
|
|
|
|
|
process.kill(process.pid, 'SIGTSTP');
|
|
|
|
|
}
|
|
|
|
|
const name = key?.name;
|
2023-02-07 18:48:46 +01:00
|
|
|
if (name === 'q') {
|
|
|
|
|
result.resolve('exit');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2023-02-07 00:52:14 +01:00
|
|
|
if (name === 'h') {
|
2023-02-07 18:48:46 +01:00
|
|
|
process.stdout.write(`${separator()}
|
2023-02-07 00:52:14 +01:00
|
|
|
Watch Usage
|
2023-02-07 18:48:46 +01:00
|
|
|
${commands.map(i => ' ' + colors.bold(i[0]) + `: ${i[1]}`).join('\n')}
|
|
|
|
|
|
2023-02-07 00:52:14 +01:00
|
|
|
`);
|
|
|
|
|
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;
|
2023-02-08 00:56:39 +01:00
|
|
|
case 'r': result.resolve('repeat'); break;
|
2023-02-07 00:52:14 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
process.stdin.on('keypress', handler);
|
|
|
|
|
result.finally(() => {
|
|
|
|
|
process.stdin.off('keypress', handler);
|
|
|
|
|
rl.close();
|
|
|
|
|
if (process.stdin.isTTY)
|
|
|
|
|
process.stdin.setRawMode(false);
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-08 00:56:39 +01:00
|
|
|
type Command = 'all' | 'failed' | 'repeat' | 'changed' | 'file' | 'grep' | 'exit' | 'interrupted';
|
2023-02-07 00:52:14 +01:00
|
|
|
|
|
|
|
|
const commands = [
|
|
|
|
|
['a', 'rerun all tests'],
|
|
|
|
|
['f', 'rerun only failed tests'],
|
2023-02-08 00:56:39 +01:00
|
|
|
['r', 'repeat last run'],
|
2023-02-07 00:52:14 +01:00
|
|
|
['p', 'filter by a filename'],
|
|
|
|
|
['t', 'filter by a test name regex pattern'],
|
|
|
|
|
['q', 'quit'],
|
|
|
|
|
];
|