Skip to content
Draft
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
6 changes: 4 additions & 2 deletions docs/specs/cli-output-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Most JSON output uses camel-case property names. Properties whose values are not
`aspire ls` lists candidate AppHost project files in the current workspace.

```bash
aspire ls [--all] [--format json] [--stream]
aspire ls [--all] [--format json] [--stream] [--no-evaluate]
```

By default, the command outputs a human-readable table. Use `--format json` for a stable JSON snapshot after discovery completes:
Expand Down Expand Up @@ -48,13 +48,15 @@ Stream output is emitted in arrival order from parallel discovery; lines are not

If discovery finds no AppHost candidates, the stream emits no lines. The stream does not emit `started`, `complete`, or `canceled` control records; use the command's exit code and end-of-file to detect stream completion.

Use `--no-evaluate` to keep discovery in strict non-inspecting mode for tooling and cold starts: it avoids fresh project inspection, uses known AppHost details when present, and otherwise reports best-effort matches from project naming and layout cues.

#### AppHost candidate fields

| Field | Applies to | Description |
| ----- | ---------- | ----------- |
| `path` | All candidates | Full path to the candidate AppHost project file. |
| `language` | All candidates | Detected AppHost language, such as `C#` or `TypeScript`. |
| `status` | All candidates | Candidate validation status, such as `buildable` or `possibly-unbuildable`. |
| `status` | All candidates | Candidate validation status: `buildable`, `possibly-unbuildable`, or `possibly-buildable`. |

### `aspire start` and `aspire run --detach`

