diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index ca1e6b6c66f..79d83d3f9a2 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -122,39 +122,34 @@ private async Task DumpCapabilitiesAsync( try { - // TODO: Support bundle mode by using DLL references instead of project references. - // In bundle mode, we'd need to add integration DLLs to the probing path rather than - // using additionalProjectReferences. For now, SDK dump only works with .NET SDK. - var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); - if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); + + // Build integrations list - optional integration project reference + var integrations = new List(); + + if (integrationProject is not null) { - InteractionService.DisplayError("SDK dump is only available with .NET SDK installed."); - return ExitCodeConstants.FailedToBuildArtifacts; + integrations.Add(IntegrationReference.FromProject( + Path.GetFileNameWithoutExtension(integrationProject.FullName), + integrationProject.FullName)); } - // Build packages list - empty since we only need core capabilities + optional integration - var packages = new List<(string Name, string Version)>(); - _logger.LogDebug("Building AppHost server for capability scanning"); - // Create project files with the integration project reference if specified - var additionalProjectRefs = integrationProject is not null - ? new[] { integrationProject.FullName } - : null; + var prepareResult = await appHostServerProject.PrepareAsync( + VersionHelper.GetDefaultTemplateVersion(), + integrations, + cancellationToken); - await appHostServerProject.CreateProjectFilesAsync( - packages, - cancellationToken, - additionalProjectReferences: additionalProjectRefs); - - var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); - - if (!buildSuccess) + if (!prepareResult.Success) { InteractionService.DisplayError("Failed to build capability scanner."); - foreach (var (_, line) in buildOutput.GetLines()) + if (prepareResult.Output is not null) { - InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + foreach (var (_, line) in prepareResult.Output.GetLines()) + { + InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + } } return ExitCodeConstants.FailedToBuildArtifacts; } diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 57264718cd5..5935fdc9561 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -120,42 +120,40 @@ private async Task GenerateSdkAsync( try { - // TODO: Support bundle mode by using DLL references instead of project references. - // In bundle mode, we'd need to add integration DLLs to the probing path rather than - // using additionalProjectReferences. For now, SDK generation only works with .NET SDK. - var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); - if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) - { - InteractionService.DisplayError("SDK generation is only available with .NET SDK installed."); - return ExitCodeConstants.FailedToBuildArtifacts; - } + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); // Get code generation package for the target language var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(languageInfo.LanguageId, cancellationToken); - // Build packages list - include the code generator - var packages = new List<(string Name, string Version)>(); + // Build integrations list — the integration project brings Aspire.Hosting transitively; + // we only need to add the codegen package and the project reference itself. + var integrations = new List(); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, DotNetBasedAppHostServerProject.DefaultSdkVersion)); + integrations.Add(IntegrationReference.FromPackage(codeGenPackage, VersionHelper.GetDefaultTemplateVersion())); } - _logger.LogDebug("Building AppHost server for SDK generation"); + // Add the integration project as a project reference + integrations.Add(IntegrationReference.FromProject( + Path.GetFileNameWithoutExtension(integrationProject.FullName), + integrationProject.FullName)); - // Create project files with the integration project reference - await appHostServerProject.CreateProjectFilesAsync( - packages, - cancellationToken, - additionalProjectReferences: [integrationProject.FullName]); + _logger.LogDebug("Building AppHost server for SDK generation"); - var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); + var prepareResult = await appHostServerProject.PrepareAsync( + VersionHelper.GetDefaultTemplateVersion(), + integrations, + cancellationToken); - if (!buildSuccess) + if (!prepareResult.Success) { InteractionService.DisplayError("Failed to build SDK generation server."); - foreach (var (_, line) in buildOutput.GetLines()) + if (prepareResult.Output is not null) { - InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + foreach (var (_, line) in prepareResult.Output.GetLines()) + { + InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + } } return ExitCodeConstants.FailedToBuildArtifacts; } diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 3b25cc8785d..609f5929fd4 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -172,24 +172,27 @@ public string GetEffectiveSdkVersion(string defaultSdkVersion) } /// - /// Gets all package references including the base Aspire.Hosting package. - /// Empty package versions in settings are resolved to the effective SDK version. + /// Gets all integration references (both NuGet packages and project references) + /// including the base Aspire.Hosting package. + /// A value ending in ".csproj" is treated as a project reference; otherwise as a NuGet version. + /// Empty package versions are resolved to the effective SDK version. /// /// Default SDK version to use when not configured. - /// Enumerable of (PackageName, Version) tuples. - public IEnumerable<(string Name, string Version)> GetAllPackages(string defaultSdkVersion) + /// The directory containing .aspire/settings.json, used to resolve relative project paths. + /// Enumerable of IntegrationReference objects. + public IEnumerable GetIntegrationReferences(string defaultSdkVersion, string settingsDirectory) { var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion); - // Base package always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) - yield return ("Aspire.Hosting", sdkVersion); + // Base package always included + yield return IntegrationReference.FromPackage("Aspire.Hosting", sdkVersion); if (Packages is null) { yield break; } - foreach (var (packageName, version) in Packages) + foreach (var (packageName, value) in Packages) { // Skip base packages and SDK-only packages if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || @@ -198,21 +201,27 @@ public string GetEffectiveSdkVersion(string defaultSdkVersion) continue; } - yield return (packageName, string.IsNullOrWhiteSpace(version) ? sdkVersion : version); + var trimmedValue = value?.Trim(); + + if (string.IsNullOrEmpty(trimmedValue)) + { + // NuGet package reference with no explicit version — fall back to the SDK version + yield return IntegrationReference.FromPackage(packageName, sdkVersion); + continue; + } + + if (trimmedValue.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + // Project reference — resolve relative path to absolute + var absolutePath = Path.GetFullPath(Path.Combine(settingsDirectory, trimmedValue)); + yield return IntegrationReference.FromProject(packageName, absolutePath); + } + else + { + // NuGet package reference with explicit version + yield return IntegrationReference.FromPackage(packageName, trimmedValue); + } } } - /// - /// Gets all package references including the base Aspire.Hosting packages. - /// Uses the SdkVersion for base packages. - /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. - /// - /// Enumerable of (PackageName, Version) tuples. - public IEnumerable<(string Name, string Version)> GetAllPackages() - { - var sdkVersion = !string.IsNullOrWhiteSpace(SdkVersion) - ? SdkVersion - : throw new InvalidOperationException("SdkVersion must be set to a non-empty value before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); - return GetAllPackages(sdkVersion); - } } diff --git a/src/Aspire.Cli/Configuration/IntegrationReference.cs b/src/Aspire.Cli/Configuration/IntegrationReference.cs new file mode 100644 index 00000000000..b6d54013ce4 --- /dev/null +++ b/src/Aspire.Cli/Configuration/IntegrationReference.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Configuration; + +/// +/// Represents a reference to an Aspire hosting integration, which can be either +/// a NuGet package (with a version) or a local project reference (with a path to a .csproj). +/// Exactly one of or must be non-null. +/// +internal sealed class IntegrationReference +{ + /// + /// Gets the package or assembly name (e.g., "Aspire.Hosting.Redis"). + /// + public required string Name { get; init; } + + /// + /// Gets the NuGet package version, or null for project references. + /// + public string? Version { get; init; } + + /// + /// Gets the absolute path to the .csproj file, or null for NuGet packages. + /// + public string? ProjectPath { get; init; } + + /// + /// Returns true if this is a project reference (has a .csproj path). + /// + public bool IsProjectReference => ProjectPath is not null; + + /// + /// Returns true if this is a NuGet package reference (has a version). + /// + public bool IsPackageReference => Version is not null; + + /// + /// Creates a NuGet package reference. + /// + /// The package name. + /// The NuGet package version. + public static IntegrationReference FromPackage(string name, string version) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(version); + + return new IntegrationReference { Name = name, Version = version }; + } + + /// + /// Creates a local project reference. + /// + /// The assembly name. + /// The absolute path to the .csproj file. + public static IntegrationReference FromProject(string name, string projectPath) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(projectPath); + + return new IntegrationReference { Name = name, ProjectPath = projectPath }; + } +} diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 8466bf77766..7e4a40d38bd 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -31,6 +31,7 @@ internal sealed class AppHostServerProjectFactory( IConfigurationService configurationService, IBundleService bundleService, BundleNuGetService bundleNuGetService, + IDotNetSdkInstaller sdkInstaller, ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { public async Task CreateAsync(string appPath, CancellationToken cancellationToken = default) @@ -83,6 +84,8 @@ public async Task CreateAsync(string appPath, Cancellatio socketPath, layout, bundleNuGetService, + dotNetCliRunner, + sdkInstaller, packagingService, configurationService, loggerFactory.CreateLogger()); diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 133181f30e1..e1376820cb0 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.Configuration; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -100,7 +101,7 @@ public AppHostServerSessionFactory( public async Task CreateAsync( string appHostPath, string sdkVersion, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, Dictionary? launchSettingsEnvVars, bool debug, CancellationToken cancellationToken) @@ -108,7 +109,7 @@ public async Task CreateAsync( var appHostServerProject = await _projectFactory.CreateAsync(appHostPath, cancellationToken); // Prepare the server (create files + build for dev mode, restore packages for prebuilt mode) - var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken); + var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken); if (!prepareResult.Success) { return new AppHostServerSessionResult( diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 47b8dcbc09e..c0e96f8e839 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -26,7 +26,7 @@ internal sealed class DotNetBasedAppHostServerProject : IAppHostServerProject private const string AppsFolder = "hosts"; public const string ProjectFileName = "AppHostServer.csproj"; private const string ProjectDllName = "AppHostServer.dll"; - private const string TargetFramework = "net10.0"; + internal const string TargetFramework = "net10.0"; public const string BuildFolder = "build"; private const string AssemblyName = "AppHostServer"; @@ -135,7 +135,7 @@ public void SaveProjectHash(string hash) /// /// Creates the project .csproj content using project references to the local Aspire repository. /// - private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> packages) + private XDocument CreateProjectFile(IEnumerable integrations) { // Determine OS/architecture for DCP package name var (buildOs, buildArch) = GetBuildPlatform(); @@ -183,12 +183,22 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p var addedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); var otherPackages = new List<(string Name, string Version)>(); - foreach (var (name, version) in packages) + foreach (var integration in integrations) { - if (name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) + if (integration.IsProjectReference) { - var projectPath = Path.Combine(_repoRoot, "src", name, $"{name}.csproj"); - if (File.Exists(projectPath) && addedProjects.Add(name)) + // Explicit project reference from settings.json + if (addedProjects.Add(integration.Name)) + { + projectRefGroup.Add(new XElement("ProjectReference", + new XAttribute("Include", integration.ProjectPath!), + new XElement("IsAspireProjectResource", "false"))); + } + } + else if (integration.Name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) + { + var projectPath = Path.Combine(_repoRoot, "src", integration.Name, $"{integration.Name}.csproj"); + if (File.Exists(projectPath) && addedProjects.Add(integration.Name)) { projectRefGroup.Add(new XElement("ProjectReference", new XAttribute("Include", projectPath), @@ -197,7 +207,11 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p } else { - otherPackages.Add((name, version)); + if (integration.Version is null) + { + throw new InvalidOperationException($"Integration '{integration.Name}' is neither a project reference nor a package reference (both Version and ProjectPath are null)."); + } + otherPackages.Add((integration.Name, integration.Version)); } } @@ -262,9 +276,8 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p /// Scaffolds the project files. /// public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( - IEnumerable<(string Name, string Version)> packages, - CancellationToken cancellationToken = default, - IEnumerable? additionalProjectReferences = null) + IEnumerable integrations, + CancellationToken cancellationToken = default) { // Clean obj folder to ensure fresh NuGet restore var objPath = Path.Combine(_projectModelPath, "obj"); @@ -288,23 +301,18 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p // Create appsettings.json with ATS assemblies var atsAssemblies = new List { "Aspire.Hosting" }; - foreach (var pkg in packages) + foreach (var integration in integrations) { - if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase)) + // Skip SDK-only packages that don't produce runtime assemblies + if (integration.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || + integration.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) { - atsAssemblies.Add(pkg.Name); + continue; } - } - if (additionalProjectReferences is not null) - { - foreach (var projectPath in additionalProjectReferences) + if (!atsAssemblies.Contains(integration.Name, StringComparer.OrdinalIgnoreCase)) { - var assemblyName = Path.GetFileNameWithoutExtension(projectPath); - if (!atsAssemblies.Contains(assemblyName, StringComparer.OrdinalIgnoreCase)) - { - atsAssemblies.Add(assemblyName); - } + atsAssemblies.Add(integration.Name); } } @@ -327,11 +335,11 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p // Handle NuGet config and channel resolution string? channelName = null; - var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config"); var userNugetConfig = FindNuGetConfig(_appPath); if (userNugetConfig is not null) { + var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config"); File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); } @@ -344,41 +352,37 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); } - PackageChannel? channel; - if (!string.IsNullOrEmpty(configuredChannelName)) - { - channel = channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)); - } - else - { - channel = channels.FirstOrDefault(c => c.Type == PackageChannelType.Explicit); - } + // Resolve channel sources and add them via RestoreAdditionalProjectSources + // This is additive — it preserves the user's nuget.config and adds channel-specific sources + var channelSources = new List(); + var matchedChannels = !string.IsNullOrEmpty(configuredChannelName) + ? channels.Where(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)) + : channels.Where(c => c.Type == PackageChannelType.Explicit); - if (channel is not null) + foreach (var ch in matchedChannels) { - await NuGetConfigMerger.CreateOrUpdateAsync( - new DirectoryInfo(_projectModelPath), - channel, - cancellationToken: cancellationToken); - channelName = channel.Name; + channelName ??= ch.Name; + if (ch.Mappings is not null) + { + foreach (var mapping in ch.Mappings) + { + if (!channelSources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) + { + channelSources.Add(mapping.Source); + } + } + } } // Create the project file - var doc = CreateProjectFile(packages); + var doc = CreateProjectFile(integrations); - // Add additional project references - if (additionalProjectReferences is not null) + // Add channel sources to the project so project references can resolve packages + if (channelSources.Count > 0) { - var additionalProjectRefs = additionalProjectReferences - .Select(path => new XElement("ProjectReference", - new XAttribute("Include", path), - new XElement("IsAspireProjectResource", "false"))) - .ToList(); - - if (additionalProjectRefs.Count > 0) - { - doc.Root!.Add(new XElement("ItemGroup", additionalProjectRefs)); - } + var sourceList = string.Join(";", channelSources); + doc.Root!.Descendants("PropertyGroup").First() + .Add(new XElement("RestoreAdditionalProjectSources", sourceList)); } // Add appsettings.json to output @@ -433,10 +437,10 @@ await NuGetConfigMerger.CreateOrUpdateAsync( /// public async Task PrepareAsync( string sdkVersion, - IEnumerable<(string Name, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken = default) { - var (_, channelName) = await CreateProjectFilesAsync(packages, cancellationToken); + var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken); var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken); if (!buildSuccess) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 2f2cb3666b4..8d8530f82c9 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -137,21 +137,22 @@ public bool IsUsingProjectReferences(FileInfo appHostFile) } /// - /// Gets all packages including the code generation package for the current language. + /// Gets all integration references including the code generation package for the current language. /// - private async Task> GetAllPackagesAsync( + private async Task> GetIntegrationReferencesAsync( AspireJsonConfiguration config, + DirectoryInfo directory, CancellationToken cancellationToken) { var defaultSdkVersion = GetEffectiveSdkVersion(); - var packages = config.GetAllPackages(defaultSdkVersion).ToList(); + var integrations = config.GetIntegrationReferences(defaultSdkVersion, directory.FullName).ToList(); var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(_resolvedLanguage.LanguageId, cancellationToken); if (codeGenPackage is not null) { var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion); - packages.Add((codeGenPackage, codeGenVersion)); + integrations.Add(IntegrationReference.FromPackage(codeGenPackage, codeGenVersion)); } - return packages; + return integrations; } private AspireJsonConfiguration LoadConfiguration(DirectoryInfo directory) @@ -171,10 +172,10 @@ private string GetPrepareSdkVersion(AspireJsonConfiguration config) private static async Task<(bool Success, OutputCollector? Output, string? ChannelName, bool NeedsCodeGen)> PrepareAppHostServerAsync( IAppHostServerProject appHostServerProject, string sdkVersion, - List<(string Name, string Version)> packages, + List integrations, CancellationToken cancellationToken) { - var result = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken); + var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken); return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration); } @@ -187,10 +188,10 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio // Step 1: Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, cancellationToken); + var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); - var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken); if (!buildSuccess) { if (buildOutput is not null) @@ -217,7 +218,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio await GenerateCodeViaRpcAsync( directory.FullName, rpcClient, - packages, + integrations, cancellationToken); } finally @@ -294,7 +295,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, cancellationToken); + var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); var buildResult = await _interactionService.ShowStatusAsync( @@ -302,7 +303,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken async () => { // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); + var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken); if (!prepareSuccess) { return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false); @@ -407,7 +408,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken await GenerateCodeViaRpcAsync( directory.FullName, rpcClient, - packages, + integrations, cancellationToken); } @@ -598,11 +599,11 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // Step 1: Load config - source of truth for SDK version and packages var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, cancellationToken); + var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); + var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken); if (!prepareSuccess) { // Set OutputCollector so PipelineCommandBase can display errors @@ -680,7 +681,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca await GenerateCodeViaRpcAsync( directory.FullName, rpcClient, - packages, + integrations, cancellationToken); } @@ -1009,16 +1010,16 @@ public async Task FindAndStopRunningInstanceAsync(FileInf private async Task GenerateCodeViaRpcAsync( string appPath, IAppHostRpcClient rpcClient, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken) { - var packagesList = packages.ToList(); + var integrationsList = integrations.ToList(); // Use CodeGenerator (e.g., "TypeScript") not LanguageId (e.g., "typescript/nodejs") // The code generator is registered by its Language property, not the runtime ID var codeGenerator = _resolvedLanguage.CodeGenerator; - _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, packagesList.Count); + _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, integrationsList.Count); // Use the typed RPC method var files = await rpcClient.GenerateCodeAsync(codeGenerator, cancellationToken); @@ -1039,35 +1040,47 @@ private async Task GenerateCodeViaRpcAsync( } // Write generation hash for caching - SaveGenerationHash(outputPath, packagesList); + SaveGenerationHash(outputPath, integrationsList); _logger.LogInformation("Generated {Count} {CodeGenerator} files in {Path}", files.Count, codeGenerator, outputPath); } /// - /// Saves a hash of the packages to avoid regenerating code unnecessarily. + /// Saves a hash of the integrations to avoid regenerating code unnecessarily. + /// When project references are present, the hash is always unique to force regeneration + /// since project outputs are mutable. /// - private static void SaveGenerationHash(string generatedPath, List<(string PackageId, string Version)> packages) + private static void SaveGenerationHash(string generatedPath, List integrations) { var hashPath = Path.Combine(generatedPath, ".codegen-hash"); - var hash = ComputePackagesHash(packages); + var hash = ComputeIntegrationsHash(integrations); File.WriteAllText(hashPath, hash); } /// - /// Computes a hash of the package list for caching purposes. + /// Computes a hash of the integration list for caching purposes. + /// If any project references are present, includes a timestamp to force regeneration + /// since project outputs can change between builds. /// - private static string ComputePackagesHash(List<(string PackageId, string Version)> packages) + private static string ComputeIntegrationsHash(List integrations) { var sb = new System.Text.StringBuilder(); - foreach (var (packageId, version) in packages.OrderBy(p => p.PackageId)) + foreach (var integration in integrations.OrderBy(p => p.Name)) { - sb.Append(packageId); + sb.Append(integration.Name); sb.Append(':'); - sb.Append(version); + sb.Append(integration.Version ?? integration.ProjectPath ?? ""); sb.Append(';'); } + + // Project references are mutable — always regenerate when they're present + if (integrations.Any(i => i.IsProjectReference)) + { + sb.Append("timestamp:"); + sb.Append(DateTime.UtcNow.Ticks); + } + var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sb.ToString())); return Convert.ToHexString(bytes); } diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 351dc2402af..8c19179b308 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.Configuration; using Aspire.Cli.Utils; namespace Aspire.Cli.Projects; @@ -38,12 +39,12 @@ internal interface IAppHostServerProject /// For bundle mode: restores integration packages from NuGet. /// /// The Aspire SDK version to use. - /// The integration packages required by the app host. + /// The integration references (NuGet packages and/or project references) required by the app host. /// Cancellation token. /// The preparation result indicating success/failure and any output. Task PrepareAsync( string sdkVersion, - IEnumerable<(string Name, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken = default); /// diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index 843ad10b97c..8668a92796c 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -4,6 +4,8 @@ using System.Diagnostics; using Aspire.Cli.Utils; +using Aspire.Cli.Configuration; + namespace Aspire.Cli.Projects; /// @@ -43,7 +45,7 @@ internal interface IAppHostServerSessionFactory /// /// The path to the AppHost project directory. /// The Aspire SDK version to use. - /// The package references to include. + /// The integration references to include. /// Optional environment variables from launch settings. /// Whether to enable debug logging. /// Cancellation token. @@ -51,7 +53,7 @@ internal interface IAppHostServerSessionFactory Task CreateAsync( string appHostPath, string sdkVersion, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, Dictionary? launchSettingsEnvVars, bool debug, CancellationToken cancellationToken); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 2a0c1c8c0d3..ed3d1d63dce 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -4,7 +4,9 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; +using System.Xml.Linq; using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; using Aspire.Cli.Layout; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; @@ -26,6 +28,8 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject private readonly string _socketPath; private readonly LayoutConfiguration _layout; private readonly BundleNuGetService _nugetService; + private readonly IDotNetCliRunner _dotNetCliRunner; + private readonly IDotNetSdkInstaller _sdkInstaller; private readonly IPackagingService _packagingService; private readonly IConfigurationService _configurationService; private readonly ILogger _logger; @@ -37,10 +41,12 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject /// /// Initializes a new instance of the PrebuiltAppHostServer class. /// - /// The path to the user's polyglot app host. + /// The path to the user's polyglot app host directory. /// The socket path for JSON-RPC communication. /// The bundle layout configuration. - /// The NuGet service for restoring integration packages. + /// The NuGet service for restoring integration packages (NuGet-only path). + /// The .NET CLI runner for building project references. + /// The SDK installer for checking .NET SDK availability. /// The packaging service for channel resolution. /// The configuration service for reading channel settings. /// The logger for diagnostic output. @@ -49,6 +55,8 @@ public PrebuiltAppHostServer( string socketPath, LayoutConfiguration layout, BundleNuGetService nugetService, + IDotNetCliRunner dotNetCliRunner, + IDotNetSdkInstaller sdkInstaller, IPackagingService packagingService, IConfigurationService configurationService, ILogger logger) @@ -57,6 +65,8 @@ public PrebuiltAppHostServer( _socketPath = socketPath; _layout = layout; _nugetService = nugetService; + _dotNetCliRunner = dotNetCliRunner; + _sdkInstaller = sdkInstaller; _packagingService = packagingService; _configurationService = configurationService; _logger = logger; @@ -88,37 +98,46 @@ public string GetServerPath() /// public async Task PrepareAsync( string sdkVersion, - IEnumerable<(string Name, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken = default) { - var packageList = packages.ToList(); + var integrationList = integrations.ToList(); + var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList(); + var projectRefs = integrationList.Where(r => r.IsProjectReference).ToList(); try { - // Generate appsettings.json with ATS assemblies for the server to scan - await GenerateAppSettingsAsync(packageList, cancellationToken); - // Resolve the configured channel (local settings.json → global config fallback) var channelName = await ResolveChannelNameAsync(cancellationToken); - // Restore integration packages - if (packageList.Count > 0) + if (projectRefs.Count > 0) { - _logger.LogDebug("Restoring {Count} integration packages", packageList.Count); - - // Get NuGet sources filtered to the resolved channel - var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); - - // Pass apphost directory for nuget.config discovery - var appHostDirectory = Path.GetDirectoryName(_appPath); + // Project references require .NET SDK — verify it's available + var (sdkAvailable, _, minimumRequired) = await _sdkInstaller.CheckAsync(cancellationToken); + if (!sdkAvailable) + { + throw new InvalidOperationException( + $"Project references in settings.json require .NET SDK {minimumRequired} or later. " + + "Install the .NET SDK from https://dotnet.microsoft.com/download or use NuGet package versions instead."); + } - _integrationLibsPath = await _nugetService.RestorePackagesAsync( - packageList, - "net10.0", - sources: sources, - workingDirectory: appHostDirectory, - ct: cancellationToken); + // Build a synthetic project with all package and project references + _integrationLibsPath = await BuildIntegrationProjectAsync( + packageRefs, projectRefs, channelName, cancellationToken); } + else if (packageRefs.Count > 0) + { + // NuGet-only — use the bundled NuGet service (no SDK required) + _integrationLibsPath = await RestoreNuGetPackagesAsync( + packageRefs, channelName, cancellationToken); + } + + // Generate appsettings.json after build/restore so we can use actual assembly names + // from the build output (project references may have custom ) + var projectRefAssemblyNames = _integrationLibsPath is not null + ? await ReadProjectRefAssemblyNamesAsync(_integrationLibsPath, cancellationToken) + : []; + await GenerateAppSettingsAsync(packageRefs, projectRefAssemblyNames, cancellationToken); return new AppHostServerPrepareResult( Success: true, @@ -139,6 +158,178 @@ public async Task PrepareAsync( } } + /// + /// Restores NuGet packages using the bundled NuGet service (no .NET SDK required). + /// + private async Task RestoreNuGetPackagesAsync( + List packageRefs, + string? channelName, + CancellationToken cancellationToken) + { + _logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count); + + var packages = packageRefs.Select(r => (r.Name, r.Version!)).ToList(); + var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); + var appHostDirectory = Path.GetDirectoryName(_appPath); + + return await _nugetService.RestorePackagesAsync( + packages, + DotNetBasedAppHostServerProject.TargetFramework, + sources: sources, + workingDirectory: appHostDirectory, + ct: cancellationToken); + } + + /// + /// Creates a synthetic .csproj with all package and project references, + /// then builds it to get the full transitive DLL closure via CopyLocalLockFileAssemblies. + /// Requires .NET SDK. + /// + private async Task BuildIntegrationProjectAsync( + List packageRefs, + List projectRefs, + string? channelName, + CancellationToken cancellationToken) + { + var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); + Directory.CreateDirectory(restoreDir); + + var outputDir = Path.Combine(restoreDir, "libs"); + // Clean stale DLLs from previous builds to prevent leftover assemblies + // from removed integrations being picked up by the assembly resolver + if (Directory.Exists(outputDir)) + { + Directory.Delete(outputDir, recursive: true); + } + Directory.CreateDirectory(outputDir); + + // Resolve channel sources to add via RestoreAdditionalProjectSources + IEnumerable? channelSources = null; + try + { + channelSources = await GetNuGetSourcesAsync(channelName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to configure NuGet sources for integration project build"); + } + + var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir, channelSources); + var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); + await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); + + // Write a Directory.Packages.props to opt out of Central Package Management + var directoryPackagesProps = """ + + + false + + + """; + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Packages.props"), directoryPackagesProps, cancellationToken); + + // Also write an empty Directory.Build.props/targets to prevent parent imports + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Build.props"), "", cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Build.targets"), "", cancellationToken); + + _logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references", + packageRefs.Count, projectRefs.Count); + + var buildOutput = new OutputCollector(); + var exitCode = await _dotNetCliRunner.BuildAsync( + new FileInfo(projectFilePath), + noRestore: false, + new DotNetCliRunnerInvocationOptions + { + StandardOutputCallback = buildOutput.AppendOutput, + StandardErrorCallback = buildOutput.AppendError + }, + cancellationToken); + + if (exitCode != 0) + { + var outputLines = string.Join(Environment.NewLine, buildOutput.GetLines().Select(l => l.Line)); + _logger.LogError("Integration project build failed. Output:\n{BuildOutput}", outputLines); + throw new InvalidOperationException($"Failed to build integration project. Exit code: {exitCode}"); + } + + return outputDir; + } + + /// + /// Generates a synthetic .csproj file that references all integration packages and projects. + /// Building this project with CopyLocalLockFileAssemblies produces the full transitive DLL closure. + /// + internal static string GenerateIntegrationProjectFile( + List packageRefs, + List projectRefs, + string outputDir, + IEnumerable? additionalSources = null) + { + var propertyGroup = new XElement("PropertyGroup", + new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework), + new XElement("EnableDefaultItems", "false"), + new XElement("CopyLocalLockFileAssemblies", "true"), + new XElement("ProduceReferenceAssembly", "false"), + new XElement("EnableNETAnalyzers", "false"), + new XElement("GenerateDocumentationFile", "false"), + new XElement("OutDir", outputDir)); + + // Add channel sources without replacing the user's nuget.config + if (additionalSources is not null) + { + var sourceList = string.Join(";", additionalSources); + if (sourceList.Length > 0) + { + propertyGroup.Add(new XElement("RestoreAdditionalProjectSources", sourceList)); + } + } + + var doc = new XDocument( + new XElement("Project", + new XAttribute("Sdk", "Microsoft.NET.Sdk"), + propertyGroup)); + + if (packageRefs.Count > 0) + { + doc.Root!.Add(new XElement("ItemGroup", + packageRefs.Select(p => + { + if (p.Version is null) + { + throw new InvalidOperationException($"Package reference '{p.Name}' is missing a version."); + } + return new XElement("PackageReference", + new XAttribute("Include", p.Name), + new XAttribute("Version", p.Version)); + }))); + } + + if (projectRefs.Count > 0) + { + doc.Root!.Add(new XElement("ItemGroup", + projectRefs.Select(p => new XElement("ProjectReference", + new XAttribute("Include", p.ProjectPath!))))); + + // Add a target that writes the resolved project reference assembly names to a file. + // This lets us discover the actual assembly names after build (which may differ from + // the settings.json key or csproj filename if is overridden). + doc.Root!.Add( + new XElement("Target", + new XAttribute("Name", "_WriteProjectRefAssemblyNames"), + new XAttribute("AfterTargets", "Build"), + new XElement("WriteLinesToFile", + new XAttribute("File", Path.Combine(outputDir, "_project-ref-assemblies.txt")), + new XAttribute("Lines", "@(_ResolvedProjectReferencePaths->'%(Filename)')"), + new XAttribute("Overwrite", "true")))); + } + + return doc.ToString(); + } + /// /// Resolves the configured channel name from local settings.json or global config. /// @@ -163,7 +354,7 @@ public async Task PrepareAsync( } /// - /// Gets NuGet sources from the resolved channel, or all explicit channels if no channel is configured. + /// Gets NuGet sources from the resolved channel for bundled restore. /// private async Task?> GetNuGetSourcesAsync(string? channelName, CancellationToken cancellationToken) { @@ -176,13 +367,11 @@ public async Task PrepareAsync( IEnumerable explicitChannels; if (!string.IsNullOrEmpty(channelName)) { - // Filter to the configured channel var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit); } else { - // No channel configured, use all explicit channels explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit); } @@ -198,7 +387,6 @@ public async Task PrepareAsync( if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) { sources.Add(mapping.Source); - _logger.LogDebug("Using channel '{Channel}' NuGet source: {Source}", channel.Name, mapping.Source); } } } @@ -319,22 +507,45 @@ public async Task PrepareAsync( /// public string GetInstanceIdentifier() => _appPath; + /// + /// Reads the project reference assembly names written by the MSBuild target during build. + /// + private async Task> ReadProjectRefAssemblyNamesAsync(string libsPath, CancellationToken cancellationToken) + { + var filePath = Path.Combine(libsPath, "_project-ref-assemblies.txt"); + if (!File.Exists(filePath)) + { + _logger.LogWarning("Project reference assembly names file not found at {Path}", filePath); + return []; + } + + var lines = await File.ReadAllLinesAsync(filePath, cancellationToken); + return lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()).ToList(); + } + private async Task GenerateAppSettingsAsync( - List<(string Name, string Version)> packages, + List packageRefs, + List projectRefAssemblyNames, CancellationToken cancellationToken) { - // Build the list of ATS assemblies (for [AspireExport] scanning) - // Skip SDK-only packages that don't have runtime DLLs var atsAssemblies = new List { "Aspire.Hosting" }; - foreach (var (name, _) in packages) + + foreach (var pkg in packageRefs) { - // Skip SDK packages that don't produce runtime assemblies - if (name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || - name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) + if (pkg.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || + pkg.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) { continue; } + if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase)) + { + atsAssemblies.Add(pkg.Name); + } + } + + foreach (var name in projectRefAssemblyNames) + { if (!atsAssemblies.Contains(name, StringComparer.OrdinalIgnoreCase)) { atsAssemblies.Add(name); diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index a60928588dd..14615451120 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -61,11 +61,11 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat // Include the code generation package for scaffolding and code gen var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(language.LanguageId, cancellationToken); - var packages = config.GetAllPackages(sdkVersion).ToList(); + var integrations = config.GetIntegrationReferences(sdkVersion, directory.FullName).ToList(); if (codeGenPackage is not null) { var codeGenVersion = config.GetEffectiveSdkVersion(sdkVersion); - packages.Add((codeGenPackage, codeGenVersion)); + integrations.Add(IntegrationReference.FromPackage(codeGenPackage, codeGenVersion)); } var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); @@ -73,7 +73,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat var prepareResult = await _interactionService.ShowStatusAsync( "Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(prepareSdkVersion, packages, cancellationToken), + () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken), emoji: KnownEmojis.Gear); if (!prepareResult.Success) { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs new file mode 100644 index 00000000000..169a918f438 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -0,0 +1,232 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end test for polyglot project reference support. +/// Creates a .NET hosting integration project and a TypeScript AppHost that references it +/// via settings.json, then verifies the integration is discovered, code-generated, and functional. +/// +public sealed class ProjectReferenceTests(ITestOutputHelper output) +{ + [Fact] + public async Task TypeScriptAppHostWithProjectReferenceIntegration() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var waitingForAppHostCreated = new CellPatternSearcher() + .Find("Created apphost.ts"); + + var waitForStartSuccess = new CellPatternSearcher() + .Find("AppHost started successfully."); + + // Pattern to verify our custom integration was code-generated + var waitForAddMyServiceInCodegen = new CellPatternSearcher() + .Find("addMyService"); + + // Pattern to verify the resource appears in describe output + var waitForMyServiceInDescribe = new CellPatternSearcher() + .Find("my-svc"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireBundleFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireBundleEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Create a TypeScript AppHost (so we get the sdkVersion in settings.json) + sequenceBuilder + .Type("aspire init --language typescript --non-interactive") + .Enter() + .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter); + + // Step 2: Create the integration project, update settings.json, and modify apphost.ts + sequenceBuilder.ExecuteCallback(() => + { + var workDir = workspace.WorkspaceRoot.FullName; + + // Read the sdkVersion from the settings.json that aspire init created + var settingsPath = Path.Combine(workDir, ".aspire", "settings.json"); + var settingsJson = File.ReadAllText(settingsPath); + using var doc = JsonDocument.Parse(settingsJson); + var sdkVersion = doc.RootElement.GetProperty("sdkVersion").GetString()!; + + // Create the .NET hosting integration project + var integrationDir = Path.Combine(workDir, "MyIntegration"); + Directory.CreateDirectory(integrationDir); + + File.WriteAllText(Path.Combine(integrationDir, "MyIntegration.csproj"), $$""" + + + net10.0 + $(NoWarn);ASPIREATS001 + + + + + + """); + + // Write a nuget.config in the workspace root so MyIntegration.csproj can resolve + // Aspire.Hosting from the configured channel's package source (hive or feed). + // Without this, NuGet walks up from MyIntegration/ and finds no config pointing + // to the hive, falling back to nuget.org which doesn't have prerelease versions. + var aspireHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); + var hivesDir = Path.Combine(aspireHome, "hives"); + if (Directory.Exists(hivesDir)) + { + var hiveDirs = Directory.GetDirectories(hivesDir); + var sourceLines = new List { """""" }; + foreach (var hiveDir in hiveDirs) + { + var packagesDir = Path.Combine(hiveDir, "packages"); + if (Directory.Exists(packagesDir)) + { + var hiveName = Path.GetFileName(hiveDir); + sourceLines.Insert(0, $""""""); + } + } + var nugetConfig = $""" + + + + + {string.Join("\n ", sourceLines)} + + + """; + File.WriteAllText(Path.Combine(workDir, "nuget.config"), nugetConfig); + } + + File.WriteAllText(Path.Combine(integrationDir, "MyIntegrationExtensions.cs"), """ + using Aspire.Hosting; + using Aspire.Hosting.ApplicationModel; + + namespace Aspire.Hosting; + + public static class MyIntegrationExtensions + { + [AspireExport("addMyService")] + public static IResourceBuilder AddMyService( + this IDistributedApplicationBuilder builder, string name) + => builder.AddContainer(name, "redis", "latest"); + } + """); + + // Update settings.json to add the project reference + using var settingsDoc = JsonDocument.Parse(settingsJson); + var settings = new Dictionary(); + foreach (var prop in settingsDoc.RootElement.EnumerateObject()) + { + settings[prop.Name] = prop.Value.Clone(); + } + settings["packages"] = new Dictionary + { + ["MyIntegration"] = "./MyIntegration/MyIntegration.csproj" + }; + + var updatedJson = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(settingsPath, updatedJson); + + // Delete the generated .modules folder to force re-codegen with the new integration + var modulesDir = Path.Combine(workDir, ".modules"); + if (Directory.Exists(modulesDir)) + { + Directory.Delete(modulesDir, recursive: true); + } + + // Update apphost.ts to use the custom integration + File.WriteAllText(Path.Combine(workDir, "apphost.ts"), """ + import { createBuilder } from './.modules/aspire.js'; + + const builder = await createBuilder(); + await builder.addMyService("my-svc"); + await builder.build().run(); + """); + }); + + // Step 3: Start the AppHost (triggers project ref build + codegen) + // Detect either success or failure + var waitForStartFailure = new CellPatternSearcher() + .Find("AppHost failed to build"); + + sequenceBuilder + .Type("aspire start --non-interactive 2>&1 | tee /tmp/aspire-start-output.txt") + .Enter() + .WaitUntil(s => + { + if (waitForStartFailure.Search(s).Count > 0) + { + // Dump child logs before failing + return true; + } + return waitForStartSuccess.Search(s).Count > 0; + }, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter); + + // If start failed, dump the child log for debugging before the test fails + sequenceBuilder + .Type("CHILD_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1) && if [ -n \"$CHILD_LOG\" ]; then echo '=== CHILD LOG ==='; cat \"$CHILD_LOG\"; echo '=== END CHILD LOG ==='; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); + + // Step 4: Verify the custom integration was code-generated + sequenceBuilder + .Type("grep addMyService .modules/aspire.ts") + .Enter() + .WaitUntil(s => waitForAddMyServiceInCodegen.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .WaitForSuccessPrompt(counter); + + // Step 5: Wait for the custom resource to be up + sequenceBuilder + .Type("aspire wait my-svc --timeout 60") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(90)); + + // Step 6: Verify the resource appears in describe + sequenceBuilder + .Type("aspire describe my-svc --format json > /tmp/my-svc-describe.json") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(15)) + .Type("cat /tmp/my-svc-describe.json") + .Enter() + .WaitUntil(s => waitForMyServiceInDescribe.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .WaitForSuccessPrompt(counter); + + // Step 7: Clean up + sequenceBuilder + .Type("aspire stop") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs b/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs new file mode 100644 index 00000000000..646321f32d6 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Configuration; + +namespace Aspire.Cli.Tests.Configuration; + +public class IntegrationReferenceTests +{ + [Fact] + public void PackageReference_HasVersionAndNoProjectPath() + { + var reference = IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0"); + + Assert.True(reference.IsPackageReference); + Assert.False(reference.IsProjectReference); + Assert.Equal("13.2.0", reference.Version); + Assert.Null(reference.ProjectPath); + } + + [Fact] + public void ProjectReference_HasProjectPathAndNoVersion() + { + var reference = IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj"); + + Assert.True(reference.IsProjectReference); + Assert.False(reference.IsPackageReference); + Assert.Null(reference.Version); + Assert.Equal("/path/to/MyIntegration.csproj", reference.ProjectPath); + } + + [Fact] + public void GetIntegrationReferences_DetectsCsprojAsProjectReference() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = "13.2.0", + ["MyIntegration"] = "../src/MyIntegration/MyIntegration.csproj" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/home/user/app").ToList(); + + // Base Aspire.Hosting + Redis (packages) + MyIntegration (project ref) = 3 + Assert.Equal(3, refs.Count); + + var packageRefs = refs.Where(r => r.IsPackageReference).ToList(); + var projectRefs = refs.Where(r => r.IsProjectReference).ToList(); + + Assert.Equal(2, packageRefs.Count); + Assert.Single(projectRefs); + Assert.Equal("MyIntegration", projectRefs[0].Name); + Assert.EndsWith(".csproj", projectRefs[0].ProjectPath!); + } + + [Fact] + public void GetIntegrationReferences_ResolvesRelativeProjectPath() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["MyIntegration"] = "../MyIntegration/MyIntegration.csproj" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/home/user/app").ToList(); + var projectRef = refs.Single(r => r.IsProjectReference); + + // Path should be resolved to absolute + Assert.True(Path.IsPathRooted(projectRef.ProjectPath!)); + } + + [Fact] + public void GetIntegrationReferences_EmptyVersionDefaultsToSdkVersion() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = "" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/tmp").ToList(); + var redis = refs.Single(r => r.Name == "Aspire.Hosting.Redis"); + + Assert.Equal("13.2.0", redis.Version); + Assert.True(redis.IsPackageReference); + } + + [Fact] + public void GetIntegrationReferences_SkipsBasePackages() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["Aspire.Hosting"] = "13.2.0", + ["Aspire.Hosting.AppHost"] = "13.2.0", + ["Aspire.Hosting.Redis"] = "13.2.0" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/tmp").ToList(); + + // Base Aspire.Hosting (auto-added) + Redis = 2 + // Aspire.Hosting from packages dict is skipped (duplicate) + // Aspire.Hosting.AppHost is skipped (SDK-only) + Assert.Equal(2, refs.Count); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis"); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 7ebdea5e7fd..85a7a59d689 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.InternalTesting; using System.Xml.Linq; +using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; @@ -47,12 +48,12 @@ public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0"), - ("Aspire.Hosting.PostgreSQL", "13.1.0"), - ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.PostgreSQL", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") }; // Act @@ -71,9 +72,9 @@ public async Task CreateProjectFiles_ProgramCs_MatchesSnapshot() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0") + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") }; // Act @@ -93,9 +94,9 @@ public async Task CreateProjectFiles_GeneratesProgramCs() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0") + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") }; // Act @@ -114,11 +115,11 @@ public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies( { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0"), - ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") }; // Act @@ -140,9 +141,9 @@ public async Task CreateProjectFiles_CopiesAppSettingsToOutput() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0") + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") }; // Act @@ -260,11 +261,11 @@ await File.WriteAllTextAsync(settingsJson, """ var projectModelPath = Path.Combine(appPath, ".aspire_server"); var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, configurationService, logger, projectModelPath); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.AppHost", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0") + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.AppHost", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0") }; // Act @@ -287,48 +288,19 @@ await File.WriteAllTextAsync(settingsJson, """ } outputHelper.WriteLine("================================"); - // Assert - verify nuget.config uses the correct channel - // Note: NuGetConfigMerger creates the file as "nuget.config" (lowercase) - var nugetConfigPath = Path.Combine(project.ProjectModelPath, "nuget.config"); - - // Build diagnostic info for assertion failure - var diagnosticInfo = new System.Text.StringBuilder(); - diagnosticInfo.AppendLine($"appPath: {appPath}"); - diagnosticInfo.AppendLine($"settingsJson path: {settingsJson}"); - diagnosticInfo.AppendLine($"settingsJson exists: {File.Exists(settingsJson)}"); - if (File.Exists(settingsJson)) - { - diagnosticInfo.AppendLine($"settingsJson content: {File.ReadAllText(settingsJson)}"); - } - diagnosticInfo.AppendLine($"project.ProjectModelPath: {project.ProjectModelPath}"); - diagnosticInfo.AppendLine($"nugetConfigPath: {nugetConfigPath}"); - diagnosticInfo.AppendLine($"nugetConfigPath exists: {File.Exists(nugetConfigPath)}"); - - // List all files for debugging case sensitivity issues - if (Directory.Exists(project.ProjectModelPath)) - { - diagnosticInfo.AppendLine("Files in ProjectModelPath:"); - foreach (var file in Directory.GetFiles(project.ProjectModelPath)) - { - diagnosticInfo.AppendLine($" - {Path.GetFileName(file)}"); - } - } - - // The nuget.config should exist - Assert.True(File.Exists(nugetConfigPath), $"nuget.config should be created\n\nDiagnostics:\n{diagnosticInfo}"); - - var nugetConfigContent = await File.ReadAllTextAsync(nugetConfigPath); - - // Normalize paths for snapshot (replace machine-specific paths) - var normalizedContent = nugetConfigContent - .Replace(prNewHive.FullName, "{PR_NEW_HIVE}") - .Replace(prOldHive.FullName, "{PR_OLD_HIVE}"); - - // Snapshot verification - this will fail if the bug exists - // Expected: Contains {PR_NEW_HIVE} (project-local channel) - // Bug behavior: Contains {PR_OLD_HIVE} (global config channel) - await Verify(normalizedContent, extension: "xml") - .UseFileName("AppHostServerProject_NuGetConfig_UsesProjectLocalChannel"); + // Assert - verify the csproj uses RestoreAdditionalProjectSources with the correct channel sources + var projectFilePath = Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName); + + Assert.True(File.Exists(projectFilePath), $"Project file should be created at {projectFilePath}"); + + var projectContent = await File.ReadAllTextAsync(projectFilePath); + var projectDoc = XDocument.Parse(projectContent); + var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value; + + // Should contain the pr-new hive path (project-local channel), NOT pr-old (global config) + Assert.NotNull(restoreSources); + Assert.Contains(prNewHive.FullName, restoreSources); + Assert.DoesNotContain(prOldHive.FullName, restoreSources); } /// diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 876be8d1451..aa0b030d4cf 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -123,7 +123,7 @@ public void AspireJsonConfiguration_AddOrUpdatePackage_UpdatesExistingPackage() } [Fact] - public void AspireJsonConfiguration_GetAllPackages_IncludesBasePackages() + public void AspireJsonConfiguration_GetIntegrationReferences_IncludesBasePackages() { // Arrange var config = new AspireJsonConfiguration @@ -137,17 +137,16 @@ public void AspireJsonConfiguration_GetAllPackages_IncludesBasePackages() }; // Act - var packages = config.GetAllPackages().ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); // Assert - should include base package (Aspire.Hosting) plus explicit packages - // Note: Aspire.Hosting.AppHost is an SDK-only package and is excluded - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); - Assert.Equal(2, packages.Count); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0" && !r.IsProjectReference); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0" && !r.IsProjectReference); + Assert.Equal(2, refs.Count); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_ReturnsBasePackagesOnly() + public void AspireJsonConfiguration_GetIntegrationReferences_WithNoExplicitPackages_ReturnsBasePackagesOnly() { // Arrange var config = new AspireJsonConfiguration @@ -157,70 +156,83 @@ public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_Return }; // Act - var packages = config.GetAllPackages().ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); // Assert - should include base package only (Aspire.Hosting) - // Note: Aspire.Hosting.AppHost is an SDK-only package and is excluded - Assert.Single(packages); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Single(refs); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0"); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithWhitespaceSdkVersion_Throws() + public void AspireJsonConfiguration_GetIntegrationReferences_WithEmptyVersion_UsesFallbackVersion() { + // Arrange var config = new AspireJsonConfiguration { - SdkVersion = " ", - Language = "typescript" + Language = "typescript", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = string.Empty + } }; - var exception = Assert.Throws(() => config.GetAllPackages().ToList()); + // Act + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); - Assert.Contains("non-empty", exception.Message); + // Assert + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0"); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithDefaultSdkVersion_UsesFallbackVersion() + public void AspireJsonConfiguration_GetIntegrationReferences_WithConfiguredSdkVersion_ReturnsConfiguredVersions() { // Arrange var config = new AspireJsonConfiguration { + SdkVersion = "13.1.0", Language = "typescript", + Channel = "daily", Packages = new Dictionary { - ["Aspire.Hosting.Redis"] = string.Empty + ["Aspire.Hosting.Redis"] = "13.1.0" } }; // Act - var packages = config.GetAllPackages("13.1.0").ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); // Assert - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0"); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithConfiguredSdkVersion_ReturnsConfiguredVersions() + public void AspireJsonConfiguration_GetIntegrationReferences_WithProjectReference_ReturnsProjectRef() { // Arrange var config = new AspireJsonConfiguration { SdkVersion = "13.1.0", Language = "typescript", - Channel = "daily", Packages = new Dictionary { - ["Aspire.Hosting.Redis"] = "13.1.0" + ["Aspire.Hosting.Redis"] = "13.1.0", + ["Aspire.Hosting.MyCustom"] = "../src/Aspire.Hosting.MyCustom/Aspire.Hosting.MyCustom.csproj" } }; // Act - var packages = config.GetAllPackages("13.1.0").ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/home/user/app").ToList(); // Assert - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.IsPackageReference); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.IsPackageReference); + var projectRef = Assert.Single(refs, r => r.IsProjectReference); + Assert.Equal("Aspire.Hosting.MyCustom", projectRef.Name); + Assert.Null(projectRef.Version); + Assert.NotNull(projectRef.ProjectPath); + Assert.EndsWith(".csproj", projectRef.ProjectPath); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs new file mode 100644 index 00000000000..b172eba4a5c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Xml.Linq; +using Aspire.Cli.Configuration; +using Aspire.Cli.Projects; + +namespace Aspire.Cli.Tests.Projects; + +public class PrebuiltAppHostServerTests +{ + [Fact] + public void GenerateIntegrationProjectFile_WithPackagesOnly_ProducesPackageReferences() + { + var packageRefs = new List + { + IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0") + }; + var projectRefs = new List(); + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var packageElements = doc.Descendants("PackageReference").ToList(); + Assert.Equal(2, packageElements.Count); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting" && e.Attribute("Version")?.Value == "13.2.0"); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "13.2.0"); + + Assert.Empty(doc.Descendants("ProjectReference")); + } + + [Fact] + public void GenerateIntegrationProjectFile_WithProjectRefsOnly_ProducesProjectReferences() + { + var packageRefs = new List(); + var projectRefs = new List + { + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") + }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var projectElements = doc.Descendants("ProjectReference").ToList(); + Assert.Single(projectElements); + Assert.Equal("/path/to/MyIntegration.csproj", projectElements[0].Attribute("Include")?.Value); + + Assert.Empty(doc.Descendants("PackageReference")); + } + + [Fact] + public void GenerateIntegrationProjectFile_WithMixed_ProducesBothReferenceTypes() + { + var packageRefs = new List + { + IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0") + }; + var projectRefs = new List + { + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") + }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); + var doc = XDocument.Parse(xml); + + Assert.Equal(2, doc.Descendants("PackageReference").Count()); + Assert.Single(doc.Descendants("ProjectReference")); + } + + [Fact] + public void GenerateIntegrationProjectFile_SetsOutDir() + { + var packageRefs = new List + { + IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0") + }; + var projectRefs = new List(); + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/custom/output/path"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + var outDir = doc.Descendants(ns + "OutDir").FirstOrDefault()?.Value; + Assert.Equal("/custom/output/path", outDir); + } + + [Fact] + public void GenerateIntegrationProjectFile_HasCopyLocalLockFileAssemblies() + { + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile([], [], "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + var copyLocal = doc.Descendants(ns + "CopyLocalLockFileAssemblies").FirstOrDefault()?.Value; + Assert.Equal("true", copyLocal); + } + + [Fact] + public void GenerateIntegrationProjectFile_DisablesAnalyzersAndDocGen() + { + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile([], [], "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + Assert.Equal("false", doc.Descendants(ns + "EnableNETAnalyzers").FirstOrDefault()?.Value); + Assert.Equal("false", doc.Descendants(ns + "GenerateDocumentationFile").FirstOrDefault()?.Value); + Assert.Equal("false", doc.Descendants(ns + "ProduceReferenceAssembly").FirstOrDefault()?.Value); + } + + [Fact] + public void GenerateIntegrationProjectFile_TargetsNet10() + { + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile([], [], "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + Assert.Equal("net10.0", doc.Descendants(ns + "TargetFramework").FirstOrDefault()?.Value); + } +} diff --git a/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_NuGetConfig_UsesProjectLocalChannel.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_NuGetConfig_UsesProjectLocalChannel.verified.xml deleted file mode 100644 index beb326e5ea6..00000000000 --- a/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_NuGetConfig_UsesProjectLocalChannel.verified.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs index 1a5451fda59..fcaa77b92ef 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Configuration; using Aspire.Cli.Projects; using Aspire.Cli.Utils; @@ -14,7 +15,7 @@ internal sealed class TestAppHostServerSessionFactory : IAppHostServerSessionFac public Task CreateAsync( string appHostPath, string sdkVersion, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, Dictionary? launchSettingsEnvVars, bool debug, CancellationToken cancellationToken)