chore(test runner): rebase watch mode onto TestServerConnection (#32156)

Closes https://github.com/microsoft/playwright/issues/32076.

This PR rewrites `watchMode.ts` to use `TestServer` under the hood. It's
essentially a complete rewrite, so don't pay too much attention on the
old implementation. Note that there's no changes to tests, so all
behaviour we have specced out there still works.

To make this work without a superfluous WebSocket connection, I had to
refactor `TestServerConnection` a little. Originally, I pulled this into
a [separate PR](https://github.com/microsoft/playwright/pull/32132), but
then realised how small the refactoring is. So it's in this PR now. Let
me know if you'd like to land it separately.
This commit is contained in:
Simon Knott 2024-09-03 15:15:44 +02:00 committed by GitHub
parent 53bf9534ec
commit 201bad75d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 199 additions and 271 deletions

View File

@ -246,4 +246,4 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
} catch { } catch {
} }
} }
} }

View File

@ -24,9 +24,9 @@ import { stopProfiling, startProfiling, gracefullyProcessExitDoNotHang } from 'p
import { serializeError } from './util'; import { serializeError } from './util';
import { showHTMLReport } from './reporters/html'; import { showHTMLReport } from './reporters/html';
import { createMergedReport } from './reporters/merge'; import { createMergedReport } from './reporters/merge';
import { loadConfigFromFileRestartIfNeeded, loadEmptyConfigForMergeReports } from './common/configLoader'; import { loadConfigFromFileRestartIfNeeded, loadEmptyConfigForMergeReports, resolveConfigLocation } from './common/configLoader';
import type { ConfigCLIOverrides } from './common/ipc'; import type { ConfigCLIOverrides } from './common/ipc';
import type { FullResult, TestError } from '../types/testReporter'; import type { TestError } from '../types/testReporter';
import type { TraceMode } from '../types/test'; import type { TraceMode } from '../types/test';
import { builtInReporters, defaultReporter, defaultTimeout } from './common/config'; import { builtInReporters, defaultReporter, defaultTimeout } from './common/config';
import { program } from 'playwright-core/lib/cli/program'; import { program } from 'playwright-core/lib/cli/program';
@ -35,6 +35,7 @@ import type { ReporterDescription } from '../types/test';
import { prepareErrorStack } from './reporters/base'; import { prepareErrorStack } from './reporters/base';
import * as testServer from './runner/testServer'; import * as testServer from './runner/testServer';
import { clearCacheAndLogToConsole } from './runner/testServer'; import { clearCacheAndLogToConsole } from './runner/testServer';
import { runWatchModeLoop } from './runner/watchMode';
function addTestCommand(program: Command) { function addTestCommand(program: Command) {
const command = program.command('test [test-filter...]'); const command = program.command('test [test-filter...]');
@ -183,6 +184,26 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
return; return;
} }
if (process.env.PWTEST_WATCH) {
if (opts.onlyChanged)
throw new Error(`--only-changed is not supported in watch mode. If you'd like that to change, file an issue and let us know about your usecase for it.`);
const status = await runWatchModeLoop(
resolveConfigLocation(opts.config),
{
projects: opts.project,
files: args,
grep: opts.grep
}
);
await stopProfiling('runner');
if (status === 'restarted')
return;
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);
gracefullyProcessExitDoNotHang(exitCode);
return;
}
const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false); const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false);
if (!config) if (!config)
return; return;
@ -202,11 +223,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
config.cliFailOnFlakyTests = !!opts.failOnFlakyTests; config.cliFailOnFlakyTests = !!opts.failOnFlakyTests;
const runner = new Runner(config); const runner = new Runner(config);
let status: FullResult['status']; const status = await runner.runAllTests();
if (process.env.PWTEST_WATCH)
status = await runner.watchAllTests();
else
status = await runner.runAllTests();
await stopProfiling('runner'); await stopProfiling('runner');
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);
gracefullyProcessExitDoNotHang(exitCode); gracefullyProcessExitDoNotHang(exitCode);

View File

@ -7,6 +7,5 @@
../plugins/ ../plugins/
../util.ts ../util.ts
../utilsBundle.ts ../utilsBundle.ts
../isomorphic/folders.ts ../isomorphic/
../isomorphic/teleReceiver.ts
../fsWatcher.ts ../fsWatcher.ts