Expand Down
2 changes: 1 addition & 1 deletion extension/scripts/run-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -1386,7 +1386,7 @@ function printFailureDiagnosticsSummary() {
hasError: state.state.hasError,
errorMessage: state.state.errorMessage,
workspaceAppHostPath: state.state.workspaceAppHostPath,
workspaceAppHostCandidatePaths: state.state.workspaceAppHostCandidatePaths,
workspaceAppHostCandidatePaths: state.state.workspaceAppHostCandidates.map(candidate => candidate.path),
workspaceResources: state.state.workspaceResources?.map(resource => `${resource.name}:${resource.state}`),
appHosts: state.state.appHosts?.map(appHost => appHost.appHostPath),
launchingPaths: state.state.launchingPaths,
Expand Down
6 changes: 5 additions & 1 deletion extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,8 @@ function createStateSnapshot(
workspaceAppHost: dataRepository.workspaceAppHost ? cloneAppHostState(dataRepository.workspaceAppHost, includeSensitiveDashboardUrls) : undefined,
workspaceAppHostName: dataRepository.workspaceAppHostName,
workspaceAppHostPath: dataRepository.workspaceAppHostPath,
workspaceAppHostCandidatePaths: [...dataRepository.workspaceAppHostCandidatePaths],
workspaceAppHostCandidatePaths: dataRepository.workspaceAppHostCandidates.map(candidate => candidate.path),
workspaceAppHostCandidates: dataRepository.workspaceAppHostCandidates.map(candidate => ({ ...candidate })),
workspaceAppHostDescription: dataRepository.workspaceAppHostDescription,
workspaceResources: dataRepository.workspaceResources.map(resource => cloneResourceState(resource, includeSensitiveDashboardUrls)),
appHosts: dataRepository.appHosts.map(appHost => cloneAppHostState(appHost, includeSensitiveDashboardUrls)),
Expand Down Expand Up @@ -628,6 +629,9 @@ function createE2eStateFileBridge(
if (typeof payload.suppressDebugLaunch === 'boolean') {
process.env.ASPIRE_EXTENSION_E2E_SUPPRESS_DEBUG_LAUNCH = payload.suppressDebugLaunch ? 'true' : 'false';
}
if (typeof payload.failDebugLaunch === 'boolean') {
process.env.ASPIRE_EXTENSION_E2E_FAIL_DEBUG_LAUNCH = payload.failDebugLaunch ? 'true' : 'false';
}
if (payload.showStatusDelayMs === null) {
delete process.env.ASPIRE_EXTENSION_E2E_SHOW_STATUS_DELAY_MS;
}
Expand Down
1 change: 1 addition & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const workspaceViewSelectedSingleAppHost = (language?: string) => languag
export const workspaceViewSelectedMultipleAppHosts = (count: number) => vscode.l10n.t('Workspace view selected because aspire ls found {0} buildable AppHosts.', count);
export const tooltipType = (type: string) => vscode.l10n.t('Type: {0}', type);
export const tooltipState = (state: string) => vscode.l10n.t('State: {0}', state);
export const tooltipStatus = (status: string) => vscode.l10n.t('Status: {0}', status);
export const tooltipHealth = (health: string) => vscode.l10n.t('Health: {0}', health);
export const tooltipEndpoints = vscode.l10n.t('Endpoints:');
export const healthChecksLabel = vscode.l10n.t('Health Checks');
Expand Down
16 changes: 16 additions & 0 deletions extension/src/services/AppHostLaunchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ export class AppHostLaunchService implements vscode.Disposable {
return;
}

// E2E-only: simulate a build/launch failure so tests can exercise the failure path
// (e.g. removing an AppHost that no longer builds) without needing a genuinely broken
// project on disk. Mirrors the suppress hook but throws instead of returning success.
const isFailureSimulated = isE2eDebugLaunchFailureSimulated();
if (isFailureSimulated) {
this.clearLaunching(appHostPath);
throw new Error(`Simulated build failure for ${vscode.workspace.asRelativePath(appHostPath)} (E2E).`);
}

try {
const started = await vscode.debug.startDebugging(undefined, config);
if (!started) {
Expand All @@ -162,6 +171,13 @@ function isE2eDebugLaunchSuppressed(): boolean {
process.env.ASPIRE_EXTENSION_E2E_SUPPRESS_DEBUG_LAUNCH === 'true';
}

function isE2eDebugLaunchFailureSimulated(): boolean {
return process.env.ASPIRE_EXTENSION_E2E_ENABLE_BRIDGE === 'true' &&
!!process.env.ASPIRE_EXTENSION_E2E_STATE_FILE &&
!!process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE &&
process.env.ASPIRE_EXTENSION_E2E_FAIL_DEBUG_LAUNCH === 'true';
}

function isMatchingAppHostPath(left: string, right: string): boolean {
const normalizedLeft = path.normalize(left);
const normalizedRight = path.normalize(right);
Expand Down
87 changes: 84 additions & 3 deletions extension/src/test-e2e/appHostTree.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as assert from 'assert';
import { getResources, getTerminalCommandCount, getTreeAppHostLabel, waitForCommandOutcome, waitForDashboardUrl, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures';
import { getResources, getTerminalCommandCount, getTreeAppHostLabel, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setDebugLaunchFailureForE2E, setE2eCliPathForE2E, setTerminalCommandExecutionSuppressedForE2E, stopPrimaryAppHostIfRunning, writeLsSequenceCliWrapper } from './helpers/fixtures';
import { getPrimaryAppHostProjectPath } from './helpers/paths';
import { cancelActiveInput, clickTreeItem, openAspireView, waitForTreeItem } from './helpers/vscode';

Expand All @@ -11,6 +11,7 @@ suite('Aspire AppHost tree E2E', function () {
await runE2eTeardown([
() => setCliUnavailableForE2E(false),
() => setTerminalCommandExecutionSuppressedForE2E(false),
() => setDebugLaunchFailureForE2E(false),
() => restoreWorkspaceCliPath(),
() => stopPrimaryAppHostIfRunning(),
() => waitForNoRunningAppHost().catch(() => undefined),
Expand All @@ -26,7 +27,7 @@ suite('Aspire AppHost tree E2E', function () {

const item = await waitForTreeItem(section, label);
assert.strictEqual(await item.getLabel(), label);
assert.ok(stateFile.state.workspaceAppHostCandidatePaths.length >= 1);
assert.ok(stateFile.state.workspaceAppHostCandidates.length >= 1);
});

test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => {
Expand Down Expand Up @@ -79,4 +80,84 @@ suite('Aspire AppHost tree E2E', function () {
await stopPrimaryAppHostIfRunning();
await waitForNoRunningAppHost();
});

test('promotes a possibly-buildable idle candidate to buildable after stop', async () => {
const appHostPath = getPrimaryAppHostProjectPath();
const wrapperPath = writeLsSequenceCliWrapper([
[{ path: appHostPath, language: 'csharp', status: 'possibly-buildable', selected: true }],
[{ path: appHostPath, language: 'csharp', status: 'buildable', selected: true }],
], 'aspire-ls-sequence-auto-buildable');
await setE2eCliPathForE2E(wrapperPath);

await openAspireView();
await waitForRepositoryIdle();
const refreshInvocationBefore = await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000).then(event => event.sequence).catch(() => 0);
await executeE2eControlCommand({ name: 'refreshAppHosts' });
await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, refreshInvocationBefore);

await waitForExtensionState(
file => file.state.workspaceAppHostCandidates.some(candidate => candidate.path === appHostPath && candidate.status === 'possibly-buildable'),
'possibly-buildable workspace candidate',
60000);

const runBefore = await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 1000).then(event => event.sequence).catch(() => 0);
await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' });
await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 120000, runBefore);
await waitForRunningAppHost();

// Snapshot the latest manual-refresh command sequence before stopping. The automatic
// refresh on debug-session-end calls dataRepository.refresh() directly and never raises the
// aspire-vscode.refreshAppHosts command, so this sequence must not advance.
const refreshSequenceBeforeStop = await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 1000).then(event => event.sequence).catch(() => 0);

const stopBefore = await waitForCommandOutcome('aspire-vscode.stopAppHost', 'success', 1000).then(event => event.sequence).catch(() => 0);
await executeE2eControlCommand({ name: 'stopAppHost', appHostPath }, { waitFor: 'started' });
await waitForCommandOutcome('aspire-vscode.stopAppHost', 'success', 120000, stopBefore);
await waitForNoRunningAppHost();

const stateWithBuildableCandidate = await waitForExtensionState(
file => file.state.workspaceAppHostCandidates.some(candidate => candidate.path === appHostPath && candidate.status === 'buildable'),
'buildable workspace candidate after automatic refresh',
60000);

const refreshSequenceAfterStop = await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 1000).then(event => event.sequence).catch(() => 0);
assert.strictEqual(
refreshSequenceAfterStop,
refreshSequenceBeforeStop,
'No manual refreshAppHosts command should run after stop; promotion must come from the automatic debug-session-end refresh.');

const appHostLabel = getTreeAppHostLabel(stateWithBuildableCandidate.state);
const section = await openAspireView();
const appHostItem = await waitForTreeItem(section, appHostLabel);
await appHostItem.expand();
await waitForTreeItem(section, 'Status: Buildable');
});

test('removes idle workspace candidate after a failed build', async () => {
const appHostPath = getPrimaryAppHostProjectPath();
const wrapperPath = writeLsSequenceCliWrapper([
[{ path: appHostPath, language: 'csharp', status: 'possibly-buildable', selected: true }],
[{ path: appHostPath, language: 'csharp', status: 'possibly-unbuildable', selected: true }],
], 'aspire-ls-sequence-failed-build');
await setE2eCliPathForE2E(wrapperPath);

await openAspireView();
await waitForRepositoryIdle();
const refreshInvocationBefore = await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000).then(event => event.sequence).catch(() => 0);
await executeE2eControlCommand({ name: 'refreshAppHosts' });
await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, refreshInvocationBefore);

await waitForExtensionState(
file => file.state.workspaceAppHostCandidates.some(candidate => candidate.path === appHostPath && candidate.status === 'possibly-buildable'),
'possibly-buildable workspace candidate',
60000);

await setDebugLaunchFailureForE2E(true);
await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' });

await waitForExtensionState(
file => file.state.workspaceAppHostCandidates.every(candidate => candidate.path !== appHostPath),
'workspace candidate removed after failed build',
60000);
});
});
4 changes: 2 additions & 2 deletions extension/src/test-e2e/commandPalette.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ suite('Aspire command palette E2E', function () {
await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, beforeRefresh);

const stateFile = await waitForExtensionState(
file => file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, secondaryAppHostPath)),
file => file.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, secondaryAppHostPath)),
'secondary AppHost candidate',
180000);

