diff --git a/src/client/unittests/common/managers/baseTestManager.ts b/src/client/unittests/common/managers/baseTestManager.ts index 6f484c96b194..443b719b8a92 100644 --- a/src/client/unittests/common/managers/baseTestManager.ts +++ b/src/client/unittests/common/managers/baseTestManager.ts @@ -1,4 +1,4 @@ -import { CancellationToken, CancellationTokenSource, Diagnostic, DiagnosticCollection, DiagnosticRelatedInformation, Disposable, languages, OutputChannel, Uri } from 'vscode'; +import { CancellationToken, CancellationTokenSource, Diagnostic, DiagnosticCollection, DiagnosticRelatedInformation, Disposable, Event, EventEmitter, languages, OutputChannel, Uri } from 'vscode'; import { IWorkspaceService } from '../../../common/application/types'; import { isNotInstalledError } from '../../../common/helpers'; import { IFileSystem } from '../../../common/platform/types'; @@ -38,6 +38,7 @@ export abstract class BaseTestManager implements ITestManager { private testRunnerCancellationTokenSource?: CancellationTokenSource; private _installer!: IInstaller; private discoverTestsPromise?: Promise; + private _onDidStatusChange = new EventEmitter(); private get installer(): IInstaller { if (!this._installer) { this._installer = this.serviceContainer.get(IInstaller); @@ -46,7 +47,7 @@ export abstract class BaseTestManager implements ITestManager { } constructor(public readonly testProvider: TestProvider, private product: Product, public readonly workspaceFolder: Uri, protected rootDirectory: string, protected serviceContainer: IServiceContainer) { - this._status = TestStatus.Unknown; + this.updateStatus(TestStatus.Unknown); const configService = serviceContainer.get(IConfigurationService); this.settings = configService.getSettings(this.rootDirectory ? Uri.file(this.rootDirectory) : undefined); const disposables = serviceContainer.get(IDisposableRegistry); @@ -70,6 +71,9 @@ export abstract class BaseTestManager implements ITestManager { public get status(): TestStatus { return this._status; } + public get onDidStatusChange(): Event { + return this._onDidStatusChange.event; + } public get workingDirectory(): string { return this.settings.unitTest.cwd && this.settings.unitTest.cwd.length > 0 ? this.settings.unitTest.cwd : this.rootDirectory; } @@ -82,8 +86,8 @@ export abstract class BaseTestManager implements ITestManager { } } public reset() { - this._status = TestStatus.Unknown; this.tests = undefined; + this.updateStatus(TestStatus.Unknown); } public resetTestResults() { if (!this.tests) { @@ -98,11 +102,10 @@ export abstract class BaseTestManager implements ITestManager { } if (!ignoreCache && this.tests! && this.tests!.testFunctions.length > 0) { - this._status = TestStatus.Idle; + this.updateStatus(TestStatus.Idle); return Promise.resolve(this.tests!); } - this._status = TestStatus.Discovering; - + this.updateStatus(TestStatus.Discovering); // If ignoreCache is true, its an indication of the fact that its a user invoked operation. // Hence we can stop the debugger. if (userInitiated) { @@ -121,8 +124,8 @@ export abstract class BaseTestManager implements ITestManager { return discoveryService.discoverTests(discoveryOptions) .then(tests => { this.tests = tests; - this._status = TestStatus.Idle; this.resetTestResults(); + this.updateStatus(TestStatus.Idle); this.discoverTestsPromise = undefined; // have errors in Discovering @@ -155,11 +158,11 @@ export abstract class BaseTestManager implements ITestManager { this.discoverTestsPromise = undefined; if (this.testDiscoveryCancellationToken && this.testDiscoveryCancellationToken.isCancellationRequested) { reason = CANCELLATION_REASON; - this._status = TestStatus.Idle; + this.updateStatus(TestStatus.Idle); } else { telementryProperties.failed = true; sendTelemetryEvent(EventName.UNITTEST_DISCOVER, undefined, telementryProperties); - this._status = TestStatus.Error; + this.updateStatus(TestStatus.Error); this.outputChannel.appendLine('Test Discovery failed: '); // tslint:disable-next-line:prefer-template this.outputChannel.appendLine(reason.toString()); @@ -211,7 +214,7 @@ export abstract class BaseTestManager implements ITestManager { this.resetTestResults(); } - this._status = TestStatus.Running; + this.updateStatus(TestStatus.Running); if (this.testRunnerCancellationTokenSource) { this.testRunnerCancellationTokenSource.cancel(); } @@ -235,16 +238,16 @@ export abstract class BaseTestManager implements ITestManager { this.createCancellationToken(CancellationTokenType.testRunner); return this.runTestImpl(tests, testsToRun, runFailedTests, debug); }).then(() => { - this._status = TestStatus.Idle; + this.updateStatus(TestStatus.Idle); this.disposeCancellationToken(CancellationTokenType.testRunner); sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, telementryProperties); return this.tests!; }).catch(reason => { if (this.testRunnerCancellationToken && this.testRunnerCancellationToken.isCancellationRequested) { reason = CANCELLATION_REASON; - this._status = TestStatus.Idle; + this.updateStatus(TestStatus.Idle); } else { - this._status = TestStatus.Error; + this.updateStatus(TestStatus.Error); telementryProperties.failed = true; sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, telementryProperties); } @@ -291,6 +294,15 @@ export abstract class BaseTestManager implements ITestManager { // tslint:disable-next-line:no-any protected abstract runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise; protected abstract getDiscoveryOptions(ignoreCache: boolean): TestDiscoveryOptions; + private updateStatus(status: TestStatus): void { + if (status === this._status) { + return; + } + this._status = status; + // Fire after 1ms, let existing code run to completion, + // We need to allow for code to get into a consistent state. + setTimeout(() => this._onDidStatusChange.fire(status), 1); + } private createCancellationToken(tokenType: CancellationTokenType) { this.disposeCancellationToken(tokenType); if (tokenType === CancellationTokenType.testDiscovery) { diff --git a/src/client/unittests/common/services/storageService.ts b/src/client/unittests/common/services/storageService.ts index a9602a4908a4..8a3bea8b5af6 100644 --- a/src/client/unittests/common/services/storageService.ts +++ b/src/client/unittests/common/services/storageService.ts @@ -1,21 +1,15 @@ import { inject, injectable } from 'inversify'; -import { - Disposable, Event, EventEmitter, - Uri, workspace -} from 'vscode'; +import { Disposable, Uri, workspace } from 'vscode'; import { IDisposableRegistry } from '../../../common/types'; import { FlattenedTestFunction, FlattenedTestSuite, ITestCollectionStorageService, TestFunction, Tests, TestSuite } from './../types'; @injectable() export class TestCollectionStorageService implements ITestCollectionStorageService { - public readonly onUpdated: Event; private testsIndexedByWorkspaceUri = new Map(); - private _onTestStoreUpdated: EventEmitter = new EventEmitter(); constructor(@inject(IDisposableRegistry) disposables: Disposable[]) { disposables.push(this); - this.onUpdated = this._onTestStoreUpdated.event; } public getTests(wkspace: Uri): Tests | undefined { const workspaceFolder = this.getWorkspaceFolderPath(wkspace) || ''; @@ -24,7 +18,6 @@ export class TestCollectionStorageService implements ITestCollectionStorageServi public storeTests(wkspace: Uri, tests: Tests | undefined): void { const workspaceFolder = this.getWorkspaceFolderPath(wkspace) || ''; this.testsIndexedByWorkspaceUri.set(workspaceFolder, tests); - this._onTestStoreUpdated.fire(wkspace); } public findFlattendTestFunction(resource: Uri, func: TestFunction): FlattenedTestFunction | undefined { const tests = this.getTests(resource); @@ -42,7 +35,6 @@ export class TestCollectionStorageService implements ITestCollectionStorageServi } public dispose() { this.testsIndexedByWorkspaceUri.clear(); - this._onTestStoreUpdated.dispose(); } private getWorkspaceFolderPath(resource: Uri): string | undefined { const folder = workspace.getWorkspaceFolder(resource); diff --git a/src/client/unittests/common/services/workspaceTestManagerService.ts b/src/client/unittests/common/services/workspaceTestManagerService.ts index 443c0614bf1f..779ef9351f53 100644 --- a/src/client/unittests/common/services/workspaceTestManagerService.ts +++ b/src/client/unittests/common/services/workspaceTestManagerService.ts @@ -7,7 +7,7 @@ import { ITestManager, ITestManagerService, ITestManagerServiceFactory, IWorkspa @injectable() export class WorkspaceTestManagerService implements IWorkspaceTestManagerService, Disposable { private workspaceTestManagers = new Map(); - constructor( @inject(IOutputChannel) @named(TEST_OUTPUT_CHANNEL) private outChannel: OutputChannel, + constructor(@inject(IOutputChannel) @named(TEST_OUTPUT_CHANNEL) private outChannel: OutputChannel, @inject(ITestManagerServiceFactory) private testManagerServiceFactory: ITestManagerServiceFactory, @inject(IDisposableRegistry) disposables: Disposable[]) { disposables.push(this); diff --git a/src/client/unittests/common/testUtils.ts b/src/client/unittests/common/testUtils.ts index feb4d7c9be50..337042d7c057 100644 --- a/src/client/unittests/common/testUtils.ts +++ b/src/client/unittests/common/testUtils.ts @@ -7,7 +7,7 @@ import { IUnitTestSettings, Product } from '../../common/types'; import { IServiceContainer } from '../../ioc/types'; import { CommandSource } from './constants'; import { TestFlatteningVisitor } from './testVisitors/flatteningVisitor'; -import { ITestsHelper, ITestVisitor, TestFile, TestFolder, TestProvider, Tests, TestSettingsPropertyNames, TestsToRun, UnitTestProduct } from './types'; +import { ITestsHelper, ITestVisitor, TestFile, TestFolder, TestFunction, TestProvider, Tests, TestSettingsPropertyNames, TestsToRun, TestSuite, UnitTestProduct } from './types'; export async function selectTestWorkspace(): Promise { if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length === 0) { @@ -206,4 +206,22 @@ export class TestsHelper implements ITestsHelper { return true; } + public getTestFile(test: TestFile | TestFolder | TestSuite | TestFunction): TestFile | undefined { + // Only TestFile has a `fullPath` property. + return Array.isArray((test as TestFile).fullPath) ? test as TestFile : undefined; + } + public getTestSuite(test: TestFile | TestFolder | TestSuite | TestFunction): TestSuite | undefined { + // Only TestSuite has a `suites` property. + return Array.isArray((test as TestSuite).suites) ? test as TestSuite : undefined; + } + public getTestFolder(test: TestFile | TestFolder | TestSuite | TestFunction): TestFolder | undefined { + // Only TestFolder has a `folders` property. + return Array.isArray((test as TestFolder).folders) ? test as TestFolder : undefined; + } + public getTestFunction(test: TestFile | TestFolder | TestSuite | TestFunction): TestFunction | undefined { + if (this.getTestFile(test) || this.getTestSuite(test) || this.getTestSuite(test)) { + return; + } + return test as TestFunction; + } } diff --git a/src/client/unittests/common/types.ts b/src/client/unittests/common/types.ts index dab75d0f160c..52f6bd64d6d2 100644 --- a/src/client/unittests/common/types.ts +++ b/src/client/unittests/common/types.ts @@ -1,7 +1,4 @@ -import { - CancellationToken, DiagnosticCollection, Disposable, - Event, OutputChannel, Uri -} from 'vscode'; +import { CancellationToken, DiagnosticCollection, Disposable, Event, OutputChannel, Uri } from 'vscode'; import { IUnitTestSettings, Product } from '../../common/types'; import { IPythonUnitTestMessage } from '../types'; import { CommandSource } from './constants'; @@ -37,7 +34,12 @@ export type TestFolder = TestResult & { status?: TestStatus; folders: TestFolder[]; }; - +export enum TestType { + testFile = 'testFile', + testFolder = 'testFolder', + testSuite = 'testSuite', + testFunction = 'testFunction' +} export type TestFile = TestResult & { name: string; fullPath: string; @@ -184,7 +186,6 @@ export interface ITestVisitor { export const ITestCollectionStorageService = Symbol('ITestCollectionStorageService'); export interface ITestCollectionStorageService extends Disposable { - onUpdated: Event; getTests(wkspace: Uri): Tests | undefined; storeTests(wkspace: Uri, tests: Tests | null | undefined): void; findFlattendTestFunction(resource: Uri, func: TestFunction): FlattenedTestFunction | undefined; @@ -232,6 +233,7 @@ export interface ITestManager extends Disposable { readonly workingDirectory: string; readonly workspaceFolder: Uri; diagnosticCollection: DiagnosticCollection; + readonly onDidStatusChange: Event; stop(): void; resetTestResults(): void; discoverTests(cmdSource: CommandSource, ignoreCache?: boolean, quietMode?: boolean, userInitiated?: boolean): Promise; diff --git a/src/client/unittests/main.ts b/src/client/unittests/main.ts index 3e603b12aa59..14e9e86aba61 100644 --- a/src/client/unittests/main.ts +++ b/src/client/unittests/main.ts @@ -5,8 +5,8 @@ import { inject, injectable } from 'inversify'; import { ConfigurationChangeEvent, Disposable, - DocumentSymbolProvider, EventEmitter, - OutputChannel, TextDocument, Uri, window + DocumentSymbolProvider, Event, + EventEmitter, OutputChannel, TextDocument, Uri, window } from 'vscode'; import { ICommandManager, IDocumentManager, IWorkspaceService @@ -26,15 +26,8 @@ import { CANCELLATION_REASON, CommandSource, TEST_OUTPUT_CHANNEL } from './common/constants'; import { selectTestWorkspace } from './common/testUtils'; -import { - ITestCollectionStorageService, ITestManager, - IWorkspaceTestManagerService, TestFile, - TestFunction, TestStatus, TestsToRun -} from './common/types'; -import { - ITestDisplay, ITestResultDisplay, - IUnitTestConfigurationService, IUnitTestManagementService -} from './types'; +import { ITestCollectionStorageService, ITestManager, IWorkspaceTestManagerService, TestFile, TestFunction, TestStatus, TestsToRun } from './common/types'; +import { ITestDisplay, ITestResultDisplay, IUnitTestConfigurationService, IUnitTestManagementService, WorkspaceTestStatus } from './types'; @injectable() export class UnitTestManagementService implements IUnitTestManagementService, Disposable { @@ -46,7 +39,8 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di private testResultDisplay?: ITestResultDisplay; private autoDiscoverTimer?: NodeJS.Timer; private configChangedTimer?: NodeJS.Timer; - private readonly onDidChange: EventEmitter = new EventEmitter(); + private testManagers = new Set(); + private readonly _onDidStatusChange: EventEmitter = new EventEmitter(); constructor(@inject(IServiceContainer) private serviceContainer: IServiceContainer) { this.disposableRegistry = serviceContainer.get(IDisposableRegistry); @@ -61,6 +55,9 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di this.workspaceTestManagerService.dispose(); } } + public get onDidStatusChange(): Event { + return this._onDidStatusChange.event; + } public async activate(symbolProvider: DocumentSymbolProvider): Promise { this.workspaceTestManagerService = this.serviceContainer.get(IWorkspaceTestManagerService); const disposablesRegistry = this.serviceContainer.get(IDisposableRegistry); @@ -78,11 +75,6 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di await this.registerSymbolProvider(symbolProvider); } - public async activateCodeLenses(symbolProvider: DocumentSymbolProvider): Promise { - const testCollectionStorage = this.serviceContainer.get(ITestCollectionStorageService); - this.disposableRegistry.push(activateCodeLenses(this.onDidChange, symbolProvider, testCollectionStorage)); - } - public async getTestManager(displayTestNotConfiguredMessage: boolean, resource?: Uri): Promise { let wkspace: Uri | undefined; if (resource) { @@ -96,6 +88,13 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di } const testManager = this.workspaceTestManagerService!.getTestManager(wkspace); if (testManager) { + if (!this.testManagers.has(testManager)) { + this.testManagers.add(testManager); + const handler = testManager.onDidStatusChange(e => { + this._onDidStatusChange.fire({ workspace: testManager.workspaceFolder, status: e }); + }); + this.disposableRegistry.push(handler); + } return testManager; } if (displayTestNotConfiguredMessage) { @@ -177,7 +176,6 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di if (!this.testResultDisplay) { this.testResultDisplay = this.serviceContainer.get(ITestResultDisplay); - this.testResultDisplay.onDidChange(() => this.onDidChange.fire()); } const discoveryPromise = testManager.discoverTests(cmdSource, ignoreCache, quietMode, userInitiated); this.testResultDisplay.displayDiscoverStatus(discoveryPromise, quietMode) @@ -295,7 +293,6 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di if (!this.testResultDisplay) { this.testResultDisplay = this.serviceContainer.get(ITestResultDisplay); - this.testResultDisplay.onDidChange(() => this.onDidChange.fire()); } const promise = testManager.runTest(cmdSource, testsToRun, runFailedTests, debug) @@ -312,7 +309,15 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di private async registerSymbolProvider(symbolProvider: DocumentSymbolProvider): Promise { const testCollectionStorage = this.serviceContainer.get(ITestCollectionStorageService); - this.disposableRegistry.push(activateCodeLenses(this.onDidChange, symbolProvider, testCollectionStorage)); + const event = new EventEmitter(); + this.disposableRegistry.push(event); + const handler = this._onDidStatusChange.event(e => { + if (e.status !== TestStatus.Discovering && e.status !== TestStatus.Running) { + event.fire(); + } + }); + this.disposableRegistry.push(handler); + this.disposableRegistry.push(activateCodeLenses(event, symbolProvider, testCollectionStorage)); } @captureTelemetry(EventName.UNITTEST_CONFIGURE, undefined, false) @@ -339,13 +344,13 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di // Ignore the exceptions returned. // This command will be invoked from other places of the extension. this.discoverTests(cmdSource, resource, true, true) - .ignoreErrors(); + .ignoreErrors(); }), commandManager.registerCommand(constants.Commands.Tests_Configure, (_, cmdSource: CommandSource = CommandSource.commandPalette, resource?: Uri) => { // Ignore the exceptions returned. // This command will be invoked from other places of the extension. this.configureTests(resource) - .ignoreErrors(); + .ignoreErrors(); }), commandManager.registerCommand(constants.Commands.Tests_Run_Failed, (_, cmdSource: CommandSource = CommandSource.commandPalette, resource: Uri) => this.runTestsImpl(cmdSource, resource, undefined, true)), commandManager.registerCommand(constants.Commands.Tests_Run, (_, cmdSource: CommandSource = CommandSource.commandPalette, file: Uri, testToRun?: TestsToRun) => this.runTestsImpl(cmdSource, file, testToRun)), diff --git a/src/client/unittests/providers/testTreeViewProvider.ts b/src/client/unittests/providers/testTreeViewProvider.ts index 4013d4bd7b34..49d0494f8f40 100644 --- a/src/client/unittests/providers/testTreeViewProvider.ts +++ b/src/client/unittests/providers/testTreeViewProvider.ts @@ -16,6 +16,7 @@ import { ITestTreeViewProvider } from '../../providers/types'; import { ITestCollectionStorageService, TestFolder, Tests, TestStatus } from '../common/types'; +import { IUnitTestManagementService, WorkspaceTestStatus } from '../types'; import { TestTreeItem, TestTreeItemType } from './testTreeViewItem'; @@ -27,6 +28,7 @@ export class TestTreeViewProvider implements ITestTreeViewProvider, IDisposable * To signal that root has changed, do not pass any argument or pass `undefined` or `null`. */ public readonly onDidChangeTreeData: Event; + private testsAreBeingDiscovered: boolean = false; private _onDidChangeTreeData: EventEmitter = new EventEmitter(); private root: TestTreeItem[]; @@ -34,14 +36,17 @@ export class TestTreeViewProvider implements ITestTreeViewProvider, IDisposable constructor( @inject(ITestCollectionStorageService) private testStore: ITestCollectionStorageService, + @inject(IUnitTestManagementService) private testService: IUnitTestManagementService, @inject(IWorkspaceService) private workspace: IWorkspaceService, @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry ) { this.onDidChangeTreeData = this._onDidChangeTreeData.event; this.root = [new TestTreeItem(TestTreeItemType.Root, undefined, undefined, '*', 'no tests discovered yet', TestStatus.Unknown, undefined)]; - this.refresh(this.workspace.workspaceFolders[0].uri); + if (this.workspace.workspaceFolders.length > 0) { + this.refresh(this.workspace.workspaceFolders[0].uri); + } disposableRegistry.push(this); - this.disposables.push(this.testStore.onUpdated(this.onTestStoreUpdated, this)); + this.disposables.push(this.testService.onDidStatusChange(this.onTestStatusChanged, this)); } // tslint:disable-next-line:no-empty @@ -109,4 +114,15 @@ export class TestTreeViewProvider implements ITestTreeViewProvider, IDisposable private onTestStoreUpdated(workspace: Uri): void { this.refresh(workspace); } + + private onTestStatusChanged(e: WorkspaceTestStatus) { + if (e.status === TestStatus.Discovering) { + this.testsAreBeingDiscovered = true; + return; + } + if (this.testsAreBeingDiscovered) { + this.testsAreBeingDiscovered = false; + this.onTestStoreUpdated(e.workspace); + } + } } diff --git a/src/client/unittests/types.ts b/src/client/unittests/types.ts index e595a1577b3a..91e7bc8d4392 100644 --- a/src/client/unittests/types.ts +++ b/src/client/unittests/types.ts @@ -36,6 +36,7 @@ export interface ITestDisplay { export const IUnitTestManagementService = Symbol('IUnitTestManagementService'); export interface IUnitTestManagementService { + readonly onDidStatusChange: Event; activate(symbolProvider: DocumentSymbolProvider): Promise; getTestManager(displayTestNotConfiguredMessage: boolean, resource?: Uri): Promise; discoverTestsForDocument(doc: TextDocument): Promise; @@ -136,3 +137,5 @@ export interface ILocationStackFrameDetails { location: Location; lineText: string; } + +export type WorkspaceTestStatus = { workspace: Uri; status: TestStatus };