diff --git a/.gitignore b/.gitignore index a82ffd06875..60371d10272 100644 --- a/.gitignore +++ b/.gitignore @@ -186,6 +186,7 @@ tests/PolyglotAppHosts/**/Go/apphost.exe #Aspire CLI .aspire/ +.modules/ # Release notes automation output tools/ReleaseNotes/analysis-output/ diff --git a/extension/gulpfile.js b/extension/gulpfile.js index 77d2a45dc1c..4e504a85808 100644 --- a/extension/gulpfile.js +++ b/extension/gulpfile.js @@ -1,4 +1,4 @@ -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); const gulp = require('gulp'); const path = require('path'); const fs = require('fs'); @@ -40,7 +40,8 @@ const exportL10n = (done) => { // Step 1: Export strings from source files to bundle.l10n.json console.log('Exporting l10n strings from source files...'); - execSync(`npx @vscode/l10n-dev export --outDir ${l10nDir} ./src`, { + const l10nDevCli = path.join(path.dirname(require.resolve('@vscode/l10n-dev/package.json')), 'dist', 'cli.js'); + execFileSync(process.execPath, [l10nDevCli, 'export', '--outDir', l10nDir, './src'], { cwd: rootDir, stdio: 'inherit' }); diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 24c1e3d3d62..d8c04afb042 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -268,6 +268,12 @@ Get started with Aspire + + Go + + + Go: {0} + Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs. @@ -541,6 +547,9 @@ This field is required. + + Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Minimum: 1000. + Unable to add folder to workspace: {0} diff --git a/extension/package.json b/extension/package.json index a3181cad200..e3106820e0d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -784,6 +784,13 @@ "description": "%configuration.aspire.globalAppHostsPollingInterval%", "scope": "window" }, + "aspire.appHostDiscoveryTimeoutMs": { + "type": "number", + "default": 30000, + "minimum": 1000, + "description": "%configuration.aspire.appHostDiscoveryTimeoutMs%", + "scope": "window" + }, "aspire.enableCodeLens": { "type": "boolean", "default": true, diff --git a/extension/package.nls.json b/extension/package.nls.json index b889ec3b2f4..32ae40fa38b 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -42,6 +42,7 @@ "configuration.aspire.enableDebugConfigEnvironmentLogging": "Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs.", "configuration.aspire.registerMcpServerInWorkspace": "Whether to register the Aspire MCP server when a workspace is open.", "configuration.aspire.globalAppHostsPollingInterval": "Polling interval in milliseconds for fetching all running AppHosts (used in global view). Minimum: 1000.", + "configuration.aspire.appHostDiscoveryTimeoutMs": "Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Minimum: 1000.", "configuration.aspire.enableCodeLens": "Show CodeLens actions (state, restart, stop, logs) inline above resource declarations in AppHost files.", "configuration.aspire.enableGutterDecorations": "Show colored status dots in the editor gutter next to resource declarations in AppHost files.", "configuration.aspire.enableAutoRestore": "Automatically run 'aspire restore' when the workspace opens and whenever aspire.config.json changes (e.g. after switching git branches). Keeps integration packages in sync and prevents editor errors.", diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index 643db6ed958..24794d8e256 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,22 +1,40 @@ import * as vscode from 'vscode'; import { defaultConfigurationName } from '../loc/strings'; +import { AppHostDiscoveryService, getDebugTargetForCandidate } from '../utils/appHostDiscovery'; +import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; +import { extensionLogOutputChannel } from '../utils/logging'; export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { + constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) { + } + async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise { if (folder === undefined) { return []; } - const configurations: vscode.DebugConfiguration[] = []; - configurations.push({ + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return [this.createDefaultConfiguration(folder)]; + } + + const activeEditorFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri); + if (activeEditorFolder?.uri.toString() !== folder.uri.toString()) { + return [this.createDefaultConfiguration(folder)]; + } + + const candidate = await this.tryFindCandidateForEditorFile(activeEditor.document.uri.fsPath, folder); + if (!candidate) { + return [this.createDefaultConfiguration(folder)]; + } + + return [{ type: 'aspire', request: 'launch', name: defaultConfigurationName, - program: '${workspaceFolder}' - }); - - return configurations; + program: getDebugTargetForCandidate(candidate) + }]; } async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { @@ -44,4 +62,41 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati return config; } + + async resolveDebugConfigurationWithSubstitutedVariables(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { + if (typeof config.program === 'string') { + config.program = await this.resolveDebugTarget(config.program, folder); + } + + return config; + } + + private async tryFindCandidateForEditorFile(filePath: string, folder: vscode.WorkspaceFolder): Promise { + try { + return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath, folder); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to discover AppHost for debug configuration file ${filePath}: ${error}`); + return undefined; + } + } + + private async resolveDebugTarget(filePath: string, folder: vscode.WorkspaceFolder | undefined): Promise { + try { + return await this._appHostDiscoveryService.resolveDebugTarget(filePath, folder); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to resolve AppHost debug target ${filePath}: ${error}`); + return filePath; + } + } + + private createDefaultConfiguration(folder: vscode.WorkspaceFolder): vscode.DebugConfiguration { + return { + type: 'aspire', + request: 'launch', + name: defaultConfigurationName, + program: folder.uri.fsPath + }; + } } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 1970d4f172d..5c041cdfade 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -3,37 +3,20 @@ import * as path from 'path'; import { noAppHostInWorkspace } from '../loc/strings'; import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions'; import { AspireCommandType } from '../dcp/types'; -import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from '../utils/cliTypes'; +import { AppHostDiscoveryService, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery'; +import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; +import { extensionLogOutputChannel } from '../utils/logging'; export class AspireEditorCommandProvider implements vscode.Disposable { - private _workspaceAppHostPath: string | null = null; - private _workspaceSettingsJsonWatchers: Map = new Map(); private _disposables: vscode.Disposable[] = []; - constructor() { - // Watch for both aspire.config.json and .aspire/settings.json changes - const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file('/')); - if (workspaceFolder) { - this._workspaceSettingsJsonWatchers.set(workspaceFolder, this.watchWorkspaceForAppHostPathChanges(workspaceFolder, this.onChangeAppHostPath.bind(this))); - } - else { - vscode.workspace.workspaceFolders?.forEach(folder => { - this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this))); - }); - } - - // As additional workspace folders are added/removed, we need to watch/unwatch them too + constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) { this._disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(event => { - event.added.forEach(folder => { - this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this))); - }); - event.removed.forEach(folder => { - const disposable = this._workspaceSettingsJsonWatchers.get(folder); - if (disposable) { - disposable.dispose(); - this._workspaceSettingsJsonWatchers.delete(folder); - } - }); + void this.updateWorkspaceAppHostContext(); + })); + + this._disposables.push(this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => { + void this.processActiveDocumentForWorkspace(workspaceFolder); })); this._disposables.push(vscode.window.onDidChangeActiveTextEditor(async (editor) => { @@ -54,160 +37,81 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } } + private async processActiveDocumentForWorkspace(workspaceFolder: vscode.WorkspaceFolder): Promise { + const activeDocument = vscode.window.activeTextEditor?.document; + if (!activeDocument) { + await this.updateWorkspaceAppHostContext(); + return; + } + + const activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder(activeDocument.uri); + if (activeWorkspaceFolder?.uri.toString() === workspaceFolder.uri.toString()) { + await this.processDocument(activeDocument); + } + } + 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.isAppHostFile(document.uri.fsPath)) { - vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', true); - } - else { - vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', false); - } + vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', await this.tryFindCandidateForEditorFile(document.uri.fsPath) !== undefined); + await this.updateWorkspaceAppHostContext(); } - private async isAppHostFile(filePath: string): Promise { - const fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString()); - const lines = fileText.split(/\r?\n/); - - // C# apphost detection - if (lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'))) { - return true; - } - - if (lines.some(line => line === 'var builder = DistributedApplication.CreateBuilder(args);')) { - return true; + private async updateWorkspaceAppHostContext(): Promise { + const workspaceFolder = vscode.window.activeTextEditor + ? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri) + : vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', false); + return; } - // TypeScript/JavaScript apphost detection - const ext = path.extname(filePath).toLowerCase(); - if (['.ts', '.js', '.mts', '.mjs'].includes(ext)) { - // Match both the new `.aspire/modules/aspire` import path and the legacy - // `.modules/aspire` path so legacy stable-channel TypeScript AppHosts that - // still import from `./.modules/aspire.js` continue to expose Run/Debug - // commands via the `aspire.fileIsAppHost` context. - if (lines.some(line => /import\s+.*createBuilder.*from\s+['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) { - return true; - } + const appHostPath = await this.trySelectWorkspaceAppHostPath(workspaceFolder); + vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', appHostPath !== undefined); + } - if (lines.some(line => /require\s*\(['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) { - return true; + /** + * Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available. + */ + public async getAppHostPath(): Promise { + if (vscode.window.activeTextEditor) { + const candidate = await this.tryFindCandidateForEditorFile(vscode.window.activeTextEditor.document.uri.fsPath); + if (candidate) { + return getDebugTargetForCandidate(candidate); } } - return false; - } + const workspaceFolder = vscode.window.activeTextEditor + ? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri) + : vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return null; + } - private onChangeAppHostPath(newPath: string | null) { - vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', !!newPath); - this._workspaceAppHostPath = newPath; + return await this.trySelectWorkspaceAppHostPath(workspaceFolder) ?? null; } - private watchWorkspaceForAppHostPathChanges(workspaceFolder: vscode.WorkspaceFolder, onChangeAppHostPath: (newPath: string | null) => void): vscode.Disposable { - const disposables: vscode.Disposable[] = []; - - // Watch new format: aspire.config.json in workspace root - const newFormatWatcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceFolder, aspireConfigFileName) - ); - newFormatWatcher.onDidCreate(async uri => readConfigFileAndInvokeCallback(uri)); - newFormatWatcher.onDidChange(uri => readConfigFileAndInvokeCallback(uri)); - newFormatWatcher.onDidDelete(() => { - // When new format is deleted, try to fall back to legacy format - const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json'); - vscode.workspace.fs.stat(legacyUri).then( - () => readConfigFileAndInvokeCallback(legacyUri), - () => onChangeAppHostPath(null) - ); - }); - disposables.push(newFormatWatcher); - - // Watch legacy format: .aspire/settings.json - const legacyWatcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceFolder, '.aspire/settings.json') - ); - legacyWatcher.onDidCreate(async uri => { - // Only use legacy if new format doesn't exist - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - try { - await vscode.workspace.fs.stat(newFormatUri); - // New format exists, ignore legacy change - } catch { - readConfigFileAndInvokeCallback(uri); - } - }); - legacyWatcher.onDidChange(async uri => { - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - try { - await vscode.workspace.fs.stat(newFormatUri); - // New format exists, ignore legacy change - } catch { - readConfigFileAndInvokeCallback(uri); - } - }); - legacyWatcher.onDidDelete(() => { - // Legacy deleted; check if new format exists - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - vscode.workspace.fs.stat(newFormatUri).then( - () => readConfigFileAndInvokeCallback(newFormatUri), - () => onChangeAppHostPath(null) - ); - }); - disposables.push(legacyWatcher); - - // Read the initial value, preferring new format over legacy - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json'); - vscode.workspace.fs.stat(newFormatUri).then( - () => readConfigFileAndInvokeCallback(newFormatUri), - () => { - // New format doesn't exist, try legacy - vscode.workspace.fs.stat(legacyUri).then( - () => readConfigFileAndInvokeCallback(legacyUri), - () => onChangeAppHostPath(null) - ); - } - ); - - return { - dispose() { - disposables.forEach(d => d.dispose()); - } - }; - - async function readConfigFileAndInvokeCallback(uri: vscode.Uri) { - try { - const json = await readJsonFile(uri); - const appHostRelativePath = getAppHostPathFromConfig(json); - if (!appHostRelativePath) { - onChangeAppHostPath(null); - return; - } - - // Resolve relative path based on the config file's directory - const configDir = path.dirname(uri.fsPath); - const appHostPath = path.isAbsolute(appHostRelativePath) - ? appHostRelativePath - : path.join(configDir, appHostRelativePath); - onChangeAppHostPath(appHostPath); - } - catch { - onChangeAppHostPath(null); - } + private async tryFindCandidateForEditorFile(filePath: string): Promise { + try { + return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to discover AppHost for editor file ${filePath}: ${error}`); + return undefined; } } - /** - * Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available. - */ - public async getAppHostPath(): Promise { - if (vscode.window.activeTextEditor && await this.isAppHostFile(vscode.window.activeTextEditor.document.uri.fsPath)) { - return vscode.window.activeTextEditor.document.uri.fsPath; + private async trySelectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder): Promise { + try { + const appHosts = await this._appHostDiscoveryService.discover(workspaceFolder); + return await selectWorkspaceAppHostPath(workspaceFolder, appHosts); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to discover AppHost candidates for workspace ${workspaceFolder.uri.fsPath}: ${error}`); + return undefined; } - - return this._workspaceAppHostPath; } public async tryExecuteRunAppHost(noDebug: boolean): Promise { @@ -251,6 +155,5 @@ export class AspireEditorCommandProvider implements vscode.Disposable { dispose() { this._disposables.forEach(disposable => disposable.dispose()); - this._workspaceSettingsJsonWatchers.forEach(disposable => disposable.dispose()); } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index b4620ee821f..eb7ab99ee8b 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -38,6 +38,7 @@ import { readGitCommitSha } from './utils/versionInfo'; import { collectResourceCommandArguments } from './views/ResourceCommandArguments'; import { createResourceCommandArgumentLoader } from './views/ResourceCommandArgumentsLoader'; import { ResourceCommandJson } from './views/AppHostDataRepository'; +import { AppHostDiscoveryService } from './utils/appHostDiscovery'; let aspireExtensionContext = new AspireExtensionContext(); @@ -61,7 +62,10 @@ export async function activate(context: vscode.ExtensionContext) { terminalProvider.dcpServerConnectionInfo = dcpServer.connectionInfo; terminalProvider.closeAllOpenAspireTerminals(); - const editorCommandProvider = new AspireEditorCommandProvider(); + const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider); + context.subscriptions.push(appHostDiscoveryService); + + const editorCommandProvider = new AspireEditorCommandProvider(appHostDiscoveryService); const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, (tp) => addCommand(tp, editorCommandProvider))); const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', terminalProvider, newCommand)); @@ -85,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext) { const verifyCliInstalledRegistration = vscode.commands.registerCommand('aspire-vscode.verifyCliInstalled', verifyCliInstalledCommand); // Aspire panel - running app hosts tree view - const dataRepository = new AppHostDataRepository(terminalProvider); + const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService); const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, context.globalState); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { treeDataProvider: appHostTreeProvider, @@ -195,7 +199,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); context.subscriptions.push(installCliStableRegistration, installCliDailyRegistration, verifyCliInstalledRegistration); - const debugConfigProvider = new AspireDebugConfigurationProvider(); + const debugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -219,7 +223,7 @@ export async function activate(context: vscode.ExtensionContext) { const getEnableSettingsFileCreationPromptOnStartup = () => vscode.workspace.getConfiguration('aspire').get('enableSettingsFileCreationPromptOnStartup', true); const setEnableSettingsFileCreationPromptOnStartup = async (value: boolean) => await vscode.workspace.getConfiguration('aspire').update('enableSettingsFileCreationPromptOnStartup', value, vscode.ConfigurationTarget.Workspace); const appHostDisposablePromise = checkForExistingAppHostPathInWorkspace( - terminalProvider, + appHostDiscoveryService, getEnableSettingsFileCreationPromptOnStartup, setEnableSettingsFileCreationPromptOnStartup ); diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 7513438ea57..dd1b6031a6d 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -50,6 +50,7 @@ suite('AppHostDataRepository', () => { let getCliPathStub: sinon.SinonStub; let spawnStub: sinon.SinonStub; let defaultWorkspaceFoldersStub: sinon.SinonStub; + let findFilesStub: sinon.SinonStub; setup(() => { subscriptions = []; @@ -58,11 +59,13 @@ suite('AppHostDataRepository', () => { spawnStub = sinon.stub(cliModule, 'spawnCliProcess'); spawnStub.callsFake(() => new TestChildProcess()); defaultWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); + findFilesStub = sinon.stub(vscode.workspace, 'findFiles').resolves([]); }); teardown(() => { spawnStub.restore(); getCliPathStub.restore(); + findFilesStub.restore(); if (defaultWorkspaceFoldersStub.restore) { defaultWorkspaceFoldersStub.restore(); } @@ -410,7 +413,7 @@ suite('AppHostDataRepository', () => { '/workspace/samples/Store/AppHost.csproj', ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.strictEqual(repository.viewMode, 'workspace'); assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); @@ -439,6 +442,12 @@ suite('AppHostDataRepository', () => { path: configuredAppHostPath, }, })); + findFilesStub.callsFake(async (include: vscode.GlobPattern) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.endsWith('aspire.config.json') + ? [vscode.Uri.file(path.join(workspaceRoot, 'aspire.config.json'))] + : []; + }); let getAppHostsLineCallback: ((line: string) => void) | undefined; let psOptions: any; @@ -677,7 +686,7 @@ suite('AppHostDataRepository', () => { selected_project_file: '/workspace/apphost/apphost.cs', all_project_file_candidates: ['/workspace/apphost/apphost.cs'], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']); @@ -751,7 +760,7 @@ suite('AppHostDataRepository', () => { }, ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); psOptions.lineCallback(JSON.stringify([ @@ -822,7 +831,7 @@ suite('AppHostDataRepository', () => { }, ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); psOptions.lineCallback(JSON.stringify([])); @@ -877,7 +886,7 @@ suite('AppHostDataRepository', () => { selected_project_file: '/workspace/labs/ops/apphost.cs', all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(describeOptions); assert.ok(psOptions); @@ -1310,7 +1319,7 @@ async function waitForAppHostDiscovery(): Promise { } async function waitForCondition(condition: () => boolean, message: string): Promise { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 100; i++) { if (condition()) { return; } diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts new file mode 100644 index 00000000000..dfeadd44878 --- /dev/null +++ b/extension/src/test/appHostDiscovery.test.ts @@ -0,0 +1,473 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as cliModule from '../debugger/languages/cli'; +import { AppHostDiscoveryService, findCandidateForEditorFile, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery'; +import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; + +suite('AppHost discovery', () => { + test('resolves SDK-style C# AppHost source file to discovered project candidate', () => { + const appHostProjectPath = buildPath('workspace', 'AppHost', 'AppHost.csproj'); + const programPath = buildPath('workspace', 'AppHost', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: appHostProjectPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostProjectPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostProjectPath); + }); + + test('keeps file-based C# AppHost candidate as source file', () => { + const appHostPath = buildPath('workspace', 'AppHost', 'apphost.cs'); + + const candidate = findCandidateForEditorFile(appHostPath, [{ + path: appHostPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath); + }); + + test('keeps TypeScript AppHost candidate as source file', () => { + const appHostPath = buildPath('workspace', 'AppHost', 'apphost.ts'); + + const candidate = findCandidateForEditorFile(appHostPath, [{ + path: appHostPath, + language: 'typescript/nodejs', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath); + }); + + test('returns undefined when no discovered candidate contains C# source file', () => { + const programPath = buildPath('workspace', 'Web', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + test('does not map source file to non-C# project candidate', () => { + const programPath = buildPath('workspace', 'AppHost', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: buildPath('workspace', 'AppHost', 'apphost.ts'), + language: 'typescript/nodejs', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + test('maps C# file in AppHost project directory to discovered project candidate', () => { + const helperPath = buildPath('workspace', 'AppHost', 'Helper.cs'); + + const candidate = findCandidateForEditorFile(helperPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, buildPath('workspace', 'AppHost', 'AppHost.csproj')); + }); + + test('does not map C# file under bin directory to discovered project candidate', () => { + const generatedPath = buildPath('workspace', 'AppHost', 'bin', 'Debug', 'net10.0', 'Generated.cs'); + + const candidate = findCandidateForEditorFile(generatedPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + suite('service', () => { + let sandbox: sinon.SinonSandbox; + let findFilesStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + findFilesStub = sandbox.stub(vscode.workspace, 'findFiles').resolves([]); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('does not force refresh discovery after cached negative editor lookup', async () => { + stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }])); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + const firstResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder); + const secondResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder); + + assert.strictEqual(firstResult, undefined); + assert.strictEqual(secondResult, undefined); + assert.strictEqual(spawnStub.callCount, 1); + } + finally { + service.dispose(); + } + }); + + test('fires change event and invalidates cache when watched files change', async () => { + const watcherCallbacks = stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + let changedWorkspaceFolder: vscode.WorkspaceFolder | undefined; + const subscription = service.onDidChangeCandidates(folder => { + changedWorkspaceFolder = folder; + }); + + try { + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + + watcherCallbacks[0](); + assert.strictEqual(changedWorkspaceFolder, workspaceFolder); + + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 2); + } + finally { + subscription.dispose(); + service.dispose(); + } + }); + + test('ignores watched files in excluded directories', async () => { + const watcherCallbacks = stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + let changeCount = 0; + const subscription = service.onDidChangeCandidates(() => { + changeCount++; + }); + + try { + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + + watcherCallbacks[0](vscode.Uri.file(buildPath('workspace', 'AppHost', 'bin', 'Debug', 'Generated.csproj'))); + assert.strictEqual(changeCount, 0); + + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + } + finally { + subscription.dispose(); + service.dispose(); + } + }); + + test('kills in-flight CLI process when disposed', async () => { + stubFileSystemWatchers(sandbox); + const childProcess = { + killed: false, + kill: sandbox.stub().callsFake(() => { + childProcess.killed = true; + return true; + }), + }; + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').returns(childProcess as any); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + + const discovery = service.discover(workspaceFolder); + await waitForMicrotasks(); + + service.dispose(); + + await assert.rejects(discovery, /disposed/); + assert.strictEqual(spawnStub.callCount, 1); + assert.strictEqual(childProcess.kill.callCount, 1); + assert.strictEqual(childProcess.killed, true); + }); + + test('times out hung CLI process and allows retry', async () => { + stubFileSystemWatchers(sandbox); + sandbox.stub(vscode.workspace, 'getConfiguration').returns({ + get: (key: string, defaultValue: T) => key === 'appHostDiscoveryTimeoutMs' ? 5000 as T : defaultValue, + } as vscode.WorkspaceConfiguration); + const clock = sandbox.useFakeTimers(); + const killedArgs: string[][] = []; + let hangCli = true; + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args = [], options) => { + const childProcess = { + killed: false, + kill: sandbox.stub().callsFake(() => { + childProcess.killed = true; + killedArgs.push(args); + return true; + }), + }; + if (!hangCli) { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + } + return childProcess as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + + try { + const discovery = service.discover(workspaceFolder); + await waitForMicrotasks(); + + await clock.tickAsync(5_000); + await waitForMicrotasks(); + await clock.tickAsync(5_000); + + await assert.rejects(discovery, /timed out after 5 seconds/); + assert.deepStrictEqual(killedArgs, [ + ['ls', '--format', 'json'], + ['extension', 'get-apphosts'], + ]); + + hangCli = false; + const retryResult = await service.discover(workspaceFolder); + assert.deepStrictEqual(retryResult, []); + assert.strictEqual(spawnStub.callCount, 3); + } + finally { + service.dispose(); + clock.restore(); + } + }); + + test('keeps valid aspire ls candidates when future entries have unexpected shape', async () => { + stubFileSystemWatchers(sandbox); + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([ + { + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }, + { + path: buildPath('workspace', 'Future', 'AppHost.csproj'), + language: 'csharp', + status: 42, + extraMetadata: true, + }, + ])); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + const result = await service.discover(makeWorkspaceFolder(buildPath('workspace'))); + + assert.deepStrictEqual(result, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + } + finally { + service.dispose(); + } + }); + + test('reports both aspire ls and legacy fallback errors when discovery fails', async () => { + stubFileSystemWatchers(sandbox); + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args = [], options) => { + options?.stderrCallback?.(`${args.join(' ')} failed`); + options?.exitCallback?.(1); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + await assert.rejects( + service.discover(makeWorkspaceFolder(buildPath('workspace'))), + /aspire ls discovery failed: ls --format json failed\naspire extension get-apphosts fallback failed: extension get-apphosts failed/); + } + finally { + service.dispose(); + } + }); + + test('selects configured path from recursive config during service discovery', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-apphost-discovery-')); + try { + stubFileSystemWatchers(sandbox); + const firstConfigPath = path.join(tempDir, 'First', 'aspire.config.json'); + const secondConfigPath = path.join(tempDir, 'Second', 'aspire.config.json'); + const matchingAppHostPath = path.join(tempDir, 'Second', 'AppHost', 'AppHost.csproj'); + const otherAppHostPath = path.join(tempDir, 'Other', 'AppHost', 'AppHost.csproj'); + + fs.mkdirSync(path.dirname(firstConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(secondConfigPath), { recursive: true }); + fs.writeFileSync(firstConfigPath, JSON.stringify({ appHost: { path: 'Missing/AppHost.csproj' } })); + fs.writeFileSync(secondConfigPath, JSON.stringify({ appHost: { path: 'AppHost/AppHost.csproj' } })); + findFilesStub.callsFake(async (include: vscode.GlobPattern) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.endsWith('aspire.config.json') + ? [vscode.Uri.file(firstConfigPath), vscode.Uri.file(secondConfigPath)] + : []; + }); + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([ + { + path: otherAppHostPath, + language: 'csharp', + status: 'buildable', + }, + { + path: matchingAppHostPath, + language: 'csharp', + status: 'buildable', + }, + ])); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + const result = await service.discover(makeWorkspaceFolder(tempDir)); + + assert.deepStrictEqual(result, [ + { + path: otherAppHostPath, + language: 'csharp', + status: 'buildable', + selected: false, + }, + { + path: matchingAppHostPath, + language: 'csharp', + status: 'buildable', + selected: true, + }, + ]); + } + finally { + service.dispose(); + } + } + finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('selects configured path that matches a later discovered candidate', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-apphost-discovery-')); + try { + const workspaceFolder = makeWorkspaceFolder(tempDir); + const firstConfigPath = path.join(tempDir, 'First', 'aspire.config.json'); + const secondConfigPath = path.join(tempDir, 'Second', 'aspire.config.json'); + const matchingAppHostPath = path.join(tempDir, 'Second', 'AppHost', 'AppHost.csproj'); + + fs.mkdirSync(path.dirname(firstConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(secondConfigPath), { recursive: true }); + fs.writeFileSync(firstConfigPath, JSON.stringify({ appHost: { path: 'Missing/AppHost.csproj' } })); + fs.writeFileSync(secondConfigPath, JSON.stringify({ appHost: { path: 'AppHost/AppHost.csproj' } })); + + findFilesStub.callsFake(async (include: vscode.GlobPattern) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.endsWith('aspire.config.json') + ? [vscode.Uri.file(firstConfigPath), vscode.Uri.file(secondConfigPath)] + : []; + }); + + const selectedPath = await selectWorkspaceAppHostPath(workspaceFolder, [{ + path: matchingAppHostPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(selectedPath, matchingAppHostPath); + } + finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); +}); + +function buildPath(...segments: string[]): string { + return path.join(path.sep, ...segments); +} + +function makeWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file(folderPath), + name: path.basename(folderPath), + index: 0, + }; +} + +function makeTerminalProvider(): AspireTerminalProvider { + return { + getAspireCliExecutablePath: async () => 'aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; +} + +async function waitForMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function stubFileSystemWatchers(sandbox: sinon.SinonSandbox): Array<(uri?: vscode.Uri) => void> { + const callbacks: Array<(uri?: vscode.Uri) => void> = []; + sandbox.stub(vscode.workspace, 'createFileSystemWatcher').callsFake(() => ({ + onDidCreate: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + onDidChange: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + onDidDelete: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + dispose: () => { }, + } as vscode.FileSystemWatcher)); + + return callbacks; +} diff --git a/extension/src/test/aspireDebugConfigurationProvider.test.ts b/extension/src/test/aspireDebugConfigurationProvider.test.ts new file mode 100644 index 00000000000..899ec4b9475 --- /dev/null +++ b/extension/src/test/aspireDebugConfigurationProvider.test.ts @@ -0,0 +1,195 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireDebugConfigurationProvider } from '../debugger/AspireDebugConfigurationProvider'; +import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; + +suite('AspireDebugConfigurationProvider', () => { + let tempDir: string; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-debug-configuration-provider-')); + }); + + teardown(() => { + sandbox.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('resolves launch config SDK-style AppHost Program.cs to containing project file', async () => { + const appHostDirectory = path.join(tempDir, 'AppHost'); + fs.mkdirSync(appHostDirectory); + + const programPath = path.join(appHostDirectory, 'Program.cs'); + const projectPath = path.join(appHostDirectory, 'AppHost.csproj'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); + fs.writeFileSync(projectPath, ''); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, projectPath); + }); + + test('leaves launch config single-file apphost.cs unchanged', async () => { + const appHostPath = path.join(tempDir, 'apphost.cs'); + fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath + }); + + assert.strictEqual(config?.program, appHostPath); + }); + + test('leaves launch config TypeScript apphost.ts unchanged', async () => { + const appHostPath = path.join(tempDir, 'apphost.ts'); + fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath, appHostPath, 'typescript/nodejs')); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath + }); + + assert.strictEqual(config?.program, appHostPath); + }); + + test('leaves launch config non-AppHost C# source file unchanged', async () => { + const appDirectory = path.join(tempDir, 'App'); + fs.mkdirSync(appDirectory); + + const programPath = path.join(appDirectory, 'Program.cs'); + fs.writeFileSync(programPath, 'Console.WriteLine("Hello");'); + fs.writeFileSync(path.join(appDirectory, 'App.csproj'), ''); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, programPath); + }); + + test('provides dynamic launch config when active file resolves to AppHost candidate', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const projectPath = path.join(tempDir, 'AppHost', 'AppHost.csproj'); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath)); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, projectPath); + }); + + test('provides default dynamic launch config when active file is not an AppHost candidate', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'Web', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath, null)); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, folder.uri.fsPath); + }); + + test('provides default dynamic launch config when discovery fails', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService()); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, folder.uri.fsPath); + }); + + test('provides default dynamic launch config when there is no active editor', async () => { + const folder = createWorkspaceFolder(tempDir); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(folder.uri.fsPath, null)); + sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, folder.uri.fsPath); + }); + + test('leaves launch config program unchanged when debug target resolution fails', async () => { + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService()); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, programPath); + }); + + function setActiveEditor(filePath: string, folder: vscode.WorkspaceFolder): void { + sandbox.stub(vscode.window, 'activeTextEditor').value({ + document: { + uri: vscode.Uri.file(filePath), + }, + }); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); + } +}); + +function createWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file(folderPath), + name: 'workspace', + index: 0, + }; +} + +function createAppHostDiscoveryService(resolvedPath: string, candidatePath: string | null = resolvedPath, language = 'csharp'): AppHostDiscoveryService { + return { + resolveDebugTarget: async () => resolvedPath, + tryFindCandidateForEditorFile: async () => candidatePath ? { + path: candidatePath, + language: language, + status: 'buildable', + } : undefined, + } as unknown as AppHostDiscoveryService; +} + +function createFailingAppHostDiscoveryService(): AppHostDiscoveryService { + return { + resolveDebugTarget: async () => { + throw new Error('discovery failed'); + }, + tryFindCandidateForEditorFile: async () => { + throw new Error('discovery failed'); + }, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/test/aspireEditorCommandProvider.test.ts b/extension/src/test/aspireEditorCommandProvider.test.ts new file mode 100644 index 00000000000..bede4044c7b --- /dev/null +++ b/extension/src/test/aspireEditorCommandProvider.test.ts @@ -0,0 +1,165 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; + +function createEditor(filePath: string): vscode.TextEditor { + return { + document: { + uri: vscode.Uri.file(filePath), + fileName: filePath, + languageId: filePath.endsWith('.ts') ? 'typescript' : 'csharp' + } as vscode.TextDocument + } as vscode.TextEditor; +} + +suite('AspireEditorCommandProvider', () => { + let tempDir: string; + let activeEditor: vscode.TextEditor | undefined; + let activeEditorStub: sinon.SinonStub; + let workspaceFoldersStub: sinon.SinonStub; + let getWorkspaceFolderStub: sinon.SinonStub; + let onDidChangeWorkspaceFoldersStub: sinon.SinonStub; + let onDidChangeActiveTextEditorStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + + setup(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-editor-command-provider-')); + activeEditor = undefined; + + activeEditorStub = sinon.stub(vscode.window, 'activeTextEditor').get(() => activeEditor); + workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); + getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => { + if (uri.fsPath.startsWith(tempDir)) { + return { uri: vscode.Uri.file(tempDir), name: 'test', index: 0 }; + } + + return undefined; + }); + onDidChangeWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'onDidChangeWorkspaceFolders').returns({ dispose: () => { } } as vscode.Disposable); + onDidChangeActiveTextEditorStub = sinon.stub(vscode.window, 'onDidChangeActiveTextEditor').returns({ dispose: () => { } } as vscode.Disposable); + executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + }); + + teardown(() => { + executeCommandStub.restore(); + onDidChangeActiveTextEditorStub.restore(); + onDidChangeWorkspaceFoldersStub.restore(); + getWorkspaceFolderStub.restore(); + workspaceFoldersStub.restore(); + activeEditorStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('returns containing project file when active editor is SDK-style AppHost Program.cs', async () => { + const appHostDirectory = path.join(tempDir, 'AppHost'); + fs.mkdirSync(appHostDirectory); + + const programPath = path.join(appHostDirectory, 'Program.cs'); + const projectPath = path.join(appHostDirectory, 'AppHost.csproj'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); + fs.writeFileSync(projectPath, ''); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath)); + try { + assert.strictEqual(await provider.getAppHostPath(), projectPath); + } + finally { + provider.dispose(); + } + }); + + test('returns source file when active editor is single-file apphost.cs', async () => { + const appHostPath = path.join(tempDir, 'apphost.cs'); + fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(appHostPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath)); + try { + assert.strictEqual(await provider.getAppHostPath(), appHostPath); + } + finally { + provider.dispose(); + } + }); + + test('returns source file when active editor is TypeScript apphost.ts', async () => { + const appHostPath = path.join(tempDir, 'apphost.ts'); + fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); + activeEditor = createEditor(appHostPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs')); + try { + assert.strictEqual(await provider.getAppHostPath(), appHostPath); + } + finally { + provider.dispose(); + } + }); + + test('clears AppHost contexts when discovery fails while processing document', async () => { + const programPath = path.join(tempDir, 'Program.cs'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + try { + await provider.processDocument(activeEditor.document); + + assert.ok(executeCommandStub.calledWith('setContext', 'aspire.fileIsAppHost', false)); + assert.ok(executeCommandStub.calledWith('setContext', 'aspire.workspaceHasAppHost', false)); + } + finally { + provider.dispose(); + } + }); + + test('returns null when discovery fails while resolving AppHost path', async () => { + const programPath = path.join(tempDir, 'Program.cs'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + try { + assert.strictEqual(await provider.getAppHostPath(), null); + } + finally { + provider.dispose(); + } + }); +}); + +function createAppHostDiscoveryService(resolvedPath: string, language = 'csharp'): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + tryFindCandidateForEditorFile: async () => ({ + path: resolvedPath, + language: language, + status: 'buildable', + }), + discover: async () => [{ + path: resolvedPath, + language: language, + status: 'buildable', + }], + } as unknown as AppHostDiscoveryService; +} + +function createFailingAppHostDiscoveryService(): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + tryFindCandidateForEditorFile: async () => { + throw new Error('discovery failed'); + }, + discover: async () => { + throw new Error('discovery failed'); + }, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts index 691e13239c6..3c4bdbeb821 100644 --- a/extension/src/test/workspace.test.ts +++ b/extension/src/test/workspace.test.ts @@ -1,15 +1,12 @@ import * as assert from 'assert'; -import type { ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import * as cliModule from '../debugger/languages/cli'; -import type { SpawnProcessOptions } from '../debugger/languages/cli'; -import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { yesLabel } from '../loc/strings'; -import { checkForExistingAppHostPathInWorkspace, findAppHostsWithAspireLs, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery'; suite('utils/workspace tests', () => { let sandbox: sinon.SinonSandbox; @@ -79,11 +76,6 @@ suite('utils/workspace tests', () => { }); test('AppHost selection quick pick shows aspire ls language and status metadata', async () => { - const terminalProvider = { - getAspireCliExecutablePath: async () => 'aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - let spawnOptions: SpawnProcessOptions | undefined; sandbox.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file('/workspace'), name: 'workspace', @@ -92,30 +84,20 @@ suite('utils/workspace tests', () => { sandbox.stub(vscode.workspace, 'findFiles').resolves([]); sandbox.stub(vscode.window, 'showInformationMessage').resolves(yesLabel as never); const showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick').resolves(undefined); - sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { - spawnOptions = options; - return { kill: () => true } as ChildProcessWithoutNullStreams; - }); - - const disposable = await checkForExistingAppHostPathInWorkspace(terminalProvider, () => true, async () => { }); - assert.ok(spawnOptions); - assert.ok(spawnOptions.stdoutCallback); - assert.ok(spawnOptions.exitCallback); - spawnOptions.stdoutCallback(JSON.stringify([ + const appHostDiscoveryService = createAppHostDiscoveryService([ { - relativePath: 'apps/Store/AppHost.csproj', path: '/workspace/apps/Store/AppHost.csproj', language: 'csharp', status: 'buildable', }, { - relativePath: 'samples/Store/AppHost.csproj', path: '/workspace/samples/Store/AppHost.csproj', language: 'typescript/nodejs', status: 'possibly-unbuildable', }, - ])); - spawnOptions.exitCallback(0); + ]); + + const disposable = await checkForExistingAppHostPathInWorkspace(appHostDiscoveryService, () => true, async () => { }); await waitForStubCall(showQuickPickStub); const items = showQuickPickStub.getCall(0).args[0] as readonly vscode.QuickPickItem[]; @@ -125,12 +107,12 @@ suite('utils/workspace tests', () => { detail: item.detail, })), [ { - label: 'apps/Store/AppHost.csproj', + label: path.join('apps', 'Store', 'AppHost.csproj'), description: 'C# · buildable', detail: '/workspace/apps/Store/AppHost.csproj', }, { - label: 'samples/Store/AppHost.csproj', + label: path.join('samples', 'Store', 'AppHost.csproj'), description: 'TypeScript · possibly-unbuildable', detail: '/workspace/samples/Store/AppHost.csproj', }, @@ -146,20 +128,15 @@ suite('utils/workspace tests', () => { const secondDiscoveredAppHostPath = path.join(workspaceRoot, 'samples', 'Store', 'AppHost.csproj'); try { - fs.writeFileSync(path.join(workspaceRoot, 'aspire.config.json'), JSON.stringify({ + const configPath = path.join(workspaceRoot, 'aspire.config.json'); + fs.writeFileSync(configPath, JSON.stringify({ appHost: { path: configuredAppHostPath, }, })); - - const terminalProvider = { - getAspireCliExecutablePath: async () => 'aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - let spawnOptions: SpawnProcessOptions | undefined; - sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { - spawnOptions = options; - return { kill: () => true } as ChildProcessWithoutNullStreams; + sandbox.stub(vscode.workspace, 'findFiles').callsFake(async (include) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.includes('aspire.config.json') ? [vscode.Uri.file(configPath)] : []; }); const rootFolder = { @@ -167,28 +144,24 @@ suite('utils/workspace tests', () => { name: 'workspace', index: 0, }; - const discovery = findAppHostsWithAspireLs(terminalProvider, 'aspire', rootFolder); - - assert.ok(spawnOptions); - assert.ok(spawnOptions.stdoutCallback); - assert.ok(spawnOptions.exitCallback); - spawnOptions.stdoutCallback(JSON.stringify([ + const result = await getWorkspaceAppHostProjectSearchResult(rootFolder, [ { - relativePath: 'apps/Store/AppHost.csproj', path: discoveredAppHostPath, language: 'csharp', status: 'buildable', }, { - relativePath: 'samples/Store/AppHost.csproj', path: secondDiscoveredAppHostPath, language: 'csharp', status: 'buildable', }, - ])); - spawnOptions.exitCallback(0); - - const result = await discovery.result; + { + path: configuredAppHostPath, + language: null, + status: 'buildable', + selected: true, + }, + ]); assert.strictEqual(result.selected_project_file, configuredAppHostPath); assert.deepStrictEqual(result.all_project_file_candidates, [ @@ -230,3 +203,10 @@ async function waitForStubCall(stub: sinon.SinonStub): Promise { assert.ok(stub.called); } + +function createAppHostDiscoveryService(candidates: Awaited>): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + discover: async () => candidates, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts new file mode 100644 index 00000000000..750d006d2d8 --- /dev/null +++ b/extension/src/utils/appHostDiscovery.ts @@ -0,0 +1,626 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { ChildProcessWithoutNullStreams } from 'child_process'; +import { spawnCliProcess } from '../debugger/languages/cli'; +import { AspireTerminalProvider } from './AspireTerminalProvider'; +import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes'; +import { EnvironmentVariables } from './environment'; +import { extensionLogOutputChannel } from './logging'; +import { getAppHostDiscoveryTimeoutMs } from './settings'; + +// Mirrors the `aspire ls --format json` candidate shape documented in +// docs/specs/cli-output-formats.md. Older CLI fallback results are adapted into +// this shape so extension code can keep using the modern discovery contract. +export interface CandidateAppHostDisplayInfo { + path: string; + language: string | null; + status: string | null; + selected?: boolean; +} + +export interface AppHostCandidate { + relativePath: string; + path: string; + language: string; + status: string; +} + +export interface AppHostProjectSearchResult { + selected_project_file: string | null; + all_project_file_candidates: string[]; + app_host_candidates: AppHostCandidate[]; +} + +interface LegacyAppHostProjectSearchResult { + selected_project_file: string | null; + all_project_file_candidates: string[]; +} + +const discoveryExcludePattern = '{**/artifacts/**,**/[Bb]in/**,**/[Oo]bj/**,**/node_modules/**,**/.git/**,**/.vs/**,**/.vscode-test/**,**/.idea/**,**/.aspire/modules/**}'; + +export class AppHostDiscoveryService implements vscode.Disposable { + private readonly _onDidChangeCandidates = new vscode.EventEmitter(); + private readonly _cache = new Map>(); + private readonly _watchers = new Map(); + private readonly _activeCliProcesses = new Set(); + private readonly _cancelActiveCliProcesses = new Set<(error: Error) => void>(); + private _disposed = false; + readonly onDidChangeCandidates = this._onDidChangeCandidates.event; + + constructor(private readonly _terminalProvider: AspireTerminalProvider) { + } + + async discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh = false): Promise { + this._throwIfDisposed(); + + const key = path.resolve(workspaceFolder.uri.fsPath); + if (forceRefresh) { + this._cache.delete(key); + } + + this._ensureWatchers(workspaceFolder, key); + + let resultPromise = this._cache.get(key); + if (!resultPromise) { + resultPromise = this._discoverCore(workspaceFolder) + .then(candidates => this._includeConfiguredAppHostCandidate(workspaceFolder, candidates)) + .catch(error => { + this._cache.delete(key); + throw error; + }); + this._cache.set(key, resultPromise); + } + + return resultPromise; + } + + async resolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + return await this.tryResolveDebugTarget(filePath, workspaceFolder) ?? filePath; + } + + async tryResolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + const candidate = await this.tryFindCandidateForEditorFile(filePath, workspaceFolder); + return candidate ? getDebugTargetForCandidate(candidate) : undefined; + } + + async tryFindCandidateForEditorFile(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + const folder = workspaceFolder ?? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)); + if (!folder) { + return undefined; + } + + const result = await this.discover(folder); + return findCandidateForEditorFile(filePath, result); + } + + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + for (const disposables of this._watchers.values()) { + disposables.forEach(disposable => disposable.dispose()); + } + this._watchers.clear(); + this._cache.clear(); + for (const cancel of [...this._cancelActiveCliProcesses]) { + cancel(new Error('AppHost discovery service was disposed.')); + } + this._cancelActiveCliProcesses.clear(); + this._activeCliProcesses.clear(); + this._onDidChangeCandidates.dispose(); + } + + private async _discoverCore(workspaceFolder: vscode.WorkspaceFolder): Promise { + try { + const appHosts = await this._discoverWithLs(workspaceFolder); + extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire ls`); + return appHosts; + } + catch (error) { + this._throwIfDisposed(); + extensionLogOutputChannel.warn(`aspire ls discovery failed, falling back to aspire extension get-apphosts: ${formatErrorMessage(error)}`); + try { + const appHosts = await this._discoverWithLegacyGetAppHosts(workspaceFolder); + extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire extension get-apphosts`); + return appHosts; + } + catch (fallbackError) { + this._throwIfDisposed(); + throw new Error(`aspire ls discovery failed: ${formatErrorMessage(error)}\naspire extension get-apphosts fallback failed: ${formatErrorMessage(fallbackError)}`); + } + } + } + + private async _discoverWithLs(workspaceFolder: vscode.WorkspaceFolder): Promise { + this._throwIfDisposed(); + + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + const args = ['ls', '--format', 'json']; + if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { + args.push('--cli-wait-for-debugger'); + } + + const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath); + return parseCandidateOutput(output, 'aspire ls'); + } + + private async _discoverWithLegacyGetAppHosts(workspaceFolder: vscode.WorkspaceFolder): Promise { + this._throwIfDisposed(); + + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + const args = ['extension', 'get-apphosts']; + if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { + args.push('--cli-wait-for-debugger'); + } + + const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath); + const parsed = parseLegacyGetAppHostsOutput(output); + return toCandidatesFromLegacySearchResult(parsed); + } + + private _ensureWatchers(workspaceFolder: vscode.WorkspaceFolder, key: string): void { + if (this._watchers.has(key)) { + return; + } + + const invalidate = (uri: vscode.Uri) => { + if (isExcludedDiscoveryUri(workspaceFolder, uri)) { + return; + } + + this._cache.delete(key); + this._onDidChangeCandidates.fire(workspaceFolder); + }; + const patterns = [ + '**/*.csproj', + '**/*.fsproj', + '**/*.vbproj', + '**/apphost.cs', + '**/apphost.ts', + '**/apphost.js', + '**/apphost.mts', + '**/apphost.mjs', + `**/${aspireConfigFileName}`, + '**/.aspire/settings.json', + ]; + + const watchers = patterns.map(pattern => { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(workspaceFolder, pattern)); + watcher.onDidCreate(uri => invalidate(uri)); + watcher.onDidChange(uri => invalidate(uri)); + watcher.onDidDelete(uri => invalidate(uri)); + return watcher; + }); + this._watchers.set(key, watchers); + } + + private _throwIfDisposed(): void { + if (this._disposed) { + throw new Error('AppHost discovery service has been disposed.'); + } + } + + private async _includeConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidates: CandidateAppHostDisplayInfo[]): Promise { + if (candidates.some(candidate => candidate.selected)) { + return candidates; + } + + const configuredPaths = await findConfiguredAppHostPaths(workspaceFolder); + const configuredPath = configuredPaths.find(configuredPath => candidates.some(candidate => isSamePath(candidate.path, configuredPath))) + ?? configuredPaths[0]; + if (!configuredPath) { + return candidates; + } + + const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath)); + if (matchingCandidate) { + return candidates.map(candidate => ({ + ...candidate, + selected: isSamePath(candidate.path, configuredPath), + })); + } + + return [ + ...candidates, + { + path: configuredPath, + language: null, + status: 'buildable', + selected: true, + }, + ]; + } + + private _runCliForStdout(cliPath: string, args: string[], workingDirectory: string): Promise { + return new Promise((resolve, reject) => { + this._throwIfDisposed(); + + let stdout = ''; + let stderr = ''; + let settled = false; + let childProcess: ChildProcessWithoutNullStreams | undefined; + let timeout: ReturnType | undefined; + const cancel = (error: Error) => { + if (childProcess && !childProcess.killed) { + try { + if (!childProcess.kill()) { + extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: aspire ${args.join(' ')}`); + } + } + catch (killError) { + extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: ${killError}`); + } + } + + settle(() => reject(error)); + }; + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + if (childProcess) { + this._activeCliProcesses.delete(childProcess); + } + this._cancelActiveCliProcesses.delete(cancel); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + complete(); + }; + + this._cancelActiveCliProcesses.add(cancel); + try { + childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + workingDirectory, + stdoutCallback: data => { stdout += data; }, + stderrCallback: data => { stderr += data; }, + exitCallback: code => { + settle(() => { + if (code === 0) { + resolve(stdout); + } + else { + reject(new Error(stderr || `exit code ${code ?? 1}`)); + } + }); + }, + errorCallback: error => { + settle(() => reject(error)); + }, + }); + } + catch (error) { + settle(() => reject(error instanceof Error ? error : new Error(String(error)))); + return; + } + + if (settled) { + return; + } + + this._activeCliProcesses.add(childProcess); + const timeoutMs = getAppHostDiscoveryTimeoutMs(); + timeout = setTimeout(() => { + cancel(new Error(`aspire ${args.join(' ')} timed out after ${timeoutMs / 1000} seconds.`)); + }, timeoutMs); + }); + } +} + +export function findCandidateForEditorFile(filePath: string, candidates: readonly CandidateAppHostDisplayInfo[]): CandidateAppHostDisplayInfo | undefined { + const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, filePath)); + if (matchingCandidate) { + return matchingCandidate; + } + + if (path.extname(filePath).toLowerCase() !== '.cs') { + return undefined; + } + + // IMPORTANT: `aspire ls` is still the source of truth for what is a valid AppHost. + // This block does not discover AppHosts by reading C# source files or by deciding + // that a project "looks like" an AppHost. It only handles the editor affordance gap + // in the current CLI shape: + // + // aspire ls --format json + // [ + // { "path": "/repo/AppHost/AppHost.csproj", "language": "csharp", "status": "buildable" } + // ] + // + // For SDK-style .NET AppHosts the launch target is the `.csproj`, but users usually + // have `Program.cs` or another C# source file open when they invoke Run/Debug from + // the editor or debug picker. Until the CLI returns source identity/project membership + // in the candidate payload, treat C# files under a candidate `.csproj` directory as + // editor aliases for that candidate. Pick the deepest candidate directory so nested + // AppHost candidates prefer their own project over an outer candidate. Keep this + // heuristic bounded to C# project candidates from `aspire ls` and remove it when the + // CLI can report the canonical source file or owning project for each candidate. + const projectCandidate = candidates + .filter(candidate => isCSharpProjectCandidate(candidate) && isCSharpSourceFileForProjectCandidate(filePath, candidate.path)) + .sort((left, right) => path.dirname(right.path).length - path.dirname(left.path).length)[0]; + return projectCandidate; +} + +export function getDebugTargetForCandidate(candidate: CandidateAppHostDisplayInfo): string { + return candidate.path; +} + +export function getWorkspaceAppHostProjectSearchResult(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): AppHostProjectSearchResult { + const appHostCandidates = candidates.map(candidate => toAppHostCandidate(workspaceFolder, candidate)); + const selectedAppHostPath = candidates.find(candidate => candidate.selected)?.path + ?? (candidates.length === 1 ? candidates[0].path : null); + const effectiveAppHostCandidates = selectedAppHostPath && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath)) + ? [...appHostCandidates, toConfiguredAppHostCandidate(workspaceFolder, selectedAppHostPath)] + : appHostCandidates; + const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate); + + return { + selected_project_file: selectedAppHostPath && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath)) + ? selectedAppHostPath + : null, + all_project_file_candidates: buildableCandidates.map(candidate => candidate.path), + app_host_candidates: effectiveAppHostCandidates, + }; +} + +export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolean { + return candidate.status === 'buildable'; +} + +export function formatAppHostLanguage(language: string): string | undefined { + if (!language) { + return undefined; + } + + switch (language.toLowerCase()) { + case 'csharp': + return 'C#'; + case 'typescript': + case 'typescript/nodejs': + return 'TypeScript'; + default: + return language.charAt(0).toUpperCase() + language.slice(1); + } +} + +export async function selectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): Promise { + const selectedCandidate = candidates.find(candidate => candidate.selected); + if (selectedCandidate) { + return selectedCandidate.path; + } + + const configuredPaths = await findConfiguredAppHostPaths(workspaceFolder); + for (const configuredPath of configuredPaths) { + const candidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath)); + if (candidate) { + return candidate.path; + } + } + + return candidates.length === 1 ? candidates[0].path : undefined; +} + +export async function findConfiguredAppHostPaths(workspaceFolder: vscode.WorkspaceFolder): Promise { + let newConfigFiles: vscode.Uri[]; + let legacySettingsFiles: vscode.Uri[]; + try { + [newConfigFiles, legacySettingsFiles] = await Promise.all([ + vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, `**/${aspireConfigFileName}`), discoveryExcludePattern), + vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, '**/.aspire/settings.json'), discoveryExcludePattern), + ]); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to find AppHost configuration files: ${formatErrorMessage(error)}`); + return []; + } + + const newConfigDirs = new Set(newConfigFiles.map(uri => path.dirname(uri.fsPath))); + const filteredLegacyFiles = legacySettingsFiles.filter(legacyUri => { + const projectRoot = path.dirname(path.dirname(legacyUri.fsPath)); + return !newConfigDirs.has(projectRoot); + }); + + const configuredPaths: string[] = []; + for (const uri of [...newConfigFiles, ...filteredLegacyFiles]) { + try { + const json = await readJsonFile(uri); + const appHostPath = getAppHostPathFromConfig(json); + if (appHostPath) { + configuredPaths.push(path.isAbsolute(appHostPath) ? appHostPath : path.join(path.dirname(uri.fsPath), appHostPath)); + } + } + catch { + } + } + + return configuredPaths; +} + +function isExcludedDiscoveryUri(workspaceFolder: vscode.WorkspaceFolder, uri: vscode.Uri): boolean { + const relativePath = path.relative(workspaceFolder.uri.fsPath, uri.fsPath); + if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return true; + } + + const segments = relativePath.split(/[\\/]+/); + return segments.some((segment, index) => { + const lowerSegment = segment.toLowerCase(); + return lowerSegment === 'artifacts' + || lowerSegment === 'bin' + || lowerSegment === 'obj' + || lowerSegment === 'node_modules' + || lowerSegment === '.git' + || lowerSegment === '.vs' + || lowerSegment === '.vscode-test' + || lowerSegment === '.idea' + || (lowerSegment === '.aspire' && segments[index + 1]?.toLowerCase() === 'modules'); + }); +} + +function toAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidate: CandidateAppHostDisplayInfo): AppHostCandidate { + return { + relativePath: path.relative(workspaceFolder.uri.fsPath, candidate.path), + path: candidate.path, + language: candidate.language ?? '', + status: candidate.status ?? 'buildable', + }; +} + +function toConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, appHostPath: string): AppHostCandidate { + return { + relativePath: path.relative(workspaceFolder.uri.fsPath, appHostPath), + path: appHostPath, + language: '', + status: 'buildable', + }; +} + +function parseCandidateOutput(output: string, commandName: string): CandidateAppHostDisplayInfo[] { + const trimmed = output.trim(); + if (!trimmed) { + return []; + } + + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + const appHosts = parsed + .filter(isLsCandidate) + .map(candidate => ({ + path: candidate.path, + language: candidate.language, + status: candidate.status, + })); + + const unexpectedCandidateCount = parsed.length - appHosts.length; + if (unexpectedCandidateCount > 0) { + extensionLogOutputChannel.warn(`${commandName} returned ${unexpectedCandidateCount} candidate(s) with an unexpected shape; ignoring those entries.`); + } + + return appHosts; + } + + if (isAppHostProjectSearchResult(parsed)) { + return parsed.app_host_candidates.map(candidate => ({ + path: candidate.path, + language: candidate.language, + status: candidate.status, + selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidate.path), + })); + } + + if (isLegacyAppHostProjectSearchResult(parsed)) { + return toCandidatesFromLegacySearchResult(parsed); + } + + throw new Error(`${commandName} returned an unexpected output shape.`); +} + +function parseLegacyGetAppHostsOutput(output: string): LegacyAppHostProjectSearchResult { + // `aspire extension get-apphosts` prints a single JSON object: + // {"selected_project_file":"/repo/AppHost/AppHost.csproj","all_project_file_candidates":["/repo/AppHost/AppHost.csproj"]} + // Older builds can include log lines, so scan for the first line with the expected shape. + for (const line of output.split(/\r?\n/)) { + try { + const parsed = JSON.parse(line); + if (isLegacyAppHostProjectSearchResult(parsed)) { + return parsed; + } + } + catch { + } + } + + const parsed = JSON.parse(output.trim()); + if (isLegacyAppHostProjectSearchResult(parsed)) { + return parsed; + } + + throw new Error('aspire extension get-apphosts returned an unexpected output shape.'); +} + +function isLsCandidate(obj: unknown): obj is CandidateAppHostDisplayInfo { + return !!obj + && typeof obj === 'object' + && typeof (obj as CandidateAppHostDisplayInfo).path === 'string' + && typeof (obj as CandidateAppHostDisplayInfo).language === 'string' + && typeof (obj as CandidateAppHostDisplayInfo).status === 'string'; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isLegacyAppHostProjectSearchResult(obj: unknown): obj is LegacyAppHostProjectSearchResult { + return !!obj + && typeof obj === 'object' + && (typeof (obj as LegacyAppHostProjectSearchResult).selected_project_file === 'string' || (obj as LegacyAppHostProjectSearchResult).selected_project_file === null) + && Array.isArray((obj as LegacyAppHostProjectSearchResult).all_project_file_candidates); +} + +function isAppHostProjectSearchResult(obj: unknown): obj is AppHostProjectSearchResult { + return !!obj + && typeof obj === 'object' + && (typeof (obj as AppHostProjectSearchResult).selected_project_file === 'string' || (obj as AppHostProjectSearchResult).selected_project_file === null) + && Array.isArray((obj as AppHostProjectSearchResult).app_host_candidates) + && (obj as AppHostProjectSearchResult).app_host_candidates.every(candidate => + candidate + && typeof candidate.relativePath === 'string' + && typeof candidate.path === 'string' + && typeof candidate.language === 'string' + && typeof candidate.status === 'string'); +} + +function toCandidatesFromLegacySearchResult(parsed: LegacyAppHostProjectSearchResult): CandidateAppHostDisplayInfo[] { + return parsed.all_project_file_candidates.filter(candidate => typeof candidate === 'string').map(candidatePath => ({ + path: candidatePath, + language: null, + status: null, + selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidatePath), + })); +} + +function isCSharpProjectCandidate(candidate: CandidateAppHostDisplayInfo): boolean { + // Only `.csproj` candidates can own nearby C# source files for the editor alias + // heuristic above. Modern `aspire ls` candidates include the CLI language id + // (`language: "csharp"`); legacy `aspire extension get-apphosts` fallback + // candidates do not have a language, so `null` is treated as C# here to + // preserve old CLI support while keeping the compatibility gap local to + // candidate adaptation/matching. + return path.extname(candidate.path).toLowerCase() === '.csproj' + && (candidate.language === null || candidate.language.toLowerCase() === 'csharp'); +} + +function isCSharpSourceFileForProjectCandidate(filePath: string, projectPath: string): boolean { + const projectDirectory = path.dirname(path.resolve(projectPath)); + const sourcePath = path.resolve(filePath); + const comparison = process.platform === 'win32' || process.platform === 'darwin' + ? 'case-insensitive' + : 'case-sensitive'; + const normalizedProjectDirectory = comparison === 'case-insensitive' ? projectDirectory.toLowerCase() : projectDirectory; + const normalizedSourcePath = comparison === 'case-insensitive' ? sourcePath.toLowerCase() : sourcePath; + const relativePath = path.relative(normalizedProjectDirectory, normalizedSourcePath); + return relativePath !== '' + && !relativePath.startsWith('..') + && !path.isAbsolute(relativePath) + && !relativePath.split(path.sep).some(segment => segment.toLowerCase() === 'bin' || segment.toLowerCase() === 'obj'); +} + +function isSamePath(left: string, right: string): boolean { + const comparison = process.platform === 'win32' || process.platform === 'darwin' + ? 'case-insensitive' + : 'case-sensitive'; + const resolvedLeft = path.resolve(left); + const resolvedRight = path.resolve(right); + return comparison === 'case-insensitive' + ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + : resolvedLeft === resolvedRight; +} diff --git a/extension/src/utils/cliTypes.ts b/extension/src/utils/cliTypes.ts index 861a788cb47..ff2185b91e3 100644 --- a/extension/src/utils/cliTypes.ts +++ b/extension/src/utils/cliTypes.ts @@ -7,7 +7,7 @@ import { stripComments } from 'jsonc-parser'; */ export async function readJsonFile(uri: vscode.Uri): Promise { const buffer = await vscode.workspace.fs.readFile(uri); - const raw = buffer.toString(); + const raw = Buffer.from(buffer).toString('utf8'); return JSON.parse(stripComments(raw)); } diff --git a/extension/src/utils/settings.ts b/extension/src/utils/settings.ts index 93008fad1e9..7582e58e998 100644 --- a/extension/src/utils/settings.ts +++ b/extension/src/utils/settings.ts @@ -19,3 +19,8 @@ export function getRegisterMcpServerInWorkspace(): boolean { export function getEnableAutoRestore(): boolean { return getAspireConfig().get('enableAutoRestore', false); } + +export function getAppHostDiscoveryTimeoutMs(): number { + const timeoutMs = getAspireConfig().get('appHostDiscoveryTimeoutMs', 30000); + return Number.isFinite(timeoutMs) ? Math.max(timeoutMs, 1000) : 30000; +} diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 87e7edc6216..771006d7cf4 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -1,13 +1,11 @@ import * as vscode from 'vscode'; import { appHostCandidateDescription, cliNotAvailable, cliFoundAtDefaultPath, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; import path from 'path'; -import { spawnCliProcess } from '../debugger/languages/cli'; -import { AspireTerminalProvider } from './AspireTerminalProvider'; -import { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireConfigFile, aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; -import { EnvironmentVariables } from './environment'; import { resolveCliPath } from './cliPath'; +import { AppHostDiscoveryService, AppHostProjectSearchResult, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult } from './appHostDiscovery'; +import type { AppHostCandidate } from './appHostDiscovery'; /** * Common file patterns to exclude from workspace file searches. @@ -110,19 +108,6 @@ export function getRelativePathToWorkspace(filePath: string): string { return filePath; } -export interface AppHostCandidate { - relativePath: string; - path: string; - language: string; - status: string; -} - -export interface AppHostProjectSearchResult { - selected_project_file: string | null; - all_project_file_candidates: string[]; - app_host_candidates: AppHostCandidate[]; -} - interface AppHostQuickPickItem extends vscode.QuickPickItem { appHostPath: string; } @@ -131,132 +116,6 @@ export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolea return candidate.status === 'buildable'; } -function isAppHostCandidate(obj: any): obj is AppHostCandidate { - return obj - && typeof obj.relativePath === 'string' - && typeof obj.path === 'string' - && typeof obj.language === 'string' - && typeof obj.status === 'string'; -} - -interface ParsedAppHostCandidates { - candidates: AppHostCandidate[]; - selectedProjectFile: string | null; - isAspireLsOutput: boolean; -} - -function parseAppHostCandidates(stdout: string): ParsedAppHostCandidates { - const parsed = JSON.parse(stdout); - if (Array.isArray(parsed)) { - return { - candidates: parsed.filter(isAppHostCandidate), - selectedProjectFile: null, - isAspireLsOutput: true, - }; - } - - if (parsed - && (typeof parsed.selected_project_file === 'string' || parsed.selected_project_file === null) - && Array.isArray(parsed.all_project_file_candidates)) { - const candidates = parsed.all_project_file_candidates - .filter((appHostPath: unknown): appHostPath is string => typeof appHostPath === 'string') - .map((appHostPath: string) => ({ - relativePath: path.basename(appHostPath), - path: appHostPath, - language: '', - status: 'buildable', - })); - - return { - candidates, - selectedProjectFile: parsed.selected_project_file, - isAspireLsOutput: false, - }; - } - - return { - candidates: [], - selectedProjectFile: null, - isAspireLsOutput: true, - }; -} - -async function getConfiguredAppHostPathFromWorkspaceRoot(rootFolder: vscode.WorkspaceFolder): Promise { - const configUris = [ - vscode.Uri.joinPath(rootFolder.uri, aspireConfigFileName), - vscode.Uri.joinPath(rootFolder.uri, '.aspire', 'settings.json'), - ]; - - for (const uri of configUris) { - try { - const json = await readJsonFile(uri); - const appHostPath = getAppHostPathFromConfig(json); - if (!appHostPath) { - continue; - } - - const configDir = path.dirname(uri.fsPath); - return path.isAbsolute(appHostPath) - ? appHostPath - : path.join(configDir, appHostPath); - } catch { - // Missing or invalid settings files do not block AppHost discovery. - } - } - - return null; -} - -function createAppHostProjectSearchResult(appHostCandidates: AppHostCandidate[], selectedProjectFile: string | null, rootFolder: vscode.WorkspaceFolder): AppHostProjectSearchResult { - const effectiveAppHostCandidates = selectedProjectFile && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile)) - ? [...appHostCandidates, createConfiguredAppHostCandidate(selectedProjectFile, rootFolder)] - : appHostCandidates; - const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate); - const allProjectFileCandidates = buildableCandidates.map(candidate => candidate.path); - const selectedCandidate = selectedProjectFile && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile)) - ? selectedProjectFile - : null; - - return { - selected_project_file: selectedCandidate, - all_project_file_candidates: allProjectFileCandidates, - app_host_candidates: effectiveAppHostCandidates, - }; -} - -function createConfiguredAppHostCandidate(appHostPath: string, rootFolder: vscode.WorkspaceFolder): AppHostCandidate { - return { - relativePath: path.relative(rootFolder.uri.fsPath, appHostPath), - path: appHostPath, - language: '', - status: 'buildable', - }; -} - -function isSamePath(left: string, right: string): boolean { - const normalizedLeft = path.normalize(left); - const normalizedRight = path.normalize(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -export function formatAppHostLanguage(language: string): string | undefined { - if (!language) { - return undefined; - } - - switch (language.toLowerCase()) { - case 'csharp': - return 'C#'; - case 'typescript': - case 'typescript/nodejs': - return 'TypeScript'; - default: - return language.charAt(0).toUpperCase() + language.slice(1); - } -} - function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFolder: vscode.WorkspaceFolder): AppHostQuickPickItem[] { const candidates = result.app_host_candidates.length > 0 ? result.app_host_candidates @@ -279,66 +138,7 @@ function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFol }); } -export function findAppHostsWithAspireLs(terminalProvider: AspireTerminalProvider, cliPath: string, rootFolder: vscode.WorkspaceFolder): { process: ChildProcessWithoutNullStreams; result: Promise } { - let stdout = ''; - let stderr = ''; - const configuredAppHostPathPromise = getConfiguredAppHostPathFromWorkspaceRoot(rootFolder); - - const args = ['ls', '--format', 'json']; - if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { - args.push('--cli-wait-for-debugger'); - } - - let proc: ChildProcessWithoutNullStreams; - const result = new Promise((resolve, reject) => { - let settled = false; - proc = spawnCliProcess(terminalProvider, cliPath, args, { - errorCallback: error => { - settled = true; - extensionLogOutputChannel.error(`Error executing aspire ls command: ${error}`); - reject(error); - }, - exitCallback: async code => { - if (settled) { - return; - } - - if (code !== 0) { - settled = true; - extensionLogOutputChannel.warn(`aspire ls command exited with code: ${code}`); - reject(new Error(stderr || `aspire ls exited with code ${code}`)); - return; - } - - try { - const parsed = parseAppHostCandidates(stdout); - const selectedProjectFile = parsed.isAspireLsOutput - ? await configuredAppHostPathPromise - : null; - const effectiveSelectedProjectFile = selectedProjectFile ?? parsed.selectedProjectFile; - extensionLogOutputChannel.info(`Found ${parsed.candidates.length} AppHost candidates with aspire ls`); - settled = true; - resolve(createAppHostProjectSearchResult(parsed.candidates, effectiveSelectedProjectFile, rootFolder)); - } catch (error) { - settled = true; - reject(error); - } - }, - stdoutCallback: data => { - stdout += data; - }, - stderrCallback: data => { - stderr += data; - }, - noExtensionVariables: true, - workingDirectory: rootFolder.uri.fsPath - }); - }); - - return { process: proc!, result }; -} - -export async function checkForExistingAppHostPathInWorkspace(terminalProvider: AspireTerminalProvider, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { +export async function checkForExistingAppHostPathInWorkspace(appHostDiscoveryService: AppHostDiscoveryService, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { extensionLogOutputChannel.info('Checking for existing AppHost path in workspace'); const enabled = getEnableSettingsFileCreationPromptOnStartup(); @@ -392,24 +192,16 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A } const settingsFile = settingsFiles[0]; - extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire ls'); + extensionLogOutputChannel.info('Searching for AppHost projects using shared AppHost discovery'); - let proc: ChildProcessWithoutNullStreams | undefined; - const cliPath = await terminalProvider.getAspireCliExecutablePath(); - const discovery = findAppHostsWithAspireLs(terminalProvider, cliPath, rootFolder); - proc = discovery.process; - discovery.result + appHostDiscoveryService.discover(rootFolder, true) + .then(appHosts => getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts)) .then(result => promptToAddAppHostPathToSettingsFile(result, settingsFileExists, settingsFile, rootFolder, setEnableSettingsFileCreationPromptOnStartup)) .catch(error => { extensionLogOutputChannel.error(`Failed to retrieve AppHost projects: ${error}`); - }) - .finally(() => proc = undefined); + }); - return { - dispose() { - proc?.kill(); - } - }; + return null; } async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearchResult, settingsFileExists: boolean, settingsFileLocation: vscode.Uri, rootFolder: vscode.WorkspaceFolder, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 16c2592433b..42cd419fe54 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -5,7 +5,7 @@ import { spawnCliProcess } from '../debugger/languages/cli'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { extensionLogOutputChannel } from '../utils/logging'; import { appHostDescribeMayNotBeSupported, aspireCliDescribeNotSupported, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings'; -import { AppHostCandidate, findAppHostsWithAspireLs, formatAppHostLanguage, isBuildableAppHostCandidate } from '../utils/workspace'; +import { AppHostCandidate, AppHostDiscoveryService, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult, isBuildableAppHostCandidate } from '../utils/appHostDiscovery'; export interface ResourceUrlJson { name: string | null; @@ -141,7 +141,9 @@ export class AppHostDataRepository { private _workspaceAppHostDescription: string | undefined; private _workspaceAppHostDiscoveryComplete = false; private _workspaceAppHostDiscoveryUsesWorkspaceRoot = false; - private _getAppHostsProcess: ChildProcessWithoutNullStreams | undefined; + private readonly _appHostDiscoveryChangeDisposable: vscode.Disposable; + private readonly _appHostDiscoveryService: AppHostDiscoveryService; + private readonly _ownsAppHostDiscoveryService: boolean; // ── Error state ── private _describeErrorMessage: string | undefined; @@ -155,7 +157,15 @@ export class AppHostDataRepository { private readonly _configChangeDisposable: vscode.Disposable; private _disposed = false; - constructor(private readonly _terminalProvider: AspireTerminalProvider) { + constructor(private readonly _terminalProvider: AspireTerminalProvider, appHostDiscoveryService?: AppHostDiscoveryService) { + this._appHostDiscoveryService = appHostDiscoveryService ?? new AppHostDiscoveryService(_terminalProvider); + this._ownsAppHostDiscoveryService = appHostDiscoveryService === undefined; + this._appHostDiscoveryChangeDisposable = this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => { + const rootFolder = vscode.workspace.workspaceFolders?.[0]; + if (rootFolder?.uri.toString() === workspaceFolder.uri.toString()) { + this._fetchWorkspaceAppHost(); + } + }); this._fetchWorkspaceAppHost(); this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('aspire.globalAppHostsPollingInterval') && this._shouldPoll) { @@ -266,13 +276,12 @@ export class AppHostDataRepository { this._disposed = true; this._stopPolling(); this._stopDescribeWatch(); - if (this._getAppHostsProcess) { - const getAppHostsProcess = this._getAppHostsProcess; - this._getAppHostsProcess = undefined; - this._terminateProcess(getAppHostsProcess, 'aspire ls'); - } this._configChangeDisposable.dispose(); + this._appHostDiscoveryChangeDisposable.dispose(); this._onDidChangeData.dispose(); + if (this._ownsAppHostDiscoveryService) { + this._appHostDiscoveryService.dispose(); + } } // ── PS polling lifecycle ── @@ -333,29 +342,16 @@ export class AppHostDataRepository { const rootFolder = workspaceFolders[0]; this._workspaceAppHostDiscoveryUsesWorkspaceRoot = true; - extensionLogOutputChannel.info('Fetching workspace apphost via: aspire ls'); + extensionLogOutputChannel.info('Fetching workspace apphost via shared AppHost discovery'); - this._terminalProvider.getAspireCliExecutablePath().then(cliPath => { + this._appHostDiscoveryService.discover(rootFolder).then(appHosts => { if (this._disposed) { return; } - const discovery = findAppHostsWithAspireLs(this._terminalProvider, cliPath, rootFolder); - this._getAppHostsProcess = discovery.process; - discovery.result.then(result => { - if (this._disposed) { - return; - } - - this._getAppHostsProcess = undefined; - this._workspaceAppHostDiscoveryComplete = true; - this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file); - }).catch(error => { - this._getAppHostsProcess = undefined; - this._workspaceAppHostDiscoveryComplete = true; - extensionLogOutputChannel.warn(`aspire ls error: ${error}`); - this._syncPolling(); - }); + const result = getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts); + this._workspaceAppHostDiscoveryComplete = true; + this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file); }).catch(error => { this._workspaceAppHostDiscoveryComplete = true; extensionLogOutputChannel.warn(`Failed to fetch workspace apphost: ${error}`); diff --git a/playground/TypeScriptAppHost/.gitignore b/playground/TypeScriptAppHost/.gitignore index 9d70b9f5312..3f818ff5441 100644 --- a/playground/TypeScriptAppHost/.gitignore +++ b/playground/TypeScriptAppHost/.gitignore @@ -1,3 +1,4 @@ !.aspire/ .aspire/dcp/ .aspire/modules/ +.modules/ diff --git a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs index 0bcfdb3864f..326b876cf77 100644 --- a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs @@ -18,6 +18,7 @@ internal interface IAppHostCliBackchannel Task GetDashboardUrlsAsync(CancellationToken cancellationToken); IAsyncEnumerable GetAppHostLogEntriesAsync(CancellationToken cancellationToken); IAsyncEnumerable GetResourceStatesAsync(CancellationToken cancellationToken); + Task WaitForDisconnectAsync(CancellationToken cancellationToken); Task ConnectAsync(string socketPath, int retryCount, CancellationToken cancellationToken); Task ConnectAsync(string socketPath, bool autoReconnect, int retryCount, CancellationToken cancellationToken); IAsyncEnumerable GetPublishingActivitiesAsync(CancellationToken cancellationToken); @@ -34,6 +35,7 @@ internal sealed class AppHostCliBackchannel( { private const string BaselineCapability = "baseline.v2"; private TaskCompletionSource _rpcTaskCompletionSource = new(); + private TaskCompletionSource _disconnectTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private string? _socketPath; private bool _autoReconnect; private CancellationToken _cancellationToken; @@ -51,6 +53,17 @@ private Task GetRpcTaskAsync() } } + public async Task WaitForDisconnectAsync(CancellationToken cancellationToken) + { + Task disconnectTask; + lock (_lock) + { + disconnectTask = _disconnectTaskCompletionSource.Task; + } + + await disconnectTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + public async Task RequestStopAsync(CancellationToken cancellationToken) { // This RPC call is required to allow the CLI to trigger a clean shutdown @@ -301,6 +314,10 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC _socketPath = socketPath; _autoReconnect = autoReconnect; _cancellationToken = cancellationToken; + lock (_lock) + { + _disconnectTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } var connectingLogLevel = retryCount % 10 == 0 ? LogLevel.Debug : LogLevel.Trace; logger.Log(connectingLogLevel, "Connecting to AppHost backchannel at {SocketPath} (autoReconnect={AutoReconnect}, retryCount={RetryCount})", socketPath, autoReconnect, retryCount); @@ -340,6 +357,8 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC ); } + rpc.Disconnected += OnRpcDisconnected; + // Set up auto-reconnect if enabled if (autoReconnect) { @@ -401,6 +420,15 @@ private void OnDisconnected(object? sender, JsonRpcDisconnectedEventArgs args) }); } + private void OnRpcDisconnected(object? sender, JsonRpcDisconnectedEventArgs args) + { + logger.LogDebug("Backchannel disconnected: {Reason}", args.Reason); + lock (_lock) + { + _disconnectTaskCompletionSource.TrySetResult(); + } + } + private void ResetForReconnection() { lock (_lock) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 475728cdab0..56ffdb74fcb 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -160,7 +160,14 @@ await extensionInteractionService.LaunchAppHostAsync( execution.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(), options.StartDebugSession); - _ = StartBackchannelAsync(null, socketPath!, backchannelCompletionSource, backchannelParentContext, cancellationToken); + await StartBackchannelAsync(null, socketPath!, backchannelCompletionSource, backchannelParentContext, cancellationToken).ConfigureAwait(false); + var backchannel = await backchannelCompletionSource.Task.ConfigureAwait(false); + + // The extension launched the AppHost process, so there is no local Process to await. + // Keep this CLI process alive because it owns the AppHost backchannel; the extension + // will stop the CLI through the RPC endpoint when the managed debug session ends, + // or the AppHost backchannel will disconnect if the AppHost exits first. + await backchannel.WaitForDisconnectAsync(cancellationToken).ConfigureAwait(false); return CliExitCodes.Success; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 8d6cc53c443..3a78e399b7b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -219,8 +219,6 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv var isMcpStartCommand = args?.Length >= 2 && ((args[0] == "mcp" && args[1] == "start") || (args[0] == "agent" && args[1] == "mcp")); - var extensionEndpoint = Environment.GetEnvironmentVariable(KnownConfigNames.ExtensionEndpoint); - // Create file logger provider from pre-computed path info var fileLoggerProvider = new FileLoggerProvider(loggingOptions.LogFilePath, errorWriter); @@ -253,8 +251,12 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv builder.AddFilter("Aspire.Cli.Certificates.NativeCertificateToolRunner", LogLevel.Information); } - // Configure console logging based on --verbosity or --debug - if (consoleLogLevel is not null && !isMcpStartCommand && extensionEndpoint is null) + // Configure console logging based on --verbosity or --debug. + // When the CLI is hosted by the VS Code extension, stderr is captured line-by-line + // and surfaced in the Debug Console, so debug logs still need to flow to stderr there; + // suppressing them only because an extension endpoint is present meant that + // `--debug` produced no visible output under F5, even though it works in a terminal. + if (consoleLogLevel is not null && !isMcpStartCommand) { // Use custom Spectre Console logger for clean debug output to stderr builder.AddProvider(new SpectreConsoleLoggerProvider(Console.Error, logBufferContext)); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index b026298b55b..095a1f6363d 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -434,11 +434,80 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand logger.LogDebug("Stopping AppHost discovery early after finding multiple valid AppHost projects."); } + await AddSettingsAppHostCandidateAsync().ConfigureAwait(false); + // This sort is done here to make results deterministic since we get all the app // host information in parallel and the order may vary. appHostProjects.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal)); return (appHostProjects, unbuildableSuspectedAppHostProjects, hasUnsupportedProjects); + + async Task AddSettingsAppHostCandidateAsync() + { + var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: true, cancellationToken).ConfigureAwait(false); + if (settingsAppHost is null) + { + return; + } + + var pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (appHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)) + || unbuildableSuspectedAppHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))) + { + return; + } + + var handler = projectFactory.TryGetProject(settingsAppHost); + if (handler is null) + { + var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName); + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, relativePath)); + } + + logger.LogDebug("Skipping configured AppHost project {SettingsAppHost} because no project handler was found.", settingsAppHost.FullName); + hasUnsupportedProjects = true; + return; + } + + var validationResult = await handler.ValidateAppHostAsync(settingsAppHost, cancellationToken).ConfigureAwait(false); + var settingsAppHostRelativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName); + if (validationResult.IsValid) + { + if (displayProgress) + { + interactionService.DisplaySubtleMessage(settingsAppHostRelativePath); + } + + var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId); + appHostProjects.Add(appHostProject); + await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false); + } + else if (validationResult.IsPossiblyUnbuildable) + { + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileMayBeUnbuildableAppHost, settingsAppHostRelativePath)); + } + + var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId, AppHostProjectCandidateStatus.PossiblyUnbuildable); + unbuildableSuspectedAppHostProjects.Add(appHostProject); + await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false); + } + else if (validationResult.IsUnsupported) + { + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, settingsAppHostRelativePath)); + } + + logger.LogDebug("Skipping unsupported configured AppHost project {SettingsAppHost}", settingsAppHost.FullName); + hasUnsupportedProjects = true; + } + } } if (displayProgress) diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore index d0324a42d14..855df1ccf12 100644 --- a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore +++ b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore @@ -1,2 +1,3 @@ .java-build/ .aspire/ +.modules/ diff --git a/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore index 615cf938939..050768f6060 100644 --- a/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore +++ b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .venv/ .aspire/ +.modules/ diff --git a/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore index b9485af2bd7..c420eff27d7 100644 --- a/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore +++ b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .aspire/ +.modules/ diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index 26a0394f69c..59a2655e32b 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -164,6 +164,46 @@ public async Task LsCommand_JsonFormat_WhenNoCandidates_ReturnsEmptyArray() Assert.Equal("", stderrText); } + [Fact] + public async Task LsCommand_JsonFormat_IncludesConfiguredAppHostOutsideWorkingDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + var workingDirectory = workspace.WorkspaceRoot.CreateSubdirectory("WorkingDir"); + var configuredAppHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "ConfiguredAppHost.csproj")); + await File.WriteAllTextAsync(configuredAppHost.FullName, "Not a real apphost"); + await File.WriteAllTextAsync(Path.Combine(workingDirectory.FullName, "aspire.config.json"), JsonSerializer.Serialize(new + { + appHost = new + { + path = "../ConfiguredAppHost.csproj" + } + })); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.WorkingDirectory = workingDirectory; + options.OutputTextWriter = textWriter; + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory(); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + var candidateAppHosts = JsonSerializer.Deserialize(jsonOutput, JsonSourceGenerationContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + Assert.NotNull(candidateAppHosts); + var candidate = Assert.Single(candidateAppHosts); + Assert.Equal(configuredAppHost.FullName, candidate.Path); + Assert.Equal(KnownLanguageId.CSharp, candidate.Language); + Assert.Equal("buildable", candidate.Status); + } + [Fact] public async Task LsCommand_JsonFormat_OnlyJsonOnStdout_StatusMessagesOnStderr() { diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index d226eac99fc..3e554a7f97b 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -831,6 +831,7 @@ public async IAsyncEnumerable GetResourceStatesAsync([Enumerat } public Task ConnectAsync(string socketPath, int retryCount, CancellationToken cancellationToken) => Task.CompletedTask; public Task ConnectAsync(string socketPath, bool autoReconnect, int retryCount, CancellationToken cancellationToken) => Task.CompletedTask; + public Task WaitForDisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task GetCapabilitiesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { "baseline.v2" }); public Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken) => diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index b047cd2537f..6a1a7a8266c 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -800,13 +800,17 @@ public async Task RunAsyncDoesNotOverrideUserProvidedVersionCheckDisabledValue() } [Fact] - public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected() + public async Task RunAsyncKeepsExtensionLaunchedAppHostAliveUntilBackchannelDisconnects() { using var workspace = TemporaryWorkspace.Create(outputHelper); var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); - var launchAppHostCalledTcs = new TaskCompletionSource(); + var launchAppHostCalledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var backchannel = new TestAppHostBackchannel + { + ConnectAsyncCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + }; TestExtensionInteractionService? testExtensionInteractionService = null; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { @@ -826,25 +830,36 @@ public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected() { HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "devkit" or "project"), }; - options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel(); + options.AppHostBackchannelFactory = _ => backchannel; }); using var provider = services.BuildServiceProvider(); var runner = provider.GetRequiredService(); + var backchannelCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var exitCode = await runner.RunAsync( + var runTask = runner.RunAsync( projectFile: projectFile, watch: false, noBuild: false, noRestore: false, args: [], - env: null, - backchannelCompletionSource: new TaskCompletionSource(), + env: new Dictionary + { + [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock") + }, + backchannelCompletionSource, options: new ProcessInvocationOptions(), - cancellationToken: CancellationToken.None).DefaultTimeout(); + cancellationToken: CancellationToken.None); await launchAppHostCalledTcs.Task.DefaultTimeout(); - Assert.Equal(CliExitCodes.Success, exitCode); + await backchannel.ConnectAsyncCalled.Task.DefaultTimeout(); + Assert.Same(backchannel, await backchannelCompletionSource.Task.DefaultTimeout()); + + var completedTask = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken)); + Assert.NotSame(runTask, completedTask); + + backchannel.DisconnectCompletionSource.SetResult(); + Assert.Equal(CliExitCodes.Success, await runTask.DefaultTimeout()); } [Fact] @@ -876,23 +891,20 @@ public async Task RunAsyncFailsBackchannelWhenExtensionLaunchedAppHostDoesNotCon var runner = provider.GetRequiredService(); var backchannelCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var exitCode = await runner.RunAsync( - projectFile: projectFile, - watch: false, - noBuild: false, - noRestore: false, - args: [], - env: new Dictionary - { - [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock") - }, - backchannelCompletionSource, - new ProcessInvocationOptions(), - CancellationToken.None).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); var exception = await Assert.ThrowsAsync( - () => backchannelCompletionSource.Task.WaitAsync(TimeSpan.FromSeconds(3))); + () => runner.RunAsync( + projectFile: projectFile, + watch: false, + noBuild: false, + noRestore: false, + args: [], + env: new Dictionary + { + [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock") + }, + backchannelCompletionSource, + new ProcessInvocationOptions(), + CancellationToken.None).DefaultTimeout()); Assert.Contains("Timed out waiting for AppHost backchannel", exception.Message); } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index fadc6f70402..8fc9e2bdb46 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -442,7 +442,12 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new })); var executionContext = CreateExecutionContext(workingDirectory); - var projectLocator = CreateProjectLocator(executionContext); + var displayedSubtleMessages = new List(); + var interactionService = new TestInteractionService + { + DisplaySubtleMessageCallback = displayedSubtleMessages.Add + }; + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); var result = await projectLocator.UseOrFindAppHostProjectFileAsync( projectFile: null, @@ -452,6 +457,41 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new Assert.Equal(settingsAppHostFile.FullName, result.SelectedProjectFile?.FullName); Assert.Contains(result.AllProjectFileCandidates, file => file.FullName == settingsAppHostFile.FullName); + Assert.Contains(Path.Join("..", "SettingsAppHost.csproj"), displayedSubtleMessages); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileTreatsSettingsAppHostWithoutProjectHandlerAsUnsupported() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var settingsAppHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.custom")); + await File.WriteAllTextAsync(settingsAppHostFile.FullName, "Not a supported apphost type"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new + { + appHost = new + { + path = "apphost.custom" + } + })); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var interactionService = new TestInteractionService(); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + var exception = await Assert.ThrowsAsync(() => + projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + multipleAppHostProjectsFoundBehavior: MultipleAppHostProjectsFoundBehavior.None, + createSettingsFile: false, + CancellationToken.None)).DefaultTimeout(); + + Assert.Equal(ProjectLocatorFailureReason.UnsupportedProjects, exception.FailureReason); + var warning = Assert.Single(interactionService.DisplayedMessages); + Assert.Equal(KnownEmojis.Warning, warning.Emoji); + Assert.Contains("apphost.custom", warning.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs index 565c289dea8..f80db942b48 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs @@ -23,6 +23,7 @@ internal sealed class TestAppHostBackchannel : IAppHostCliBackchannel public TaskCompletionSource? ConnectAsyncCalled { get; set; } public Func? ConnectAsyncCallback { get; set; } + public TaskCompletionSource DisconnectCompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource? GetPublishingActivitiesAsyncCalled { get; set; } public Func>? GetPublishingActivitiesAsyncCallback { get; set; } @@ -115,6 +116,11 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC } } + public async Task WaitForDisconnectAsync(CancellationToken cancellationToken) + { + await DisconnectCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + public async IAsyncEnumerable GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { GetPublishingActivitiesAsyncCalled?.SetResult();