cherry-pick(#21787): chore: allow watching all tests
This commit is contained in:
parent
ec76a817ed
commit
53c40e24d2
|
|
@ -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<FullResult['status']>, stop: ManualPromise<void> } | 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;
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
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);
|
||||
|
||||
|
|
@ -157,8 +158,9 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
<Toolbar noShadow={true}>
|
||||
<img src='icon-32x32.png' />
|
||||
<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='eye' title='Watch all' toggled={watchAll} onClick={() => setWatchAll(!watchAll)}></ToolbarButton>
|
||||
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
|
||||
</Toolbar>
|
||||
<FiltersView
|
||||
|
|
@ -189,7 +191,8 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
runningState={runningState}
|
||||
runTests={runTests}
|
||||
onItemSelected={setSelectedItem}
|
||||
setVisibleTestIds={setVisibleTestIds} />
|
||||
setVisibleTestIds={setVisibleTestIds}
|
||||
watchAll={watchAll} />
|
||||
</div>
|
||||
</SplitView>
|
||||
</div>;
|
||||
|
|
@ -272,20 +275,25 @@ const TestList: React.FC<{
|
|||
testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined },
|
||||
runTests: (testIds: string[]) => void,
|
||||
runningState?: { testIds: Set<string>, 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<TreeState>({ expandedItems: new Map() });
|
||||
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);
|
||||
filterTree(rootItem, filterText, statusFilters);
|
||||
hideOnlyTests(rootItem);
|
||||
const treeItemMap = new Map<string, TreeItem>();
|
||||
const visibleTestIds = new Set<string>();
|
||||
const fileNames = new Set<string>();
|
||||
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<string>) => {
|
||||
const fileNames = new Set<string>();
|
||||
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<string>();
|
||||
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<{
|
|||
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
|
||||
<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='eye' title='Watch' onClick={() => {
|
||||
{!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
|
||||
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)}></ToolbarButton>
|
||||
setWatchedTreeIds({ ...watchedTreeIds });
|
||||
}} toggled={watchedTreeIds.value.has(treeItem.id)}></ToolbarButton>}
|
||||
</div>;
|
||||
}}
|
||||
icon={treeItem => {
|
||||
|
|
@ -532,8 +559,8 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (message.method === 'fileChanged') {
|
||||
runWatchedTests(message.params.fileName);
|
||||
if (message.method === 'filesChanged') {
|
||||
runWatchedTests(message.params.fileNames);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ export function dumpTestTree(page: Page): () => Promise<string> {
|
|||
export const test = base
|
||||
.extend<Fixtures>({
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue