/** * 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(); 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 { if (this._dirtyFiles.size) return; await new Promise(f => this._notifyDirtyFiles = f); } takeDirtyFiles(): Set { 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, projectClosure: FullProjectInternal[], files: Set) { const commandLineFileMatcher = config._internal.cliFileFilters.length ? createFileMatcherFromFilters(config._internal.cliFileFilters) : () => true; // Collect projects with changes. const filesByProject = new Map(); 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, projectsToIgnore?: Set, 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 { const result = new Set(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 { const result = new ManualPromise(); 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'], ];