assert.ok(stateFile.state.workspaceAppHostCandidatePaths.length >= 2);
assert.ok(stateFile.state.workspaceAppHostCandidates.length >= 2);
});
});
22 changes: 11 additions & 11 deletions extension/src/test-e2e/discoveryConfiguration.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,21 @@ suite('Aspire workspace discovery and configuration E2E', function () {
await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, refreshWithoutConfigBefore);

const primaryCandidate = await waitForExtensionState(
file => file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, getPrimaryAppHostProjectPath())) && !file.state.hasError,
file => file.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, getPrimaryAppHostProjectPath())) && !file.state.hasError,
'primary AppHost candidate after removing aspire.config.json',
60000);
assert.ok(primaryCandidate.state.workspaceAppHostCandidatePaths.length >= 1);
assert.ok(primaryCandidate.state.workspaceAppHostCandidates.length >= 1);

const secondaryAppHostPath = createAdditionalAppHostCandidate('AspireE2E.SecondAppHost', 'single-file');
const refreshWithSecondCandidateBefore = getCommandInvocationCount('aspire-vscode.refreshAppHosts');
await executeE2eControlCommand({ name: 'refreshAppHosts' });
await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, refreshWithSecondCandidateBefore);

const multipleCandidates = await waitForExtensionState(
file => file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, secondaryAppHostPath)),
file => file.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, secondaryAppHostPath)),
'secondary AppHost candidate',
60000);
assert.ok(multipleCandidates.state.workspaceAppHostCandidatePaths.length >= 2);
assert.ok(multipleCandidates.state.workspaceAppHostCandidates.length >= 2);

restoreWorkspaceAppHostConfig();
removeAdditionalAppHostCandidate();
Expand All @@ -56,11 +56,11 @@ suite('Aspire workspace discovery and configuration E2E', function () {
const restored = await waitForExtensionState(
file => file.state.workspaceAppHostPath !== undefined
&& isSamePath(file.state.workspaceAppHostPath, getPrimaryAppHostProjectPath())
&& file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, getPrimaryAppHostProjectPath()))
&& !file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, secondaryAppHostPath)),
&& file.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, getPrimaryAppHostProjectPath()))
&& !file.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, secondaryAppHostPath)),
'restored primary AppHost without stale secondary candidate',
60000);
assert.ok(restored.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, getPrimaryAppHostProjectPath())));
assert.ok(restored.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, getPrimaryAppHostProjectPath())));
});

test('handles malformed, JSONC, absolute, and legacy AppHost configuration files', async () => {
Expand All @@ -76,10 +76,10 @@ suite('Aspire workspace discovery and configuration E2E', function () {
await executeE2eControlCommand({ name: 'refreshAppHosts' });
await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, before);
const malformedFallback = await waitForExtensionState(
file => file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, getPrimaryAppHostProjectPath())),
file => file.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, getPrimaryAppHostProjectPath())),
'CLI-discovered AppHost after malformed aspire.config.json',
60000);
assert.ok(malformedFallback.state.workspaceAppHostCandidatePaths.length >= 1);
assert.ok(malformedFallback.state.workspaceAppHostCandidates.length >= 1);

writeWorkspaceAppHostConfigRaw(`{
// JSONC comments are supported by the shared config parser.
Expand Down Expand Up @@ -137,13 +137,13 @@ suite('Aspire workspace discovery and configuration E2E', function () {
const emptyWorkspace = await waitForExtensionState(
file => file.state.isWorkspaceAppHostDiscoveryComplete
&& !file.state.isRepositoryLoading
&& file.state.workspaceAppHostCandidatePaths.length === 0
&& file.state.workspaceAppHostCandidates.length === 0
&& file.state.workspaceResources.length === 0
&& file.state.appHosts.length === 0
&& !file.state.hasError,
'empty workspace discovery to complete without loading forever',
60000);
assert.deepStrictEqual(emptyWorkspace.state.workspaceAppHostCandidatePaths, []);
assert.deepStrictEqual(emptyWorkspace.state.workspaceAppHostCandidates, []);

await waitForWorkbenchText('No Aspire AppHosts detected in this workspace.', 30000);
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion extension/src/test-e2e/helpers/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export async function waitForWorkspaceAppHost(timeoutMs = 120000): Promise<Exten
const deadline = createDeadline(timeoutMs);
await ensureWorkspaceFolderOpen(deadline);
return await waitForExtensionState(
file => file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, getPrimaryAppHostProjectPath())),
file => file.state.workspaceAppHostCandidates.some(candidate => isSamePath(candidate.path, getPrimaryAppHostProjectPath())),
'workspace AppHost candidate',
getRemainingTimeout(deadline, 'workspace AppHost candidate'));
}
Expand Down
Loading
Loading