chore(test runner): refactor watch mode onto testserver

This commit is contained in:
Simon Knott 2024-08-13 12:13:13 +02:00
parent 1b220c5289
commit 068379aaa2
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
11 changed files with 209 additions and 256 deletions

View file

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

View file

@ -203,10 +203,13 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
const runner = new Runner(config); const runner = new Runner(config);
let status: FullResult['status']; let status: FullResult['status'];
if (process.env.PWTEST_WATCH) 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.`);
status = await runner.watchAllTests(); status = await runner.watchAllTests();
else } else {
status = await runner.runAllTests(); 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

@ -9,4 +9,5 @@
../utilsBundle.ts ../utilsBundle.ts
../isomorphic/folders.ts ../isomorphic/folders.ts
../isomorphic/teleReceiver.ts ../isomorphic/teleReceiver.ts
../isomorphic/testServerConnection.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

@ -134,7 +134,10 @@ export class Runner {
async watchAllTests(): Promise<FullResult['status']> { async watchAllTests(): Promise<FullResult['status']> {
const config = this._config; const config = this._config;
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
return await runWatchModeLoop(config); return await runWatchModeLoop(
{ configDir: config.configDir, resolvedConfigFile: config.config.configFile },
{ projects: config.cliProjectFilter, files: config.cliArgs, grep: config.cliGrep, onlyChanged: config.cliOnlyChanged }
);
} }
async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise<FindRelatedTestFilesReport> { async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise<FindRelatedTestFilesReport> {

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,106 @@
*/ */
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 TestServerSocket, TestServerConnection } from '../isomorphic/testServerConnection';
import { createFileMatcherFromArguments } from '../util';
import { TeleSuiteUpdater } from '../isomorphic/teleSuiteUpdater';
class FSWatcher { class InMemoryServerSocket extends EventEmitter implements TestServerSocket {
private _dirtyTestFiles = new Map<FullProjectInternal, Set<string>>(); public readonly send: (data: string) => void;
private _notifyDirtyFiles: (() => void) | undefined; public readonly close: () => void;
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, close: () => void = () => {}) {
super();
this.send = send;
this.close = close;
} }
async onDirtyTestFiles(): Promise<void> { addEventListener(event: string, listener: (e: any) => void) {
if (this._dirtyTestFiles.size) this.addListener(event, listener);
return;
await new Promise<void>(f => this._notifyDirtyFiles = f);
}
takeDirtyTestFiles(): Map<FullProjectInternal, Set<string>> {
const result = this._dirtyTestFiles;
this._dirtyTestFiles = new Map();
return result;
} }
} }
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; onlyChanged?: string;
}
// Perform global setup. export async function runWatchModeLoop(configLocation: ConfigLocation, initialOptions: WatchModeOptions): Promise<FullResult['status']> {
const testRun = new TestRun(config); const options: WatchModeOptions = { ...initialOptions };
const taskRunner = createTaskRunnerForWatchSetup(config, [new ListReporter()]);
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 testServerDispatcher = new TestServerDispatcher(configLocation);
const failedTestIdCollector = new Set<string>(); const inMemorySocket = new InMemoryServerSocket(
const originalWorkers = config.config.workers; async data => {
const fsWatcher = new FSWatcher(); const { id, method, params } = JSON.parse(data);
await fsWatcher.update(config); try {
const result = await testServerDispatcher.transport.dispatch(method, params);
inMemorySocket.emit('message', { data: JSON.stringify({ id, result }) });
} catch (e) {
inMemorySocket.emit('message', { data: JSON.stringify({ id, error: String(e) }) });
}
}
);
testServerDispatcher.transport.sendEvent = (method, params) => {
inMemorySocket.emit('message', { data: JSON.stringify({ method, params }) });
};
const testServerConnection = new TestServerConnection(inMemorySocket);
inMemorySocket.emit('open');
let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: Set<string>, dirtyTestFiles?: Map<FullProjectInternal, Set<string>> } = { type: 'regular' }; const telesuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } });
const dirtyTestFiles: string[] = []; // we're never clearing this! seems wrong.
const onDirtyTestFiles: { resolve?(): void } = {};
testServerConnection.onTestFilesChanged(async ({ testFiles: changedFiles }) => {
if (changedFiles.length === 0)
return;
const { report } = await testServerConnection.listTests({ locations: options.files, projects: options.projects, grep: options.grep });
telesuiteUpdater.processListReport(report);
for (const project of telesuiteUpdater.rootSuite!.suites) {
for (const suite of project.suites) {
if (suite.location?.file && changedFiles.includes(suite.location.file))
dirtyTestFiles.push(suite.location.file);
}
}
if (dirtyTestFiles.length === 0)
return;
onDirtyTestFiles.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[], dirtyTestFiles?: 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(), new Promise<void>(resolve => { onDirtyTestFiles.resolve = resolve; }),
readCommandPromise, readCommandPromise,
]); ]);
if (!readCommandPromise.isDone()) if (!readCommandPromise.isDone())
@ -147,32 +123,29 @@ 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(); await runChangedTests(options, testServerConnection, dirtyTestFiles);
// Resolve files that depend on the changed files. lastRun = { type: 'changed', dirtyTestFiles: [...dirtyTestFiles] };
await runChangedTests(config, failedTestIdCollector, dirtyTestFiles);
lastRun = { type: 'changed', dirtyTestFiles };
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 +159,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 +176,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 runChangedTests(options, testServerConnection, lastRun.dirtyTestFiles!, 're-running tests');
} 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 +217,36 @@ 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) {
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. async function runChangedTests(watchOptions: WatchModeOptions, testServerConnection: TestServerConnection, changedFiles: string[], title?: string) {
// Prepare to exclude all the projects that do not depend on this file, as if they did not exist. if (watchOptions.files?.length)
const projects = filterProjects(config.projects, config.cliProjectFilter); changedFiles = changedFiles.filter(createFileMatcherFromArguments(watchOptions.files));
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. await runTests(watchOptions, testServerConnection, { title: title || 'files changed', locations: changedFiles });
// 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?: { async function runTests(watchOptions: WatchModeOptions, testServerConnection: TestServerConnection, options?: {
projectsToIgnore?: Set<FullProjectInternal>,
additionalFileMatcher?: Matcher,
title?: string, title?: string,
testIds?: string[],
locations?: 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: options?.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 +309,21 @@ 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.onlyChanged)
tokens.push(colors.yellow(`--only-changed ${options.onlyChanged}`));
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 +345,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 }) => {
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

@ -251,6 +251,60 @@ test('should run added test in watched file', async ({ runUITest, writeFiles })
`); `);
}); });
test('should run dependency of watched test', async ({ runUITest, writeFiles }) => {
const { page } = await runUITest({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'setup', testMatch: 'global.setup.ts', },
{ name: 'main', dependencies: ['setup'] },
],
};
`,
'global.setup.ts': `
import { test as setup } from '@playwright/test';
setup('setup test', async ({ page }) => {
console.log('setup test is executed')
});
`,
'a.test.ts': `
import { test } from '@playwright/test';
test('foo', () => {});
`,
});
await page.getByText('Status:').click();
await page.getByLabel('setup').setChecked(true);
await page.getByLabel('main').setChecked(true);
await page.getByText('a.test.ts').click();
await page.getByRole('listitem').filter({ hasText: 'a.test.ts' }).getByTitle('Watch').click();
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts 👁 <=
foo
global.setup.ts
setup test
`);
await writeFiles({
'a.test.ts': `
import { test } from '@playwright/test';
test('foo', () => {});
test('bar', () => {});
`,
});
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts 👁 <=
foo
bar
global.setup.ts
setup test
`);
});
test('should queue watches', async ({ runUITest, writeFiles, createLatch }) => { test('should queue watches', async ({ runUITest, writeFiles, createLatch }) => {
const latch = createLatch(); const latch = createLatch();
const { page } = await runUITest({ const { page } = await runUITest({

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' });
@ -545,7 +546,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 +604,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.');
}); });