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 @@ Authorization header must start with 'Bearer '. + + 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();