From ec2ae1ed2d37803b0816d558b90d981c5ef7f24b Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 17 Sep 2024 08:45:44 +0200 Subject: [PATCH] feat(watch mode): buffer mode (#32631) Closes https://github.com/microsoft/playwright/issues/32578. Adds a buffer mode that can be toggled by pressing b. When engaged, changed test files are collected and shown on screen. The test run is then kicked off by pressing Enter. This changes the signal to start a test run from Cmd+s to a Enter press in the test terminal. It should help users with auto-save and make it easier to run on long-running tests. It feels very similar to running `npx playwright test`, but without having to write a filter. https://github.com/user-attachments/assets/71e16139-9427-4e90-b523-8d218f09ed9d --- packages/playwright/src/runner/watchMode.ts | 64 ++++++++++++++++----- tests/playwright-test/watch.spec.ts | 51 ++++++++++++++++ 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index 603f066601..a47ca0fe32 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -73,6 +73,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp return 'restarted'; const options: WatchModeOptions = { ...initialOptions }; + let bufferMode = false; const testServerDispatcher = new TestServerDispatcher(configLocation); const transport = new InMemoryTransport( @@ -94,8 +95,9 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } }); + const dirtyTestFiles = new Set(); const dirtyTestIds = new Set(); - let onDirtyTests = new ManualPromise(); + let onDirtyTests = new ManualPromise<'changed'>(); let queue = Promise.resolve(); const changedFiles = new Set(); @@ -110,14 +112,17 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp teleSuiteUpdater.processListReport(report); for (const test of teleSuiteUpdater.rootSuite!.allTests()) { - if (changedFiles.has(test.location.file)) + if (changedFiles.has(test.location.file)) { + dirtyTestFiles.add(test.location.file); dirtyTestIds.add(test.id); + } } - changedFiles.clear(); - if (dirtyTestIds.size > 0) - onDirtyTests.resolve?.(); + if (dirtyTestIds.size > 0) { + onDirtyTests.resolve('changed'); + onDirtyTests = new ManualPromise(); + } }); }); testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report)); @@ -134,21 +139,27 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp let result: FullResult['status'] = 'passed'; while (true) { - printPrompt(); - const readCommandPromise = readCommand(); - await Promise.race([ + if (bufferMode) + printBufferPrompt(dirtyTestFiles, teleSuiteUpdater.config!.rootDir); + else + printPrompt(); + + const command = await Promise.race([ onDirtyTests, - readCommandPromise, + readCommand(), ]); - if (!readCommandPromise.isDone()) - readCommandPromise.resolve('changed'); - const command = await readCommandPromise; + if (bufferMode && command === 'changed') + continue; + + const shouldRunChangedFiles = bufferMode ? command === 'run' : command === 'changed'; + if (shouldRunChangedFiles) { + if (dirtyTestIds.size === 0) + continue; - if (command === 'changed') { - onDirtyTests = new ManualPromise(); const testIds = [...dirtyTestIds]; dirtyTestIds.clear(); + dirtyTestFiles.clear(); await runTests(options, testServerConnection, { testIds, title: 'files changed' }); lastRun = { type: 'changed', dirtyTestIds: testIds }; continue; @@ -234,6 +245,11 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp continue; } + if (command === 'toggle-buffer-mode') { + bufferMode = !bufferMode; + continue; + } + if (command === 'exit') break; @@ -300,6 +316,7 @@ Change settings ${colors.bold('p')} ${colors.dim('set file filter')} ${colors.bold('t')} ${colors.dim('set title filter')} ${colors.bold('s')} ${colors.dim('toggle show & reuse the browser')} + ${colors.bold('b')} ${colors.dim('toggle buffer mode')} `); return; } @@ -312,6 +329,7 @@ Change settings case 't': result.resolve('grep'); break; case 'f': result.resolve('failed'); break; case 's': result.resolve('toggle-show-browser'); break; + case 'b': result.resolve('toggle-buffer-mode'); break; } }; @@ -350,6 +368,22 @@ function printConfiguration(options: WatchModeOptions, title?: string) { process.stdout.write(lines.join('\n')); } +function printBufferPrompt(dirtyTestFiles: Set, rootDir: string) { + const sep = separator(); + process.stdout.write('\x1Bc'); + process.stdout.write(`${sep}\n`); + + if (dirtyTestFiles.size === 0) { + process.stdout.write(`${colors.dim('Waiting for file changes. Press')} ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`); + return; + } + + process.stdout.write(`${colors.dim(`${dirtyTestFiles.size} test ${dirtyTestFiles.size === 1 ? 'file' : 'files'} changed:`)}\n\n`); + for (const file of dirtyTestFiles) + process.stdout.write(` · ${path.relative(rootDir, file)}\n`); + process.stdout.write(`\n${colors.dim(`Press`)} ${colors.bold('enter')} ${colors.dim('to run')}, ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`); +} + function printPrompt() { const sep = separator(); process.stdout.write(` @@ -371,4 +405,4 @@ async function toggleShowBrowser() { } } -type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser'; +type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser' | 'toggle-buffer-mode'; diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index ec05e5bb68..b56fb470ea 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -313,12 +313,17 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) => await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('[bar] › a.test.ts:3:11 › passes'); + await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles(files); // file change triggers listTests with project filter await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); + testProcess.clearOutput(); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.write('c'); + testProcess.clearOutput(); await testProcess.waitForOutput('Select projects'); await testProcess.waitForOutput('foo'); await testProcess.waitForOutput('bar'); // second selection should still show all @@ -812,3 +817,49 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => { testProcess.write('\x1B'); await testProcess.waitForOutput('running teardown'); }); + +test('buffer mode', async ({ runWatchTest, writeFiles }) => { + const testProcess = await runWatchTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes', () => {}); + `, + 'b.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes in b', () => {}); + `, + }); + + testProcess.clearOutput(); + testProcess.write('b'); + await testProcess.waitForOutput('Waiting for file changes. Press q to quit'); + + + testProcess.clearOutput(); + await writeFiles({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes again', () => {}); + `, + }); + + await testProcess.waitForOutput('1 test file changed:'); + await testProcess.waitForOutput('a.test.ts'); + + testProcess.clearOutput(); + await writeFiles({ + 'b.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes in b again', () => {}); + `, + }); + await testProcess.waitForOutput('2 test files changed:'); + await testProcess.waitForOutput('a.test.ts'); + await testProcess.waitForOutput('b.test.ts'); + + testProcess.clearOutput(); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('a.test.ts:3:11 › passes'); + await testProcess.waitForOutput('b.test.ts:3:11 › passes'); +}); \ No newline at end of file