diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf
index e3eb3765056..b4f11058fd9 100644
--- a/extension/loc/xlf/aspire-vscode.xlf
+++ b/extension/loc/xlf/aspire-vscode.xlf
@@ -37,6 +37,9 @@
+
+ Build failed for project {0} with error: {1}.
+
Build failed with exit code {0}.
diff --git a/extension/package.json b/extension/package.json
index 46d66b12630..3f5497c2e8d 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -159,12 +159,12 @@
"editor/title/run": [
{
"command": "aspire-vscode.runAppHost",
- "when": "aspire.workspaceHasAppHost && aspire.editorSupportsRunDebug",
+ "when": "(aspire.fileIsAppHostCs || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"group": "navigation@-4"
},
{
"command": "aspire-vscode.debugAppHost",
- "when": "aspire.workspaceHasAppHost && aspire.editorSupportsRunDebug",
+ "when": "(aspire.fileIsAppHostCs || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"group": "navigation@-3"
}
],
diff --git a/extension/package.nls.json b/extension/package.nls.json
index 15f80da811b..0da3e7d8a70 100644
--- a/extension/package.nls.json
+++ b/extension/package.nls.json
@@ -82,5 +82,6 @@
"aspire-vscode.strings.invalidOrMissingToken": "Invalid or missing token in Authorization header.",
"aspire-vscode.strings.invalidTokenLength": "Invalid token length in Authorization header.",
"aspire-vscode.strings.authorizationHeaderMustStartWithBearer": "Authorization header must start with 'Bearer '.",
- "aspire-vscode.strings.authorizationAndDcpHeadersRequired": "Authorization and Microsoft-Developer-DCP-Instance-ID headers are required."
+ "aspire-vscode.strings.authorizationAndDcpHeadersRequired": "Authorization and Microsoft-Developer-DCP-Instance-ID headers are required.",
+ "aspire-vscode.strings.buildFailedForProjectWithError": "Build failed for project {0} with error: {1}."
}
diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts
index 878e847807b..e5b9ac26afa 100644
--- a/extension/src/capabilities.ts
+++ b/extension/src/capabilities.ts
@@ -7,7 +7,7 @@ function isExtensionInstalled(extensionId: string): boolean {
return !!extension;
}
-function isCsDevKitInstalled() {
+export function isCsDevKitInstalled() {
return isExtensionInstalled("ms-dotnettools.csdevkit");
}
diff --git a/extension/src/dcp/AspireDcpServer.ts b/extension/src/dcp/AspireDcpServer.ts
index 32426e371ad..234bfdd6594 100644
--- a/extension/src/dcp/AspireDcpServer.ts
+++ b/extension/src/dcp/AspireDcpServer.ts
@@ -135,7 +135,15 @@ export default class AspireDcpServer {
return;
}
- const config = await createDebugSessionConfiguration(aspireDebugSession.configuration, launchConfig, payload.args ?? [], payload.env ?? [], { debug: launchConfig.mode === "Debug", runId, debugSessionId: dcpId, isApphost: false }, foundDebuggerExtension);
+ const config = await createDebugSessionConfiguration(
+ aspireDebugSession.configuration,
+ launchConfig,
+ payload.args ?? [],
+ payload.env ?? [],
+ { debug: launchConfig.mode === "Debug", runId, debugSessionId: dcpId, isApphost: false, debugSession: aspireDebugSession },
+ foundDebuggerExtension
+ );
+
const resourceDebugSession = await aspireDebugSession.startAndGetDebugSession(config);
if (!resourceDebugSession) {
@@ -237,7 +245,7 @@ export default class AspireDcpServer {
// If no WebSocket is available for the session, log a warning
const ws = this.wsBySession.get(notification.dcp_id);
if (!ws || ws.readyState !== WebSocket.OPEN) {
- extensionLogOutputChannel.warn(`No WebSocket found for DCP ID: ${notification.dcp_id} or WebSocket is not open (state: ${ws?.readyState})`);
+ extensionLogOutputChannel.trace(`No WebSocket found for DCP ID: ${notification.dcp_id} or WebSocket is not open (state: ${ws?.readyState})`);
this.pendingNotificationQueueByDcpId.set(notification.dcp_id, [...(this.pendingNotificationQueueByDcpId.get(notification.dcp_id) || []), notification]);
return;
}
diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts
index 060b6afe324..e76e7f3bc21 100644
--- a/extension/src/dcp/types.ts
+++ b/extension/src/dcp/types.ts
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
+import { AspireDebugSession } from '../debugger/AspireDebugSession';
export interface ErrorResponse {
error: ErrorDetails;
@@ -96,6 +97,7 @@ export interface LaunchOptions {
runId: string;
debugSessionId: string;
isApphost: boolean;
+ debugSession: AspireDebugSession;
};
export interface AspireResourceDebugSession {
diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts
index 74321204092..b1851dfef6e 100644
--- a/extension/src/debugger/AspireDebugSession.ts
+++ b/extension/src/debugger/AspireDebugSession.ts
@@ -14,6 +14,7 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider";
import { ICliRpcClient } from "../server/rpcClient";
import path from "path";
import { EnvironmentVariables } from "../utils/environment";
+import { isCsDevKitInstalled } from "../capabilities";
export class AspireDebugSession implements vscode.DebugAdapter {
private readonly _onDidSendMessage = new EventEmitter();
@@ -86,6 +87,10 @@ export class AspireDebugSession implements vscode.DebugAdapter {
args.push('--wait-for-debugger');
}
+ if (this._terminalProvider.isCliDebugLoggingEnabled()) {
+ args.push('--debug');
+ }
+
if (isDirectory(appHostPath)) {
this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath));
@@ -197,7 +202,13 @@ export class AspireDebugSession implements vscode.DebugAdapter {
this.createDebugAdapterTrackerCore(projectDebuggerExtension.debugAdapter);
extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with args: ${args.join(' ')}`);
- const appHostDebugSessionConfiguration = await createDebugSessionConfiguration(this.configuration, { project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration, args, environment, { debug, forceBuild: debug, runId: '', debugSessionId: this.debugSessionId, isApphost: true }, projectDebuggerExtension);
+ const appHostDebugSessionConfiguration = await createDebugSessionConfiguration(
+ this.configuration,
+ { project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration,
+ args,
+ environment,
+ { debug, forceBuild: isCsDevKitInstalled(), runId: '', debugSessionId: this.debugSessionId, isApphost: true, debugSession: this },
+ projectDebuggerExtension);
const appHostDebugSession = await this.startAndGetDebugSession(appHostDebugSessionConfiguration);
if (!appHostDebugSession) {
@@ -225,6 +236,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {
async startAndGetDebugSession(debugConfig: AspireResourceExtendedDebugConfiguration): Promise {
return new Promise(async (resolve) => {
+ extensionLogOutputChannel.info(`Starting debug session with configuration: ${JSON.stringify(debugConfig)}`);
this.createDebugAdapterTrackerCore(debugConfig.type);
const disposable = vscode.debug.onDidStartDebugSession(session => {
@@ -252,7 +264,6 @@ export class AspireDebugSession implements vscode.DebugAdapter {
}
});
- extensionLogOutputChannel.info(`Starting debug session with configuration: ${JSON.stringify(debugConfig)}`);
const started = await vscode.debug.startDebugging(undefined, debugConfig, this._session);
if (!started) {
disposable.dispose();
diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts
index 7bf64de53a0..a14ff8ad308 100644
--- a/extension/src/debugger/languages/dotnet.ts
+++ b/extension/src/debugger/languages/dotnet.ts
@@ -1,6 +1,6 @@
import * as vscode from 'vscode';
import { extensionLogOutputChannel } from '../../utils/logging';
-import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration } from '../../loc/strings';
+import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode } from '../../loc/strings';
import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process';
import * as util from 'util';
import * as path from 'path';
@@ -17,6 +17,8 @@ import {
determineWorkingDirectory,
determineServerReadyAction
} from '../launchProfiles';
+import { AspireDebugSession } from '../AspireDebugSession';
+import { isCsDevKitInstalled } from '../../capabilities';
interface IDotNetService {
getAndActivateDevKit(): Promise
@@ -26,8 +28,18 @@ interface IDotNetService {
}
class DotNetService implements IDotNetService {
+ private _debugSession: AspireDebugSession;
+
+ constructor(debugSession: AspireDebugSession) {
+ this._debugSession = debugSession;
+ }
+
execFileAsync = util.promisify(execFile);
+ writeToDebugConsole(message: string, category: 'stdout' | 'stderr') {
+ this._debugSession.sendMessage(message, false, category);
+ }
+
async getAndActivateDevKit(): Promise {
const csharpDevKit = vscode.extensions.getExtension('ms-dotnettools.csdevkit');
if (!csharpDevKit) {
@@ -45,6 +57,52 @@ class DotNetService implements IDotNetService {
}
async buildDotNetProject(projectFile: string): Promise {
+ const isDevKitEnabled = await this.getAndActivateDevKit();
+
+ if (!isDevKitEnabled) {
+ this.writeToDebugConsole('C# Dev Kit not available, building project using dotnet CLI...', 'stdout');
+ const args = ['build', projectFile];
+
+ return new Promise((resolve, reject) => {
+ const buildProcess = spawn('dotnet', args);
+
+ let stdoutOutput = '';
+ let stderrOutput = '';
+
+ // Stream stdout in real-time
+ buildProcess.stdout?.on('data', (data: Buffer) => {
+ const output = data.toString();
+ stdoutOutput += output;
+ this.writeToDebugConsole(output, 'stdout');
+ });
+
+ // Stream stderr in real-time
+ buildProcess.stderr?.on('data', (data: Buffer) => {
+ const output = data.toString();
+ stderrOutput += output;
+ this.writeToDebugConsole(output, 'stderr');
+ });
+
+ buildProcess.on('error', (err) => {
+ extensionLogOutputChannel.error(`dotnet build process error: ${err}`);
+ reject(new Error(buildFailedForProjectWithError(projectFile, err.message)));
+ });
+
+ buildProcess.on('close', (code) => {
+ if (code === 0) {
+ // if build succeeds, simply return. otherwise throw to trigger error handling
+ if (stderrOutput) {
+ reject(new Error(stderrOutput));
+ } else {
+ resolve();
+ }
+ } else {
+ reject(new Error(buildFailedForProjectWithError(projectFile, stdoutOutput || stderrOutput || `Exit code ${code}`)));
+ }
+ });
+ });
+ }
+
// C# Dev Kit may not register the build task immediately, so we need to retry until it is available
const pRetry = (await import('p-retry')).default;
const buildTask = await pRetry(async () => {
@@ -117,9 +175,10 @@ class DotNetService implements IDotNetService {
}
async getDotNetRunApiOutput(projectPath: string): Promise {
+ let childProcess: ChildProcessWithoutNullStreams;
+
return new Promise(async (resolve, reject) => {
try {
- let childProcess: ChildProcessWithoutNullStreams;
const timeout = setTimeout(() => {
childProcess?.kill();
reject(new Error('Timeout while waiting for dotnet run-api response'));
@@ -136,7 +195,9 @@ class DotNetService implements IDotNetService {
childProcess.on('error', reject);
childProcess.on('exit', (code, signal) => {
clearTimeout(timeout);
- reject(new Error(`dotnet run-api exited with ${code ?? signal}`));
+ if (code !== 0) {
+ reject(new Error(processExitedWithCode(code?.toString() ?? "unknown")));
+ }
});
const rl = readline.createInterface(childProcess.stdout);
@@ -153,15 +214,20 @@ class DotNetService implements IDotNetService {
} catch (e) {
reject(e);
}
- });
+ }).finally(() => childProcess.removeAllListeners());
}
}
-function isSingleFileAppHost(projectPath: string): boolean {
- return path.basename(projectPath).toLowerCase() === 'apphost.cs';
+export function isSingleFileApp(projectPath: string): boolean {
+ return path.extname(projectPath).toLowerCase().endsWith('.cs');
+}
+
+interface RunApiOutput {
+ executablePath: string;
+ env?: { [key: string]: string };
}
-function applyRunApiOutputToDebugConfiguration(runApiOutput: string, debugConfiguration: AspireResourceExtendedDebugConfiguration) {
+function getRunApiConfigFromOutput(runApiOutput: string, debugConfiguration: AspireResourceExtendedDebugConfiguration): RunApiOutput {
const parsed = JSON.parse(runApiOutput);
if (parsed.$type === 'Error') {
throw new Error(`dotnet run-api failed: ${parsed.Message}`);
@@ -170,16 +236,13 @@ function applyRunApiOutputToDebugConfiguration(runApiOutput: string, debugConfig
throw new Error(`dotnet run-api failed: Unexpected response type '${parsed.$type}'`);
}
- debugConfiguration.program = parsed.ExecutablePath;
- if (parsed.EnvironmentVariables) {
- debugConfiguration.env = {
- ...debugConfiguration.env,
- ...parsed.EnvironmentVariables
- };
- }
+ return {
+ executablePath: parsed.ExecutablePath,
+ env: parsed.EnvironmentVariables
+ };
}
-export function createProjectDebuggerExtension(dotNetService: IDotNetService): ResourceDebuggerExtension {
+export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession) => IDotNetService): ResourceDebuggerExtension {
return {
resourceType: 'project',
debugAdapter: 'coreclr',
@@ -194,6 +257,8 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R
throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
},
createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => {
+ const dotNetService: IDotNetService = dotNetServiceProducer(launchOptions.debugSession);
+
if (!isProjectLaunchConfiguration(launchConfig)) {
extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`);
throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
@@ -201,6 +266,8 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R
const projectPath = launchConfig.project_path;
+ extensionLogOutputChannel.info(`Reading launch settings for: ${projectPath}`);
+
// Apply launch profile settings if available
const launchSettings = await readLaunchSettings(projectPath);
if (!isProjectLaunchConfiguration(launchConfig)) {
@@ -217,26 +284,30 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R
// Configure debug session with launch profile settings
debugConfiguration.cwd = determineWorkingDirectory(projectPath, baseProfile);
debugConfiguration.args = determineArguments(baseProfile?.commandLineArgs, args);
- debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env));
debugConfiguration.executablePath = baseProfile?.executablePath;
debugConfiguration.checkForDevCert = baseProfile?.useSSL;
debugConfiguration.serverReadyAction = determineServerReadyAction(baseProfile?.launchBrowser, baseProfile?.applicationUrl);
- // Build project if needed
- if (!isSingleFileAppHost(projectPath)) {
+ if (!isSingleFileApp(projectPath)) {
const outputPath = await dotNetService.getDotNetTargetPath(projectPath);
- if ((!(await doesFileExist(outputPath)) || launchOptions.forceBuild) && await dotNetService.getAndActivateDevKit()) {
+ if ((!(await doesFileExist(outputPath)) || launchOptions.forceBuild)) {
await dotNetService.buildDotNetProject(projectPath);
}
debugConfiguration.program = outputPath;
+ debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env));
}
else {
+ // Single file apps should always be built
+ await dotNetService.buildDotNetProject(projectPath);
const runApiOutput = await dotNetService.getDotNetRunApiOutput(projectPath);
- applyRunApiOutputToDebugConfiguration(runApiOutput, debugConfiguration);
+ const runApiConfig = getRunApiConfigFromOutput(runApiOutput, debugConfiguration);
+ debugConfiguration.program = runApiConfig.executablePath;
+
+ debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env, runApiConfig.env));
}
}
};
}
-export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(new DotNetService());
+export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(debugSession => new DotNetService(debugSession));
diff --git a/extension/src/debugger/launchProfiles.ts b/extension/src/debugger/launchProfiles.ts
index 1c04cc1ca4d..a9b2d68db6b 100644
--- a/extension/src/debugger/launchProfiles.ts
+++ b/extension/src/debugger/launchProfiles.ts
@@ -2,6 +2,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { ExecutableLaunchConfiguration, EnvVar, ProjectLaunchConfiguration } from '../dcp/types';
import { extensionLogOutputChannel } from '../utils/logging';
+import { isSingleFileApp } from './languages/dotnet';
/*
* Represents a launchSettings.json profile.
@@ -9,6 +10,7 @@ import { extensionLogOutputChannel } from '../utils/logging';
* *and* in the launchSettings.json is available here.
*/
export interface LaunchProfile {
+ commandName: string;
executablePath?: string;
workingDirectory?: string;
// args in debug configuration
@@ -37,8 +39,15 @@ export interface LaunchProfileResult {
*/
export async function readLaunchSettings(projectPath: string): Promise {
try {
- const projectDir = path.dirname(projectPath);
- const launchSettingsPath = path.join(projectDir, 'Properties', 'launchSettings.json');
+ let launchSettingsPath: string;
+
+ if (isSingleFileApp(projectPath)) {
+ const fileNameWithoutExt = path.basename(projectPath, path.extname(projectPath));
+ launchSettingsPath = path.join(path.dirname(projectPath), `${fileNameWithoutExt}.run.json`);
+ } else {
+ const projectDir = path.dirname(projectPath);
+ launchSettingsPath = path.join(projectDir, 'Properties', 'launchSettings.json');
+ }
if (!fs.existsSync(launchSettingsPath)) {
extensionLogOutputChannel.debug(`Launch settings file not found at: ${launchSettingsPath}`);
@@ -88,6 +97,14 @@ export function determineBaseLaunchProfile(
}
}
+ // If launch_profile is absent, choose the first one with commandName='Project'
+ for (const [name, profile] of Object.entries(launchSettings.profiles)) {
+ if (profile.commandName === 'Project') {
+ extensionLogOutputChannel.debug(`Using default launch profile: ${name}`);
+ return { profile, profileName: name };
+ }
+ }
+
// TODO: If launch_profile is absent, check for a ServiceDefaults project in the workspace
// and look for a launch profile with that ServiceDefaults project name in the current project's launch settings
extensionLogOutputChannel.debug('No base launch profile determined');
@@ -100,7 +117,8 @@ export function determineBaseLaunchProfile(
*/
export function mergeEnvironmentVariables(
baseProfileEnv: { [key: string]: string } | undefined,
- runSessionEnv: EnvVar[]
+ runSessionEnv: EnvVar[],
+ runApiEnv?: { [key: string]: string }
): [string, string][] {
const merged: { [key: string]: string } = {};
@@ -109,6 +127,11 @@ export function mergeEnvironmentVariables(
Object.assign(merged, baseProfileEnv);
}
+ // Override with run API environment variables
+ if (runApiEnv) {
+ Object.assign(merged, runApiEnv);
+ }
+
// Override with run session environment variables (these take precedence)
for (const envVar of runSessionEnv) {
merged[envVar.name] = envVar.value;
diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts
index 8f07c6b5884..0d72b0d92e7 100644
--- a/extension/src/editor/AspireEditorCommandProvider.ts
+++ b/extension/src/editor/AspireEditorCommandProvider.ts
@@ -55,7 +55,22 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
public async processDocument(document: vscode.TextDocument): Promise {
const fileExtension = path.extname(document.uri.fsPath).toLowerCase();
const isSupportedFile = getResourceDebuggerExtensions().some(extension => extension.getSupportedFileTypes().includes(fileExtension));
+
vscode.commands.executeCommand('setContext', 'aspire.editorSupportsRunDebug', isSupportedFile);
+
+ if (await this.isAppHostCsFile(document.uri.fsPath)) {
+ vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHostCs', true);
+ }
+ else {
+ vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHostCs', false);
+ }
+ }
+
+ private async isAppHostCsFile(filePath: string): Promise {
+ const fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString());
+ const lines = fileText.split(/\r?\n/);
+
+ return lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'));
}
private onChangeAppHostPath(newPath: string | null) {
@@ -99,16 +114,23 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
}
public async tryExecuteRunAppHost(noDebug: boolean): Promise {
- if (!this._workspaceAppHostPath) {
+ let appHostToRun: string;
+ if (vscode.window.activeTextEditor && await this.isAppHostCsFile(vscode.window.activeTextEditor.document.uri.fsPath)) {
+ appHostToRun = vscode.window.activeTextEditor.document.uri.fsPath;
+ }
+ else if (this._workspaceAppHostPath) {
+ appHostToRun = this._workspaceAppHostPath;
+ }
+ else {
vscode.window.showErrorMessage(noAppHostInWorkspace);
return;
}
await vscode.debug.startDebugging(undefined, {
type: 'aspire',
- name: `Aspire: ${vscode.workspace.asRelativePath(this._workspaceAppHostPath)}`,
+ name: `Aspire: ${vscode.workspace.asRelativePath(appHostToRun)}`,
request: 'launch',
- program: this._workspaceAppHostPath,
+ program: appHostToRun,
noDebug: noDebug
});
}
diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts
index 556cc4152b0..0ed546f0e15 100644
--- a/extension/src/loc/strings.ts
+++ b/extension/src/loc/strings.ts
@@ -62,3 +62,4 @@ export const invalidOrMissingToken = vscode.l10n.t('Invalid or missing token in
export const invalidTokenLength = vscode.l10n.t('Invalid token length in Authorization header.');
export const authorizationHeaderMustStartWithBearer = vscode.l10n.t('Authorization header must start with \'Bearer \'.');
export const authorizationAndDcpHeadersRequired = vscode.l10n.t('Authorization and Microsoft-Developer-DCP-Instance-ID headers are required.');
+export const buildFailedForProjectWithError = (project: string, error: string) => vscode.l10n.t('Build failed for project {0} with error: {1}.', project, error);
diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts
index 7450eb8998d..cca8f256933 100644
--- a/extension/src/test/dotnetDebugger.test.ts
+++ b/extension/src/test/dotnetDebugger.test.ts
@@ -5,6 +5,7 @@ import { createProjectDebuggerExtension, projectDebuggerExtension } from '../deb
import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, ProjectLaunchConfiguration } from '../dcp/types';
import * as io from '../utils/io';
import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions';
+import { AspireDebugSession } from '../debugger/AspireDebugSession';
class TestDotNetService {
private _getDotNetTargetPathStub: sinon.SinonStub;
@@ -48,7 +49,7 @@ suite('Dotnet Debugger Extension Tests', () => {
function createDebuggerExtension(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean, doesOutputFileExist: boolean): { dotNetService: TestDotNetService, extension: ResourceDebuggerExtension, doesFileExistStub: sinon.SinonStub } {
const fakeDotNetService = new TestDotNetService(outputPath, rejectBuild, hasDevKit);
- return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) };
+ return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) };
}
test('project is built when C# dev kit is installed and executable not found', async () => {
const outputPath = 'C:\\temp\\bin\\Debug\\net7.0\\TestProject.dll';
@@ -68,35 +69,12 @@ suite('Dotnet Debugger Extension Tests', () => {
request: 'launch'
};
- await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig);
+ const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession);
- assert.strictEqual(debugConfig.program, outputPath);
- assert.strictEqual(dotNetService.buildDotNetProjectStub.called, true);
- });
-
- test('project is not built when C# dev kit is not installed and executable not found', async () => {
- const outputPath = 'C:\\temp\\bin\\Debug\\net7.0\\TestProject.dll';
-
- const { extension, dotNetService } = createDebuggerExtension(outputPath, null, false, false);
-
- const projectPath = 'C:\\temp\\TestProject.csproj';
- const launchConfig: ProjectLaunchConfiguration = {
- type: 'project',
- project_path: projectPath
- };
-
- const debugConfig: AspireResourceExtendedDebugConfiguration = {
- runId: '1',
- debugSessionId: '1',
- type: 'coreclr',
- name: 'Test Debug Config',
- request: 'launch'
- };
-
- await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig);
+ await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig);
assert.strictEqual(debugConfig.program, outputPath);
- assert.strictEqual(dotNetService.buildDotNetProjectStub.notCalled, true);
+ assert.strictEqual(dotNetService.buildDotNetProjectStub.called, true);
});
test('project is not built when C# dev kit is installed and executable found', async () => {
@@ -117,7 +95,9 @@ suite('Dotnet Debugger Extension Tests', () => {
request: 'launch'
};
- await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig);
+ const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession);
+
+ await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig);
assert.strictEqual(debugConfig.program, outputPath);
assert.strictEqual(dotNetService.buildDotNetProjectStub.notCalled, true);
@@ -177,7 +157,9 @@ suite('Dotnet Debugger Extension Tests', () => {
request: 'launch'
};
- await extension.createDebugSessionConfigurationCallback!(launchConfig, undefined, runEnv, { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig);
+ const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession);
+
+ await extension.createDebugSessionConfigurationCallback!(launchConfig, undefined, runEnv, { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig);
// program should be set
assert.strictEqual(debugConfig.program, outputPath);
diff --git a/extension/src/test/launchProfiles.test.ts b/extension/src/test/launchProfiles.test.ts
index 0d79e8ca88f..0044f134476 100644
--- a/extension/src/test/launchProfiles.test.ts
+++ b/extension/src/test/launchProfiles.test.ts
@@ -19,14 +19,22 @@ suite('Launch Profile Tests', () => {
const sampleLaunchSettings: LaunchSettings = {
profiles: {
'Development': {
+ commandName: 'Project',
environmentVariables: {
ASPNETCORE_ENVIRONMENT: 'Development'
}
},
'Production': {
+ commandName: 'Project',
environmentVariables: {
ASPNETCORE_ENVIRONMENT: 'Production'
}
+ },
+ 'IISExpress': {
+ commandName: 'IISExpress',
+ environmentVariables: {
+ ASPNETCORE_ENVIRONMENT: 'Development'
+ }
}
}
};
@@ -81,6 +89,55 @@ suite('Launch Profile Tests', () => {
assert.strictEqual(result.profile, null);
assert.strictEqual(result.profileName, null);
});
+
+ test('returns first profile with commandName=Project when no explicit profile specified', () => {
+ const launchConfig: ProjectLaunchConfiguration = {
+ type: 'project',
+ project_path: '/test/project.csproj'
+ };
+
+ const result = determineBaseLaunchProfile(launchConfig, sampleLaunchSettings);
+
+ assert.strictEqual(result.profileName, 'Development');
+ assert.strictEqual(result.profile?.commandName, 'Project');
+ assert.strictEqual(result.profile?.environmentVariables?.ASPNETCORE_ENVIRONMENT, 'Development');
+ });
+
+ test('returns null when no profile has commandName=Project', () => {
+ const settingsWithoutProject: LaunchSettings = {
+ profiles: {
+ 'IISExpress': {
+ commandName: 'IISExpress',
+ environmentVariables: {
+ ASPNETCORE_ENVIRONMENT: 'Development'
+ }
+ }
+ }
+ };
+
+ const launchConfig: ProjectLaunchConfiguration = {
+ type: 'project',
+ project_path: '/test/project.csproj'
+ };
+
+ const result = determineBaseLaunchProfile(launchConfig, settingsWithoutProject);
+
+ assert.strictEqual(result.profile, null);
+ assert.strictEqual(result.profileName, null);
+ });
+
+ test('explicit profile takes precedence over default commandName=Project logic', () => {
+ const launchConfig: ProjectLaunchConfiguration = {
+ type: 'project',
+ project_path: '/test/project.csproj',
+ launch_profile: 'IISExpress'
+ };
+
+ const result = determineBaseLaunchProfile(launchConfig, sampleLaunchSettings);
+
+ assert.strictEqual(result.profileName, 'IISExpress');
+ assert.strictEqual(result.profile?.commandName, 'IISExpress');
+ });
});
suite('mergeEnvironmentVariables', () => {
@@ -107,6 +164,89 @@ suite('Launch Profile Tests', () => {
assert.strictEqual(resultMap.get('VAR4'), 'session4');
});
+ test('merges with run API environment variables taking precedence over base profile', () => {
+ const baseProfileEnv = {
+ 'VAR1': 'base1',
+ 'VAR2': 'base2',
+ 'VAR3': 'base3'
+ };
+
+ const runApiEnv = {
+ 'VAR2': 'api2',
+ 'VAR5': 'api5'
+ };
+
+ const runSessionEnv: EnvVar[] = [];
+
+ const result = mergeEnvironmentVariables(baseProfileEnv, runSessionEnv, runApiEnv);
+
+ assert.strictEqual(result.length, 4);
+
+ const resultMap = new Map(result);
+ assert.strictEqual(resultMap.get('VAR1'), 'base1');
+ assert.strictEqual(resultMap.get('VAR2'), 'api2'); // Run API takes precedence over base
+ assert.strictEqual(resultMap.get('VAR3'), 'base3');
+ assert.strictEqual(resultMap.get('VAR5'), 'api5');
+ });
+
+ test('run session environment takes precedence over run API environment', () => {
+ const baseProfileEnv = {
+ 'VAR1': 'base1',
+ 'VAR2': 'base2'
+ };
+
+ const runApiEnv = {
+ 'VAR2': 'api2',
+ 'VAR3': 'api3'
+ };
+
+ const runSessionEnv: EnvVar[] = [
+ { name: 'VAR2', value: 'session2' },
+ { name: 'VAR4', value: 'session4' }
+ ];
+
+ const result = mergeEnvironmentVariables(baseProfileEnv, runSessionEnv, runApiEnv);
+
+ assert.strictEqual(result.length, 4);
+
+ const resultMap = new Map(result);
+ assert.strictEqual(resultMap.get('VAR1'), 'base1');
+ assert.strictEqual(resultMap.get('VAR2'), 'session2'); // Run session has highest precedence
+ assert.strictEqual(resultMap.get('VAR3'), 'api3');
+ assert.strictEqual(resultMap.get('VAR4'), 'session4');
+ });
+
+ test('handles all three sources with correct precedence: session > api > base', () => {
+ const baseProfileEnv = {
+ 'BASE_ONLY': 'base_value',
+ 'OVERRIDDEN_BY_API': 'base_value',
+ 'OVERRIDDEN_BY_SESSION': 'base_value',
+ 'OVERRIDDEN_BY_BOTH': 'base_value'
+ };
+
+ const runApiEnv = {
+ 'API_ONLY': 'api_value',
+ 'OVERRIDDEN_BY_API': 'api_value',
+ 'OVERRIDDEN_BY_BOTH': 'api_value'
+ };
+
+ const runSessionEnv: EnvVar[] = [
+ { name: 'SESSION_ONLY', value: 'session_value' },
+ { name: 'OVERRIDDEN_BY_SESSION', value: 'session_value' },
+ { name: 'OVERRIDDEN_BY_BOTH', value: 'session_value' }
+ ];
+
+ const result = mergeEnvironmentVariables(baseProfileEnv, runSessionEnv, runApiEnv);
+
+ const resultMap = new Map(result);
+ assert.strictEqual(resultMap.get('BASE_ONLY'), 'base_value');
+ assert.strictEqual(resultMap.get('API_ONLY'), 'api_value');
+ assert.strictEqual(resultMap.get('SESSION_ONLY'), 'session_value');
+ assert.strictEqual(resultMap.get('OVERRIDDEN_BY_API'), 'api_value');
+ assert.strictEqual(resultMap.get('OVERRIDDEN_BY_SESSION'), 'session_value');
+ assert.strictEqual(resultMap.get('OVERRIDDEN_BY_BOTH'), 'session_value');
+ });
+
test('handles empty base profile environment', () => {
const runSessionEnv: EnvVar[] = [
{ name: 'VAR1', value: 'session1' }
@@ -133,6 +273,21 @@ suite('Launch Profile Tests', () => {
assert.strictEqual(resultMap.get('VAR1'), 'base1');
assert.strictEqual(resultMap.get('VAR2'), 'base2');
});
+
+ test('handles only run API environment without base or session', () => {
+ const runApiEnv = {
+ 'VAR1': 'api1',
+ 'VAR2': 'api2'
+ };
+
+ const result = mergeEnvironmentVariables(undefined, [], runApiEnv);
+
+ assert.strictEqual(result.length, 2);
+
+ const resultMap = new Map(result);
+ assert.strictEqual(resultMap.get('VAR1'), 'api1');
+ assert.strictEqual(resultMap.get('VAR2'), 'api2');
+ });
});
suite('determineArguments', () => {
@@ -184,6 +339,7 @@ suite('Launch Profile Tests', () => {
test('uses absolute working directory from launch profile', () => {
const baseProfile: LaunchProfile = {
+ commandName: 'Project',
workingDirectory: path.join('C:', 'custom', 'working', 'dir')
};
@@ -194,6 +350,7 @@ suite('Launch Profile Tests', () => {
test('resolves relative working directory from launch profile', () => {
const baseProfile: LaunchProfile = {
+ commandName: 'Project',
workingDirectory: 'custom'
};
@@ -204,6 +361,7 @@ suite('Launch Profile Tests', () => {
test('uses project directory when no working directory specified', () => {
const baseProfile: LaunchProfile = {
+ commandName: 'Project'
};
const result = determineWorkingDirectory(projectPath, baseProfile);
diff --git a/playground/FileBasedApps/apphost.cs b/playground/FileBasedApps/apphost.cs
index c75b86e37ed..d6375a87a2b 100644
--- a/playground/FileBasedApps/apphost.cs
+++ b/playground/FileBasedApps/apphost.cs
@@ -2,6 +2,9 @@
// $ dotnet apphost.cs --no-cache
// These directives are not required in regular apps, only here in the aspire repo itself
+/*
+#:sdk Aspire.AppHost.Sdk
+*/
#:property IsAspireHost=true
#:property PublishAot=false
diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs
index 8803768083c..da1902b1c26 100644
--- a/src/Aspire.Cli/Commands/RunCommand.cs
+++ b/src/Aspire.Cli/Commands/RunCommand.cs
@@ -218,7 +218,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
cancellationToken);
// Wait for the backchannel to be established.
- var backchannel = await InteractionService.ShowStatusAsync(InteractionServiceStrings.BuildingAppHost, async () => { return await backchannelCompletitionSource.Task.WaitAsync(cancellationToken); });
+ var backchannel = await InteractionService.ShowStatusAsync(isExtensionHost ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost, async () => { return await backchannelCompletitionSource.Task.WaitAsync(cancellationToken); });
var logFile = GetAppHostLogFile();