View File

@ -33,7 +33,7 @@ import { sourceMapSupport } from '../utilsBundle';
import type { RawSourceMap } from 'source-map'; import type { RawSourceMap } from 'source-map';
export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean, additionalFileMatcher?: Matcher) { export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean) {
const config = testRun.config; const config = testRun.config;
const fsCache = new Map(); const fsCache = new Map();
const sourceMapCache = new Map(); const sourceMapCache = new Map();
@ -52,8 +52,6 @@ export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTest
for (const [project, files] of allFilesForProject) { for (const [project, files] of allFilesForProject) {
const matchedFiles = files.filter(file => { const matchedFiles = files.filter(file => {
const hasMatchingSources = sourceMapSources(file, sourceMapCache).some(source => { const hasMatchingSources = sourceMapSources(file, sourceMapCache).some(source => {
if (additionalFileMatcher && !additionalFileMatcher(source))
return false;
if (cliFileMatcher && !cliFileMatcher(source)) if (cliFileMatcher && !cliFileMatcher(source))
return false; return false;
return true; return true;

View File

@ -24,7 +24,6 @@ import { collectFilesForProject, filterProjects } from './projectUtils';
import { createReporters } from './reporters'; import { createReporters } from './reporters';
import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks'; import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import { runWatchModeLoop } from './watchMode';
import type { Suite } from '../common/test'; import type { Suite } from '../common/test';
import { wrapReporterAsV2 } from '../reporters/reporterV2'; import { wrapReporterAsV2 } from '../reporters/reporterV2';
import { affectedTestFiles } from '../transform/compilationCache'; import { affectedTestFiles } from '../transform/compilationCache';
@ -131,12 +130,6 @@ export class Runner {
return { status, suite: testRun.rootSuite, errors }; return { status, suite: testRun.rootSuite, errors };
} }
async watchAllTests(): Promise<FullResult['status']> {
const config = this._config;
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
return await runWatchModeLoop(config);
}
async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise<FindRelatedTestFilesReport> { async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise<FindRelatedTestFilesReport> {
const result = await this.loadAllTests(mode); const result = await this.loadAllTests(mode);
if (result.status !== 'passed' || !result.suite) if (result.status !== 'passed' || !result.suite)

View File

@ -74,13 +74,6 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForWatch(config: FullConfigInternal, reporters: ReporterV2[], additionalFileMatcher?: Matcher): TaskRunner<TestRun> {
const taskRunner = TaskRunner.create<TestRun>(reporters);
taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher }));
addRunTasks(taskRunner, config);
return taskRunner;
}
export function createTaskRunnerForTestServer(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner<TestRun> { export function createTaskRunnerForTestServer(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner<TestRun> {
const taskRunner = TaskRunner.create<TestRun>(reporters); const taskRunner = TaskRunner.create<TestRun>(reporters);
taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true })); taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true }));
@ -222,10 +215,10 @@ function createListFilesTask(): Task<TestRun> {
}; };
} }
function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> { function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean }): Task<TestRun> {
return { return {
setup: async (reporter, testRun, errors, softErrors) => { setup: async (reporter, testRun, errors, softErrors) => {
await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher); await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter);
await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors);
let cliOnlyChangedMatcher: Matcher | undefined = undefined; let cliOnlyChangedMatcher: Matcher | undefined = undefined;

View File

@ -62,7 +62,7 @@ class TestServer {
} }
} }
class TestServerDispatcher implements TestServerInterface { export class TestServerDispatcher implements TestServerInterface {
private _configLocation: ConfigLocation; private _configLocation: ConfigLocation;
private _watcher: Watcher; private _watcher: Watcher;

View File

@ -15,130 +15,130 @@
*/ */
import readline from 'readline'; import readline from 'readline';
import path from 'path';
import { createGuid, getPackageManagerExecCommand, ManualPromise } from 'playwright-core/lib/utils'; import { createGuid, getPackageManagerExecCommand, ManualPromise } from 'playwright-core/lib/utils';
import type { FullConfigInternal, FullProjectInternal } from '../common/config'; import type { ConfigLocation } from '../common/config';
import { createFileMatcher, createFileMatcherFromArguments } from '../util';
import type { Matcher } from '../util';
import { TestRun, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks';
import { buildProjectsClosure, filterProjects } from './projectUtils';
import { collectAffectedTestFiles } from '../transform/compilationCache';
import type { FullResult } from '../../types/testReporter'; import type { FullResult } from '../../types/testReporter';
import { chokidar } from '../utilsBundle';
import type { FSWatcher as CFSWatcher } from 'chokidar';
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'; import { separator } from '../reporters/base';
import { PlaywrightServer } from 'playwright-core/lib/remote/playwrightServer'; import { PlaywrightServer } from 'playwright-core/lib/remote/playwrightServer';
import ListReporter from '../reporters/list'; import { TestServerDispatcher } from './testServer';
import { EventEmitter } from 'stream';
import { type TestServerTransport, TestServerConnection } from '../isomorphic/testServerConnection';
import { TeleSuiteUpdater } from '../isomorphic/teleSuiteUpdater';
import { restartWithExperimentalTsEsm } from '../common/configLoader';
class FSWatcher { class InMemoryTransport extends EventEmitter implements TestServerTransport {
private _dirtyTestFiles = new Map<FullProjectInternal, Set<string>>(); public readonly _send: (data: string) => void;
private _notifyDirtyFiles: (() => void) | undefined;
private _watcher: CFSWatcher | undefined;
private _timer: NodeJS.Timeout | undefined;
async update(config: FullConfigInternal) {
const commandLineFileMatcher = config.cliArgs.length ? createFileMatcherFromArguments(config.cliArgs) : () => true;
const projects = filterProjects(config.projects, config.cliProjectFilter);
const projectClosure = buildProjectsClosure(projects);
const projectFilters = new Map<FullProjectInternal, Matcher>();
for (const [project, type] of projectClosure) {
const testMatch = createFileMatcher(project.project.testMatch);
const testIgnore = createFileMatcher(project.project.testIgnore);
projectFilters.set(project, file => {
if (!file.startsWith(project.project.testDir) || !testMatch(file) || testIgnore(file))
return false;
return type === 'dependency' || commandLineFileMatcher(file);
});
}
if (this._timer)
clearTimeout(this._timer);
if (this._watcher)
await this._watcher.close();
this._watcher = chokidar.watch([...projectClosure.keys()].map(p => p.project.testDir), { ignoreInitial: true }).on('all', async (event, file) => {
if (event !== 'add' && event !== 'change')
return;
const testFiles = new Set<string>();
collectAffectedTestFiles(file, testFiles);
const testFileArray = [...testFiles];
let hasMatches = false;
for (const [project, filter] of projectFilters) {
const filteredFiles = testFileArray.filter(filter);
if (!filteredFiles.length)
continue;
let set = this._dirtyTestFiles.get(project);
if (!set) {
set = new Set();
this._dirtyTestFiles.set(project, set);
}
filteredFiles.map(f => set!.add(f));
hasMatches = true;
}
if (!hasMatches)
return;
if (this._timer)
clearTimeout(this._timer);
this._timer = setTimeout(() => {
this._notifyDirtyFiles?.();
}, 250);
});
constructor(send: (data: any) => void) {
super();
this._send = send;
} }
async onDirtyTestFiles(): Promise<void> { close() {
if (this._dirtyTestFiles.size) this.emit('close');
return;
await new Promise<void>(f => this._notifyDirtyFiles = f);
} }
takeDirtyTestFiles(): Map<FullProjectInternal, Set<string>> { onclose(listener: () => void): void {
const result = this._dirtyTestFiles; this.on('close', listener);
this._dirtyTestFiles = new Map(); }
return result;
onerror(listener: () => void): void {
// no-op to fulfil the interface, the user of InMemoryTransport doesn't emit any errors.
}
onmessage(listener: (message: string) => void): void {
this.on('message', listener);
}
onopen(listener: () => void): void {
this.on('open', listener);
}
send(data: string): void {
this._send(data);
} }
} }
export async function runWatchModeLoop(config: FullConfigInternal): Promise<FullResult['status']> { interface WatchModeOptions {
// Reset the settings that don't apply to watch. files?: string[];
config.cliPassWithNoTests = true; projects?: string[];
for (const p of config.projects) grep?: string;
p.project.retries = 0; }
// Perform global setup. export async function runWatchModeLoop(configLocation: ConfigLocation, initialOptions: WatchModeOptions): Promise<FullResult['status'] | 'restarted'> {
const testRun = new TestRun(config); if (restartWithExperimentalTsEsm(undefined, true))
const taskRunner = createTaskRunnerForWatchSetup(config, [new ListReporter()]); return 'restarted';
taskRunner.reporter.onConfigure(config.config);
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0);
if (status !== 'passed')
await globalCleanup();
await taskRunner.reporter.onEnd({ status });
await taskRunner.reporter.onExit();
if (status !== 'passed')
return status;
// Prepare projects that will be watched, set up watcher. const options: WatchModeOptions = { ...initialOptions };
const failedTestIdCollector = new Set<string>();
const originalWorkers = config.config.workers;
const fsWatcher = new FSWatcher();
await fsWatcher.update(config);
let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: Set<string>, dirtyTestFiles?: Map<FullProjectInternal, Set<string>> } = { type: 'regular' }; const testServerDispatcher = new TestServerDispatcher(configLocation);
const transport = new InMemoryTransport(
async data => {
const { id, method, params } = JSON.parse(data);
try {
const result = await testServerDispatcher.transport.dispatch(method, params);
transport.emit('message', JSON.stringify({ id, result }));
} catch (e) {
transport.emit('message', JSON.stringify({ id, error: String(e) }));
}
}
);
testServerDispatcher.transport.sendEvent = (method, params) => {
transport.emit('message', JSON.stringify({ method, params }));
};
const testServerConnection = new TestServerConnection(transport);
transport.emit('open');
const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } });
const dirtyTestIds = new Set<string>();
let onDirtyTests = new ManualPromise();
let queue = Promise.resolve();
const changedFiles = new Set<string>();
testServerConnection.onTestFilesChanged(({ testFiles }) => {
testFiles.forEach(file => changedFiles.add(file));
queue = queue.then(async () => {
if (changedFiles.size === 0)
return;
const { report } = await testServerConnection.listTests({ locations: options.files, projects: options.projects, grep: options.grep });
teleSuiteUpdater.processListReport(report);
for (const test of teleSuiteUpdater.rootSuite!.allTests()) {
if (changedFiles.has(test.location.file))
dirtyTestIds.add(test.id);
}
changedFiles.clear();
if (dirtyTestIds.size > 0)
onDirtyTests.resolve?.();
});
});
testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report));
await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true });
await testServerConnection.runGlobalSetup({});
const { report } = await testServerConnection.listTests({ locations: options.files, projects: options.projects, grep: options.grep });
teleSuiteUpdater.processListReport(report);
let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: string[], dirtyTestIds?: string[] } = { type: 'regular' };
let result: FullResult['status'] = 'passed'; let result: FullResult['status'] = 'passed';
// Enter the watch loop. // Enter the watch loop.
await runTests(config, failedTestIdCollector); await runTests(options, testServerConnection);
while (true) { while (true) {
printPrompt(); printPrompt();
const readCommandPromise = readCommand(); const readCommandPromise = readCommand();
await Promise.race([ await Promise.race([
fsWatcher.onDirtyTestFiles(), onDirtyTests,
readCommandPromise, readCommandPromise,
]); ]);
if (!readCommandPromise.isDone()) if (!readCommandPromise.isDone())
@ -147,32 +147,32 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
const command = await readCommandPromise; const command = await readCommandPromise;
if (command === 'changed') { if (command === 'changed') {
const dirtyTestFiles = fsWatcher.takeDirtyTestFiles(); onDirtyTests = new ManualPromise();
// Resolve files that depend on the changed files. const testIds = [...dirtyTestIds];
await runChangedTests(config, failedTestIdCollector, dirtyTestFiles); dirtyTestIds.clear();
lastRun = { type: 'changed', dirtyTestFiles }; await runTests(options, testServerConnection, { testIds, title: 'files changed' });
lastRun = { type: 'changed', dirtyTestIds: testIds };
continue; continue;
} }
if (command === 'run') { if (command === 'run') {
// All means reset filters. // All means reset filters.
await runTests(config, failedTestIdCollector); await runTests(options, testServerConnection);
lastRun = { type: 'regular' }; lastRun = { type: 'regular' };
continue; continue;
} }
if (command === 'project') { if (command === 'project') {
const { projectNames } = await enquirer.prompt<{ projectNames: string[] }>({ const { selectedProjects } = await enquirer.prompt<{ selectedProjects: string[] }>({
type: 'multiselect', type: 'multiselect',
name: 'projectNames', name: 'selectedProjects',
message: 'Select projects', message: 'Select projects',
choices: config.projects.map(p => ({ name: p.project.name })), choices: teleSuiteUpdater.rootSuite!.suites.map(s => s.title),
}).catch(() => ({ projectNames: null })); }).catch(() => ({ selectedProjects: null }));
if (!projectNames) if (!selectedProjects)
continue; continue;
config.cliProjectFilter = projectNames.length ? projectNames : undefined; options.projects = selectedProjects.length ? selectedProjects : undefined;
await fsWatcher.update(config); await runTests(options, testServerConnection);
await runTests(config, failedTestIdCollector);
lastRun = { type: 'regular' }; lastRun = { type: 'regular' };
continue; continue;
} }
@ -186,11 +186,10 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
if (filePattern === null) if (filePattern === null)
continue; continue;
if (filePattern.trim()) if (filePattern.trim())
config.cliArgs = filePattern.split(' '); options.files = filePattern.split(' ');
else else
config.cliArgs = []; options.files = undefined;
await fsWatcher.update(config); await runTests(options, testServerConnection);
await runTests(config, failedTestIdCollector);
lastRun = { type: 'regular' }; lastRun = { type: 'regular' };
continue; continue;
} }
@ -204,40 +203,35 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
if (testPattern === null) if (testPattern === null)
continue; continue;
if (testPattern.trim()) if (testPattern.trim())
config.cliGrep = testPattern; options.grep = testPattern;
else else
config.cliGrep = undefined; options.grep = undefined;
await fsWatcher.update(config); await runTests(options, testServerConnection);
await runTests(config, failedTestIdCollector);
lastRun = { type: 'regular' }; lastRun = { type: 'regular' };
continue; continue;
} }
if (command === 'failed') { if (command === 'failed') {
config.testIdMatcher = id => failedTestIdCollector.has(id); const failedTestIds = teleSuiteUpdater.rootSuite!.allTests().filter(t => !t.ok()).map(t => t.id);
const failedTestIds = new Set(failedTestIdCollector); await runTests({}, testServerConnection, { title: 'running failed tests', testIds: failedTestIds });
await runTests(config, failedTestIdCollector, { title: 'running failed tests' });
config.testIdMatcher = undefined;
lastRun = { type: 'failed', failedTestIds }; lastRun = { type: 'failed', failedTestIds };
continue; continue;
} }
if (command === 'repeat') { if (command === 'repeat') {
if (lastRun.type === 'regular') { if (lastRun.type === 'regular') {
await runTests(config, failedTestIdCollector, { title: 're-running tests' }); await runTests(options, testServerConnection, { title: 're-running tests' });
continue; continue;
} else if (lastRun.type === 'changed') { } else if (lastRun.type === 'changed') {
await runChangedTests(config, failedTestIdCollector, lastRun.dirtyTestFiles!, 're-running tests'); await runTests(options, testServerConnection, { title: 're-running tests', testIds: lastRun.dirtyTestIds });
} else if (lastRun.type === 'failed') { } else if (lastRun.type === 'failed') {
config.testIdMatcher = id => lastRun.failedTestIds!.has(id); await runTests({}, testServerConnection, { title: 're-running tests', testIds: lastRun.failedTestIds });
await runTests(config, failedTestIdCollector, { title: 're-running tests' });
config.testIdMatcher = undefined;
} }
continue; continue;
} }
if (command === 'toggle-show-browser') { if (command === 'toggle-show-browser') {
await toggleShowBrowser(config, originalWorkers); await toggleShowBrowser();
continue; continue;
} }
@ -250,71 +244,27 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
} }
} }
const cleanupStatus = await globalCleanup(); const teardown = await testServerConnection.runGlobalTeardown({});
return result === 'passed' ? cleanupStatus : result;
return result === 'passed' ? teardown.status : result;
} }
async function runChangedTests(config: FullConfigInternal, failedTestIdCollector: Set<string>, filesByProject: Map<FullProjectInternal, Set<string>>, title?: string) { async function runTests(watchOptions: WatchModeOptions, testServerConnection: TestServerConnection, options?: {
const testFiles = new Set<string>();
for (const files of filesByProject.values())
files.forEach(f => testFiles.add(f));
// 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 projects = filterProjects(config.projects, config.cliProjectFilter);
const projectClosure = buildProjectsClosure(projects);
const affectedProjects = affectedProjectsClosure([...projectClosure.keys()], [...filesByProject.keys()]);
const affectsAnyDependency = [...affectedProjects].some(p => projectClosure.get(p) === 'dependency');
// 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) => testFiles.has(file);
await runTests(config, failedTestIdCollector, { additionalFileMatcher, title: title || 'files changed' });
}
async function runTests(config: FullConfigInternal, failedTestIdCollector: Set<string>, options?: {
projectsToIgnore?: Set<FullProjectInternal>,
additionalFileMatcher?: Matcher,
title?: string, title?: string,
testIds?: string[],
}) { }) {
printConfiguration(config, options?.title); printConfiguration(watchOptions, options?.title);
const taskRunner = createTaskRunnerForWatch(config, [new ListReporter()], options?.additionalFileMatcher);
const testRun = new TestRun(config);
taskRunner.reporter.onConfigure(config.config);
const taskStatus = await taskRunner.run(testRun, 0);
let status: FullResult['status'] = 'passed';
let hasFailedTests = false; await testServerConnection.runTests({
for (const test of testRun.rootSuite?.allTests() || []) { grep: watchOptions.grep,
if (test.outcome() === 'unexpected') { testIds: options?.testIds,
failedTestIdCollector.add(test.id); locations: watchOptions?.files,
hasFailedTests = true; projects: watchOptions.projects,
} else { connectWsEndpoint,
failedTestIdCollector.delete(test.id); reuseContext: connectWsEndpoint ? true : undefined,
} workers: connectWsEndpoint ? 1 : undefined,
} headed: connectWsEndpoint ? true : undefined,
});
if (testRun.failureTracker.hasWorkerErrors() || hasFailedTests)
status = 'failed';
if (status === 'passed' && taskStatus !== 'passed')
status = taskStatus;
await taskRunner.reporter.onEnd({ status });
await taskRunner.reporter.onExit();
}
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.deps) {
if (result.has(dep))
result.add(p);
}
if (p.teardown && result.has(p.teardown))
result.add(p);
}
}
return result;
} }
function readCommand(): ManualPromise<Command> { function readCommand(): ManualPromise<Command> {
@ -377,17 +327,19 @@ Change settings
} }
let showBrowserServer: PlaywrightServer | undefined; let showBrowserServer: PlaywrightServer | undefined;
let connectWsEndpoint: string | undefined = undefined;
let seq = 0; let seq = 0;
function printConfiguration(config: FullConfigInternal, title?: string) { function printConfiguration(options: WatchModeOptions, title?: string) {
const packageManagerCommand = getPackageManagerExecCommand(); const packageManagerCommand = getPackageManagerExecCommand();
const tokens: string[] = []; const tokens: string[] = [];
tokens.push(`${packageManagerCommand} playwright test`); tokens.push(`${packageManagerCommand} playwright test`);
tokens.push(...(config.cliProjectFilter || [])?.map(p => colors.blue(`--project ${p}`))); if (options.projects)
if (config.cliGrep) tokens.push(...options.projects.map(p => colors.blue(`--project ${p}`)));
tokens.push(colors.red(`--grep ${config.cliGrep}`)); if (options.grep)
if (config.cliArgs) tokens.push(colors.red(`--grep ${options.grep}`));
tokens.push(...config.cliArgs.map(a => colors.bold(a))); if (options.files)
tokens.push(...options.files.map(a => colors.bold(a)));
if (title) if (title)
tokens.push(colors.dim(`(${title})`)); tokens.push(colors.dim(`(${title})`));
if (seq) if (seq)
@ -409,25 +361,15 @@ ${colors.dim('Waiting for file changes. Press')} ${colors.bold('enter')} ${color
`); `);
} }
async function toggleShowBrowser(config: FullConfigInternal, originalWorkers: number) { async function toggleShowBrowser() {
if (!showBrowserServer) { if (!showBrowserServer) {
config.config.workers = 1;
showBrowserServer = new PlaywrightServer({ mode: 'extension', path: '/' + createGuid(), maxConnections: 1 }); showBrowserServer = new PlaywrightServer({ mode: 'extension', path: '/' + createGuid(), maxConnections: 1 });
const wsEndpoint = await showBrowserServer.listen(); connectWsEndpoint = await showBrowserServer.listen();
config.configCLIOverrides.use = {
...config.configCLIOverrides.use,
_optionContextReuseMode: 'when-possible',
_optionConnectOptions: { wsEndpoint },
};
process.stdout.write(`${colors.dim('Show & reuse browser:')} ${colors.bold('on')}\n`); process.stdout.write(`${colors.dim('Show & reuse browser:')} ${colors.bold('on')}\n`);
} else { } else {
config.config.workers = originalWorkers;
if (config.configCLIOverrides.use) {
delete config.configCLIOverrides.use._optionContextReuseMode;
delete config.configCLIOverrides.use._optionConnectOptions;
}
await showBrowserServer?.close(); await showBrowserServer?.close();
showBrowserServer = undefined; showBrowserServer = undefined;
connectWsEndpoint = undefined;
process.stdout.write(`${colors.dim('Show & reuse browser:')} ${colors.bold('off')}\n`); process.stdout.write(`${colors.dim('Show & reuse browser:')} ${colors.bold('off')}\n`);
} }
} }

