From 53c40e24d2154a799c4e71c7ef99149a8c378cdc Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sun, 19 Mar 2023 14:50:09 -0700 Subject: [PATCH] cherry-pick(#21787): chore: allow watching all tests --- packages/playwright-test/src/runner/uiMode.ts | 36 +++- packages/trace-viewer/src/ui/watchMode.tsx | 81 ++++++--- tests/playwright-test/ui-mode-fixtures.ts | 3 +- .../ui-mode-test-watch.spec.ts | 162 ++++++++++++++++++ 4 files changed, 245 insertions(+), 37 deletions(-) diff --git a/packages/playwright-test/src/runner/uiMode.ts b/packages/playwright-test/src/runner/uiMode.ts index c5492ab10a..38f52d288d 100644 --- a/packages/playwright-test/src/runner/uiMode.ts +++ b/packages/playwright-test/src/runner/uiMode.ts @@ -18,7 +18,7 @@ import { showTraceViewer } from 'playwright-core/lib/server'; import type { Page } from 'playwright-core/lib/server/page'; import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils'; import type { FullResult } from '../../reporter'; -import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache'; +import { clearCompilationCache, collectAffectedTestFiles, dependenciesForTestFile } from '../common/compilationCache'; import type { FullConfigInternal } from '../common/types'; import { Multiplexer } from '../reporters/multiplexer'; import { TeleReporterEmitter } from '../reporters/teleEmitter'; @@ -34,7 +34,7 @@ class UIMode { private _page!: Page; private _testRun: { run: Promise, stop: ManualPromise } | undefined; globalCleanup: (() => Promise) | undefined; - private _testWatcher: FSWatcher | undefined; + private _testWatcher: { watcher: FSWatcher, watchedFiles: string[], collector: Set, timer?: NodeJS.Timeout } | undefined; private _originalStderr: (buffer: string | Uint8Array) => void; constructor(config: FullConfigInternal) { @@ -187,22 +187,40 @@ class UIMode { } private async _watchFiles(fileNames: string[]) { - if (this._testWatcher) - await this._testWatcher.close(); - if (!fileNames.length) - return; - const files = new Set(); for (const fileName of fileNames) { files.add(fileName); dependenciesForTestFile(fileName).forEach(file => files.add(file)); } + const watchedFiles = [...files].sort(); + if (this._testWatcher && JSON.stringify(this._testWatcher.watchedFiles.toString()) === JSON.stringify(watchedFiles)) + return; - this._testWatcher = chokidar.watch([...files], { ignoreInitial: true }).on('all', async (event, file) => { + if (this._testWatcher) { + if (this._testWatcher.collector.size) + this._dispatchEvent({ method: 'filesChanged', params: { fileNames: [...this._testWatcher.collector] } }); + clearTimeout(this._testWatcher.timer); + this._testWatcher.watcher.close().then(() => {}); + this._testWatcher = undefined; + } + + if (!watchedFiles.length) + return; + + const collector = new Set(); + const watcher = chokidar.watch(watchedFiles, { ignoreInitial: true }).on('all', async (event, file) => { if (event !== 'add' && event !== 'change') return; - this._dispatchEvent({ method: 'fileChanged', params: { fileName: file } }); + collectAffectedTestFiles(file, collector); + if (this._testWatcher!.timer) + clearTimeout(this._testWatcher!.timer); + this._testWatcher!.timer = setTimeout(() => { + const fileNames = [...collector]; + collector.clear(); + this._dispatchEvent({ method: 'filesChanged', params: { fileNames } }); + }, 250); }); + this._testWatcher = { watcher, watchedFiles, collector }; } private async _stopTests() { diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx index a3769c3648..4bf6986341 100644 --- a/packages/trace-viewer/src/ui/watchMode.tsx +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -34,10 +34,10 @@ import { XtermWrapper } from '@web/components/xtermWrapper'; import { Expandable } from '@web/components/expandable'; import { toggleTheme } from '@web/theme'; import { artifactsFolderName } from '@testIsomorphic/folders'; -import { settings } from '@web/uiUtils'; +import { settings, useSetting } from '@web/uiUtils'; let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {}; -let runWatchedTests = (fileName: string) => {}; +let runWatchedTests = (fileNames: string[]) => {}; let xtermSize = { cols: 80, rows: 24 }; const xtermDataSource: XtermDataSource = { @@ -72,6 +72,7 @@ export const WatchModeView: React.FC<{}> = ({ const [visibleTestIds, setVisibleTestIds] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean } | undefined>(); + const [watchAll, setWatchAll] = useSetting('watch-all', false); const inputRef = React.useRef(null); @@ -157,8 +158,9 @@ export const WatchModeView: React.FC<{}> = ({
Playwright
- toggleTheme()} /> + toggleTheme()} /> reloadTests()} disabled={isRunningTest || isLoading}> + setWatchAll(!watchAll)}> { setIsShowingOutput(!isShowingOutput); }} />
= ({ runningState={runningState} runTests={runTests} onItemSelected={setSelectedItem} - setVisibleTestIds={setVisibleTestIds} /> + setVisibleTestIds={setVisibleTestIds} + watchAll={watchAll} /> ; @@ -272,20 +275,25 @@ const TestList: React.FC<{ testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined }, runTests: (testIds: string[]) => void, runningState?: { testIds: Set, itemSelectedByUser?: boolean }, + watchAll?: boolean, setVisibleTestIds: (testIds: string[]) => void, onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void, -}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onItemSelected, setVisibleTestIds }) => { +}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, onItemSelected, setVisibleTestIds }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); - const [watchedTreeIds, innerSetWatchedTreeIds] = React.useState<{ value: Set }>({ value: new Set() }); + const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set }>({ value: new Set() }); - const { rootItem, treeItemMap } = React.useMemo(() => { + // Build the test tree. + const { rootItem, treeItemMap, fileNames } = React.useMemo(() => { const rootItem = createTree(testModel.rootSuite, projectFilters); filterTree(rootItem, filterText, statusFilters); hideOnlyTests(rootItem); const treeItemMap = new Map(); const visibleTestIds = new Set(); + const fileNames = new Set(); const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'group' && treeItem.location.file) + fileNames.add(treeItem.location.file); if (treeItem.kind === 'case') treeItem.tests.forEach(t => visibleTestIds.add(t.id)); treeItem.children.forEach(visit); @@ -293,11 +301,11 @@ const TestList: React.FC<{ }; visit(rootItem); setVisibleTestIds([...visibleTestIds]); - return { rootItem, treeItemMap }; + return { rootItem, treeItemMap, fileNames }; }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds]); + // Look for a first failure within the run batch to select it. React.useEffect(() => { - // Look for a first failure within the run batch to select it. if (!runningState || runningState.itemSelectedByUser) return; let selectedTreeItem: TreeItem | undefined; @@ -318,6 +326,7 @@ const TestList: React.FC<{ setSelectedTreeItemId(selectedTreeItem.id); }, [runningState, setSelectedTreeItemId, rootItem]); + // Compute selected item. const { selectedTreeItem } = React.useMemo(() => { const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; const location = selectedTreeItem?.location; @@ -330,27 +339,45 @@ const TestList: React.FC<{ return { selectedTreeItem }; }, [onItemSelected, selectedTreeItemId, treeItemMap]); - const setWatchedTreeIds = (watchedTreeIds: Set) => { - const fileNames = new Set(); - for (const itemId of watchedTreeIds) { - const treeItem = treeItemMap.get(itemId)!; - fileNames.add(fileNameForTreeItem(treeItem)!); + // Update watch all. + React.useEffect(() => { + if (watchAll) { + sendMessageNoReply('watch', { fileNames: [...fileNames] }); + } else { + const fileNames = new Set(); + for (const itemId of watchedTreeIds.value) { + const treeItem = treeItemMap.get(itemId)!; + const fileName = fileNameForTreeItem(treeItem); + if (fileName) + fileNames.add(fileName); + } + sendMessageNoReply('watch', { fileNames: [...fileNames] }); } - sendMessageNoReply('watch', { fileNames: [...fileNames] }); - innerSetWatchedTreeIds({ value: watchedTreeIds }); - }; + }, [rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]); const runTreeItem = (treeItem: TreeItem) => { setSelectedTreeItemId(treeItem.id); runTests(collectTestIds(treeItem)); }; - runWatchedTests = (fileName: string) => { + runWatchedTests = (fileNames: string[]) => { const testIds: string[] = []; - for (const treeId of watchedTreeIds.value) { - const treeItem = treeItemMap.get(treeId)!; - if (fileNameForTreeItem(treeItem) === fileName) - testIds.push(...collectTestIds(treeItem)); + const set = new Set(fileNames); + if (watchAll) { + const visit = (treeItem: TreeItem) => { + const fileName = fileNameForTreeItem(treeItem); + if (fileName && set.has(fileName)) + testIds.push(...collectTestIds(treeItem)); + treeItem.children.forEach(visit); + }; + visit(rootItem); + } else { + for (const treeId of watchedTreeIds.value) { + const treeItem = treeItemMap.get(treeId)!; + const fileName = fileNameForTreeItem(treeItem); + if (fileName && set.has(fileName)) + testIds.push(...collectTestIds(treeItem)); + } } runTests(testIds); }; @@ -365,13 +392,13 @@ const TestList: React.FC<{
{treeItem.title}
runTreeItem(treeItem)} disabled={!!runningState}> sendMessageNoReply('open', { location: locationToOpen(treeItem) })}> - { + {!watchAll && { if (watchedTreeIds.value.has(treeItem.id)) watchedTreeIds.value.delete(treeItem.id); else watchedTreeIds.value.add(treeItem.id); - setWatchedTreeIds(watchedTreeIds.value); - }} toggled={watchedTreeIds.value.has(treeItem.id)}> + setWatchedTreeIds({ ...watchedTreeIds }); + }} toggled={watchedTreeIds.value.has(treeItem.id)}>} ; }} icon={treeItem => { @@ -532,8 +559,8 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { return; } - if (message.method === 'fileChanged') { - runWatchedTests(message.params.fileName); + if (message.method === 'filesChanged') { + runWatchedTests(message.params.fileNames); return; } diff --git a/tests/playwright-test/ui-mode-fixtures.ts b/tests/playwright-test/ui-mode-fixtures.ts index 13cf88b6c3..442b890f99 100644 --- a/tests/playwright-test/ui-mode-fixtures.ts +++ b/tests/playwright-test/ui-mode-fixtures.ts @@ -69,7 +69,8 @@ export function dumpTestTree(page: Page): () => Promise { export const test = base .extend({ runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => { - testInfo.slow(); + if (process.env.CI) + testInfo.slow(); const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-')); let testProcess: TestChildProcess | undefined; let browser: Browser | undefined; diff --git a/tests/playwright-test/ui-mode-test-watch.spec.ts b/tests/playwright-test/ui-mode-test-watch.spec.ts index 8b5d48c782..b796cf036f 100644 --- a/tests/playwright-test/ui-mode-test-watch.spec.ts +++ b/tests/playwright-test/ui-mode-test-watch.spec.ts @@ -57,3 +57,165 @@ test('should watch files', async ({ runUITest, writeFiles }) => { ✅ fails 👁 <= `); }); + +test('should watch e2e deps', async ({ runUITest, writeFiles }) => { + const page = await runUITest({ + 'playwright.config.ts': ` + import { defineConfig } from '@playwright/test'; + export default defineConfig({ testDir: 'tests' }); + `, + 'src/helper.ts': ` + export const answer = 41; + `, + 'tests/a.test.ts': ` + import { test, expect } from '@playwright/test'; + import { answer } from '../src/helper'; + test('answer', () => { expect(answer).toBe(42); }); + `, + }); + + await page.getByText('answer').click(); + await page.getByRole('listitem').filter({ hasText: 'answer' }).getByTitle('Watch').click(); + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ◯ a.test.ts + ◯ answer 👁 <= + `); + + await writeFiles({ + 'src/helper.ts': ` + export const answer = 42; + ` + }); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ✅ a.test.ts + ✅ answer 👁 <= + `); +}); + +test('should batch watch updates', async ({ runUITest, writeFiles }) => { + const page = await runUITest({ + 'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + }); + + await page.getByText('a.test.ts').click(); + await page.getByRole('listitem').filter({ hasText: 'a.test.ts' }).getByTitle('Watch').click(); + await page.getByText('b.test.ts').click(); + await page.getByRole('listitem').filter({ hasText: 'b.test.ts' }).getByTitle('Watch').click(); + await page.getByText('c.test.ts').click(); + await page.getByRole('listitem').filter({ hasText: 'c.test.ts' }).getByTitle('Watch').click(); + await page.getByText('d.test.ts').click(); + await page.getByRole('listitem').filter({ hasText: 'd.test.ts' }).getByTitle('Watch').click(); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ◯ a.test.ts 👁 + ◯ test + ▼ ◯ b.test.ts 👁 + ◯ test + ▼ ◯ c.test.ts 👁 + ◯ test + ▼ ◯ d.test.ts 👁 <= + ◯ test + `); + + await writeFiles({ + 'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + }); + + await expect(page.getByTestId('status-line')).toHaveText('4/4 passed (100%)'); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ✅ a.test.ts 👁 + ✅ test + ▼ ✅ b.test.ts 👁 + ✅ test + ▼ ✅ c.test.ts 👁 + ✅ test + ▼ ✅ d.test.ts 👁 <= + ✅ test + `); +}); + +test('should watch all', async ({ runUITest, writeFiles }) => { + const page = await runUITest({ + 'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + }); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ◯ a.test.ts + ◯ test + ▼ ◯ b.test.ts + ◯ test + ▼ ◯ c.test.ts + ◯ test + ▼ ◯ d.test.ts + ◯ test + `); + await page.getByTitle('Watch all').click(); + + await writeFiles({ + 'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + 'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + }); + + await expect(page.getByTestId('status-line')).toHaveText('2/2 passed (100%)'); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ✅ a.test.ts + ✅ test + ▼ ◯ b.test.ts + ◯ test + ▼ ◯ c.test.ts + ◯ test + ▼ ✅ d.test.ts + ✅ test + `); +}); + +test('should watch new file', async ({ runUITest, writeFiles }) => { + const page = await runUITest({ + 'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`, + }); + + await page.getByTitle('Watch all').click(); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ◯ a.test.ts + ◯ test + `); + + // First time add file. + await writeFiles({ + 'b.test.ts': ` import { test } from '@playwright/test'; test('test', () => {});`, + }); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ◯ a.test.ts + ◯ test + ▼ ◯ b.test.ts + ◯ test + `); + + // Second time run file. + await writeFiles({ + 'b.test.ts': ` import { test } from '@playwright/test'; test('test', () => {});`, + }); + + await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ◯ a.test.ts + ◯ test + ▼ ✅ b.test.ts + ✅ test + `); +});