cherry-pick(#21787): chore: allow watching all tests

This commit is contained in:
Pavel Feldman 2023-03-19 14:50:09 -07:00 committed by Pavel
parent ec76a817ed
commit 53c40e24d2
4 changed files with 245 additions and 37 deletions

View file

@ -18,7 +18,7 @@ import { showTraceViewer } from 'playwright-core/lib/server';
import type { Page } from 'playwright-core/lib/server/page'; import type { Page } from 'playwright-core/lib/server/page';
import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils'; import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils';
import type { FullResult } from '../../reporter'; 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 type { FullConfigInternal } from '../common/types';
import { Multiplexer } from '../reporters/multiplexer'; import { Multiplexer } from '../reporters/multiplexer';
import { TeleReporterEmitter } from '../reporters/teleEmitter'; import { TeleReporterEmitter } from '../reporters/teleEmitter';
@ -34,7 +34,7 @@ class UIMode {
private _page!: Page; private _page!: Page;
private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined; private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined;
globalCleanup: (() => Promise<FullResult['status']>) | undefined; globalCleanup: (() => Promise<FullResult['status']>) | undefined;
private _testWatcher: FSWatcher | undefined; private _testWatcher: { watcher: FSWatcher, watchedFiles: string[], collector: Set<string>, timer?: NodeJS.Timeout } | undefined;
private _originalStderr: (buffer: string | Uint8Array) => void; private _originalStderr: (buffer: string | Uint8Array) => void;
constructor(config: FullConfigInternal) { constructor(config: FullConfigInternal) {
@ -187,22 +187,40 @@ class UIMode {
} }
private async _watchFiles(fileNames: string[]) { private async _watchFiles(fileNames: string[]) {
if (this._testWatcher)
await this._testWatcher.close();
if (!fileNames.length)
return;
const files = new Set<string>(); const files = new Set<string>();
for (const fileName of fileNames) { for (const fileName of fileNames) {
files.add(fileName); files.add(fileName);
dependenciesForTestFile(fileName).forEach(file => files.add(file)); 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<string>();
const watcher = chokidar.watch(watchedFiles, { ignoreInitial: true }).on('all', async (event, file) => {
if (event !== 'add' && event !== 'change') if (event !== 'add' && event !== 'change')
return; 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() { private async _stopTests() {

View file

@ -34,10 +34,10 @@ import { XtermWrapper } from '@web/components/xtermWrapper';
import { Expandable } from '@web/components/expandable'; import { Expandable } from '@web/components/expandable';
import { toggleTheme } from '@web/theme'; import { toggleTheme } from '@web/theme';
import { artifactsFolderName } from '@testIsomorphic/folders'; 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 updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileName: string) => {}; let runWatchedTests = (fileNames: string[]) => {};
let xtermSize = { cols: 80, rows: 24 }; let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = { const xtermDataSource: XtermDataSource = {
@ -72,6 +72,7 @@ export const WatchModeView: React.FC<{}> = ({
const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]); const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>(); const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
const [watchAll, setWatchAll] = useSetting<boolean>('watch-all', false);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
@ -157,8 +158,9 @@ export const WatchModeView: React.FC<{}> = ({
<Toolbar noShadow={true}> <Toolbar noShadow={true}>
<img src='icon-32x32.png' /> <img src='icon-32x32.png' />
<div className='section-title'>Playwright</div> <div className='section-title'>Playwright</div>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()} /> <ToolbarButton icon='color-mode' title='Toggle color mode' onClick={() => toggleTheme()} />
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton> <ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch all' toggled={watchAll} onClick={() => setWatchAll(!watchAll)}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} /> <ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
</Toolbar> </Toolbar>
<FiltersView <FiltersView
@ -189,7 +191,8 @@ export const WatchModeView: React.FC<{}> = ({
runningState={runningState} runningState={runningState}
runTests={runTests} runTests={runTests}
onItemSelected={setSelectedItem} onItemSelected={setSelectedItem}
setVisibleTestIds={setVisibleTestIds} /> setVisibleTestIds={setVisibleTestIds}
watchAll={watchAll} />
</div> </div>
</SplitView> </SplitView>
</div>; </div>;
@ -272,20 +275,25 @@ const TestList: React.FC<{
testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined }, testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined },
runTests: (testIds: string[]) => void, runTests: (testIds: string[]) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean }, runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
watchAll?: boolean,
setVisibleTestIds: (testIds: string[]) => void, setVisibleTestIds: (testIds: string[]) => void,
onItemSelected: (item: { testCase?: TestCase, location?: Location }) => 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<TreeState>({ expandedItems: new Map() }); const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>(); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [watchedTreeIds, innerSetWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() }); const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() });
const { rootItem, treeItemMap } = React.useMemo(() => { // Build the test tree.
const { rootItem, treeItemMap, fileNames } = React.useMemo(() => {
const rootItem = createTree(testModel.rootSuite, projectFilters); const rootItem = createTree(testModel.rootSuite, projectFilters);
filterTree(rootItem, filterText, statusFilters); filterTree(rootItem, filterText, statusFilters);
hideOnlyTests(rootItem); hideOnlyTests(rootItem);
const treeItemMap = new Map<string, TreeItem>(); const treeItemMap = new Map<string, TreeItem>();
const visibleTestIds = new Set<string>(); const visibleTestIds = new Set<string>();
const fileNames = new Set<string>();
const visit = (treeItem: TreeItem) => { const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'group' && treeItem.location.file)
fileNames.add(treeItem.location.file);
if (treeItem.kind === 'case') if (treeItem.kind === 'case')
treeItem.tests.forEach(t => visibleTestIds.add(t.id)); treeItem.tests.forEach(t => visibleTestIds.add(t.id));
treeItem.children.forEach(visit); treeItem.children.forEach(visit);
@ -293,11 +301,11 @@ const TestList: React.FC<{
}; };
visit(rootItem); visit(rootItem);
setVisibleTestIds([...visibleTestIds]); setVisibleTestIds([...visibleTestIds]);
return { rootItem, treeItemMap }; return { rootItem, treeItemMap, fileNames };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds]); }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds]);
React.useEffect(() => {
// Look for a first failure within the run batch to select it. // Look for a first failure within the run batch to select it.
React.useEffect(() => {
if (!runningState || runningState.itemSelectedByUser) if (!runningState || runningState.itemSelectedByUser)
return; return;
let selectedTreeItem: TreeItem | undefined; let selectedTreeItem: TreeItem | undefined;
@ -318,6 +326,7 @@ const TestList: React.FC<{
setSelectedTreeItemId(selectedTreeItem.id); setSelectedTreeItemId(selectedTreeItem.id);
}, [runningState, setSelectedTreeItemId, rootItem]); }, [runningState, setSelectedTreeItemId, rootItem]);
// Compute selected item.
const { selectedTreeItem } = React.useMemo(() => { const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
const location = selectedTreeItem?.location; const location = selectedTreeItem?.location;
@ -330,28 +339,46 @@ const TestList: React.FC<{
return { selectedTreeItem }; return { selectedTreeItem };
}, [onItemSelected, selectedTreeItemId, treeItemMap]); }, [onItemSelected, selectedTreeItemId, treeItemMap]);
const setWatchedTreeIds = (watchedTreeIds: Set<string>) => { // Update watch all.
React.useEffect(() => {
if (watchAll) {
sendMessageNoReply('watch', { fileNames: [...fileNames] });
} else {
const fileNames = new Set<string>(); const fileNames = new Set<string>();
for (const itemId of watchedTreeIds) { for (const itemId of watchedTreeIds.value) {
const treeItem = treeItemMap.get(itemId)!; const treeItem = treeItemMap.get(itemId)!;
fileNames.add(fileNameForTreeItem(treeItem)!); 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) => { const runTreeItem = (treeItem: TreeItem) => {
setSelectedTreeItemId(treeItem.id); setSelectedTreeItemId(treeItem.id);
runTests(collectTestIds(treeItem)); runTests(collectTestIds(treeItem));
}; };
runWatchedTests = (fileName: string) => { runWatchedTests = (fileNames: string[]) => {
const testIds: string[] = []; const testIds: string[] = [];
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) { for (const treeId of watchedTreeIds.value) {
const treeItem = treeItemMap.get(treeId)!; const treeItem = treeItemMap.get(treeId)!;
if (fileNameForTreeItem(treeItem) === fileName) const fileName = fileNameForTreeItem(treeItem);
if (fileName && set.has(fileName))
testIds.push(...collectTestIds(treeItem)); testIds.push(...collectTestIds(treeItem));
} }
}
runTests(testIds); runTests(testIds);
}; };
@ -365,13 +392,13 @@ const TestList: React.FC<{
<div className='watch-mode-list-item-title'>{treeItem.title}</div> <div className='watch-mode-list-item-title'>{treeItem.title}</div>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton> <ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton> <ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch' onClick={() => { {!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
if (watchedTreeIds.value.has(treeItem.id)) if (watchedTreeIds.value.has(treeItem.id))
watchedTreeIds.value.delete(treeItem.id); watchedTreeIds.value.delete(treeItem.id);
else else
watchedTreeIds.value.add(treeItem.id); watchedTreeIds.value.add(treeItem.id);
setWatchedTreeIds(watchedTreeIds.value); setWatchedTreeIds({ ...watchedTreeIds });
}} toggled={watchedTreeIds.value.has(treeItem.id)}></ToolbarButton> }} toggled={watchedTreeIds.value.has(treeItem.id)}></ToolbarButton>}
</div>; </div>;
}} }}
icon={treeItem => { icon={treeItem => {
@ -532,8 +559,8 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
return; return;
} }
if (message.method === 'fileChanged') { if (message.method === 'filesChanged') {
runWatchedTests(message.params.fileName); runWatchedTests(message.params.fileNames);
return; return;
} }

View file

@ -69,6 +69,7 @@ export function dumpTestTree(page: Page): () => Promise<string> {
export const test = base export const test = base
.extend<Fixtures>({ .extend<Fixtures>({
runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => { runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => {
if (process.env.CI)
testInfo.slow(); testInfo.slow();
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-')); const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-'));
let testProcess: TestChildProcess | undefined; let testProcess: TestChildProcess | undefined;

View file

@ -57,3 +57,165 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
fails 👁 <= 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
`);
});