View File

@ -166,39 +166,13 @@ test('should understand dependency structure', async ({ runInlineTest, git, writ
expect(result.output).not.toContain('c.spec.ts'); expect(result.output).not.toContain('c.spec.ts');
}); });
test('should support watch mode', async ({ git, writeFiles, runWatchTest }) => { test('watch mode is not supported', async ({ runWatchTest }) => {
await writeFiles({ const testProcess = await runWatchTest({}, { 'only-changed': true });
'a.spec.ts': ` await testProcess.exited;
import { test, expect } from '@playwright/test'; expect(testProcess.output).toContain('--only-changed is not supported in watch mode');
test('fails', () => { expect(1).toBe(2); });
`,
'b.spec.ts': `
import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); });
`,
});
git(`add .`);
git(`commit -m init`);
await writeFiles({
'b.spec.ts': `
import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(3); });
`,
});
git(`commit -a -m update`);
const testProcess = await runWatchTest({}, { 'only-changed': `HEAD~1` });
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('r');
await testProcess.waitForOutput('b.spec.ts:3:13 fails');
expect(testProcess.output).not.toContain('a.spec');
}); });
test('should throw nice error message if git doesnt work', async ({ git, runInlineTest }) => { test('should throw nice error message if git doesnt work', async ({ runInlineTest, git }) => {
const result = await runInlineTest({}, { 'only-changed': `this-commit-does-not-exist` }); const result = await runInlineTest({}, { 'only-changed': `this-commit-does-not-exist` });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);

View File

@ -15,6 +15,7 @@
*/ */
import path from 'path'; import path from 'path';
import timers from 'timers/promises';
import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures'; import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
@ -418,6 +419,17 @@ test('should run on changed files', async ({ runWatchTest, writeFiles }) => {
expect(testProcess.output).not.toContain('a.test.ts:3:11 passes'); expect(testProcess.output).not.toContain('a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('b.test.ts:3:11 passes'); expect(testProcess.output).not.toContain('b.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
'b.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
});
await testProcess.waitForOutput('b.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('c.test.ts:3:11 passes');
}); });
test('should run on changed deps', async ({ runWatchTest, writeFiles }) => { test('should run on changed deps', async ({ runWatchTest, writeFiles }) => {
@ -545,7 +557,7 @@ test('should not trigger on changes to non-tests', async ({ runWatchTest, writeF
`, `,
}); });
await new Promise(f => setTimeout(f, 1000)); await timers.setTimeout(1000);
expect(testProcess.output).not.toContain('Waiting for file changes.'); expect(testProcess.output).not.toContain('Waiting for file changes.');
}); });
@ -603,7 +615,7 @@ test('should watch filtered files', async ({ runWatchTest, writeFiles }) => {
`, `,
}); });
await new Promise(f => setTimeout(f, 1000)); await timers.setTimeout(1000);
expect(testProcess.output).not.toContain('Waiting for file changes.'); expect(testProcess.output).not.toContain('Waiting for file changes.');
}); });