diff --git a/Aspire.slnx b/Aspire.slnx index a1ea50410f8..cc0eb9037b9 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -171,6 +171,9 @@ + + + @@ -414,6 +417,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 48bf97791f5..1b82e9adf47 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -79,6 +79,7 @@ + diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs new file mode 100644 index 00000000000..035e32b2b80 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using DotnetTool.AppHost; +using Microsoft.Extensions.DependencyInjection; + +var builder = DistributedApplication.CreateBuilder(args); + +var simpleUsage = builder.AddDotnetTool("simpleUsage", "dotnet-ef"); + +var wildcardVersion = builder.AddDotnetTool("wildcard", "dotnet-ef") + .WithPackageVersion("10.0.*") + .WithParentRelationship(simpleUsage); + +var preRelease = builder.AddDotnetTool("prerelease", "dotnet-ef") + .WithPackagePrerelease() + .WithParentRelationship(simpleUsage); + +// Multiple versions +var differentVersion = builder.AddDotnetTool("sameToolDifferentVersion1", "dotnet-dump") + .WithArgs("--version") + .WithPackageVersion("9.0.652701"); +builder.AddDotnetTool("sameToolDifferentVersion2", "dotnet-dump") + .WithPackageVersion("9.0.621003") + .WithArgs("--version") + .WithParentRelationship(differentVersion); + +// Concurrency +IResourceBuilder? concurrencyParent = null; +for (int i = 0; i < 5; i++) +{ + var concurrency = builder.AddDotnetTool($"sametoolconcurrency-{i}", "dotnet-trace") + .WithArgs("--version"); + + if (concurrencyParent == null) + { + concurrencyParent = concurrency; + } + else + { + concurrency.WithParentRelationship(concurrencyParent); + } +} + +// Substitution +var substituted = builder.AddDotnetTool("substituted", "dotnet-ef") + .WithCommand("calc") + .WithIconName("Calculator") + .WithExplicitStart(); +foreach(var toolAnnotation in substituted.Resource.Annotations.OfType().ToList()) +{ + substituted.Resource.Annotations.Remove(toolAnnotation); +} + +// Fake Offline by using "empty" package feeds +var fakeSourcesPath = Path.Combine(Path.GetTempPath(), "does-not-exist", Guid.NewGuid().ToString()); +var offline = builder.AddDotnetTool("offlineSimpleUsage", "dotnet-ef") + .WaitForCompletion(simpleUsage) + .WithPackageSource(fakeSourcesPath) + .WithPackageIgnoreExistingFeeds() + .WithPackageIgnoreFailedSources() + ; + +builder.AddDotnetTool("offlineWildcard", "dotnet-ef") + .WithPackageVersion("10.0.*") + .WaitForCompletion(wildcardVersion) + .WithParentRelationship(offline) + .WithPackageSource(fakeSourcesPath) + .WithPackageIgnoreExistingFeeds() + .WithPackageIgnoreFailedSources(); + +builder.AddDotnetTool("offlinePrerelease", "dotnet-ef") + .WithPackagePrerelease() + .WaitForCompletion(preRelease) + .WithParentRelationship(offline) + .WithPackageSource(fakeSourcesPath) + .WithPackageIgnoreExistingFeeds() + .WithPackageIgnoreFailedSources(); + +// Some issues only show up when installing for first time, rather than using existing downloaded versions +// Use a specific NUGET_PACKAGES path for these playground tools, so we can easily reset them +builder.Eventing.Subscribe(async (evt, _) => +{ + var nugetPackagesPath = Path.Join(evt.Services.GetRequiredService().BasePath, "nuget"); + + foreach (var resource in builder.Resources.OfType()) + { + builder.CreateResourceBuilder(resource) + .WithEnvironment("NUGET_PACKAGES", nugetPackagesPath) + .WithCommand("reset", "Reset Packages", ctx => + { + try + { + Directory.Delete(nugetPackagesPath, true); + return Task.FromResult(CommandResults.Success()); + } + catch (Exception ex) + { + return Task.FromResult(CommandResults.Failure(ex)); + } + }, new CommandOptions + { + IconName = "Delete" + }); + } +}); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); + diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs new file mode 100644 index 00000000000..a46f4cba47d --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs @@ -0,0 +1,16 @@ +#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project) +using Aspire.Hosting.ApplicationModel; +#pragma warning restore IDE0005 + +namespace DotnetTool.AppHost; + +public class DotNetToolAnnotation : IResourceAnnotation +{ + public required string PackageId { get; set; } + public string? Version { get; set; } + public bool Prerelease { get; set; } + public List Sources { get; } = []; + public bool IgnoreExistingFeeds { get; set; } + public bool IgnoreFailedSources { get; set; } + public bool AllowDowngrade { get; set; } +} diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs new file mode 100644 index 00000000000..d43d3f9e69f --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs @@ -0,0 +1,112 @@ +#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project) +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +#pragma warning restore IDE0005 + +namespace DotnetTool.AppHost; + +/// +/// Provides extension methods for adding Dotnet Tool resources to the application model. +/// +public static class DotNetToolExtensions +{ + public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string packageId) + => builder.AddDotnetTool(new DotnetToolResource(name, packageId)); + + public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, T resource) + where T : DotnetToolResource + { + return builder.AddResource(resource) + .WithIconName("Toolbox") + .WithCommand("dotnet") + .WithArgs(x => + { + if (!x.Resource.TryGetLastAnnotation(out var toolConfig)) + { + // If the annotation has been removed, don't add any dotnet tool arguments. + return; + } + + x.Args.Add("tool"); + x.Args.Add("exec"); + x.Args.Add(toolConfig.PackageId); + + var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source"; + + foreach (var source in toolConfig.Sources) + { + x.Args.Add(sourceArg); + x.Args.Add(source); + } + + if (toolConfig.IgnoreFailedSources) + { + x.Args.Add("--ignore-failed-sources"); + } + + if (toolConfig.Version is not null) + { + x.Args.Add("--version"); + x.Args.Add(toolConfig.Version); + } + else if (toolConfig.Prerelease) + { + x.Args.Add("--prerelease"); + } + + x.Args.Add("--verbosity"); + x.Args.Add("detailed"); + x.Args.Add("--yes"); + x.Args.Add("--"); + }); + } + + public static IResourceBuilder WithPackageId(this IResourceBuilder builder, string packageId) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration.PackageId = packageId; + return builder; + } + + /// + /// Set the package version for a tool to use + /// + /// The Dotnet Tool resource type + /// The . + /// The package version to use + /// The for chaining. + public static IResourceBuilder WithPackageVersion(this IResourceBuilder builder, string version) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration.Version = version; + return builder; + } + + public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder builder) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration.Prerelease = true; + return builder; + } + + public static IResourceBuilder WithPackageSource(this IResourceBuilder builder, string source) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration.Sources.Add(source); + return builder; + } + + public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResourceBuilder builder) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration.IgnoreExistingFeeds = true; + return builder; + } + + public static IResourceBuilder WithPackageIgnoreFailedSources(this IResourceBuilder builder) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration.IgnoreFailedSources = true; + return builder; + } +} diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj new file mode 100644 index 00000000000..9ef0172c69b --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj @@ -0,0 +1,23 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + + + diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs new file mode 100644 index 00000000000..4fc5b3a22ec --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs @@ -0,0 +1,35 @@ +#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project) +using Aspire.Hosting.ApplicationModel; +#pragma warning restore IDE0005 + +namespace DotnetTool.AppHost; + +/// +/// Represents a .NET tool resource that encapsulates metadata about a .NET CLI tool, including its name, package ID, +/// and command. +/// +/// This class is used to define and manage resources for .NET CLI tools. It associates a tool's name and +/// command with its package ID, and ensures that the required metadata is properly annotated. +public class DotnetToolResource : ExecutableResource +{ + /// The name of the resource. + /// The package id of the tool + public DotnetToolResource(string name, string packageId) + : base(name, "dotnet", ".") + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId)); + Annotations.Add(new DotNetToolAnnotation { PackageId = packageId }); + } + + internal DotNetToolAnnotation ToolConfiguration + { + get + { + if (!this.TryGetLastAnnotation(out var toolConfig)) + { + throw new InvalidOperationException("DotNetToolAnnotation is missing"); + } + return toolConfig; + } + } +} diff --git a/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..be00d6140cb --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:16179;http://localhost:16180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17119", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18036", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17119", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:16180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18037", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17120", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:16180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120" + } + } + } +} diff --git a/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json b/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/DotnetTool/DotnetTool.AppHost/appsettings.json b/playground/DotnetTool/DotnetTool.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs new file mode 100644 index 00000000000..59767ec4e81 --- /dev/null +++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs @@ -0,0 +1,400 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using DotnetTool.AppHost; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Hosting.DotnetTool.Tests; + +public class AddDotnetToolTests +{ + [Fact] + public void AddDotnetToolAddsResourceWithCorrectName() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + Assert.Equal("mytool", tool.Resource.Name); + Assert.IsType(tool.Resource); + } + + [Fact] + public void AddDotnetToolAddsToolAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + var annotation = Assert.Single(tool.Resource.Annotations.OfType()); + Assert.Equal("dotnet-ef", annotation.PackageId); + } + + [Fact] + public void AddDotnetToolThrowsWhenPackageIdIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddDotnetTool("mytool", null!)); + } + + [Fact] + public void AddDotnetToolThrowsWhenPackageIdIsEmpty() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddDotnetTool("mytool", "")); + } + + [Fact] + public void AddDotnetToolThrowsWhenPackageIdIsWhitespace() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddDotnetTool("mytool", " ")); + } + + [Fact] + public void AddDotnetToolSetsIconName() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + var annotation = Assert.Single(tool.Resource.Annotations.OfType()); + Assert.Equal("Toolbox", annotation.IconName); + } + + [Fact] + public async Task AddDotnetToolWithDefaultSettingsGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithVersionGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageVersion("10.0.0"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--version", arg), + arg => Assert.Equal("10.0.0", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithPrereleaseGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackagePrerelease(); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--prerelease", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithCustomSourceGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageSource("https://custom.nuget.org/v3/index.json"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithMultipleSourcesGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageSource("https://source1.nuget.org/v3/index.json") + .WithPackageSource("https://source2.nuget.org/v3/index.json"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://source1.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://source2.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithIgnoreExistingFeedsUsesSourceInsteadOfAddSource() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageSource("https://custom.nuget.org/v3/index.json") + .WithPackageIgnoreExistingFeeds(); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--source", arg), + arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithIgnoreFailedSourcesGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageIgnoreFailedSources(); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--ignore-failed-sources", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithAdditionalArgsPassedThrough() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithArgs("database", "update"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg), + arg => Assert.Equal("database", arg), + arg => Assert.Equal("update", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithComplexConfigurationGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageVersion("9.0.1") + .WithPackageSource("https://custom.nuget.org/v3/index.json") + .WithPackageIgnoreFailedSources() + .WithArgs("database", "update"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--ignore-failed-sources", arg), + arg => Assert.Equal("--version", arg), + arg => Assert.Equal("9.0.1", arg), + arg => Assert.Equal("--verbosity", arg), + arg => Assert.Equal("detailed", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg), + arg => Assert.Equal("database", arg), + arg => Assert.Equal("update", arg) + ); + } + + [Fact] + public void WithPackageVersionSetsVersionInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageVersion("10.0.*"); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.Equal("10.0.*", annotation.Version); + } + + [Fact] + public void WithPackagePrereleaseSetsPreReleaseInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackagePrerelease(); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.True(annotation.Prerelease); + } + + [Fact] + public void WithPackageSourceAddsSourceToAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageSource("https://custom.nuget.org/v3/index.json"); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.Single(annotation.Sources); + Assert.Equal("https://custom.nuget.org/v3/index.json", annotation.Sources[0]); + } + + [Fact] + public void WithPackageIgnoreExistingFeedsSetsIgnoreExistingFeedsInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageIgnoreExistingFeeds(); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.True(annotation.IgnoreExistingFeeds); + } + + [Fact] + public void WithPackageIgnoreFailedSourcesSetsIgnoreFailedSourcesInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithPackageIgnoreFailedSources(); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.True(annotation.IgnoreFailedSources); + } + + [Fact] + public async Task RemovingToolAnnotationResultsInNoArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + // Remove the annotation to simulate it being removed + var annotation = tool.Resource.Annotations.OfType().Single(); + tool.Resource.Annotations.Remove(annotation); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + // Should be empty since annotation was removed + Assert.Empty(args); + } + + [Fact] + public async Task AddDotnetToolGeneratesCorrectManifest() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("ef-tool", "dotnet-ef") + .WithPackageVersion("10.0.0") + .WithArgs("--version"); + + using var app = builder.Build(); + + var manifest = await ManifestUtils.GetManifest(tool.Resource).DefaultTimeout(); + + var expectedManifest = + """ + { + "type": "executable.v0", + "workingDirectory": ".", + "command": "dotnet", + "args": [ + "tool", + "exec", + "dotnet-ef", + "--version", + "10.0.0", + "--verbosity", + "detailed", + "--yes", + "--", + "--version" + ] + } + """; + + Assert.Equal(expectedManifest, manifest.ToString()); + } +} diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj new file mode 100644 index 00000000000..409b5fc5188 --- /dev/null +++ b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj @@ -0,0 +1,19 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + + + + + + +