From 068379aaa25cc29e3eaae98b1c6b78c3def63233 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 13 Aug 2024 12:13:13 +0200 Subject: [PATCH] chore(test runner): refactor watch mode onto testserver --- .../src/isomorphic/testServerConnection.ts | 2 +- packages/playwright/src/program.ts | 7 +- packages/playwright/src/runner/DEPS.list | 1 + packages/playwright/src/runner/loadUtils.ts | 4 +- packages/playwright/src/runner/runner.ts | 5 +- packages/playwright/src/runner/tasks.ts | 11 +- packages/playwright/src/runner/testServer.ts | 2 +- packages/playwright/src/runner/watchMode.ts | 338 +++++++----------- tests/playwright-test/only-changed.spec.ts | 36 +- .../ui-mode-test-watch.spec.ts | 54 +++ tests/playwright-test/watch.spec.ts | 5 +- 11 files changed, 209 insertions(+), 256 deletions(-) diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index 00dafdf20b..22bdce30ff 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -246,4 +246,4 @@ export class TestServerConnection implements TestServerInterface, TestServerInte } catch { } } -} \ No newline at end of file +} diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 803b9d2291..adba73331a 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -203,10 +203,13 @@ async function runTests(args: string[], opts: { [key: string]: any }) { const runner = new Runner(config); 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(); - else + } else { status = await runner.runAllTests(); + } await stopProfiling('runner'); const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); gracefullyProcessExitDoNotHang(exitCode); diff --git a/packages/playwright/src/runner/DEPS.list b/packages/playwright/src/runner/DEPS.list index cdf6044844..a34ec612af 100644 --- a/packages/playwright/src/runner/DEPS.list +++ b/packages/playwright/src/runner/DEPS.list @@ -9,4 +9,5 @@ ../utilsBundle.ts ../isomorphic/folders.ts ../isomorphic/teleReceiver.ts +../isomorphic/testServerConnection.ts ../fsWatcher.ts diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index cd735ceca3..63a2307507 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -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; diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index a7fd28ec87..fd47b03647 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -134,7 +134,10 @@ export class Runner { async watchAllTests(): Promise { const config = this._config; 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 { diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 0a1e001a91..09a8a1fdaf 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -74,13 +74,6 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report return taskRunner; } -export function createTaskRunnerForWatch(config: FullConfigInternal, reporters: ReporterV2[], additionalFileMatcher?: Matcher): TaskRunner { - const taskRunner = TaskRunner.create(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 { const taskRunner = TaskRunner.create(reporters); taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true })); @@ -222,10 +215,10 @@ function createListFilesTask(): Task { }; } -function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { +function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean }): Task { 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; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 7ed1d18191..2aa513b90f 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -62,7 +62,7 @@ class TestServer { } } -class TestServerDispatcher implements TestServerInterface { +export class TestServerDispatcher implements TestServerInterface { private _configLocation: ConfigLocation; private _watcher: Watcher; diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index 709e39100b..4e7a133ce4 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -15,130 +15,106 @@ */ 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 TestServerSocket, TestServerConnection } from '../isomorphic/testServerConnection'; +import { createFileMatcherFromArguments } from '../util'; +import { TeleSuiteUpdater } from '../isomorphic/teleSuiteUpdater'; -class FSWatcher { - private _dirtyTestFiles = new Map>(); - 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(); - 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(); - 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 InMemoryServerSocket extends EventEmitter implements TestServerSocket { + public readonly send: (data: string) => void; + public readonly close: () => void; + constructor(send: (data: any) => void, close: () => void = () => {}) { + super(); + this.send = send; + this.close = close; } - async onDirtyTestFiles(): Promise { - if (this._dirtyTestFiles.size) - return; - await new Promise(f => this._notifyDirtyFiles = f); - } - - takeDirtyTestFiles(): Map> { - const result = this._dirtyTestFiles; - this._dirtyTestFiles = new Map(); - return result; + addEventListener(event: string, listener: (e: any) => void) { + this.addListener(event, listener); } } -export async function runWatchModeLoop(config: FullConfigInternal): Promise { - // 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; + onlyChanged?: 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 { + const options: WatchModeOptions = { ...initialOptions }; - // Prepare projects that will be watched, set up watcher. - const failedTestIdCollector = new Set(); - const originalWorkers = config.config.workers; - const fsWatcher = new FSWatcher(); - await fsWatcher.update(config); + const testServerDispatcher = new TestServerDispatcher(configLocation); + const inMemorySocket = new InMemoryServerSocket( + async data => { + const { id, method, params } = JSON.parse(data); + 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, dirtyTestFiles?: Map> } = { 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'; // Enter the watch loop. - await runTests(config, failedTestIdCollector); + await runTests(options, testServerConnection); while (true) { printPrompt(); const readCommandPromise = readCommand(); await Promise.race([ - fsWatcher.onDirtyTestFiles(), + new Promise(resolve => { onDirtyTestFiles.resolve = resolve; }), readCommandPromise, ]); if (!readCommandPromise.isDone()) @@ -147,32 +123,29 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise({ + 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 +159,10 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise 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 runChangedTests(options, testServerConnection, lastRun.dirtyTestFiles!, 're-running tests'); } 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 +217,36 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise, filesByProject: Map>, title?: string) { - const testFiles = new Set(); - 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'); +async function runChangedTests(watchOptions: WatchModeOptions, testServerConnection: TestServerConnection, changedFiles: string[], title?: string) { + if (watchOptions.files?.length) + changedFiles = changedFiles.filter(createFileMatcherFromArguments(watchOptions.files)); - // 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' }); + await runTests(watchOptions, testServerConnection, { title: title || 'files changed', locations: changedFiles }); } -async function runTests(config: FullConfigInternal, failedTestIdCollector: Set, options?: { - projectsToIgnore?: Set, - additionalFileMatcher?: Matcher, +async function runTests(watchOptions: WatchModeOptions, testServerConnection: TestServerConnection, options?: { title?: string, + testIds?: string[], + locations?: 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 { - const result = new Set(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: options?.locations ?? watchOptions?.files, + projects: watchOptions.projects, + connectWsEndpoint, + reuseContext: connectWsEndpoint ? true : undefined, + workers: connectWsEndpoint ? 1 : undefined, + headed: connectWsEndpoint ? true : undefined, + }); } function readCommand(): ManualPromise { @@ -377,17 +309,21 @@ 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.onlyChanged) + tokens.push(colors.yellow(`--only-changed ${options.onlyChanged}`)); + if (options.files) + tokens.push(...options.files.map(a => colors.bold(a))); if (title) tokens.push(colors.dim(`(${title})`)); 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) { - 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`); } } diff --git a/tests/playwright-test/only-changed.spec.ts b/tests/playwright-test/only-changed.spec.ts index 6fe915f702..02f2a994c5 100644 --- a/tests/playwright-test/only-changed.spec.ts +++ b/tests/playwright-test/only-changed.spec.ts @@ -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 }) => { const result = await runInlineTest({}, { 'only-changed': `this-commit-does-not-exist` }); expect(result.exitCode).toBe(1); diff --git a/tests/playwright-test/ui-mode-test-watch.spec.ts b/tests/playwright-test/ui-mode-test-watch.spec.ts index bd04750a1f..7ff716480c 100644 --- a/tests/playwright-test/ui-mode-test-watch.spec.ts +++ b/tests/playwright-test/ui-mode-test-watch.spec.ts @@ -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 }) => { const latch = createLatch(); const { page } = await runUITest({ diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index 946377a357..f5751a43cb 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -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' }); @@ -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.'); }); @@ -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.'); });