chore(ui): maintain test order when updating (#21478)

This commit is contained in:
Pavel Feldman 2023-03-07 20:34:57 -08:00 committed by GitHub
parent f5894ed089
commit a2490a8fc8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 100 additions and 35 deletions

View file

@ -26,6 +26,7 @@ export type JsonStackFrame = { file: string, line: number, column: number };
export type JsonConfig = { export type JsonConfig = {
rootDir: string; rootDir: string;
configFile: string | undefined; configFile: string | undefined;
listOnly: boolean;
}; };
export type JsonPattern = { export type JsonPattern = {
@ -148,6 +149,7 @@ export class TeleReporterReceiver {
} }
private _onBegin(config: JsonConfig, projects: JsonProject[]) { private _onBegin(config: JsonConfig, projects: JsonProject[]) {
const removeMissing = config.listOnly;
for (const project of projects) { for (const project of projects) {
let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.id === project.id); let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.id === project.id);
if (!projectSuite) { if (!projectSuite) {
@ -157,7 +159,7 @@ export class TeleReporterReceiver {
} }
const p = this._parseProject(project); const p = this._parseProject(project);
projectSuite.project = () => p; projectSuite.project = () => p;
this._mergeSuitesInto(project.suites, projectSuite); this._mergeSuitesInto(project.suites, projectSuite, removeMissing);
} }
this._reporter.onBegin?.(this._parseConfig(config), this._rootSuite); this._reporter.onBegin?.(this._parseConfig(config), this._rootSuite);
} }
@ -260,7 +262,7 @@ export class TeleReporterReceiver {
}; };
} }
private _mergeSuitesInto(jsonSuites: JsonSuite[], parent: TeleSuite) { private _mergeSuitesInto(jsonSuites: JsonSuite[], parent: TeleSuite, removeMissing: boolean) {
for (const jsonSuite of jsonSuites) { for (const jsonSuite of jsonSuites) {
let targetSuite = parent.suites.find(s => s.title === jsonSuite.title); let targetSuite = parent.suites.find(s => s.title === jsonSuite.title);
if (!targetSuite) { if (!targetSuite) {
@ -271,12 +273,16 @@ export class TeleReporterReceiver {
targetSuite.location = jsonSuite.location; targetSuite.location = jsonSuite.location;
targetSuite._fileId = jsonSuite.fileId; targetSuite._fileId = jsonSuite.fileId;
targetSuite._parallelMode = jsonSuite.parallelMode; targetSuite._parallelMode = jsonSuite.parallelMode;
this._mergeSuitesInto(jsonSuite.suites, targetSuite); this._mergeSuitesInto(jsonSuite.suites, targetSuite, removeMissing);
this._mergeTestsInto(jsonSuite.tests, targetSuite); this._mergeTestsInto(jsonSuite.tests, targetSuite, removeMissing);
}
if (removeMissing) {
const suiteMap = new Map(parent.suites.map(p => [p.title, p]));
parent.suites = jsonSuites.map(s => suiteMap.get(s.title)).filter(Boolean) as TeleSuite[];
} }
} }
private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) { private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite, removeMissing: boolean) {
for (const jsonTest of jsonTests) { for (const jsonTest of jsonTests) {
let targetTest = parent.tests.find(s => s.title === jsonTest.title); let targetTest = parent.tests.find(s => s.title === jsonTest.title);
if (!targetTest) { if (!targetTest) {
@ -287,6 +293,10 @@ export class TeleReporterReceiver {
} }
this._updateTest(jsonTest, targetTest); this._updateTest(jsonTest, targetTest);
} }
if (removeMissing) {
const testMap = new Map(parent.tests.map(p => [p.title, p]));
parent.tests = jsonTests.map(s => testMap.get(s.title)).filter(Boolean) as TeleTestCase[];
}
} }
private _updateTest(payload: JsonTestCase, test: TeleTestCase): TeleTestCase { private _updateTest(payload: JsonTestCase, test: TeleTestCase): TeleTestCase {

View file

@ -18,12 +18,11 @@ import type { FullConfig, FullResult, Reporter, TestError, TestResult, TestStep
import type { Suite, TestCase } from '../common/test'; import type { Suite, TestCase } from '../common/test';
import type { JsonConfig, JsonProject, JsonSuite, JsonTestCase, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver'; import type { JsonConfig, JsonProject, JsonSuite, JsonTestCase, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver';
import type { SuitePrivate } from '../../types/reporterPrivate'; import type { SuitePrivate } from '../../types/reporterPrivate';
import type { FullProjectInternal } from '../common/types'; import type { FullConfigInternal, FullProjectInternal } from '../common/types';
import { createGuid } from 'playwright-core/lib/utils'; import { createGuid } from 'playwright-core/lib/utils';
import { serializeRegexPatterns } from '../isomorphic/teleReceiver'; import { serializeRegexPatterns } from '../isomorphic/teleReceiver';
export class TeleReporterEmitter implements Reporter { export class TeleReporterEmitter implements Reporter {
private config!: FullConfig;
private _messageSink: (message: any) => void; private _messageSink: (message: any) => void;
constructor(messageSink: (message: any) => void) { constructor(messageSink: (message: any) => void) {
@ -31,7 +30,6 @@ export class TeleReporterEmitter implements Reporter {
} }
onBegin(config: FullConfig, suite: Suite) { onBegin(config: FullConfig, suite: Suite) {
this.config = config;
const projects: any[] = []; const projects: any[] = [];
for (const projectSuite of suite.suites) { for (const projectSuite of suite.suites) {
const report = this._serializeProject(projectSuite); const report = this._serializeProject(projectSuite);
@ -116,6 +114,7 @@ export class TeleReporterEmitter implements Reporter {
return { return {
rootDir: config.rootDir, rootDir: config.rootDir,
configFile: config.configFile, configFile: config.configFile,
listOnly: (config as FullConfigInternal)._internal.listOnly,
}; };
} }

View file

@ -57,7 +57,7 @@ export class Runner {
webServerPluginsForConfig(config).forEach(p => config._internal.plugins.push({ factory: p })); webServerPluginsForConfig(config).forEach(p => config._internal.plugins.push({ factory: p }));
const reporter = await createReporter(config, listOnly ? 'list' : 'run'); const reporter = await createReporter(config, listOnly ? 'list' : 'run');
const taskRunner = listOnly ? createTaskRunnerForList(config, reporter) const taskRunner = listOnly ? createTaskRunnerForList(config, reporter, 'in-process')
: createTaskRunner(config, reporter); : createTaskRunner(config, reporter);
const context: TaskRunnerState = { const context: TaskRunnerState = {

View file

@ -91,9 +91,9 @@ function addRunTasks(taskRunner: TaskRunner<TaskRunnerState>, config: FullConfig
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForList(config: FullConfigInternal, reporter: Multiplexer): TaskRunner<TaskRunnerState> { export function createTaskRunnerForList(config: FullConfigInternal, reporter: Multiplexer, mode: 'in-process' | 'out-of-process'): TaskRunner<TaskRunnerState> {
const taskRunner = new TaskRunner<TaskRunnerState>(reporter, config.globalTimeout); const taskRunner = new TaskRunner<TaskRunnerState>(reporter, config.globalTimeout);
taskRunner.addTask('load tests', createLoadTask('in-process', false)); taskRunner.addTask('load tests', createLoadTask(mode, false));
taskRunner.addTask('report begin', async ({ reporter, rootSuite }) => { taskRunner.addTask('report begin', async ({ reporter, rootSuite }) => {
reporter.onBegin?.(config, rootSuite!); reporter.onBegin?.(config, rootSuite!);
return () => reporter.onEnd(); return () => reporter.onEnd();

View file

@ -35,9 +35,10 @@ 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 _watcher: FSWatcher | undefined; private _testWatcher: FSWatcher | undefined;
private _watchTestFile: string | undefined; private _watchTestFile: string | undefined;
private _originalStderr: (buffer: string | Uint8Array) => void; private _originalStderr: (buffer: string | Uint8Array) => void;
private _globalWatcher: FSWatcher;
constructor(config: FullConfigInternal) { constructor(config: FullConfigInternal) {
this._config = config; this._config = config;
@ -46,6 +47,7 @@ class UIMode {
p.retries = 0; p.retries = 0;
config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {}; config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {};
config._internal.configCLIOverrides.use.trace = 'on'; config._internal.configCLIOverrides.use.trace = 'on';
this._originalStderr = process.stderr.write.bind(process.stderr); this._originalStderr = process.stderr.write.bind(process.stderr);
process.stdout.write = (chunk: string | Buffer) => { process.stdout.write = (chunk: string | Buffer) => {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) }); this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) });
@ -55,6 +57,25 @@ class UIMode {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) }); this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) });
return true; return true;
}; };
this._globalWatcher = this._installGlobalWatcher();
}
private _installGlobalWatcher(): FSWatcher {
const projectDirs = new Set<string>();
for (const p of this._config.projects)
projectDirs.add(p.testDir);
let coalescingTimer: NodeJS.Timeout | undefined;
const watcher = chokidar.watch([...projectDirs], { ignoreInitial: true, persistent: true }).on('all', async event => {
if (event !== 'add' && event !== 'change')
return;
if (coalescingTimer)
clearTimeout(coalescingTimer);
coalescingTimer = setTimeout(() => {
this._dispatchEvent({ method: 'listChanged' });
}, 200);
});
return watcher;
} }
async runGlobalSetup(): Promise<FullResult['status']> { async runGlobalSetup(): Promise<FullResult['status']> {
@ -79,30 +100,45 @@ class UIMode {
this._page = await showTraceViewer([], 'chromium', { app: 'watch.html' }); this._page = await showTraceViewer([], 'chromium', { app: 'watch.html' });
const exitPromise = new ManualPromise(); const exitPromise = new ManualPromise();
this._page.on('close', () => exitPromise.resolve()); this._page.on('close', () => exitPromise.resolve());
let queue = Promise.resolve();
this._page.exposeBinding('sendMessage', false, async (source, data) => { this._page.exposeBinding('sendMessage', false, async (source, data) => {
const { method, params }: { method: string, params: any } = data; const { method, params }: { method: string, params: any } = data;
if (method === 'list') if (method === 'exit') {
await this._listTests(); exitPromise.resolve();
if (method === 'run') return;
await this._runTests(params.testIds); }
if (method === 'stop') if (method === 'watch') {
this._stopTests();
if (method === 'watch')
this._watchFile(params.fileName); this._watchFile(params.fileName);
if (method === 'open' && params.location) return;
}
if (method === 'open' && params.location) {
open.openApp('code', { arguments: ['--goto', params.location] }).catch(() => {}); open.openApp('code', { arguments: ['--goto', params.location] }).catch(() => {});
return;
}
if (method === 'resizeTerminal') { if (method === 'resizeTerminal') {
process.stdout.columns = params.cols; process.stdout.columns = params.cols;
process.stdout.rows = params.rows; process.stdout.rows = params.rows;
process.stderr.columns = params.cols; process.stderr.columns = params.cols;
process.stderr.columns = params.rows; process.stderr.columns = params.rows;
return;
} }
if (method === 'exit') if (method === 'stop') {
exitPromise.resolve(); this._stopTests();
return;
}
queue = queue.then(() => this._queueListOrRun(method, params));
await queue;
}); });
await exitPromise; await exitPromise;
} }
private async _queueListOrRun(method: string, params: any) {
if (method === 'list')
await this._listTests();
if (method === 'run')
await this._runTests(params.testIds);
}
private _dispatchEvent(message: any) { private _dispatchEvent(message: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
this._page.mainFrame().evaluateExpression(dispatchFuncSource, true, message).catch(e => this._originalStderr(String(e))); this._page.mainFrame().evaluateExpression(dispatchFuncSource, true, message).catch(e => this._originalStderr(String(e)));
@ -111,8 +147,11 @@ class UIMode {
private async _listTests() { private async _listTests() {
const listReporter = new TeleReporterEmitter(e => this._dispatchEvent(e)); const listReporter = new TeleReporterEmitter(e => this._dispatchEvent(e));
const reporter = new Multiplexer([listReporter]); const reporter = new Multiplexer([listReporter]);
const taskRunner = createTaskRunnerForList(this._config, reporter); this._config._internal.listOnly = true;
this._config._internal.testIdMatcher = undefined;
const taskRunner = createTaskRunnerForList(this._config, reporter, 'out-of-process');
const context: TaskRunnerState = { config: this._config, reporter, phases: [] }; const context: TaskRunnerState = { config: this._config, reporter, phases: [] };
clearCompilationCache();
reporter.onConfigure(this._config); reporter.onConfigure(this._config);
await taskRunner.run(context, 0); await taskRunner.run(context, 0);
} }
@ -121,6 +160,7 @@ class UIMode {
await this._stopTests(); await this._stopTests();
const testIdSet = testIds ? new Set<string>(testIds) : null; const testIdSet = testIds ? new Set<string>(testIds) : null;
this._config._internal.listOnly = false;
this._config._internal.testIdMatcher = id => !testIdSet || testIdSet.has(id); this._config._internal.testIdMatcher = id => !testIdSet || testIdSet.has(id);
const runReporter = new TeleReporterEmitter(e => this._dispatchEvent(e)); const runReporter = new TeleReporterEmitter(e => this._dispatchEvent(e));
@ -143,14 +183,14 @@ class UIMode {
private async _watchFile(fileName: string) { private async _watchFile(fileName: string) {
if (this._watchTestFile === fileName) if (this._watchTestFile === fileName)
return; return;
if (this._watcher) if (this._testWatcher)
await this._watcher.close(); await this._testWatcher.close();
this._watchTestFile = fileName; this._watchTestFile = fileName;
if (!fileName) if (!fileName)
return; return;
const files = [fileName, ...dependenciesForTestFile(fileName)]; const files = [fileName, ...dependenciesForTestFile(fileName)];
this._watcher = chokidar.watch(files, { ignoreInitial: true }).on('all', async (event, file) => { this._testWatcher = chokidar.watch(files, { 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 } }); this._dispatchEvent({ method: 'fileChanged', params: { fileName: file } });

View file

@ -90,7 +90,7 @@ export const WatchModeView: React.FC<{}> = ({
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div> <div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
<ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton> <ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton> <ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton>
<ToolbarButton icon='refresh' title='Reload' onClick={resetCollectingRootSuite} disabled={isRunningTest}></ToolbarButton> <ToolbarButton icon='refresh' title='Reload' onClick={() => refreshRootSuite(true)} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='eye-watch' title='Watch' toggled={isWatchingFiles} onClick={() => setIsWatchingFiles(!isWatchingFiles)}></ToolbarButton> <ToolbarButton icon='eye-watch' title='Watch' toggled={isWatchingFiles} onClick={() => setIsWatchingFiles(!isWatchingFiles)}></ToolbarButton>
<div className='spacer'></div> <div className='spacer'></div>
<ToolbarButton icon='gear' title='Toggle color mode' toggled={settingsVisible} onClick={() => { setSettingsVisible(!settingsVisible); }}></ToolbarButton> <ToolbarButton icon='gear' title='Toggle color mode' toggled={settingsVisible} onClick={() => { setSettingsVisible(!settingsVisible); }}></ToolbarButton>
@ -128,7 +128,7 @@ export const TestList: React.FC<{
React.useEffect(() => { React.useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
resetCollectingRootSuite(); refreshRootSuite(true);
}, []); }, []);
const { filteredItems, treeItemMap, visibleTestIds } = React.useMemo(() => { const { filteredItems, treeItemMap, visibleTestIds } = React.useMemo(() => {
@ -165,8 +165,8 @@ export const TestList: React.FC<{
}, [selectedTreeItemId, treeItemMap]); }, [selectedTreeItemId, treeItemMap]);
React.useEffect(() => { React.useEffect(() => {
sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTestItem) : undefined }); sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTreeItem) : undefined });
}, [selectedTestItem, isWatchingFiles]); }, [selectedTreeItem, isWatchingFiles]);
onTestItemSelected(selectedTestItem); onTestItemSelected(selectedTestItem);
@ -327,7 +327,12 @@ declare global {
let receiver: TeleReporterReceiver | undefined; let receiver: TeleReporterReceiver | undefined;
const resetCollectingRootSuite = () => { const refreshRootSuite = (eraseResults: boolean) => {
if (!eraseResults) {
sendMessageNoReply('list');
return;
}
let rootSuite: Suite; let rootSuite: Suite;
const progress: Progress = { const progress: Progress = {
total: 0, total: 0,
@ -367,18 +372,27 @@ const resetCollectingRootSuite = () => {
}; };
(window as any).dispatch = (message: any) => { (window as any).dispatch = (message: any) => {
if (message.method === 'listChanged') {
refreshRootSuite(false);
return;
}
if (message.method === 'fileChanged') { if (message.method === 'fileChanged') {
runWatchedTests(); runWatchedTests();
} else if (message.method === 'stdio') { return;
}
if (message.method === 'stdio') {
if (message.params.buffer) { if (message.params.buffer) {
const data = atob(message.params.buffer); const data = atob(message.params.buffer);
xtermDataSource.write(data); xtermDataSource.write(data);
} else { } else {
xtermDataSource.write(message.params.text); xtermDataSource.write(message.params.text);
} }
} else { return;
receiver?.dispatch(message);
} }
receiver?.dispatch(message);
}; };
const sendMessage = async (method: string, params: any) => { const sendMessage = async (method: string, params: any) => {
@ -442,6 +456,7 @@ type TreeItemBase = {
type FileItem = TreeItemBase & { type FileItem = TreeItemBase & {
kind: 'file', kind: 'file',
file: string; file: string;
children?: TestCaseItem[];
}; };
type TestCaseItem = TreeItemBase & { type TestCaseItem = TreeItemBase & {
@ -501,6 +516,7 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
test, test,
}); });
} }
(fileItem.children as TestCaseItem[]).sort((a, b) => a.location.line - b.location.line);
} }
} }
return [...fileItems.values()]; return [...fileItems.values()];
@ -512,7 +528,7 @@ function filterTree(fileItems: FileItem[], filterText: string): FileItem[] {
const result: FileItem[] = []; const result: FileItem[] = [];
for (const fileItem of fileItems) { for (const fileItem of fileItems) {
if (trimmedFilterText) { if (trimmedFilterText) {
const filteredCases: TreeItem[] = []; const filteredCases: TestCaseItem[] = [];
for (const testCaseItem of fileItem.children!) { for (const testCaseItem of fileItem.children!) {
const fullTitle = (fileItem.title + ' ' + testCaseItem.title).toLowerCase(); const fullTitle = (fileItem.title + ' ' + testCaseItem.title).toLowerCase();
if (filterTokens.every(token => fullTitle.includes(token))) if (filterTokens.every(token => fullTitle.includes(token)))