Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import {
DotnetAcquisitionCompleted,
DotnetAcquisitionFinalError,
DotnetAcquisitionStarted,
DotnetInstallExpectedAbort,
} from './EventStreamEvents';
import { EventType } from './EventType';
import { IEvent } from './IEvent';
import { IEventStreamObserver } from './IEventStreamObserver';
Expand All @@ -14,17 +20,40 @@ enum StatusBarColors {
}

export class StatusBarObserver implements IEventStreamObserver {
// Use a Set so duplicate start events for the same installId (possible in the global SDK path,
// where DotnetAcquisitionStarted fires before the inner installer lock) are naturally deduplicated.
private readonly inProgressDownloads: Set<string> = new Set<string>();

constructor(private readonly statusBarItem: vscode.StatusBarItem, private readonly showLogCommand: string) {
}

public post(event: IEvent): void {
switch (event.type) {
case EventType.DotnetAcquisitionStart:
const acquisitionStarted = event as DotnetAcquisitionStarted;
this.inProgressDownloads.add(acquisitionStarted.install.installId);
this.setAndShowStatusBar('$(cloud-download) Downloading .NET...', this.showLogCommand, '', 'Downloading .NET...');
break;
case EventType.DotnetAcquisitionCompleted:
const acquisitionCompleted = event as DotnetAcquisitionCompleted;
this.removeFromInProgress(acquisitionCompleted.install.installId);
if (this.inProgressDownloads.size === 0) {
this.resetAndHideStatusBar();
}
break;
case EventType.DotnetInstallExpectedAbort:
this.resetAndHideStatusBar();
const abortEvent = event as DotnetInstallExpectedAbort;
this.removeFromInProgress(abortEvent.install?.installId);
if (this.inProgressDownloads.size === 0) {
this.resetAndHideStatusBar();
}
break;
case EventType.DotnetAcquisitionFinalError:
const finalError = event as DotnetAcquisitionFinalError;
this.removeFromInProgress(finalError.install?.installId);
if (this.inProgressDownloads.size === 0) {
this.resetAndHideStatusBar();
}
break;
case EventType.DotnetAcquisitionError:
this.setAndShowStatusBar('$(alert) Error acquiring .NET!', this.showLogCommand, StatusBarColors.Red, 'Error acquiring .NET');
Expand Down Expand Up @@ -52,4 +81,11 @@ export class StatusBarObserver implements IEventStreamObserver {
this.statusBarItem.tooltip = undefined;
this.statusBarItem.hide();
}

private removeFromInProgress(installId: string | null | undefined): void {
if (installId)
{
Comment on lines +86 to +87
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeFromInProgress uses a different brace style than the rest of this file (and the surrounding code in this method), which is likely to trip formatting/lint rules or at least makes the style inconsistent. Consider formatting the if block consistently with the file’s existing style.

Suggested change
if (installId)
{
if (installId) {

Copilot uses AI. Check for mistakes.
this.inProgressDownloads.delete(installId);
}
}
}
193 changes: 193 additions & 0 deletions vscode-dotnet-runtime-library/src/test/unit/StatusBarObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*---------------------------------------------------------------------------------------------
* Licensed to the .NET Foundation under one or more agreements.
* The .NET Foundation licenses this file to you under the MIT license.
*--------------------------------------------------------------------------------------------*/
import * as chai from 'chai';
import { DotnetInstall } from '../../Acquisition/DotnetInstall';
import {
DotnetAcquisitionCompleted,
DotnetAcquisitionFinalError,
DotnetAcquisitionStarted,
DotnetInstallCancelledByUserError,
EventBasedError,
} from '../../EventStream/EventStreamEvents';
import { StatusBarObserver } from '../../EventStream/StatusBarObserver';

const assert = chai.assert;
const defaultTimeoutTime = 5000;

class MockStatusBarItem
{
public text = '';
public command: string | undefined = undefined;
public color: string | undefined = undefined;
public tooltip: string | undefined = undefined;
public isVisible = false;

public show(): void
{
this.isVisible = true;
}

public hide(): void
{
this.isVisible = false;
}
}

const makeInstall = (id: string): DotnetInstall => ({
version: id,
isGlobal: false,
architecture: 'x64',
installId: id,
installMode: 'runtime',
});

suite('StatusBarObserver Unit Tests', function ()
{
const showLogCommand = 'dotnet.showLog';
let mockStatusBarItem: MockStatusBarItem;
let observer: StatusBarObserver;

setup(() =>
{
mockStatusBarItem = new MockStatusBarItem();
// Cast to any to avoid needing the full vscode.StatusBarItem interface
// eslint-disable-next-line @typescript-eslint/no-explicit-any
observer = new StatusBarObserver(mockStatusBarItem as any, showLogCommand);
});

test('Status bar shows when acquisition starts', () =>
{
const install = makeInstall('8.0~x64');
observer.post(new DotnetAcquisitionStarted(install, 'test-ext'));

assert.isTrue(mockStatusBarItem.isVisible, 'Status bar should be visible after acquisition starts');
assert.include(mockStatusBarItem.text, 'Downloading .NET', 'Status bar should display downloading text');
}).timeout(defaultTimeoutTime);

test('Status bar hides when single acquisition completes', () =>
{
const install = makeInstall('8.0~x64');
observer.post(new DotnetAcquisitionStarted(install, 'test-ext'));
observer.post(new DotnetAcquisitionCompleted(install, '/path/to/dotnet', '8.0'));

assert.isFalse(mockStatusBarItem.isVisible, 'Status bar should be hidden after acquisition completes');
}).timeout(defaultTimeoutTime);

test('Status bar stays visible when one of multiple concurrent downloads completes', () =>
{
const installA = makeInstall('8.0~x64');
const installB = makeInstall('9.0~x64');

observer.post(new DotnetAcquisitionStarted(installA, 'test-ext'));
observer.post(new DotnetAcquisitionStarted(installB, 'test-ext'));

// Complete the first download - status bar should remain visible because B is still in progress
observer.post(new DotnetAcquisitionCompleted(installA, '/path/to/dotnet', '8.0'));

assert.isTrue(mockStatusBarItem.isVisible, 'Status bar should remain visible while other downloads are in progress');
}).timeout(defaultTimeoutTime);

test('Status bar hides only after all concurrent downloads complete', () =>
{
const installA = makeInstall('8.0~x64');
const installB = makeInstall('9.0~x64');

observer.post(new DotnetAcquisitionStarted(installA, 'test-ext'));
observer.post(new DotnetAcquisitionStarted(installB, 'test-ext'));

observer.post(new DotnetAcquisitionCompleted(installA, '/path/to/dotnet', '8.0'));
assert.isTrue(mockStatusBarItem.isVisible, 'Status bar should remain visible after first of two downloads completes');

observer.post(new DotnetAcquisitionCompleted(installB, '/path/to/dotnet', '9.0'));
assert.isFalse(mockStatusBarItem.isVisible, 'Status bar should hide after all downloads complete');
}).timeout(defaultTimeoutTime);

test('Status bar hides when acquisition is aborted', () =>
{
const install = makeInstall('8.0~x64');
observer.post(new DotnetAcquisitionStarted(install, 'test-ext'));

const abortError = new EventBasedError('TestAbort', 'Installation cancelled');
observer.post(new DotnetInstallCancelledByUserError(abortError, install));

assert.isFalse(mockStatusBarItem.isVisible, 'Status bar should hide when acquisition is aborted');
}).timeout(defaultTimeoutTime);

test('Status bar hides when final error occurs for single download', () =>
{
const install = makeInstall('8.0~x64');
observer.post(new DotnetAcquisitionStarted(install, 'test-ext'));

const finalError = new DotnetAcquisitionFinalError(new EventBasedError('TestError', 'Download failed'), 'TestEvent', install);
observer.post(finalError);

assert.isFalse(mockStatusBarItem.isVisible, 'Status bar should hide when acquisition fails with final error');
}).timeout(defaultTimeoutTime);

test('Status bar stays visible when one of multiple concurrent downloads fails with final error', () =>
{
const installA = makeInstall('8.0~x64');
const installB = makeInstall('9.0~x64');

observer.post(new DotnetAcquisitionStarted(installA, 'test-ext'));
observer.post(new DotnetAcquisitionStarted(installB, 'test-ext'));

const finalError = new DotnetAcquisitionFinalError(new EventBasedError('TestError', 'Download failed'), 'TestEvent', installA);
observer.post(finalError);

assert.isTrue(mockStatusBarItem.isVisible, 'Status bar should remain visible when one download fails but another is still in progress');
}).timeout(defaultTimeoutTime);

test('Status bar hides correctly when same installId is started twice (duplicate request)', () =>
{
// Simulates the global SDK path where DotnetAcquisitionStarted fires before the inner lock,
// so two extensions requesting the same version can each trigger a start event.
const install = makeInstall('8.0~x64');
observer.post(new DotnetAcquisitionStarted(install, 'ext-csharp'));
observer.post(new DotnetAcquisitionStarted(install, 'ext-csharpdk'));

Comment on lines +143 to +150
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests that aim to simulate “different extensions requesting the same version” are passing the extension id as the 2nd constructor argument to DotnetAcquisitionStarted, but that parameter is startingVersion (the requesting extension id is the 3rd parameter). This makes the scenario/comment misleading and could hide future regressions if the observer ever starts relying on requestingExtensionId.

Consider constructing DotnetAcquisitionStarted with both a realistic startingVersion (e.g., the version being acquired) and the requestingExtensionId as the 3rd argument, and/or updating the comment to match what the test actually does.

Copilot uses AI. Check for mistakes.
assert.isTrue(mockStatusBarItem.isVisible, 'Status bar should be visible after duplicate starts');

// Only one completion fires (the actual install)
observer.post(new DotnetAcquisitionCompleted(install, '/path/to/dotnet', '8.0'));

assert.isFalse(mockStatusBarItem.isVisible, 'Status bar should hide after completion even with duplicate starts');
}).timeout(defaultTimeoutTime);

test('Status bar handles completion for unknown installId gracefully', () =>
{
const installA = makeInstall('8.0~x64');
const installB = makeInstall('9.0~x64');

// Only start A
observer.post(new DotnetAcquisitionStarted(installA, 'test-ext'));

// Complete B (never started) - should not crash or affect status bar
observer.post(new DotnetAcquisitionCompleted(installB, '/path/to/dotnet', '9.0'));

assert.isTrue(mockStatusBarItem.isVisible, 'Status bar should remain visible since A is still in progress');
}).timeout(defaultTimeoutTime);

test('Status bar handles duplicate start with mixed completion and error', () =>
{
const install = makeInstall('8.0~x64');
const otherInstall = makeInstall('9.0~x64');

observer.post(new DotnetAcquisitionStarted(install, 'ext-a'));
observer.post(new DotnetAcquisitionStarted(install, 'ext-b'));
observer.post(new DotnetAcquisitionStarted(otherInstall, 'ext-c'));

// Error on the duplicate - should remove it from tracking
const finalError = new DotnetAcquisitionFinalError(new EventBasedError('TestError', 'Failed'), 'TestEvent', install);
observer.post(finalError);

assert.isTrue(mockStatusBarItem.isVisible, 'Status bar should remain visible since 9.0 is still in progress');

// Complete the other install
observer.post(new DotnetAcquisitionCompleted(otherInstall, '/path/to/dotnet', '9.0'));

assert.isFalse(mockStatusBarItem.isVisible, 'Status bar should hide after all unique installs complete');
}).timeout(defaultTimeoutTime);
});