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 {
}
}
}
}

View File

@ -24,9 +24,9 @@ import { stopProfiling, startProfiling, gracefullyProcessExitDoNotHang } from 'p
import { serializeError } from './util';
import { showHTMLReport } from './reporters/html';
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 { FullResult, TestError } from '../types/testReporter';
import type { TestError } from '../types/testReporter';
import type { TraceMode } from '../types/test';
import { builtInReporters, defaultReporter, defaultTimeout } from './common/config';
import { program } from 'playwright-core/lib/cli/program';
@ -35,6 +35,7 @@ import type { ReporterDescription } from '../types/test';
import { prepareErrorStack } from './reporters/base';
import * as testServer from './runner/testServer';
import { clearCacheAndLogToConsole } from './runner/testServer';
import { runWatchModeLoop } from './runner/watchMode';
function addTestCommand(program: Command) {
const command = program.command('test [test-filter...]');
@ -183,6 +184,26 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
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);
if (!config)
return;
@ -202,11 +223,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
config.cliFailOnFlakyTests = !!opts.failOnFlakyTests;
const runner = new Runner(config);
let status: FullResult['status'];
if (process.env.PWTEST_WATCH)
status = await runner.watchAllTests();
else
status = await runner.runAllTests();
const status = await runner.runAllTests();
await stopProfiling('runner');
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);
gracefullyProcessExitDoNotHang(exitCode);

View File

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

View File

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

View File

@ -24,7 +24,6 @@ import { collectFilesForProject, filterProjects } from './projectUtils';
import { createReporters } from './reporters';
import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks';
import type { FullConfigInternal } from '../common/config';
import { runWatchModeLoop } from './watchMode';
import type { Suite } from '../common/test';
import { wrapReporterAsV2 } from '../reporters/reporterV2';
import { affectedTestFiles } from '../transform/compilationCache';
@ -131,12 +130,6 @@ export class Runner {
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> {
const result = await this.loadAllTests(mode);
if (result.status !== 'passed' || !result.suite)

View File

@ -74,13 +74,6 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report
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> {
const taskRunner = TaskRunner.create<TestRun>(reporters);
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 {
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);
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 _watcher: Watcher;

View File

@ -15,130 +15,130 @@
*/
import readline from 'readline';
import path from 'path';
import { createGuid, getPackageManagerExecCommand, ManualPromise } from 'playwright-core/lib/utils';
import type { FullConfigInternal, FullProjectInternal } 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 { ConfigLocation } from '../common/config';
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 { enquirer } from '../utilsBundle';
import { separator } from '../reporters/base';
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 {
private _dirtyTestFiles = new Map<FullProjectInternal, Set<string>>();
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);
});
class InMemoryTransport extends EventEmitter implements TestServerTransport {
public readonly _send: (data: string) => void;
constructor(send: (data: any) => void) {
super();
this._send = send;
}
async onDirtyTestFiles(): Promise<void> {
if (this._dirtyTestFiles.size)
return;
await new Promise<void>(f => this._notifyDirtyFiles = f);
close() {
this.emit('close');
}
takeDirtyTestFiles(): Map<FullProjectInternal, Set<string>> {
const result = this._dirtyTestFiles;
this._dirtyTestFiles = new Map();
return result;
onclose(listener: () => void): void {
this.on('close', listener);
}
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']> {
// Reset the settings that don't apply to watch.
config.cliPassWithNoTests = true;
for (const p of config.projects)
p.project.retries = 0;
interface WatchModeOptions {
files?: string[];
projects?: string[];
grep?: string;
}
// Perform global setup.
const testRun = new TestRun(config);
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;
export async function runWatchModeLoop(configLocation: ConfigLocation, initialOptions: WatchModeOptions): Promise<FullResult['status'] | 'restarted'> {
if (restartWithExperimentalTsEsm(undefined, true))
return 'restarted';
// Prepare projects that will be watched, set up watcher.
const failedTestIdCollector = new Set<string>();
const originalWorkers = config.config.workers;
const fsWatcher = new FSWatcher();
await fsWatcher.update(config);
const options: WatchModeOptions = { ...initialOptions };
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';
// Enter the watch loop.
await runTests(config, failedTestIdCollector);
await runTests(options, testServerConnection);
while (true) {
printPrompt();
const readCommandPromise = readCommand();
await Promise.race([
fsWatcher.onDirtyTestFiles(),
onDirtyTests,
readCommandPromise,
]);
if (!readCommandPromise.isDone())
@ -147,32 +147,32 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
const command = await readCommandPromise;
if (command === 'changed') {
const dirtyTestFiles = fsWatcher.takeDirtyTestFiles();
// Resolve files that depend on the changed files.
await runChangedTests(config, failedTestIdCollector, dirtyTestFiles);
lastRun = { type: 'changed', dirtyTestFiles };
onDirtyTests = new ManualPromise();
const testIds = [...dirtyTestIds];
dirtyTestIds.clear();
await runTests(options, testServerConnection, { testIds, title: 'files changed' });
lastRun = { type: 'changed', dirtyTestIds: testIds };
continue;
}
if (command === 'run') {
// All means reset filters.
await runTests(config, failedTestIdCollector);
await runTests(options, testServerConnection);
lastRun = { type: 'regular' };
continue;
}
if (command === 'project') {
const { projectNames } = await enquirer.prompt<{ projectNames: string[] }>({
const { selectedProjects } = await enquirer.prompt<{ selectedProjects: string[] }>({
type: 'multiselect',
name: 'projectNames',
name: 'selectedProjects',
message: 'Select projects',
choices: config.projects.map(p => ({ name: p.project.name })),
}).catch(() => ({ projectNames: null }));
if (!projectNames)
choices: teleSuiteUpdater.rootSuite!.suites.map(s => s.title),
}).catch(() => ({ selectedProjects: null }));
if (!selectedProjects)
continue;
config.cliProjectFilter = projectNames.length ? projectNames : undefined;
await fsWatcher.update(config);
await runTests(config, failedTestIdCollector);
options.projects = selectedProjects.length ? selectedProjects : undefined;
await runTests(options, testServerConnection);
lastRun = { type: 'regular' };
continue;
}
@ -186,11 +186,10 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
if (filePattern === null)
continue;
if (filePattern.trim())
config.cliArgs = filePattern.split(' ');
options.files = filePattern.split(' ');
else
config.cliArgs = [];
await fsWatcher.update(config);
await runTests(config, failedTestIdCollector);
options.files = undefined;
await runTests(options, testServerConnection);
lastRun = { type: 'regular' };
continue;
}
@ -204,40 +203,35 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
if (testPattern === null)
continue;
if (testPattern.trim())
config.cliGrep = testPattern;
options.grep = testPattern;
else
config.cliGrep = undefined;
await fsWatcher.update(config);
await runTests(config, failedTestIdCollector);
options.grep = undefined;
await runTests(options, testServerConnection);
lastRun = { type: 'regular' };
continue;
}
if (command === 'failed') {
config.testIdMatcher = id => failedTestIdCollector.has(id);
const failedTestIds = new Set(failedTestIdCollector);
await runTests(config, failedTestIdCollector, { title: 'running failed tests' });
config.testIdMatcher = undefined;
const failedTestIds = teleSuiteUpdater.rootSuite!.allTests().filter(t => !t.ok()).map(t => t.id);
await runTests({}, testServerConnection, { title: 'running failed tests', testIds: failedTestIds });
lastRun = { type: 'failed', failedTestIds };
continue;
}
if (command === 'repeat') {
if (lastRun.type === 'regular') {
await runTests(config, failedTestIdCollector, { title: 're-running tests' });
await runTests(options, testServerConnection, { title: 're-running tests' });
continue;
} 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') {
config.testIdMatcher = id => lastRun.failedTestIds!.has(id);
await runTests(config, failedTestIdCollector, { title: 're-running tests' });
config.testIdMatcher = undefined;
await runTests({}, testServerConnection, { title: 're-running tests', testIds: lastRun.failedTestIds });
}
continue;
}
if (command === 'toggle-show-browser') {
await toggleShowBrowser(config, originalWorkers);
await toggleShowBrowser();
continue;
}
@ -250,71 +244,27 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
}
}
const cleanupStatus = await globalCleanup();
return result === 'passed' ? cleanupStatus : result;
const teardown = await testServerConnection.runGlobalTeardown({});
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.
// 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,
async function runTests(watchOptions: WatchModeOptions, testServerConnection: TestServerConnection, options?: {
title?: string,
testIds?: string[],
}) {
printConfiguration(config, 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';
printConfiguration(watchOptions, options?.title);
let hasFailedTests = false;
for (const test of testRun.rootSuite?.allTests() || []) {
if (test.outcome() === 'unexpected') {
failedTestIdCollector.add(test.id);
hasFailedTests = true;
} else {
failedTestIdCollector.delete(test.id);
}
}
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;
await testServerConnection.runTests({
grep: watchOptions.grep,
testIds: options?.testIds,
locations: watchOptions?.files,
projects: watchOptions.projects,
connectWsEndpoint,
reuseContext: connectWsEndpoint ? true : undefined,
workers: connectWsEndpoint ? 1 : undefined,
headed: connectWsEndpoint ? true : undefined,
});
}
function readCommand(): ManualPromise<Command> {
@ -377,17 +327,19 @@ Change settings
}
let showBrowserServer: PlaywrightServer | undefined;
let connectWsEndpoint: string | undefined = undefined;
let seq = 0;
function printConfiguration(config: FullConfigInternal, title?: string) {
function printConfiguration(options: WatchModeOptions, title?: string) {
const packageManagerCommand = getPackageManagerExecCommand();
const tokens: string[] = [];
tokens.push(`${packageManagerCommand} playwright test`);
tokens.push(...(config.cliProjectFilter || [])?.map(p => colors.blue(`--project ${p}`)));
if (config.cliGrep)
tokens.push(colors.red(`--grep ${config.cliGrep}`));
if (config.cliArgs)
tokens.push(...config.cliArgs.map(a => colors.bold(a)));
if (options.projects)
tokens.push(...options.projects.map(p => colors.blue(`--project ${p}`)));
if (options.grep)
tokens.push(colors.red(`--grep ${options.grep}`));
if (options.files)
tokens.push(...options.files.map(a => colors.bold(a)));
if (title)
tokens.push(colors.dim(`(${title})`));
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) {
config.config.workers = 1;
showBrowserServer = new PlaywrightServer({ mode: 'extension', path: '/' + createGuid(), maxConnections: 1 });
const wsEndpoint = await showBrowserServer.listen();
config.configCLIOverrides.use = {
...config.configCLIOverrides.use,
_optionContextReuseMode: 'when-possible',
_optionConnectOptions: { wsEndpoint },
};
connectWsEndpoint = await showBrowserServer.listen();
process.stdout.write(`${colors.dim('Show & reuse browser:')} ${colors.bold('on')}\n`);
} else {
config.config.workers = originalWorkers;
if (config.configCLIOverrides.use) {
delete config.configCLIOverrides.use._optionContextReuseMode;
delete config.configCLIOverrides.use._optionConnectOptions;
}
await showBrowserServer?.close();
showBrowserServer = undefined;
connectWsEndpoint = undefined;
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');
});
test('should support watch mode', async ({ git, writeFiles, runWatchTest }) => {
await writeFiles({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
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('watch mode is not supported', async ({ runWatchTest }) => {
const testProcess = await runWatchTest({}, { 'only-changed': true });
await testProcess.exited;
expect(testProcess.output).toContain('--only-changed is not supported in watch mode');
});
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` });
expect(result.exitCode).toBe(1);

View File

@ -15,6 +15,7 @@
*/
import path from 'path';
import timers from 'timers/promises';
import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures';
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('b.test.ts:3:11 passes');
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 }) => {
@ -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.');
});
@ -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.');
});