// 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.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
using Microsoft.AspNetCore.InternalTesting;
namespace Aspire.Cli.Tests.Commands;
public class AddCommandTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task AddCommandWithHelpArgumentReturnsZero()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("add --help");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
}
[Fact]
public async Task IntegrationAddCommandWithHelpArgumentReturnsZero()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration add --help");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
}
[Fact]
public async Task IntegrationSearchCommandWithJsonOptionDoesNotEmitDiscoveryJson()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => testInteractionService;
options.DotNetCliRunnerFactory = _ =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("Should not search packages for the removed --json alias.");
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration search redis --json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
Assert.Empty(rawJson);
}
[Fact]
public async Task IntegrationSearchCommandRequiresQuery()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => testInteractionService;
options.DotNetCliRunnerFactory = _ =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("Should not search packages when the required search query is missing.");
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration search --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
Assert.Empty(rawJson);
}
[Fact]
public async Task IntegrationListCommandFormatJsonReturnsAvailableIntegrationsWithoutPromptingOrAddingPackage()
{
var addPackageWasCalled = false;
var projectLocatorWasCalled = false;
var promptedForIntegration = false;
var promptedForVersion = false;
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
{
projectLocatorWasCalled = true;
return Task.FromResult(new AppHostProjectSearchResult(null, []));
}
};
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (_) =>
{
promptedForIntegration = true;
throw new InvalidOperationException("Should not prompt for integration when listing integrations.");
};
prompter.PromptForIntegrationVersionCallback = (_) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not prompt for version when listing integrations.");
};
return prompter;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) =>
{
return (0, new[]
{
CreatePackage("Aspire.Hosting.Docker", "9.2.0"),
CreatePackage("Aspire.Hosting.Redis", "9.2.0"),
CreatePackage("Aspire.Hosting.Redis", "9.3.0"),
CreatePackage("Aspire.Hosting.Azure.Redis", "9.2.0")
});
};
runner.AddPackageAsyncCallback = (_, _, _, _, _, _, _) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration list --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(projectLocatorWasCalled);
Assert.False(promptedForIntegration);
Assert.False(promptedForVersion);
Assert.False(addPackageWasCalled);
var integrations = ReadIntegrationResults(rawJson);
Assert.Equal(3, integrations.Length);
Assert.Contains(integrations, i => i.Name == "azure-redis" && i.Package == "Aspire.Hosting.Azure.Redis" && i.Version == "9.2.0");
Assert.Contains(integrations, i => i.Name == "docker" && i.Package == "Aspire.Hosting.Docker" && i.Version == "9.2.0");
Assert.Contains(integrations, i => i.Name == "redis" && i.Package == "Aspire.Hosting.Redis" && i.Version == "9.3.0");
}
[Theory]
[InlineData("integration list --format json")]
[InlineData("integration search redis --format json")]
public async Task IntegrationDiscoveryCommandFormatJsonReturnsEmptyArrayWhenNoIntegrationsAreAvailable(string commandLine)
{
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.DotNetCliRunnerFactory = _ =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => (0, []);
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse(commandLine);
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Empty(ReadIntegrationResults(rawJson));
}
[Fact]
public async Task IntegrationDiscoveryCommandReturnsSearchFailureExitCodeWhenPackageDiscoveryFails()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.DotNetCliRunnerFactory = _ =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("Search failed.");
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration list");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToSearchIntegrations, exitCode);
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonFiltersAvailableIntegrationsWithoutAddingPackage()
{
var addPackageWasCalled = false;
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Should not locate an AppHost when searching integrations.")
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) =>
{
return (0, new[]
{
CreatePackage("Aspire.Hosting.Docker", "9.2.0"),
CreatePackage("Aspire.Hosting.Redis", "9.2.0"),
CreatePackage("Aspire.Hosting.Azure.Redis", "9.2.0")
});
};
runner.AddPackageAsyncCallback = (_, _, _, _, _, _, _) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration search redis --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(addPackageWasCalled);
var integrations = ReadIntegrationResults(rawJson);
Assert.Equal(2, integrations.Length);
Assert.Contains(integrations, i => i.Package == "Aspire.Hosting.Redis");
Assert.Contains(integrations, i => i.Package == "Aspire.Hosting.Azure.Redis");
Assert.DoesNotContain(integrations, i => i.Package == "Aspire.Hosting.Docker");
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonUsesFuzzyIntegrationMatching()
{
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) =>
{
return (0, new[]
{
CreatePackage("Aspire.Hosting.Docker", "9.2.0"),
CreatePackage("Aspire.Hosting.Redis", "9.2.0"),
CreatePackage("Aspire.Hosting.RabbitMQ", "9.2.0")
});
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration search rdis --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var integrations = ReadIntegrationResults(rawJson);
var integration = Assert.Single(integrations);
Assert.Equal("redis", integration.Name);
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToChannelAlsoSearchesImplicitChannel()
{
// Regression for https://github.com/microsoft/aspire/issues/17724 + https://github.com/microsoft/aspire/issues/17725.
//
// Layer 1 (latent bug, born 2026-01-13 in PR #13705): IntegrationPackageSearchService used to
// narrow the channel set to whatever `configuredChannel` resolved to whenever the apphost was
// non-C#. This dropped the implicit channel and any other channels from discovery.
// Layer 2 (PR #17452, 2026-05-26): `aspire init` started writing `"channel": "<identity>"` into
// the scaffolded aspire.config.json for polyglot apphosts. This activated the Layer 1 bug for
// every newly-initialized TS apphost in 13.4.
//
// Fix: IntegrationPackageSearchService no longer narrows. The full channel set (implicit +
// pinned channel + any hives) is searched.
//
// This test pins the TS apphost to the "daily" channel via aspire.config.json. Pre-fix only the
// daily channel was searched and Redis 2.0.0 (daily) was the only result. Post-fix the implicit
// channel is ALSO searched, and SelectPreferredIntegrationPackage prefers the implicit channel
// when versions collide on Id, so Redis 1.0.0 (implicit) wins the dedupe.
//
// The structural guarantee asserted below — both `implicitHits` AND `dailyHits` being > 0 — is
// what defends against a regression that drops either channel from the search. Asserting only
// on the resulting Redis version is insufficient because implicit-only and daily-only searches
// both happen to produce a single result.
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """
{
"channel": "daily"
}
""");
// Track per-channel invocation. IntegrationPackageSearchService walks channels via
// Parallel.ForEachAsync, so callbacks may run concurrently; Interlocked guards that.
var implicitHits = 0;
var dailyHits = 0;
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) =>
{
Interlocked.Increment(ref implicitHits);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]);
}
};
var dailyCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) =>
{
Interlocked.Increment(ref dailyHits);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]);
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance),
PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "daily")], dailyCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Structural regression signal: BOTH channels must have been searched.
Assert.True(implicitHits > 0, "Implicit channel was not queried — discovery is dropping it.");
Assert.True(dailyHits > 0, "Daily channel was not queried — pinned channel is being dropped from discovery.");
// Implicit channel result wins the dedupe (SelectPreferredIntegrationPackage prefers implicit).
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
Assert.Equal("1.0.0", integration.Version);
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToStagingChannelAlsoSearchesImplicitChannel()
{
// See companion test above for the full Layer 1 / Layer 2 regression story.
// This variant covers the staging-channel pin: a stable-shaped CLI dogfooder whose apphost
// was init'd by PR #17452 and now has `"channel": "staging"` written into aspire.config.json.
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """
{
"channel": "staging"
}
""");
var implicitHits = 0;
var stagingHits = 0;
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) =>
{
Interlocked.Increment(ref implicitHits);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]);
}
};
var stagingCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) =>
{
Interlocked.Increment(ref stagingHits);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]);
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Stable);
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance),
PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(implicitHits > 0, "Implicit channel was not queried — discovery is dropping it.");
Assert.True(stagingHits > 0, "Staging channel was not queried — pinned channel is being dropped from discovery.");
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
Assert.Equal("1.0.0", integration.Version);
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToStableChannelStillSurfacesPrereleaseOnlyPackages()
{
// Regression for https://github.com/microsoft/aspire/issues/17725 specifically.
//
// Aspire.Hosting.Foundry has never shipped a stable version — it only exists as prerelease.
// Pre-fix, a TS apphost with `"channel": "stable"` in aspire.config.json got narrowed to the
// stable channel only. That channel is Quality.Stable, so only `prerelease: false` queries
// were issued, and Foundry never appeared in the result set. Users dogfooding the staging CLI
// (which writes `"channel": "stable"` for a stable-shaped build) could not discover Foundry.
//
// Post-fix the implicit channel (Quality.Both) is also searched, which DOES issue
// `prerelease: true` queries, and Foundry surfaces.
//
// The fake here respects the `prerelease` arg passed to GetIntegrationPackagesAsync so the
// stable channel sees Redis only, while the implicit channel sees Redis + Foundry. The
// existence of Foundry in the result is the regression signal.
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """
{
"channel": "stable"
}
""");
// Implicit channel: Quality.Both. Returns Redis when prerelease=false, Redis+Foundry when prerelease=true.
var implicitHits = 0;
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
{
Interlocked.Increment(ref implicitHits);
return Task.FromResult<IEnumerable<NuGetPackage>>(
prerelease
? [CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foundry", "1.0.0-preview.1")]
: [CreatePackage("Aspire.Hosting.Redis", "1.0.0")]);
}
};
// Stable channel: Quality.Stable. PackageChannel only issues prerelease=false queries against it,
// so Foundry (prerelease-only) never appears regardless of what the cache could return.
var stableHits = 0;
var stableCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) =>
{
Interlocked.Increment(ref stableHits);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]);
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Stable);
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance),
PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, [new PackageMapping("Aspire*", "stable")], stableCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search foundry --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Both channels must be queried. The implicit channel is what surfaces Foundry (via
// prerelease=true), but the stable channel must also be searched so users who pinned to
// it don't lose stable-only packages.
Assert.True(implicitHits > 0, "Implicit channel was not queried — Foundry would not be discoverable.");
Assert.True(stableHits > 0, "Stable channel was not queried — pinned channel is being dropped from discovery.");
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Foundry", integration.Package);
Assert.Equal("1.0.0-preview.1", integration.Version);
}
[Theory]
[InlineData(null, false)] // No persisted channel — only implicit is searched, the explicit channel is excluded.
[InlineData("\"daily\"", true)] // Persisted daily channel — implicit AND daily are searched.
[InlineData("\"staging\"", true)] // Persisted staging channel — implicit AND staging are searched. Proves the gate is channel-name-opaque,
// so the post-fix behavior verified for "daily" applies equally to a staging-stamped release where
// `aspire new` would write `"channel": "staging"` into the polyglot apphost's aspire.config.json.
// (See IntegrationPackageSearchService.GetIntegrationPackagesWithChannelsAsync: the gate is
// `hasHives || !string.IsNullOrEmpty(configuredChannel)` — it never inspects the channel name.)
public async Task IntegrationSearchCommandTypeScriptAppHostPersistedChannelExpandsDiscoveryWithoutChangingPreferredResult(string? configFileChannelJson, bool expectExplicitChannelHit)
{
// Durable regression guard against re-introducing the Layer-1 narrowing bug.
//
// Pre-fix: aspire.config.json with `"channel"` set caused IntegrationPackageSearchService to
// narrow the channel set to that single channel, so the with-channel arm would have returned
// ONLY the daily channel's Redis (2.0.0) while the without-channel arm returned Redis 1.0.0.
// Post-fix two things hold simultaneously:
// (a) Both arms yield the SAME preferred Redis to the user (1.0.0, the implicit channel
// wins via SelectPreferredIntegrationPackage) — because the pin no longer overrides
// what the user sees as the top-ranked result.
// (b) The with-channel arm ALSO queries the pinned (daily) channel; the without-channel arm
// does not — because the explicit channel set is gated on `hasHives || !empty(configuredChannel)`.
//
// Both halves matter. (a) alone would pass for an implementation that incorrectly narrowed
// to implicit-only when a channel was pinned (a different regression than the original bug
// but still wrong — it would mean users who pin to `daily` lose access to packages that only
// exist on the daily feed). (b) is the new structural guarantee on top of (a).
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
if (configFileChannelJson is not null)
{
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), $$"""
{
"channel": {{configFileChannelJson}}
}
""");
}
var implicitHits = 0;
var dailyHits = 0;
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) =>
{
Interlocked.Increment(ref implicitHits);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]);
}
};
var dailyCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) =>
{
Interlocked.Increment(ref dailyHits);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]);
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance),
PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "daily")], dailyCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// (a) User-visible result is identical across arms: implicit Redis 1.0.0 wins.
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
Assert.Equal("1.0.0", integration.Version);
// (b) Per-channel search invocation differs based on whether a channel was pinned.
Assert.True(implicitHits > 0, "Implicit channel must always be searched.");
if (expectExplicitChannelHit)
{
// The explicit (daily) channel registered in the fake PackagingService gets searched
// regardless of what channel NAME the apphost pinned (the gate is channel-name-opaque —
// it only checks `!string.IsNullOrEmpty(configuredChannel)`). That's how a real CLI
// built with `AspireCliChannel=staging` (writing `"channel": "staging"` into apphosts
// via `aspire new`) will exercise the same gate path as a CLI that pinned `"daily"`.
Assert.True(dailyHits > 0, $"With-channel arm: explicit channel must also be searched when apphost pin is non-empty (configured: {configFileChannelJson}).");
}
else
{
Assert.Equal(0, dailyHits);
}
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonWithAppHostOutsideLaunchDirectoryUsesConfiguredStagingChannelWithRealPackagingService()
{
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere"));
var appHostFile = new FileInfo(Path.Combine(projectDirectory.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
File.WriteAllText(Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName), """
{
"channel": "staging"
}
""");
var cache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Stable);
options.InteractionServiceFactory = _ => testInteractionService;
options.NuGetPackageCacheFactory = _ => cache;
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
Assert.Equal("2.0.0", integration.Version);
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonWithUnpinnedAppHostUsesImplicitChannelUnderStagingCli()
{
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")])
};
var stagingCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Staging);
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance),
PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
Assert.Equal("1.0.0", integration.Version);
}
[Fact]
public async Task IntegrationSearchCommandStagingStampedCliWithPinnedStagingApphostQueriesBothImplicitAndStagingChannelsAndSurfacesPrereleaseOnlyPackages()
{
// High-confidence shipping-shape regression guard for #17724 and #17725.
//
// This test simulates EXACTLY what a real CLI built and shipped as staging will do when
// the user runs `aspire add <name>` against a polyglot apphost that `aspire new` created:
//
// * The CLI binary is stamped `AspireCliChannel=staging` -> `IdentityChannel == "staging"`.
// This triggers the real PackagingService.GetChannelsAsync to synthesize a real staging
// channel alongside implicit + stable (no fake TestPackagingService is used here).
// * `aspire new` writes `"channel": "staging"` into aspire.config.json (see
// CliTemplateFactory.TypeScriptStarterTemplate). We mirror that here.
// * There are NO PR hives. This is a real shipped install, not a dogfood/PR build.
//
// Pre-fix (the regression introduced before 13.4): the gate narrowed the search to ONLY the
// pinned staging channel. Implicit was excluded. Prerelease-only integrations (e.g.,
// Aspire.Hosting.Foundry) were invisible because the only feed queried was the staging
// feed, which doesn't surface them. The `aspire add kubernetes` regression had the same
// root cause: kubernetes was reachable via implicit (nuget.org) but invisible under the
// narrowed staging-only search.
//
// Post-fix invariants verified here:
// (i) BOTH implicit AND the synthesized staging channel are queried (cache call count
// is >= 2). Pre-fix this would have been exactly 1.
// (ii) A prerelease-only package returned by the cache only when prerelease=true (which
// is what Quality.Both channels request) is reachable to the user.
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """
{
"channel": "staging"
}
""");
var totalCacheCalls = 0;
var prereleaseRequested = 0;
var cache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
{
Interlocked.Increment(ref totalCacheCalls);
if (prerelease)
{
Interlocked.Increment(ref prereleaseRequested);
return Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Foundry", "13.4.0-rc.1")]);
}
return Task.FromResult<IEnumerable<NuGetPackage>>([]);
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// Stamp the running CLI as the staging release identity. The real PackagingService
// (left un-overridden here) reads this from CliExecutionContext.IdentityChannel and
// synthesizes the staging channel automatically (see PackagingService.GetChannelsAsync
// -> stagingIdentityChannel branch).
options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Staging);
options.InteractionServiceFactory = _ => testInteractionService;
options.NuGetPackageCacheFactory = _ => cache;
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search foundry --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// (ii) The prerelease-only package is reachable to the user.
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Foundry", integration.Package);
Assert.Equal("13.4.0-rc.1", integration.Version);
// (i) Both implicit AND staging were queried. Pre-fix narrowing would have produced exactly 1 call.
// Real PackagingService.GetChannelsAsync under IdentityChannel=Staging returns at least
// [implicit, stable, staging]; the IPSS gate now lets all of them through (hasHives=false,
// configuredChannel="staging" -> not empty -> gate evaluates true). At minimum the implicit
// and staging channels must have run, so we require >= 2 calls. Using `>= 2` rather than
// `== N` keeps the test robust to PackagingService adding additional explicit channels
// (e.g., stable) without weakening the regression guard.
Assert.True(totalCacheCalls >= 2, $"Expected >= 2 cache calls (both implicit and staging channels), got {totalCacheCalls}. Pre-fix narrowing would have produced 1 call.");
Assert.True(prereleaseRequested >= 1, $"Expected at least one channel to request prerelease=true (Quality.Both channels do); got {prereleaseRequested}.");
}
[Fact]
public async Task IntegrationListCommandFormatJsonPrefersImplicitChannelWhenMultipleChannelsContainSameIntegration()
{
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives", "test-hive"));
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")])
};
var explicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance),
PackageChannel.CreateExplicitChannel("test-hive", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "test-hive")], explicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration list --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
Assert.Equal("1.0.0", integration.Version);
}
[Fact]
public async Task IntegrationSearchCommandFormatJsonReturnsEmptyArrayWhenNoIntegrationsMatch()
{
var addPackageWasCalled = false;
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Should not locate an AppHost when searching integrations.")
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) =>
{
return (0, new[]
{
CreatePackage("Aspire.Hosting.Docker", "9.2.0"),
CreatePackage("Aspire.Hosting.Redis", "9.2.0")
});
};
runner.AddPackageAsyncCallback = (_, _, _, _, _, _, _) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("integration search azure --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(addPackageWasCalled);
var integrations = ReadIntegrationResults(rawJson);
Assert.Empty(integrations);
}
[Fact]
public async Task AddCommandInteractiveFlowSmokeTest()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
return new TestAddCommandPrompter(interactionService);
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var dockerPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Docker",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var azureRedisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Azure.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } //
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
// Simulate adding the package.
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
}
[Fact]
public async Task AddCommandDoesNotPromptForIntegrationArgumentIfSpecifiedOnCommandLine()
{
var promptedForIntegrationPackages = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegrationPackages = true;
throw new InvalidOperationException("Should not have been prompted for integration packages.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var dockerPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Docker",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var azureRedisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Azure.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } //
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
// Simulate adding the package.
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add docker");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForIntegrationPackages);
}
[Fact]
public async Task AddCommandDoesNotPromptForVersionIfSpecifiedOnCommandLine()
{
var promptedForIntegrationPackages = false;
var promptedForVersion = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegrationPackages = true;
throw new InvalidOperationException("Should not have been prompted for integration packages.");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var dockerPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Docker",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var azureRedisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Azure.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } //
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
// Simulate adding the package.
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add docker --version 9.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForIntegrationPackages);
Assert.False(promptedForVersion);
}
[Fact]
public async Task AddCommandInteractiveDoesNotPromptForVersionIfSpecifiedOnCommandLine()
{
var promptedForIntegrationPackages = false;
var promptedForVersion = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegrationPackages = true;
throw new InvalidOperationException("Should not have been prompted for integration packages.");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var dockerPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Docker",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var azureRedisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Azure.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (
0,
new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage }
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) => 0;
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add docker --version 9.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForIntegrationPackages);
Assert.False(promptedForVersion);
}
[Fact]
public async Task AddCommandDoesNotPromptForVersionWhenSpecifiedVersionIsFoundViaExactMatchSearch()
{
var promptedForVersion = false;
var selectedPackageVersion = string.Empty;
var exactMatchQueries = new List<string>();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "13.3.0"
}
]);
}
exactMatchQueries.Add(query);
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForVersion);
Assert.Equal("13.2.0", selectedPackageVersion);
Assert.Equal(2, exactMatchQueries.Count);
Assert.All(exactMatchQueries, query => Assert.Equal("Aspire.Hosting.Redis", query));
}
[Fact]
public async Task AddCommandInteractiveDoesNotPromptForVersionWhenSpecifiedVersionIsFoundViaExactMatchSearch()
{
var promptedForIntegration = false;
var promptedForVersion = false;
var selectedPackageVersion = string.Empty;
var exactMatchQueries = new List<string>();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
throw new InvalidOperationException("Should not have been prompted for integration selection.");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "13.3.0"
}
]);
}
exactMatchQueries.Add(query);
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForIntegration);
Assert.False(promptedForVersion);
Assert.Equal("13.2.0", selectedPackageVersion);
Assert.Equal(2, exactMatchQueries.Count);
Assert.All(exactMatchQueries, query => Assert.Equal("Aspire.Hosting.Redis", query));
}
[Theory]
[InlineData("redis")]
[InlineData("Aspire.Hosting.Redis")]
public async Task AddCommandInteractiveDoesNotPromptForIntegrationWhenExactMatchIsFound(string integrationName)
{
var promptedForIntegration = false;
var promptedForVersion = false;
var selectedPackageName = string.Empty;
var selectedPackageVersion = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
throw new InvalidOperationException("Should not have been prompted for integration selection.");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
return packages.Single(package => package.Package.Version == "13.2.0");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
]);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageName = packageName;
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add {integrationName}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForIntegration);
Assert.True(promptedForVersion);
Assert.Equal("Aspire.Hosting.Redis", selectedPackageName);
Assert.Equal("13.2.0", selectedPackageVersion);
}
[Fact]
public async Task AddCommandSearchesEachPackageIdOnceWhenExactMatchFallsBackAcrossSharedChannel()
{
var promptedForVersion = false;
var selectedPackageVersion = string.Empty;
var exactMatchQueryCounts = new Dictionary<string, int>(StringComparer.Ordinal);
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.3.1" }
]);
}
exactMatchQueryCounts[query] = exactMatchQueryCounts.GetValueOrDefault(query) + 1;
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.2.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForVersion);
Assert.Equal("13.2.0", selectedPackageVersion);
Assert.Equal(2, exactMatchQueryCounts["Aspire.Hosting.Redis"]);
}
[Fact]
public async Task AddCommandWithoutIntegrationNameDoesNotPromptForVersionWhenSpecifiedVersionIsFoundViaExactMatchSearch()
{
var promptedForVersion = false;
var selectedPackageVersion = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) => packages.Single(package => package.Package.Id == "Aspire.Hosting.Redis");
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
]),
"Aspire.Hosting.Docker" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForVersion);
Assert.Equal("13.2.0", selectedPackageVersion);
}
[Fact]
public async Task AddCommandShowsStatusWhenSearchingForSpecifiedVersionAfterPackageSelection()
{
var statusMessages = new List<string>();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var testInteractionService = new TestInteractionService
{
ShowStatusCallback = statusMessages.Add
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) => packages.Single(package => package.Package.Id == "Aspire.Hosting.Redis");
prompter.PromptForIntegrationVersionCallback = (packages) =>
throw new InvalidOperationException("Should not have been prompted for integration version.");
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
]),
"Aspire.Hosting.Docker" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => 0;
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Contains(AddCommandStrings.SearchingForAspirePackages, statusMessages);
Assert.Contains(string.Format(AddCommandStrings.SearchingForSpecifiedPackageVersion, "Aspire.Hosting.Redis", "13.2.0"), statusMessages);
}
[Fact]
public async Task AddCommandFailsWhenSpecifiedVersionDoesNotExist()
{
var promptedForVersion = false;
var addPackageWasCalled = false;
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.False(promptedForVersion);
Assert.False(addPackageWasCalled);
Assert.Contains(testInteractionService.DisplayedErrors, error => error.Contains("13.2.0") && error.Contains("Aspire.Hosting.Redis"));
}
[Fact]
public async Task AddCommandInteractiveFailsWhenSpecifiedVersionDoesNotExist()
{
var promptedForIntegration = false;
var promptedForVersion = false;
var addPackageWasCalled = false;
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
throw new InvalidOperationException("Should not have been prompted for integration selection.");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.False(promptedForIntegration);
Assert.False(promptedForVersion);
Assert.False(addPackageWasCalled);
Assert.Contains(testInteractionService.DisplayedErrors, error => error.Contains("13.2.0") && error.Contains("Aspire.Hosting.Redis"));
}
[Fact]
public async Task AddCommandPromptsForDisambiguation()
{
IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>? promptedPackages = null;
string? addedPackageName = null;
string? addedPackageVersion = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedPackages = packages;
return packages.Single(p => p.Package.Id == "Aspire.Hosting.Redis");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var dockerPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Docker",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var azureRedisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Azure.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } //
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addedPackageName = packageName;
addedPackageVersion = packageVersion;
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add red");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Collection(
promptedPackages!,
p => Assert.Equal("Aspire.Hosting.Redis", p.Package.Id),
p => Assert.Equal("Aspire.Hosting.Azure.Redis", p.Package.Id)
);
Assert.Equal("Aspire.Hosting.Redis", addedPackageName);
Assert.Equal("9.2.0", addedPackageVersion);
}
[Fact]
public async Task AddCommandPreservesSourceArgumentInBothCommands()
{
// Arrange
string? addUsedSource = null;
const string expectedSource = "https://custom-nuget-source.test/v3/index.json";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// Makes it easier to isolate behavior in test case by disabling one
// of the concurrent calls to the NuGetCache from the prefetcher.
options.DisabledFeatures = [KnownFeatures.UpdateNotificationsEnabled];
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
return new TestAddCommandPrompter(interactionService);
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { redisPackage } //
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
// Capture the source used for add
addUsedSource = nugetSource;
// Simulate adding the package.
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
// Act
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add redis --source {expectedSource}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(0, exitCode);
Assert.Equal(expectedSource, addUsedSource);
}
[Fact]
public async Task AddCommand_EmptyPackageList_DisplaysErrorMessage()
{
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
return (0, Array.Empty<NuGetPackage>());
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.Contains(testInteractionService.DisplayedErrors, e => e.Contains(AddCommandStrings.NoIntegrationPackagesFound));
}
[Fact]
public async Task AddCommand_NoMatchingPackages_DisplaysNoMatchesMessage()
{
string? displayedSubtleMessage = null;
bool promptedForIntegration = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = (sp) =>
{
var testInteractionService = new TestInteractionService();
testInteractionService.DisplaySubtleMessageCallback = (message) =>
{
displayedSubtleMessage = message;
};
return testInteractionService;
};
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
return packages.First();
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var dockerPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Docker",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (0, new NuGetPackage[] { dockerPackage, redisPackage });
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add nonexistentpackage");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.True(promptedForIntegration);
Assert.Equal(string.Format(AddCommandStrings.NoPackagesMatchedSearchTerm, "nonexistentpackage"), displayedSubtleMessage);
}
[Theory]
[InlineData("Aspire.Hosting.Azure.Redis", "azure-redis")]
[InlineData("CommunityToolkit.Aspire.Hosting.Cosmos", "communitytoolkit-cosmos")]
[InlineData("Aspire.Hosting.Postgres", "postgres")]
[InlineData("Acme.Aspire.Hosting.Foo.Bar", "acme-foo-bar")]
[InlineData("Aspire.Hosting.Docker", "docker")]
[InlineData("SomeOther.Package.Name", "someother-package-name")]
public void GenerateFriendlyName_ProducesExpectedResults(string packageId, string expectedFriendlyName)
{
// Arrange
var package = new NuGetPackage { Id = packageId, Version = "1.0.0", Source = "test" };
// Act
var result = IntegrationPackageSearchService.GenerateFriendlyName((package, null!)); // Null is OK for this test.
// Assert
Assert.Equal(expectedFriendlyName, result.FriendlyName);
Assert.Equal(package, result.Package);
}
[Fact]
public async Task AddCommandPrompter_FiltersToHighestVersionPerPackageId()
{
// Arrange
List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>? displayedPackages = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = (sp) =>
{
var mockInteraction = new TestInteractionService();
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
// Capture what the prompter passes to the interaction service
var choicesList = choices.Cast<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>().ToList();
displayedPackages = choicesList;
return choicesList.First();
};
return mockInteraction;
};
});
using var provider = services.BuildServiceProvider();
var interactionService = provider.GetRequiredService<IInteractionService>();
var prompter = new AddCommandPrompter(interactionService);
// Create a fake channel
var fakeCache = new FakeNuGetPackageCache();
var channel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
// Create multiple versions of the same package
var packages = new[]
{
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, channel),
};
// Act
await prompter.PromptForIntegrationAsync(packages, CancellationToken.None).DefaultTimeout();
// Assert - should only show highest version (9.2.0) for the package ID
Assert.NotNull(displayedPackages);
Assert.Single(displayedPackages!);
Assert.Equal("9.2.0", displayedPackages!.First().Package.Version);
}
[Fact]
public async Task AddCommandPrompter_FiltersToHighestVersionPerChannel()
{
// Arrange
List<object>? displayedChoices = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = (sp) =>
{
var mockInteraction = new TestInteractionService();
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
// Capture what the prompter passes to the interaction service
var choicesList = choices.Cast<object>().ToList();
displayedChoices = choicesList;
return choicesList.First();
};
return mockInteraction;
};
});
using var provider = services.BuildServiceProvider();
var interactionService = provider.GetRequiredService<IInteractionService>();
var prompter = new AddCommandPrompter(interactionService);
// Create a fake channel
var fakeCache = new FakeNuGetPackageCache();
var channel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
// Create multiple versions of the same package from same channel
var packages = new[]
{
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.1-preview.1", Source = "nuget" }, channel),
};
// Act
var result = await prompter.PromptForIntegrationVersionAsync(packages, configuredChannel: null, CancellationToken.None).DefaultTimeout();
// Assert - For implicit channel with no explicit channels, should automatically select highest version without prompting
Assert.Null(displayedChoices); // No prompt should be shown
Assert.Equal("9.2.0", result.Package.Version); // Should return highest version
}
[Fact]
public async Task AddCommandPrompter_ShowsHighestVersionPerChannelWhenMultipleChannels()
{
// Arrange
List<object>? displayedChoices = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = (sp) =>
{
var mockInteraction = new TestInteractionService();
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
// Capture what the prompter passes to the interaction service
var choicesList = choices.Cast<object>().ToList();
displayedChoices = choicesList;
return choicesList.First();
};
return mockInteraction;
};
});
using var provider = services.BuildServiceProvider();
var interactionService = provider.GetRequiredService<IInteractionService>();
var prompter = new AddCommandPrompter(interactionService);
// Create two different channels
var fakeCache = new FakeNuGetPackageCache();
var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
var mappings = new[] { new PackageMapping("Aspire*", "https://preview-feed") };
var explicitChannel = PackageChannel.CreateExplicitChannel("preview", PackageChannelQuality.Prerelease, mappings, fakeCache, new TestFeatures(), NullLogger.Instance);
// Create packages from different channels with different versions
var packages = new[]
{
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, implicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, implicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, implicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "10.0.0-preview.1", Source = "preview-feed" }, explicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "10.0.0-preview.2", Source = "preview-feed" }, explicitChannel),
};
// Act
await prompter.PromptForIntegrationVersionAsync(packages, configuredChannel: null, CancellationToken.None).DefaultTimeout();
// Assert - should show 2 root choices: one for implicit channel, one submenu for explicit channel
Assert.NotNull(displayedChoices);
Assert.Equal(2, displayedChoices!.Count);
}
[Fact]
public async Task AddCommandPrompter_ShowsConfiguredChannelAsFirstChoiceWhenChannelPinned()
{
// Regression for https://github.com/microsoft/aspire/issues/18114.
//
// When the apphost pins a channel (e.g. a polyglot apphost that persists `"channel": "daily"`
// in aspire.config.json), `aspire add` must surface that channel's package as the FIRST/default
// menu option. Pre-fix the implicit/ambient channel was always rendered first, so the default
// selection was the stable nuget.org version (e.g. 13.4.3) even though the project can only
// restore from the pinned daily feed — producing a confusing default and a failed restore.
List<string>? displayedLabels = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = (sp) =>
{
var mockInteraction = new TestInteractionService();
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
var choicesList = choices.Cast<object>().ToList();
displayedLabels = choicesList.Select(formatter).ToList();
return choicesList.First();
};
return mockInteraction;
};
});
using var provider = services.BuildServiceProvider();
var interactionService = provider.GetRequiredService<IInteractionService>();
var prompter = new AddCommandPrompter(interactionService);
var fakeCache = new FakeNuGetPackageCache();
var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "daily")], fakeCache, new TestFeatures(), NullLogger.Instance);
// The implicit (ambient) channel surfaces a higher-precedence STABLE version; the pinned daily
// channel surfaces a PRERELEASE version. Pre-fix the higher stable version was always the default.
var packages = new[]
{
("storage", new NuGetPackage { Id = "Aspire.Hosting.Azure.Storage", Version = "13.4.3", Source = "nuget" }, implicitChannel),
("storage", new NuGetPackage { Id = "Aspire.Hosting.Azure.Storage", Version = "13.5.0-preview.1", Source = "daily" }, dailyChannel),
};
var result = await prompter.PromptForIntegrationVersionAsync(packages, configuredChannel: "daily", CancellationToken.None).DefaultTimeout();
Assert.NotNull(displayedLabels);
// The pinned (daily) channel is the first/default choice; the implicit channel follows.
Assert.Equal("daily", displayedLabels![0]);
// Selecting the default (first) choice resolves to the daily channel's prerelease package.
Assert.Equal("13.5.0-preview.1", result.Package.Version);
Assert.Same(dailyChannel, result.Channel);
}
[Fact]
public async Task AddCommandPrompter_StagingChannelPreservesAllChannelChoices()
{
List<string>? displayedLabels = null;
var interactionService = new TestInteractionService
{
PromptForSelectionCallback = (_, choices, formatter, _) =>
{
var choicesList = choices.Cast<object>().ToList();
displayedLabels = choicesList.Select(formatter).ToList();
return choicesList.First();
}
};
var prompter = new AddCommandPrompter(interactionService);
var cache = new FakeNuGetPackageCache();
var implicitChannel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance);
var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], cache, new TestFeatures(), NullLogger.Instance);
var dailyChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Daily, PackageChannelQuality.Prerelease, [new PackageMapping("Aspire*", "daily")], cache, new TestFeatures(), NullLogger.Instance);
var prChannel = PackageChannel.CreateExplicitChannel("pr-19404", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "pr-19404")], cache, new TestFeatures(), NullLogger.Instance);
var packages = new[]
{
("azure-kubernetes", CreatePackage("Aspire.Hosting.Azure.Kubernetes", "13.5.0-preview.1.26415.2"), stagingChannel),
("azure-kubernetes", CreatePackage("Aspire.Hosting.Azure.Kubernetes", "13.4.3"), implicitChannel),
("azure-kubernetes", CreatePackage("Aspire.Hosting.Azure.Kubernetes", "13.6.0-preview.1.26415.1"), dailyChannel),
("azure-kubernetes", CreatePackage("Aspire.Hosting.Azure.Kubernetes", "13.6.0-pr.19404.gf51e8e1d"), prChannel)
};
var result = await prompter.PromptForIntegrationVersionAsync(packages, PackageChannelNames.Staging, CancellationToken.None).DefaultTimeout();
Assert.Collection(
Assert.IsType<List<string>>(displayedLabels),
label => Assert.Equal(PackageChannelNames.Staging, label),
label => Assert.Contains("13.4.3", label, StringComparison.Ordinal),
label => Assert.Equal(PackageChannelNames.Daily, label),
label => Assert.Equal("pr-19404", label));
Assert.Equal("13.5.0-preview.1.26415.2", result.Package.Version);
Assert.Same(stagingChannel, result.Channel);
}
[Theory]
// The staging channel can see the current SHA package alongside an older NuGet.org fallback.
// A same-line stable package outranks its preview, while a current-line preview-only integration
// outranks a stable package from the previous release.
[InlineData("13.5.0", "13.5.0-preview.1.26415.2", "13.5.0")]
[InlineData("13.4.6", "13.5.0-preview.1.26415.2", "13.5.0-preview.1.26415.2")]
public async Task AddCommandPrompter_StagingChannelSelectsHighestVersionAcrossPackageQualities(
string stableVersion,
string prereleaseVersion,
string expectedVersion)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var cache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", prerelease ? prereleaseVersion : stableVersion)])
};
var stagingChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Staging,
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", "staging")],
cache,
new TestFeatures(),
NullLogger.Instance);
var packages = (await stagingChannel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout())
.Select(package => ("redis", package, stagingChannel))
.ToArray();
var interactionService = new TestInteractionService
{
PromptForSelectionCallback = (_, choices, _, _) => choices.Cast<object>().First()
};
var prompter = new AddCommandPrompter(interactionService);
var result = await prompter.PromptForIntegrationVersionAsync(packages, PackageChannelNames.Staging, CancellationToken.None).DefaultTimeout();
Assert.Equal(expectedVersion, result.Package.Version);
Assert.Same(stagingChannel, result.Channel);
}
[Theory]
[InlineData(KnownLanguageId.CSharp, PackageChannelNames.Staging, "13.5.0", "13.5.0-preview.1.26415.2")]
[InlineData(KnownLanguageId.TypeScript, PackageChannelNames.Staging, "13.5.0", "13.5.0-preview.1.26415.2")]
[InlineData(KnownLanguageId.CSharp, PackageChannelNames.Daily, "13.6.0-preview.1.26415.1", "13.6.0-preview.1.26415.1")]
[InlineData(KnownLanguageId.TypeScript, PackageChannelNames.Daily, "13.6.0-preview.1.26415.1", "13.6.0-preview.1.26415.1")]
[InlineData(KnownLanguageId.CSharp, "pr-19404", "13.6.0-pr.19404.gf51e8e1d", "13.6.0-pr.19404.gf51e8e1d")]
[InlineData(KnownLanguageId.TypeScript, "pr-19404", "13.6.0-pr.19404.gf51e8e1d", "13.6.0-pr.19404.gf51e8e1d")]
public async Task AddCommandNonInteractiveSelectsExpectedVersionAcrossLanguageAndChannelMatrix(
string languageId,
string targetChannel,
string cliVersion,
string expectedPackageVersion)
{
const string packageId = "Aspire.Hosting.Azure.Kubernetes";
const string stagingVersion = "13.5.0-preview.1.26415.2";
const string dailyVersion = "13.6.0-preview.1.26415.1";
const string prVersion = "13.6.0-pr.19404.gf51e8e1d";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var isTypeScript = string.Equals(languageId, KnownLanguageId.TypeScript, StringComparison.Ordinal);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, isTypeScript ? "apphost.ts" : "AppHost.csproj"));
File.WriteAllText(appHostFile.FullName, isTypeScript ? string.Empty : "<Project />");
if (isTypeScript)
{
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), $$"""
{
"channel": "{{targetChannel}}"
}
""");
}
// Force every channel into discovery so each matrix cell proves precedence against
// competing implicit, staging, daily, and PR candidates.
var hivesDirectory = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
hivesDirectory.Create();
hivesDirectory.CreateSubdirectory("pr-19404");
var addedPackageId = string.Empty;
var addedPackageVersion = string.Empty;
var promptedForVersion = false;
var projectFactory = new TestAppHostProjectFactory
{
LanguageId = languageId,
DisplayName = isTypeScript ? "TypeScript (Node.js)" : "C# (.NET)",
CanHandleCallback = file => string.Equals(file.FullName, appHostFile.FullName, StringComparison.Ordinal),
AddPackageAsyncCallback = (context, _) =>
{
addedPackageId = context.PackageId;
addedPackageVersion = context.PackageVersion;
return Task.FromResult(true);
}
};
// C# resolves its configured feed through the implicit NuGet.config channel. TypeScript
// keeps nuget.org implicit and persists the selected explicit channel in aspire.config.json.
var implicitVersion = !isTypeScript && !string.Equals(targetChannel, "pr-19404", StringComparison.Ordinal)
? expectedPackageVersion
: "13.4.3";
var implicitCache = CreateIntegrationPackageCache(packageId, implicitVersion);
var stagingCache = CreateIntegrationPackageCache(packageId, stagingVersion);
var dailyCache = CreateIntegrationPackageCache(packageId, dailyVersion);
var prCache = CreateIntegrationPackageCache(packageId, prVersion);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: targetChannel,
identityVersion: cliVersion);
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.AppHostProjectFactory = _ => projectFactory;
options.AddCommandPrompterFactory = sp =>
{
var prompter = new TestAddCommandPrompter(sp.GetRequiredService<IInteractionService>());
prompter.PromptForIntegrationVersionCallback = _ =>
{
promptedForVersion = true;
throw new InvalidOperationException("The channel matrix must resolve deterministically without prompting.");
};
return prompter;
};
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
[
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance, currentCliVersion: cliVersion),
// Official staging feeds contain stable packages alongside integrations that
// deliberately remain prerelease, so staging discovery always uses Both.
PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache, new TestFeatures(), NullLogger.Instance, currentCliVersion: cliVersion),
PackageChannel.CreateExplicitChannel(PackageChannelNames.Daily, PackageChannelQuality.Prerelease, [new PackageMapping("Aspire*", "daily")], dailyCache, new TestFeatures(), NullLogger.Instance, currentCliVersion: cliVersion),
PackageChannel.CreateExplicitChannel("pr-19404", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "pr-19404")], prCache, new TestFeatures(), NullLogger.Instance, currentCliVersion: cliVersion)
])
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add {packageId} --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(promptedForVersion);
Assert.Equal(packageId, addedPackageId);
Assert.Equal(expectedPackageVersion, addedPackageVersion);
}
[Fact]
public async Task AddCommandNonInteractiveTypeScriptAppHostPinnedToDailyPrefersDailyChannelOverImplicitStable()
{
// Regression for https://github.com/microsoft/aspire/issues/18114.
//
// Repro: a polyglot (TypeScript) apphost created by a daily CLI persists `"channel": "daily"`
// in aspire.config.json, and its NuGet.config maps Aspire* to the daily (dotnet9) feed only.
// `aspire add azure-storage --non-interactive` discovers BOTH the implicit channel (ambient
// nuget.org -> stable 13.4.3) and the pinned daily channel (dotnet9 -> 13.5.0-preview.1).
//
// Pre-fix: GetPackageByInteractiveFlow ranked the implicit channel first, so the non-interactive
// path auto-selected the stable 13.4.3 — which the project then could NOT restore from the daily
// feed (the dotnet9 feed has no stable 13.4.3), surfacing as a hard restore failure.
//
// Post-fix: a pinned channel outranks the implicit channel, so the daily 13.5.0-preview.1 package
// is selected and restore from the pinned feed succeeds.
var addedPackageId = string.Empty;
var addedPackageVersion = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """
{
"channel": "daily"
}
""");
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Azure.Storage", "13.4.3")])
};
var dailyCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([CreatePackage("Aspire.Hosting.Azure.Storage", "13.5.0-preview.1")])
};
var tsFactory = new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true));
tsFactory.Project.AddPackageAsyncCallback = (context, _) =>
{
addedPackageId = context.PackageId;
addedPackageVersion = context.PackageVersion;
return Task.FromResult(true);
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService();
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance),
PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "daily")], dailyCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(tsFactory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
// Use the fully-qualified package id so it is an exact match (no fuzzy fallback) in non-interactive mode.
var result = command.Parse($"add Aspire.Hosting.Azure.Storage --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Equal("Aspire.Hosting.Azure.Storage", addedPackageId);
Assert.Equal("13.5.0-preview.1", addedPackageVersion);
}
[Fact]
public async Task AddCommand_WithoutHives_UsesImplicitChannelWithoutPrompting()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var selectedPackageId = string.Empty;
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.InteractionServiceFactory = _ => new TestInteractionService()
{
PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
return choices.Cast<object>().First();
}
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
return (0, new NuGetPackage[] { redisPackage });
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
selectedPackageId = packageName;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
// Act - without hives, should automatically select from implicit channel without prompting
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(0, exitCode);
Assert.Equal("Aspire.Hosting.Redis", selectedPackageId);
}
[Fact]
public async Task AddCommand_WithHives_PrefersImplicitChannelVersionInNonInteractiveMode()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
hivesDir.Create();
hivesDir.CreateSubdirectory("pr-12345");
var selectedPackageVersion = string.Empty;
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.InteractionServiceFactory = _ => new TestInteractionService()
{
PromptForSelectionCallback = (message, choices, formatter, ct) => choices.Cast<object>().First()
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
var implicitPackage = new NuGetPackage
{
Id = "Aspire.Hosting.Redis",
Source = "implicit",
Version = "13.2.0-pr.12345.gabc"
};
var explicitPackage = new NuGetPackage
{
Id = "Aspire.Hosting.Redis",
Source = "explicit",
Version = "13.3.0-preview.1.1"
};
return nugetSource is null
? (0, new[] { implicitPackage })
: (0, new[] { explicitPackage });
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
// Act
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(0, exitCode);
Assert.Equal("13.2.0-pr.12345.gabc", selectedPackageVersion);
}
[Fact]
public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion()
{
// PR-hive packages are discovered through the package-search code path: the
// explicit channel maps to a separate NuGet source that, when queried, returns
// a package pinned to the current CLI version.
var cliVersion = VersionHelper.GetDefaultSdkVersion();
var (exitCode, selectedVersion, prompted) = await RunAddRedisWithHiveScenarioAsync(
configureHives: workspace =>
{
var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
hivesDir.Create();
hivesDir.CreateSubdirectory("pr-12345");
},
searchCallback: nugetSource => nugetSource is null
? new[] { new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.2.2" } }
: new[] { new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "pr-hive", Version = cliVersion } },
promptFailureMessage: "Should not prompt when the current CLI version is available in a PR hive.");
Assert.Equal(0, exitCode);
Assert.False(prompted);
Assert.Equal(cliVersion, selectedVersion);
}
[Fact]
public async Task AddCommand_WithLocalHive_PrefersCurrentCliVersion()
{
// The local channel enumerates .nupkg files directly from disk and does not call
// package search — only the implicit channel goes through SearchPackagesAsync,
// which here returns a stale version that must lose to the on-disk CLI-version match.
var cliVersion = VersionHelper.GetDefaultSdkVersion();
var (exitCode, selectedVersion, prompted) = await RunAddRedisWithHiveScenarioAsync(
configureHives: workspace =>
{
var localPackagesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives", PackageChannelNames.Local, "packages"));
localPackagesDir.Create();
// Aspire.Hosting drives GetLocalHivePinnedVersion; Aspire.Hosting.Redis is the integration we add.
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{cliVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.Redis.{cliVersion}.nupkg"), string.Empty);
},
searchCallback: _ => new[] { new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.2.2" } },
promptFailureMessage: "Should not prompt when the current CLI version is available in the local hive.");
Assert.Equal(0, exitCode);
Assert.False(prompted);
Assert.Equal(cliVersion, selectedVersion);
}
[Fact]
public async Task AddCommand_WithLocalAndPrHives_PrefersHiveMatchingCurrentCliVersion()
{
// F (cross-channel mixing precedence): both `local` and `pr-12345` hives are populated.
// The local hive is pinned to the current CLI version; pr-12345 is pinned to a stale version.
// AddCommand routes through VersionHelper.TryGetCurrentCliVersionMatch, which iterates
// candidates from local-build channels (`IsLocalBuildChannel` = local | pr-* | run-*) and
// returns the first version that exactly matches GetDefaultSdkVersion(). Only the local
// hive's package matches, so it wins regardless of which channel ran first.
//
// NOTE on undocumented contract: when BOTH hives contain a CLI-version-exact match,
// selection falls through to enumeration order of GetChannelsAsync's
// HivesDirectory.GetDirectories() (filesystem-dependent, typically alphabetical),
// combined with Parallel.ForEachAsync ordering in IntegrationPackageSearchService.
// No deterministic precedence is currently defined for that case. Flagged for policy.
var cliVersion = VersionHelper.GetDefaultSdkVersion();
const string staleVersion = "13.0.0-pr.99999.gstale01";
var (exitCode, selectedVersion, prompted) = await RunAddRedisWithHiveScenarioAsync(
configureHives: workspace =>
{
var hivesRoot = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
var localPackagesDir = new DirectoryInfo(Path.Combine(hivesRoot.FullName, PackageChannelNames.Local, "packages"));
var prPackagesDir = new DirectoryInfo(Path.Combine(hivesRoot.FullName, "pr-12345", "packages"));
localPackagesDir.Create();
prPackagesDir.Create();
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{cliVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.Redis.{cliVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(prPackagesDir.FullName, $"Aspire.Hosting.{staleVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(prPackagesDir.FullName, $"Aspire.Hosting.Redis.{staleVersion}.nupkg"), string.Empty);
},
searchCallback: _ => new[] { new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.2.2" } },
promptFailureMessage: "Should not prompt; CLI-version match in local hive should win.");
Assert.Equal(0, exitCode);
Assert.False(prompted);
Assert.Equal(cliVersion, selectedVersion);
Assert.NotEqual(staleVersion, selectedVersion);
}
[Fact]
public async Task AddCommand_WithIdentityPackagesOverrideEmulatingStable_PrefersCurrentCliVersion()
{
// Emulating a released build via ASPIRE_CLI_PACKAGES / the sidecar `packages` field: the
// synthesized channel is NAMED after the emulated identity ("stable", a non-local-build
// name) yet resolves Aspire.* from a local directory. `aspire add` must still treat it as a
// CLI-version-pinned local source so the exact-CLI-version package wins over the implicit
// channel's stale version — i.e. the IsBackedByLocalPackageDirectory recognition, not the
// channel name, drives selection. Regression guard for the identity-sidecar emulation bug
// where a stable/daily/staging emulated name excluded the local channel from add resolution.
var cliVersion = VersionHelper.GetDefaultSdkVersion();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var identityPackagesDir = workspace.CreateDirectory("identity-packages");
// Aspire.Hosting drives GetLocalHivePinnedVersion; Aspire.Hosting.Redis is the integration we add.
File.WriteAllText(Path.Combine(identityPackagesDir.FullName, $"Aspire.Hosting.{cliVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(identityPackagesDir.FullName, $"Aspire.Hosting.Redis.{cliVersion}.nupkg"), string.Empty);
var selectedPackageVersion = string.Empty;
var promptedForVersion = false;
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Stable,
identityVersion: cliVersion,
identityOverridden: true,
identityPackagesDirectory: identityPackagesDir);
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not prompt when the current CLI version is available in the local package override.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
// Only the implicit channel goes through package search; it returns a stale version
// that must lose to the on-disk CLI-version match from the emulated-stable local source.
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
(0, new[] { new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "implicit", Version = "13.2.2" } });
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.False(promptedForVersion);
Assert.Equal(cliVersion, selectedPackageVersion);
}
/// <summary>
/// Shared scaffolding for "aspire add redis" + hive precedence tests. The three tests
/// (PR-hive / local-hive / both-hives) differ only in (a) how the hive directory is
/// laid out on disk and (b) what the package-search mock returns. Everything else
/// — workspace, prompter that fails on prompt, project locator, AddPackage capture —
/// is identical.
/// </summary>
private async Task<(int ExitCode, string SelectedVersion, bool PromptInvoked)> RunAddRedisWithHiveScenarioAsync(
Action<TemporaryWorkspace> configureHives,
Func<FileInfo?, NuGetPackage[]> searchCallback,
string promptFailureMessage)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
configureHives(workspace);
var selectedPackageVersion = string.Empty;
var promptedForVersion = false;
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException(promptFailureMessage);
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
return (0, searchCallback(nugetSource));
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
selectedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add redis");
var exitCode = await result.InvokeAsync().DefaultTimeout();
return (exitCode, selectedPackageVersion, promptedForVersion);
}
[Fact]
public async Task AddCommandPolyglotAppHostRejectsNamedNonPolyglotIntegration()
{
// A non-C# AppHost can only consume integrations with ATS export coverage (the `polyglot` tag).
// Naming a discoverable-but-incompatible integration must fail with a precise error rather than
// installing a package that produces nothing in the target language.
var addedPackageId = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, query, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
query == "tags:polyglot" ? [CreatePackage("Aspire.Hosting.Redis", "1.0.0")] : [])
};
var tsFactory = new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true));
tsFactory.Project.AddPackageAsyncCallback = (context, _) =>
{
addedPackageId = context.PackageId;
return Task.FromResult(true);
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// The polyglot filter is opt-in (off by default); enable it so this test exercises the filtering path.
options.EnabledFeatures = [KnownFeatures.PolyglotIntegrationFilterEnabled];
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService();
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(tsFactory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add Aspire.Hosting.Foo --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.Equal(string.Empty, addedPackageId);
}
[Fact]
public async Task AddCommandPolyglotAppHostAddsPolyglotIntegration()
{
// The polyglot-compatible integration (Redis) survives filtering and installs normally.
var addedPackageId = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, query, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
query == "tags:polyglot" ? [CreatePackage("Aspire.Hosting.Redis", "1.0.0")] : [])
};
var tsFactory = new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true));
tsFactory.Project.AddPackageAsyncCallback = (context, _) =>
{
addedPackageId = context.PackageId;
return Task.FromResult(true);
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// The polyglot filter is opt-in (off by default); enable it so this test exercises the filtering path.
options.EnabledFeatures = [KnownFeatures.PolyglotIntegrationFilterEnabled];
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService();
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(tsFactory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add Aspire.Hosting.Redis --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Equal("Aspire.Hosting.Redis", addedPackageId);
}
[Fact]
public async Task AddCommandPolyglotAppHostWithAllOptionAddsNonPolyglotIntegration()
{
// --all bypasses the polyglot filter so a not-yet-marked integration can still be added.
var addedPackageId = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
// --all must not consult the polyglot tag at all; fail loudly if it does.
GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => throw new InvalidOperationException("Polyglot filtering must be skipped when --all is passed.")
};
var tsFactory = new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true));
tsFactory.Project.AddPackageAsyncCallback = (context, _) =>
{
addedPackageId = context.PackageId;
return Task.FromResult(true);
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// The polyglot filter is opt-in (off by default); enable it so this test exercises the filtering path.
options.EnabledFeatures = [KnownFeatures.PolyglotIntegrationFilterEnabled];
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService();
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(tsFactory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add Aspire.Hosting.Foo --all --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Equal("Aspire.Hosting.Foo", addedPackageId);
}
[Fact]
public async Task IntegrationListPolyglotAppHostListsOnlyPolyglotIntegrations()
{
// `aspire integration list` mirrors `aspire add`: a polyglot AppHost only sees compatible integrations.
var rawJson = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, query, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
query == "tags:polyglot" ? [CreatePackage("Aspire.Hosting.Redis", "1.0.0")] : [])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// The polyglot filter is opt-in (off by default); enable it so this test exercises the filtering path.
options.EnabledFeatures = [KnownFeatures.PolyglotIntegrationFilterEnabled];
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration list --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var integration = Assert.Single(ReadIntegrationResults(rawJson));
Assert.Equal("Aspire.Hosting.Redis", integration.Package);
}
[Fact]
public async Task IntegrationListPolyglotAppHostShowsHiddenIntegrationCountMessage()
{
// When the polyglot filter removes integrations, list/search tells the user how many were
// hidden and that --all reveals them, matching `aspire add`.
string? displayedSubtleMessage = null;
var testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, query, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
query == "tags:polyglot" ? [CreatePackage("Aspire.Hosting.Redis", "1.0.0")] : [])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// The polyglot filter is opt-in (off by default); enable it so this test exercises the filtering path.
options.EnabledFeatures = [KnownFeatures.PolyglotIntegrationFilterEnabled];
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration list --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(string.Format(AddCommandStrings.PolyglotIntegrationsHidden, 1), displayedSubtleMessage);
}
[Fact]
public async Task IntegrationListPolyglotAppHostWithNoCompatibleIntegrationsShowsAllHint()
{
// When every integration is filtered out, the user gets the polyglot-specific "use --all"
// error instead of a generic "no packages found", and the hidden-count subtle message is
// suppressed so the user does not see a redundant pair of --all hints (matching `aspire add`).
string? displayedSubtleMessage = null;
var testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// The polyglot filter is opt-in (off by default); enable it so this test exercises the filtering path.
options.EnabledFeatures = [KnownFeatures.PolyglotIntegrationFilterEnabled];
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration list --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Contains(AddCommandStrings.NoPolyglotCompatibleIntegrationsFound, testInteractionService.DisplayedErrors);
Assert.Null(displayedSubtleMessage);
}
[Fact]
public async Task IntegrationSearchPolyglotAppHostWithSearchTermMismatchReportsSearchTermError()
{
// A polyglot AppHost where compatible integrations exist but none match the search term must
// report NoIntegrationPackagesMatchedSearchTerm, not the false NoPolyglotCompatibleIntegrationsFound.
// The polyglot filter did not empty the list (Redis is compatible); the search term did.
string? displayedSubtleMessage = null;
var testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, query, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
query == "tags:polyglot" ? [CreatePackage("Aspire.Hosting.Redis", "1.0.0")] : [])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
// The polyglot filter is opt-in (off by default); enable it so this test exercises the filtering path.
options.EnabledFeatures = [KnownFeatures.PolyglotIntegrationFilterEnabled];
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration search zzznomatch --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Contains(string.Format(AddCommandStrings.NoIntegrationPackagesMatchedSearchTerm, "zzznomatch"), testInteractionService.DisplayedErrors);
// The polyglot filter hid Foo, so the hidden-count hint still appears alongside the search-term error.
Assert.Equal(string.Format(AddCommandStrings.PolyglotIntegrationsHidden, 1), displayedSubtleMessage);
}
[Fact]
public async Task AddCommandPolyglotAppHostWithFilterDisabledOffersAllIntegrations()
{
// Regression test for https://github.com/microsoft/aspire/issues/19161. The polyglot filter is
// off by default because package sources that ignore `tags:` scoping (Azure DevOps Artifacts
// feeds) resolve an empty allow-list, and the filter fails closed. With the filter disabled a
// TypeScript AppHost must still be able to add an integration that carries no polyglot tag.
var addedPackageId = string.Empty;
string? displayedSubtleMessage = null;
var testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
// Model a feed that cannot answer a tag query, which is what makes the filter hide everything.
GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([])
};
var tsFactory = new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true));
tsFactory.Project.AddPackageAsyncCallback = (context, _) =>
{
addedPackageId = context.PackageId;
return Task.FromResult(true);
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(tsFactory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add Aspire.Hosting.Foo --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Equal("Aspire.Hosting.Foo", addedPackageId);
Assert.Empty(testInteractionService.DisplayedErrors);
Assert.Null(displayedSubtleMessage);
}
[Fact]
public async Task AddCommandPolyglotAppHostWithFilterDisabledDoesNotIssuePolyglotTagSearch()
{
// With the filter off there is no reason to resolve the polyglot allow-list, so the second
// discovery pass must be skipped entirely rather than issuing a wasted `tags:polyglot` search.
var queries = new List<string>();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, query, _, _, _, _, _) =>
{
lock (queries)
{
queries.Add(query);
}
return Task.FromResult<IEnumerable<NuGetPackage>>([]);
}
};
var tsFactory = new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true));
tsFactory.Project.AddPackageAsyncCallback = (_, _) => Task.FromResult(true);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService();
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(tsFactory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse($"add Aspire.Hosting.Redis --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Empty(queries);
}
[Fact]
public async Task IntegrationListPolyglotAppHostWithFilterDisabledListsAllIntegrations()
{
// `aspire integration list` mirrors `aspire add`: with the filter off, a TypeScript AppHost sees
// every discovered integration instead of NoPolyglotCompatibleIntegrationsFound (issue #19161).
var rawJson = string.Empty;
string? displayedSubtleMessage = null;
var testInteractionService = new TestInteractionService
{
DisplayRawTextCallback = text => rawJson = text,
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
File.WriteAllText(appHostFile.FullName, string.Empty);
var implicitCache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>(
[CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foo", "1.0.0")]),
GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([])
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => testInteractionService;
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([
PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance)
])
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"integration list --apphost \"{appHostFile.FullName}\" --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(
["Aspire.Hosting.Foo", "Aspire.Hosting.Redis"],
ReadIntegrationResults(rawJson).Select(i => i.Package).Order());
Assert.Empty(testInteractionService.DisplayedErrors);
Assert.Null(displayedSubtleMessage);
}
private static NuGetPackage CreatePackage(string id, string version)
{
return new NuGetPackage
{
Id = id,
Source = "nuget",
Version = version
};
}
private static FakeNuGetPackageCache CreateIntegrationPackageCache(string packageId, string version)
{
var package = CreatePackage(packageId, version);
var isPrerelease = version.Contains('-', StringComparison.Ordinal);
return new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(prerelease || !isPrerelease ? [package] : [])
};
}
private static (string? Name, string? Package, string? Version)[] ReadIntegrationResults(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.EnumerateArray()
.Select(element => (
Name: element.GetProperty("name").GetString(),
Package: element.GetProperty("package").GetString(),
Version: element.GetProperty("version").GetString()))
.ToArray();
}
private static CliExecutionContext CreateExecutionContext(TemporaryWorkspace workspace, string identityChannel)
{
var aspireDirectory = workspace.CreateDirectory(".aspire");
var hivesDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "hives"));
var cacheDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "cache"));
var sdksDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "sdks"));
var logsDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "logs"));
return new CliExecutionContext(
workspace.WorkspaceRoot,
hivesDirectory,
cacheDirectory,
sdksDirectory,
logsDirectory,
Path.Combine(logsDirectory.FullName, "test.log"),
identityChannel: identityChannel);
}
}
internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService)
{
public Func<IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>, (string FriendlyName, NuGetPackage Package, PackageChannel Channel)>? PromptForIntegrationCallback { get; set; }
public Func<IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>, (string FriendlyName, NuGetPackage Package, PackageChannel Channel)>? PromptForIntegrationVersionCallback { get; set; }
public override Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, CancellationToken cancellationToken)
{
return PromptForIntegrationCallback switch
{
{ } callback => Task.FromResult(callback(packages)),
_ => Task.FromResult(packages.First()) // If no callback is provided just accept the first package.
};
}
public override Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationVersionAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, string? configuredChannel, CancellationToken cancellationToken)
{
return PromptForIntegrationVersionCallback switch
{
{ } callback => Task.FromResult(callback(packages)),
_ => Task.FromResult(packages.First()) // If no callback is provided just accept the first package.
};
}
}
public class AddCommandFuzzySearchTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch()
{
var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>();
var addedPackage = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedPackages.AddRange(packages);
return packages.First();
};
return prompter;
};
// Fuzzy fallback only fires in interactive mode after the Layer-3 fix for #17724.
// The default test host environment is non-interactive (mirroring CI), so opt this
// fixture into the interactive path explicitly: the test asserts that an interactive
// user can still discover PostgreSQL by typing "postgre".
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var postgresPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.PostgreSQL",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var rabbitMQPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.RabbitMQ",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { postgresPackage, redisPackage, rabbitMQPackage }
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addedPackage = packageName;
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
// Use "postgre" instead of "postgresql" - should still find it via fuzzy search
var result = command.Parse("add postgre");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
// Verify that PostgreSQL package was added through fuzzy matching
Assert.Equal("Aspire.Hosting.PostgreSQL", addedPackage);
}
[Fact]
public async Task AddCommand_NonInteractive_NoExactMatchWithoutVersion_FailsInsteadOfFuzzyAutoPick_Regression17724()
{
// Regression for https://github.com/microsoft/aspire/issues/17724.
//
// Pre-fix: `aspire add kube --non-interactive` had no exact match for "kube" (none of the
// packages are literally named "kube"), so AddCommand fell back to fuzzy search. The fuzzy
// candidate list was then passed to GetPackageByInteractiveFlow, which in non-interactive
// mode auto-selected `distinctPackages.First()` (AddCommand.cs:368-369) and silently added
// the wrong package. In the user's report this was Aspire.Hosting.Azure because the
// companion Layer-1 bug (#17725 / IntegrationPackageSearchService narrowing) had filtered
// prerelease packages out, leaving Azure as the only fuzzy candidate.
//
// Fix: AddCommand now refuses to fall back to fuzzy search whenever the host is non-interactive
// and no exact match was found, regardless of whether --version was supplied. The error
// surfaces the new NonInteractiveRequiresExactPackageMatch resource so the user/script
// knows to supply the full package id or friendly name.
//
// This test uses the simpler C# project flow (TestDotNetCliRunner stub) because the bug is
// in AddCommand's non-interactive handling, not in package discovery — the discovery path is
// covered by the cross-language parity test above. The Aspire.Hosting.Azure and
// Aspire.Hosting.Kubernetes packages both fuzzy-match "kube"; pre-fix the first one
// (Aspire.Hosting.Azure, alphabetical) would have been silently picked.
var addPackageWasCalled = false;
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
return (
0,
new NuGetPackage[]
{
new() { Id = "Aspire.Hosting.Azure", Source = "nuget", Version = "9.2.0" },
new() { Id = "Aspire.Hosting.Kubernetes", Source = "nuget", Version = "9.2.0" }
});
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add kube");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.False(addPackageWasCalled, "AddPackageAsync must not be called when there is no exact match in non-interactive mode.");
Assert.Contains(string.Format(AddCommandStrings.NonInteractiveRequiresExactPackageMatch, "kube"), testInteractionService.DisplayedErrors);
}
[Fact]
public async Task AddCommand_NonInteractive_ExactMatchWithoutVersion_StillSucceeds()
{
// Companion regression guard for #17724: ensures the new non-interactive guard ONLY fires
// when there is no exact match. An exact match by package id (or friendly name) must still
// install successfully — this is the documented happy path for CI/scripted usage.
var addedPackage = string.Empty;
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
return (
0,
new NuGetPackage[]
{
new() { Id = "Aspire.Hosting.Azure", Source = "nuget", Version = "9.2.0" },
new() { Id = "Aspire.Hosting.Kubernetes", Source = "nuget", Version = "9.2.0" }
});
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addedPackage = packageName;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
// "kubernetes" is the friendly name (Aspire.Hosting.Kubernetes → friendlyName "kubernetes"),
// so this is an exact match and must succeed.
var result = command.Parse("add kubernetes");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Equal("Aspire.Hosting.Kubernetes", addedPackage);
}
[Fact]
public async Task AddCommand_Interactive_SingleFuzzyMatchPromptsBeforeAdding_Regression17724()
{
var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>();
var addedPackage = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedPackages.AddRange(packages);
return packages.Single();
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
return (
0,
new NuGetPackage[]
{
new() { Id = "Aspire.Hosting.Azure", Source = "nuget", Version = "9.2.0" }
});
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addedPackage = packageName;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add kube");
var exitCode = await result.InvokeAsync().DefaultTimeout();
var promptedPackage = Assert.Single(promptedPackages);
Assert.Equal(0, exitCode);
Assert.Equal("Aspire.Hosting.Azure", promptedPackage.Package.Id);
Assert.Equal("Aspire.Hosting.Azure", addedPackage);
}
[Fact]
public async Task AddCommand_Interactive_NoFuzzyMatchSinglePackagePromptsBeforeAdding()
{
var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>();
var displayedSubtleMessage = string.Empty;
var addedPackage = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedPackages.AddRange(packages);
return packages.Single();
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
return (
0,
new NuGetPackage[]
{
new() { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "9.2.0" }
});
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addedPackage = packageName;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add zzzzzzzzzz");
var exitCode = await result.InvokeAsync().DefaultTimeout();
var promptedPackage = Assert.Single(promptedPackages);
Assert.Equal(0, exitCode);
Assert.Equal(string.Format(AddCommandStrings.NoPackagesMatchedSearchTerm, "zzzzzzzzzz"), displayedSubtleMessage);
Assert.Equal("Aspire.Hosting.Redis", promptedPackage.Package.Id);
Assert.Equal("Aspire.Hosting.Redis", addedPackage);
}
[Fact]
public async Task AddCommand_WithVersionAndNonExactPackageName_FailsInsteadOfUsingFuzzySearch()
{
var addPackageWasCalled = false;
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var postgresPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.PostgreSQL",
Source = "nuget",
Version = "9.2.0"
};
return (
0,
new NuGetPackage[] { postgresPackage }
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add postgre --version 9.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.False(addPackageWasCalled);
Assert.Contains(string.Format(AddCommandStrings.SpecifiedVersionRequiresExactPackageMatch, "postgre"), testInteractionService.DisplayedErrors);
}
[Fact]
public async Task AddCommand_WithVersionAndNoMatchingPackageName_FailsInNonInteractiveMode()
{
var addPackageWasCalled = false;
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var postgresPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.PostgreSQL",
Source = "nuget",
Version = "9.2.0"
};
return (
0,
new NuGetPackage[] { postgresPackage }
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add nonexistentpackage --version 9.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.False(addPackageWasCalled);
Assert.Contains(string.Format(AddCommandStrings.SpecifiedVersionRequiresExactPackageMatch, "nonexistentpackage"), testInteractionService.DisplayedErrors);
}
[Fact]
public async Task AddCommand_WithPartialMatch_FiltersUsingFuzzySearch()
{
var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedPackages.AddRange(packages);
return packages.First();
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var postgresPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.PostgreSQL",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var rabbitMQPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.RabbitMQ",
Source = "nuget",
Version = "9.2.0"
};
var mysqlPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.MySql",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { postgresPackage, redisPackage, rabbitMQPackage, mysqlPackage }
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
// Use "sql" - should match both PostgreSQL and MySql, but not Redis or RabbitMQ
var result = command.Parse("add sql");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
// Should have prompted with packages that fuzzy match "sql"
Assert.True(promptedPackages.Count > 0);
Assert.Contains(promptedPackages, p => p.Package.Id.Contains("SQL", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task AddCommand_WithVersionAndNonExactPackageName_Interactive_UsesFuzzySearch()
{
var promptedForIntegration = false;
var promptedForVersion = false;
var addedPackageName = string.Empty;
var addedPackageVersion = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
return packages.Single(package => package.Package.Id == "Aspire.Hosting.PostgreSQL");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.PostgreSQL", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.MySql", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.PostgreSQL" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.PostgreSQL", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.PostgreSQL", Source = "nuget", Version = "13.2.0" }
]),
"Aspire.Hosting.MySql" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.MySql", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
addedPackageName = packageName;
addedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add sql --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.True(promptedForIntegration);
Assert.False(promptedForVersion);
Assert.Equal("Aspire.Hosting.PostgreSQL", addedPackageName);
Assert.Equal("13.2.0", addedPackageVersion);
}
[Fact]
public async Task AddCommand_WithVersionAndNonExactPackageName_Interactive_FailsWhenSelectedPackageDoesNotContainVersion()
{
var promptedForIntegration = false;
var promptedForVersion = false;
var addPackageWasCalled = false;
var testInteractionService = new TestInteractionService();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
return packages.Single(package => package.Package.Id == "Aspire.Hosting.MySql");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.PostgreSQL", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.MySql", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.PostgreSQL" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.PostgreSQL", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.PostgreSQL", Source = "nuget", Version = "13.2.0" }
]),
"Aspire.Hosting.MySql" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.MySql", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add sql --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.True(promptedForIntegration);
Assert.False(promptedForVersion);
Assert.False(addPackageWasCalled);
Assert.Contains(testInteractionService.DisplayedErrors, error => error.Contains("13.2.0") && error.Contains("Aspire.Hosting.MySql"));
}
[Fact]
public async Task AddCommand_WithVersionAndNoMatches_Interactive_PromptsAllPackagesAndPreservesVersion()
{
var promptedForIntegration = false;
var promptedForVersion = false;
var displayedSubtleMessage = string.Empty;
var addedPackageName = string.Empty;
var addedPackageVersion = string.Empty;
var testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
return packages.Single(package => package.Package.Id == "Aspire.Hosting.Redis");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
]),
"Aspire.Hosting.Docker" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
addedPackageName = packageName;
addedPackageVersion = packageVersion;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add nonexistentpackage --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.True(promptedForIntegration);
Assert.False(promptedForVersion);
Assert.Equal(string.Format(AddCommandStrings.NoPackagesMatchedSearchTerm, "nonexistentpackage"), displayedSubtleMessage);
Assert.Equal("Aspire.Hosting.Redis", addedPackageName);
Assert.Equal("13.2.0", addedPackageVersion);
}
[Fact]
public async Task AddCommand_WithVersionAndNoMatches_Interactive_FailsWhenSelectedPackageDoesNotContainVersion()
{
var promptedForIntegration = false;
var promptedForVersion = false;
var displayedSubtleMessage = string.Empty;
var addPackageWasCalled = false;
var testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
};
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => testInteractionService;
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestAddCommandPrompter(interactionService);
prompter.PromptForIntegrationCallback = (packages) =>
{
promptedForIntegration = true;
return packages.Single(package => package.Package.Id == "Aspire.Hosting.Docker");
};
prompter.PromptForIntegrationVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not have been prompted for integration version.");
};
return prompter;
};
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
{
if (!exactMatch)
{
return (0, [
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }
]);
}
return query switch
{
"Aspire.Hosting.Redis" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
]),
"Aspire.Hosting.Docker" => (0, [
new NuGetPackage { Id = "Aspire.Hosting.Docker", Source = "nuget", Version = "13.3.0" }
]),
_ => (0, Array.Empty<NuGetPackage>())
};
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
{
addPackageWasCalled = true;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
var result = command.Parse("add nonexistentpackage --version 13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode);
Assert.True(promptedForIntegration);
Assert.False(promptedForVersion);
Assert.False(addPackageWasCalled);
Assert.Equal(string.Format(AddCommandStrings.NoPackagesMatchedSearchTerm, "nonexistentpackage"), displayedSubtleMessage);
Assert.Contains(testInteractionService.DisplayedErrors, error => error.Contains("13.2.0") && error.Contains("Aspire.Hosting.Docker"));
}
[Fact]
public async Task AddCommand_WithTypo_FindsMatchUsingFuzzySearch()
{
var addedPackage = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AddCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
return new TestAddCommandPrompter(interactionService);
};
// Fuzzy fallback only fires in interactive mode after the Layer-3 fix for #17724;
// see companion comment on AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch.
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var appContainersPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Azure.AppContainers",
Source = "nuget",
Version = "9.2.0"
};
var redisPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.Redis",
Source = "nuget",
Version = "9.2.0"
};
var postgresPackage = new NuGetPackage()
{
Id = "Aspire.Hosting.PostgreSQL",
Source = "nuget",
Version = "9.2.0"
};
return (
0, // Exit code.
new NuGetPackage[] { appContainersPackage, redisPackage, postgresPackage }
);
};
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
addedPackage = packageName;
return 0; // Success.
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AddCommand>();
// Use "azureapp" (Azure AppContainers) - should find Azure.AppContainers via fuzzy search
var result = command.Parse("add azureapp");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
// Verify that Azure AppContainers package was found and added through fuzzy matching
Assert.Equal("Aspire.Hosting.Azure.AppContainers", addedPackage);
}
}