chore(test runner): refactor watch mode onto testserver
This commit is contained in:
parent
1b220c5289
commit
068379aaa2
|
|
@ -246,4 +246,4 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue