Skip to content
3 changes: 3 additions & 0 deletions extension/loc/xlf/aspire-vscode.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@
{
"view": "aspire-vscode.appHosts",
"contents": "%views.appHosts.errorWelcome%",
"when": "aspire.fetchAppHostsError && !aspire.loading"
"when": "aspire.fetchAppHostsError && aspire.fetchAppHostsCompatibilityError && !aspire.loading"
},
{
"view": "aspire-vscode.appHosts",
"contents": "%views.appHosts.genericErrorWelcome%",
"when": "aspire.fetchAppHostsError && !aspire.fetchAppHostsCompatibilityError && !aspire.loading"
Comment thread
ellahathaway marked this conversation as resolved.
}
],
"debuggers": [
Expand Down
1 change: 1 addition & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"views.appHosts.welcome": "No Aspire AppHosts detected in this workspace.\n[Refresh](command:aspire-vscode.refreshAppHosts)",
"views.appHosts.globalWelcome": "No running Aspire AppHosts detected on this machine.\n[Refresh](command:aspire-vscode.globalRefreshAppHosts)",
"views.appHosts.errorWelcome": "The Aspire panel workspace view requires Aspire CLI 13.2.0 or newer and an AppHost that references Aspire.Hosting 13.2.0 or newer. Install or update the Aspire CLI and update your AppHost package to get started.\n[Update Aspire CLI](command:aspire-vscode.updateSelf)\n[Refresh](command:aspire-vscode.globalRefreshAppHosts)",
"views.appHosts.genericErrorWelcome": "The Aspire panel couldn't load AppHost data right now. See Aspire Extension output for details.\n[Refresh](command:aspire-vscode.globalRefreshAppHosts)",
Comment thread
ellahathaway marked this conversation as resolved.
"command.refreshAppHosts": "Refresh AppHosts",
"command.globalRefreshAppHosts": "Refresh running AppHosts",
"command.openDashboard": "Open Aspire Dashboard",
Expand Down
3 changes: 2 additions & 1 deletion extension/src/test-e2e/packageSurface.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,5 +548,6 @@ const expectedWelcomeWhenClauses = [
'aspire.loading',
'aspire.noAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode != \'global\'',
'aspire.noAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode == \'global\'',
'aspire.fetchAppHostsError && !aspire.loading',
'aspire.fetchAppHostsError && aspire.fetchAppHostsCompatibilityError && !aspire.loading',
'aspire.fetchAppHostsError && !aspire.fetchAppHostsCompatibilityError && !aspire.loading',
];
59 changes: 43 additions & 16 deletions extension/src/test/appHostDataRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,24 +459,32 @@ suite('AppHostDataRepository', () => {
});

test('describe watch reports minimum CLI version when command help is returned', async () => {
const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined);
const repository = new AppHostDataRepository(terminalProvider);

repository.activate();
repository.setPanelVisible(true);
await waitForMicrotasks();
try {
repository.activate();
repository.setPanelVisible(true);
await waitForMicrotasks();

const lineCallback = spawnStub.firstCall.args[3].lineCallback;
const exitCallback = spawnStub.firstCall.args[3].exitCallback;
lineCallback('Description:');
lineCallback('Usage:');
lineCallback('aspire [command] [options]');
lineCallback('Commands:');
exitCallback(1);
const lineCallback = spawnStub.firstCall.args[3].lineCallback;
const exitCallback = spawnStub.firstCall.args[3].exitCallback;
lineCallback('Description:');
lineCallback('Usage:');
lineCallback('aspire [command] [options]');
lineCallback('Commands:');
exitCallback(1);

assert.strictEqual(repository.hasError, true);
assert.ok(repository.errorMessage?.includes('Aspire CLI 13.2.0'), repository.errorMessage);
assert.strictEqual(repository.hasError, true);
assert.ok(repository.errorMessage?.includes('Aspire CLI 13.2.0'), repository.errorMessage);

repository.dispose();
const compatibilityContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsCompatibilityError');
assert.strictEqual(compatibilityContextCalls.at(-1)?.args[2], true);
} finally {
repository.dispose();
executeCommandStub.restore();
}
});

test('describe watch does not report compatibility error when workspace AppHost returns no data successfully', async () => {
Expand Down Expand Up @@ -520,7 +528,7 @@ suite('AppHostDataRepository', () => {
}
});

test('describe watch reports minimum AppHost version when workspace AppHost exits without unsupported command output', async () => {
test('describe watch reports generic error when workspace AppHost exits with runtime failure', async () => {
let getAppHostsLineCallback: ((line: string) => void) | undefined;
spawnStub.onFirstCall().callsFake((_terminalProvider, _command, _args, options) => {
getAppHostsLineCallback = createLsLineCallback(options);
Expand All @@ -532,6 +540,7 @@ suite('AppHostDataRepository', () => {
name: 'workspace',
index: 0,
}]);
const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined);
const repository = new AppHostDataRepository(terminalProvider);

try {
Expand All @@ -550,14 +559,20 @@ suite('AppHostDataRepository', () => {

const describeCall = spawnStub.getCalls().find(call => (call.args[2] as string[])[0] === 'describe');
assert.ok(describeCall);
const stderrCallback = describeCall.args[3].stderrCallback;
const exitCallback = describeCall.args[3].exitCallback;
stderrCallback('No container runtime detected');
exitCallback(1);

assert.strictEqual(repository.hasError, true);
assert.ok(repository.errorMessage?.includes('Aspire.Hosting 13.2.0'), repository.errorMessage);
assert.ok(!repository.errorMessage?.includes('Aspire CLI 13.2.0'), repository.errorMessage);
assert.ok(repository.errorMessage?.includes('No container runtime detected'), repository.errorMessage);

const compatibilityContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsCompatibilityError');
assert.strictEqual(compatibilityContextCalls.at(-1)?.args[2], false);
} finally {
repository.dispose();
executeCommandStub.restore();
workspaceFoldersStub.restore();
}
});
Expand Down Expand Up @@ -2254,6 +2269,10 @@ suite('AppHostDataRepository', () => {
const errorContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsError');
assert.strictEqual(errorContextCalls.at(-1)?.args[2], true);

const compatibilityContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsCompatibilityError');
assert.strictEqual(compatibilityContextCalls.at(-1)?.args[2], false);
} finally {
repository.dispose();
executeCommandStub.restore();
Expand Down Expand Up @@ -2299,6 +2318,10 @@ suite('AppHostDataRepository', () => {
const errorContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsError');
assert.strictEqual(errorContextCalls.at(-1)?.args[2], true);

const compatibilityContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsCompatibilityError');
assert.strictEqual(compatibilityContextCalls.at(-1)?.args[2], false);
} finally {
repository.dispose();
executeCommandStub.restore();
Expand Down Expand Up @@ -2333,6 +2356,10 @@ suite('AppHostDataRepository', () => {
const errorContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsError');
assert.strictEqual(errorContextCalls.at(-1)?.args[2], true);

const compatibilityContextCalls = executeCommandStub.getCalls().filter(call =>
call.args[0] === 'setContext' && call.args[1] === 'aspire.fetchAppHostsCompatibilityError');
assert.strictEqual(compatibilityContextCalls.at(-1)?.args[2], false);
} finally {
repository.dispose();
executeCommandStub.restore();
Expand Down
4 changes: 4 additions & 0 deletions extension/src/test/packageManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,13 @@ suite('extension/package.json', () => {

const workspaceWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.appHosts.welcome%');
const globalWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.appHosts.globalWelcome%');
const compatibilityErrorWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.appHosts.errorWelcome%');
const genericErrorWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.appHosts.genericErrorWelcome%');

assertContains(workspaceWelcome?.when, "aspire.viewMode != 'global'");
assertContains(globalWelcome?.when, "aspire.viewMode == 'global'");
assertContains(compatibilityErrorWelcome?.when, 'aspire.fetchAppHostsCompatibilityError');
assertContains(genericErrorWelcome?.when, '!aspire.fetchAppHostsCompatibilityError');
});

test('running apphosts title actions use string view and view mode checks', () => {
Expand Down
59 changes: 47 additions & 12 deletions extension/src/views/AppHostDataRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ interface GlobalDescribeStream {
version: number;
}

interface DescribeNoDataError {
message: string | undefined;
isCompatibilityError: boolean;
}

interface PostStopRefreshTimer {
timer: ReturnType<typeof setTimeout>;
}
Expand Down Expand Up @@ -239,8 +244,10 @@ export class AppHostDataRepository {

// ── Error state ──
private _describeErrorMessage: string | undefined;
private _describeErrorIsCompatibility = false;
private _psErrorMessage: string | undefined;
private _errorMessage: string | undefined;
private _errorIsCompatibility = false;

// ── Loading state ──
private _loadingWorkspace = true;
Expand Down Expand Up @@ -863,7 +870,8 @@ export class AppHostDataRepository {

extensionLogOutputChannel.warn(`aspire describe --follow exited (code ${code}) without producing data; not auto-restarting.`);
this._workspaceResources.clear();
this._setDescribeError(this._getDescribeNoDataError(code, describeNonJsonLines, describeStderr));
const noDataError = this._getDescribeNoDataError(code, describeNonJsonLines, describeStderr);
this._setDescribeError(noDataError.message, { compatibility: noDataError.isCompatibilityError });
this._updateWorkspaceContext({ clearLoading: true });
return;
}
Expand Down Expand Up @@ -987,19 +995,35 @@ export class AppHostDataRepository {
return false;
}

private _getDescribeNoDataError(exitCode: number | null, nonJsonLines: readonly string[], stderr: string): string | undefined {
private _getDescribeNoDataError(exitCode: number | null, nonJsonLines: readonly string[], stderr: string): DescribeNoDataError {
if (isDescribeUnsupportedOutput(nonJsonLines, stderr)) {
return aspireCliDescribeNotSupported(aspireDescribeMinimumVersion);
return {
message: aspireCliDescribeNotSupported(aspireDescribeMinimumVersion),
isCompatibilityError: true,
};
}

if (this._workspaceAppHostPath && exitCode !== 0) {
return {
message: errorFetchingAppHosts(stderr || `exit code ${exitCode ?? 1}`),
isCompatibilityError: false,
};
}

// A clean exit before `ps` observes the AppHost can happen while the app is still starting.
// Once `ps` reports the workspace AppHost as running, an empty describe stream means the
// AppHost cannot serve workspace resources even if the CLI process exits successfully.
if (this._workspaceAppHostPath && (exitCode !== 0 || this._workspaceAppHost !== undefined)) {
return appHostDescribeMayNotBeSupported(aspireDescribeMinimumVersion);
// Once `ps` reports the workspace AppHost as running, an empty successful describe stream means
// the AppHost cannot serve workspace resources even though the CLI command itself was accepted.
if (this._workspaceAppHostPath && this._workspaceAppHost !== undefined) {
return {
message: appHostDescribeMayNotBeSupported(aspireDescribeMinimumVersion),
isCompatibilityError: true,
};
}

return undefined;
return {
message: undefined,
isCompatibilityError: false,
};
}

// ── Global mode: per-AppHost describe fan-out ──
Expand Down Expand Up @@ -1530,13 +1554,16 @@ export class AppHostDataRepository {

private _clearErrors(): void {
this._describeErrorMessage = undefined;
this._describeErrorIsCompatibility = false;
this._psErrorMessage = undefined;
this._updateErrorMessage();
}

private _setDescribeError(message: string | undefined): void {
if (this._describeErrorMessage !== message) {
private _setDescribeError(message: string | undefined, options?: { compatibility?: boolean }): void {
const compatibility = message !== undefined && (options?.compatibility ?? false);
if (this._describeErrorMessage !== message || this._describeErrorIsCompatibility !== compatibility) {
this._describeErrorMessage = message;
this._describeErrorIsCompatibility = compatibility;
this._updateErrorMessage();
}
}
Expand All @@ -1549,16 +1576,24 @@ export class AppHostDataRepository {
}

private _updateErrorMessage(): void {
const message = this._viewMode === 'workspace'
const workspaceMode = this._viewMode === 'workspace';
const message = workspaceMode
? this._describeErrorMessage ?? this._psErrorMessage
: this._psErrorMessage;
const isCompatibilityError = workspaceMode
? (this._describeErrorMessage !== undefined
? this._describeErrorIsCompatibility
: false)
: false;
const hasError = message !== undefined;
if (this._errorMessage !== message) {
if (this._errorMessage !== message || this._errorIsCompatibility !== isCompatibilityError) {
this._errorMessage = message;
this._errorIsCompatibility = isCompatibilityError;
if (message) {
extensionLogOutputChannel.warn(message);
}
vscode.commands.executeCommand('setContext', 'aspire.fetchAppHostsError', hasError);
vscode.commands.executeCommand('setContext', 'aspire.fetchAppHostsCompatibilityError', hasError && isCompatibilityError);
this._onDidChangeData.fire();
}
}
Expand Down
Loading