// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.Tests.Commands;
/// <summary>
/// Behavioral guard on the <c>channel</c> value that <c>aspire new <template></c>
/// persists into <c>aspire.config.json</c>. Drives <see cref="NewCommand"/> end-to-end
/// through real <c>CliTemplateFactory</c> + real <c>ScaffoldingService</c> /
/// <c>TypeScriptStarterTemplate</c> / <c>{Go,Python}StarterTemplate</c> writers, with only
/// <see cref="IAppHostServerProjectFactory"/> swapped for a fake whose
/// <see cref="IAppHostServerProject.PrepareAsync"/> returns failure so the early on-disk
/// side-effect is captured without touching the network, the dotnet CLI, npm, or RPC.
/// <para>
/// This is the integration-level regression net for the channel-pin/SDK-version mismatch
/// bug class (PR #17120 + davidfowl follow-up). The original fix landed in the TS starter
/// factory; the follow-up extended it to <see cref="Aspire.Cli.Scaffolding.ScaffoldingService"/>
/// (the path used by every empty template + <c>aspire init</c> polyglot). The bug class
/// itself, however, lives at any writer of <c>aspire.config.json#channel</c> — so this
/// class parameterises across <em>every</em> CLI template that ships an
/// <c>aspire.config.json</c> writer, asserting both shapes of the contract:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>
/// Templates that pin a channel (TS starter, all empty templates + polyglot init):
/// the value is persisted iff <see cref="NewCommand"/> resolved one (Explicit
/// <c>--channel</c>, or an identity-match against a registered Explicit channel) —
/// never as a silent fallback to <see cref="CliExecutionContext.IdentityChannel"/>.
/// </description>
/// </item>
/// <item>
/// <description>
/// Templates that never pin a channel (Go starter, Python starter): the persisted
/// <c>channel</c> stays <see langword="null"/> regardless of CLI identity or
/// <c>--channel</c>. This is the tripwire that catches anyone re-introducing the
/// dead channel-write code that the PR #17120 fix removed.
/// </description>
/// </item>
/// </list>
/// <para>
/// Companion to <c>NewCommandChannelResolutionTests</c> (verifies the value
/// <see cref="NewCommand"/> hands to template factories via <c>TemplateInputs.Channel</c>),
/// <c>ChannelReseedTests</c> (verifies <see cref="Aspire.Cli.Scaffolding.ScaffoldingService"/>
/// persists <c>ScaffoldContext.Channel</c> verbatim), and
/// <c>TypeScriptStarterSmokeTests</c> (end-to-end coverage on the daily smoke workflow).
/// </para>
/// </summary>
public class NewCommandTemplateConfigPersistenceTests(ITestOutputHelper outputHelper)
{
private const string PrChannelName = "pr-17225";
private static readonly string s_prVersion = VersionHelper.GetDefaultSdkVersion();
private static readonly PrDogfoodNewTemplateCase[] s_prDogfoodNewTemplateCases =
[
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.TypeScriptStarter, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.CSharp, PrDogfoodNewTemplateContract.CSharpEmptyAppHost, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.TypeScript, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Python, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Go, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Java, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Rust, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.TypeScriptEmptyAppHost, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.PythonEmptyAppHost, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.JavaEmptyAppHost, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.GoEmptyAppHost, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.RustEmptyAppHost, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.PythonStarter, ["--localhost-tld", "false", "--use-redis-cache", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.GoStarter, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.JavaStarter, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.DotNet("aspire-starter", ["--localhost-tld", "false", "--use-redis-cache", "false"]),
PrDogfoodNewTemplateCase.DotNet("aspire-ts-cs-starter", ["--localhost-tld", "false", "--use-redis-cache", "false"]),
PrDogfoodNewTemplateCase.DotNet(KnownTemplateId.DotNetEmptyAppHost, ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.DotNet("aspire-apphost", ["--localhost-tld", "false"]),
PrDogfoodNewTemplateCase.DotNet("aspire-servicedefaults"),
];
private static readonly PrDogfoodNewTemplateExclusion[] s_prDogfoodNewTemplateExclusions =
[
new("aspire-test", "This wrapper requires an interactive framework sub-template selection before it reaches package resolution.")
];
public static TheoryData<PrDogfoodNewTemplateCase> PrDogfoodNewTemplateCases()
{
var data = new TheoryData<PrDogfoodNewTemplateCase>();
foreach (var testCase in s_prDogfoodNewTemplateCases)
{
data.Add(testCase);
}
return data;
}
/// <summary>
/// Templates that pin <c>aspire.config.json#channel</c> when <see cref="NewCommand"/>
/// resolves an Explicit channel. Covers both writer code paths:
/// <see cref="Aspire.Cli.Scaffolding.ScaffoldingService"/> (the empty templates + polyglot init)
/// and <c>CliTemplateFactory.TypeScriptStarterTemplate</c> (the TS starter's own write).
/// Direct regression for the bug class fixed by PR #17120 + davidfowl follow-up:
/// when the CLI's identity isn't a registered Explicit channel, no channel must be
/// persisted — otherwise <c>aspire add</c> / <c>aspire restore</c> route
/// <c>Aspire.*</c> through a PSM that can't satisfy the SDK version.
/// </summary>
[Theory]
[InlineData(KnownTemplateId.TypeScriptEmptyAppHost, "apphost.mts")]
[InlineData(KnownTemplateId.PythonEmptyAppHost, "apphost.py")]
[InlineData(KnownTemplateId.GoEmptyAppHost, "apphost.go")]
[InlineData(KnownTemplateId.JavaEmptyAppHost, "AppHost.java")]
[InlineData(KnownTemplateId.TypeScriptStarter, "apphost.mts")]
[InlineData(KnownTemplateId.PythonStarter, "apphost.mts")]
[InlineData(KnownTemplateId.GoStarter, "apphost.go")]
[InlineData(KnownTemplateId.JavaStarter, "AppHost.java")]
public async Task ChannelPinningTemplate_IdentityNotRegistered_DoesNotPinChannel(string templateId, string _)
{
var persisted = await ScaffoldAndReadPersistedChannelAsync(
templateId: templateId,
identityChannel: PackageChannelNames.Daily,
registerIdentityChannel: false,
explicitChannelArg: null);
// No channel pin → PrebuiltAppHostServer aggregates sources from every registered
// channel when aspire add / aspire restore run later.
Assert.Null(persisted);
}
/// <summary>
/// Happy path: when the identity matches a registered Explicit channel,
/// <see cref="NewCommand"/> resolves it and every channel-pinning template persists
/// the name into <c>aspire.config.json#channel</c>. This is the pin that lets
/// <c>aspire add</c> route <c>Aspire.*</c> through the matching feed via PSM.
/// </summary>
[Theory]
[InlineData(KnownTemplateId.TypeScriptEmptyAppHost, "apphost.mts")]
[InlineData(KnownTemplateId.PythonEmptyAppHost, "apphost.py")]
[InlineData(KnownTemplateId.GoEmptyAppHost, "apphost.go")]
[InlineData(KnownTemplateId.JavaEmptyAppHost, "AppHost.java")]
[InlineData(KnownTemplateId.TypeScriptStarter, "apphost.mts")]
[InlineData(KnownTemplateId.PythonStarter, "apphost.mts")]
[InlineData(KnownTemplateId.GoStarter, "apphost.go")]
[InlineData(KnownTemplateId.JavaStarter, "AppHost.java")]
public async Task ChannelPinningTemplate_IdentityMatchesRegisteredChannel_PinsThatChannel(string templateId, string _)
{
var persisted = await ScaffoldAndReadPersistedChannelAsync(
templateId: templateId,
identityChannel: PackageChannelNames.Daily,
registerIdentityChannel: true,
explicitChannelArg: null);
Assert.Equal(PackageChannelNames.Daily, persisted);
}
/// <summary>
/// Explicit <c>--channel</c> overrides identity at the resolution layer and propagates
/// through to the persisted pin for every channel-pinning template — covered at the
/// resolution layer by <c>NewCommand_ExplicitChannelArg_OverridesIdentityChannel</c>
/// and asserted here at the persistence layer so a future drift between the two is
/// caught.
/// </summary>
[Theory]
[InlineData(KnownTemplateId.TypeScriptEmptyAppHost, "apphost.mts")]
[InlineData(KnownTemplateId.PythonEmptyAppHost, "apphost.py")]
[InlineData(KnownTemplateId.GoEmptyAppHost, "apphost.go")]
[InlineData(KnownTemplateId.JavaEmptyAppHost, "AppHost.java")]
[InlineData(KnownTemplateId.TypeScriptStarter, "apphost.mts")]
[InlineData(KnownTemplateId.PythonStarter, "apphost.mts")]
[InlineData(KnownTemplateId.GoStarter, "apphost.go")]
[InlineData(KnownTemplateId.JavaStarter, "AppHost.java")]
public async Task ChannelPinningTemplate_ExplicitChannelArg_OverridesIdentityAndPersists(string templateId, string _)
{
var persisted = await ScaffoldAndReadPersistedChannelAsync(
templateId: templateId,
identityChannel: PackageChannelNames.Daily,
registerIdentityChannel: true,
explicitChannelArg: PackageChannelNames.Stable);
Assert.Equal(PackageChannelNames.Stable, persisted);
}
/// <summary>
/// Issue #17121 regression guard: <c>PackagingService</c> now registers the
/// <c>staging</c> channel for a staging-identity CLI. Once <see cref="NewCommand"/>
/// resolves that registered identity channel, channel-pinning templates must persist
/// <c>channel: staging</c> so later add/restore operations keep using staging.
/// </summary>
[Theory]
[InlineData(KnownTemplateId.TypeScriptEmptyAppHost)]
[InlineData(KnownTemplateId.TypeScriptStarter)]
public async Task ChannelPinningTemplate_StagingIdentityWithRegisteredChannel_PinsStagingChannel(string templateId)
{
var persisted = await ScaffoldAndReadPersistedChannelAsync(
templateId: templateId,
identityChannel: PackageChannelNames.Staging,
registerIdentityChannel: true,
explicitChannelArg: null);
Assert.Equal(PackageChannelNames.Staging, persisted);
}
[Fact]
public void PrDogfoodNewTemplateContract_AccountsForEveryRegisteredNewTemplate()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreatePrDogfoodNewTemplateServices(workspace, processPath: null, dotNetRunner: new TestDotNetCliRunner());
using var serviceProvider = services.BuildServiceProvider();
var templateProvider = serviceProvider.GetRequiredService<ITemplateProvider>();
var registeredTemplateKeys = templateProvider.GetTemplates()
.SelectMany(GetPrDogfoodTemplateCoverageKeys)
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
.ToArray();
var accountedForTemplateKeys = s_prDogfoodNewTemplateCases.Select(static testCase => testCase.CoverageKey)
.Concat(s_prDogfoodNewTemplateExclusions.Select(static exclusion => exclusion.CoverageKey))
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
.ToArray();
Assert.Equal(accountedForTemplateKeys, registeredTemplateKeys);
Assert.All(s_prDogfoodNewTemplateExclusions, static exclusion => Assert.False(string.IsNullOrWhiteSpace(exclusion.Reason)));
}
/// <summary>
/// Issue #17225 regression guard: when <c>PackagingService</c> discovers the running
/// <c>pr-<N></c> CLI's matching dogfood install hive, every registered <c>aspire new</c>
/// template that consumes Aspire packages must scaffold from that channel/source.
/// </summary>
[Theory]
[MemberData(nameof(PrDogfoodNewTemplateCases))]
public async Task NewTemplate_PrDogfoodInstallHiveDiscovered_UsesPrChannel(PrDogfoodNewTemplateCase testCase)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (processPath, packagesDirectory) = CreatePrDogfoodInstallLayout(workspace);
var dotNetTemplateInstalls = new List<DotNetTemplateInstall>();
var dotNetRunner = new TestDotNetCliRunner
{
InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, _, _, _) =>
{
dotNetTemplateInstalls.Add(new DotNetTemplateInstall(packageName, version, nugetConfigFile?.FullName, nugetSource));
return (0, version);
},
NewProjectAsyncCallback = (templateName, name, outputPath, _, _) =>
{
Directory.CreateDirectory(outputPath);
File.WriteAllText(Path.Combine(outputPath, $"{name}.generated"), templateName);
return 0;
}
};
var services = CreatePrDogfoodNewTemplateServices(workspace, processPath, dotNetRunner);
services.AddSingleton<IAppHostServerProjectFactory>(_ => new TestAppHostServerProjectFactory
{
CreateAsyncCallback = (path, _) =>
Task.FromResult<IAppHostServerProject>(new FakeFailingAppHostServerProject(path))
});
using var serviceProvider = services.BuildServiceProvider();
var newCommand = serviceProvider.GetRequiredService<NewCommand>();
const string outputDirectoryName = "TemplateOut";
var commandArguments = new List<string>
{
"new",
testCase.TemplateId,
"--name",
"TemplateOut",
"--output",
$"./{outputDirectoryName}",
};
if (testCase.LanguageId is not null)
{
commandArguments.Add("--language");
commandArguments.Add(testCase.LanguageId);
}
commandArguments.AddRange(testCase.ExtraArguments);
var parseResult = newCommand.Parse(string.Join(" ", commandArguments));
var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, outputDirectoryName);
switch (testCase.Contract)
{
case PrDogfoodNewTemplateContract.CSharpEmptyAppHost:
Assert.Empty(dotNetTemplateInstalls);
var appHostFile = Path.Combine(outputDirectory, "apphost.cs");
Assert.True(File.Exists(appHostFile));
Assert.Contains(s_prVersion, await File.ReadAllTextAsync(appHostFile));
var csharpConfig = AspireConfigFile.Load(outputDirectory);
Assert.NotNull(csharpConfig);
Assert.Equal(PrChannelName, csharpConfig.Channel);
Assert.Equal(s_prVersion, csharpConfig.SdkVersion);
var csharpNuGetConfig = await File.ReadAllTextAsync(Path.Combine(outputDirectory, "nuget.config"));
Assert.Contains(packagesDirectory.FullName.Replace('\\', '/'), csharpNuGetConfig);
break;
case PrDogfoodNewTemplateContract.AspireConfig:
Assert.Empty(dotNetTemplateInstalls);
var config = AspireConfigFile.Load(outputDirectory);
Assert.NotNull(config);
Assert.Equal(PrChannelName, config.Channel);
Assert.Equal(s_prVersion, config.SdkVersion);
break;
case PrDogfoodNewTemplateContract.DotNetTemplate:
Assert.Equal((int)CliExitCodes.Success, exitCode);
var install = Assert.Single(dotNetTemplateInstalls);
Assert.Equal(TemplateNuGetConfigService.TemplatesPackageName, install.PackageName);
Assert.Equal(s_prVersion, install.Version);
Assert.Equal(packagesDirectory.FullName.Replace('\\', '/'), install.NuGetSource);
var dotNetConfig = AspireConfigFile.Load(outputDirectory);
Assert.NotNull(dotNetConfig);
Assert.Equal(PrChannelName, dotNetConfig.Channel);
Assert.Equal(s_prVersion, dotNetConfig.SdkVersion);
var dotNetNuGetConfig = await File.ReadAllTextAsync(Path.Combine(outputDirectory, "nuget.config"));
Assert.Contains(packagesDirectory.FullName.Replace('\\', '/'), dotNetNuGetConfig);
break;
default:
throw new InvalidOperationException($"Unknown template contract: {testCase.Contract}");
}
}
/// <summary>
/// Drives <c>aspire new <templateId></c> against the real <see cref="NewCommand"/>,
/// real <c>CliTemplateFactory</c>, and real <see cref="Aspire.Cli.Scaffolding.ScaffoldingService"/>
/// — only the <see cref="IAppHostServerProjectFactory"/> is swapped out for a fake whose
/// <c>PrepareAsync</c> returns failure, so the run terminates cleanly after the early
/// channel write side-effect we want to inspect.
/// </summary>
/// <returns>
/// The <c>channel</c> value persisted to <c>aspire.config.json</c> in the scaffolded
/// output directory, or <see langword="null"/> if no <c>aspire.config.json</c> was
/// written or its <c>channel</c> property is absent.
/// </returns>
private async Task<string?> ScaffoldAndReadPersistedChannelAsync(
string templateId,
string identityChannel,
bool registerIdentityChannel,
string? explicitChannelArg)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => BuildExecutionContextWithIdentity(workspace, identityChannel);
options.PackagingServiceFactory = _ => BuildPackagingService(identityChannel, registerIdentityChannel);
// Enable all polyglot feature flags so the Python / Go / Java / Rust templates
// register as subcommands of `aspire new`. Outside tests these are gated by
// KnownFeatures.ExperimentalPolyglot* flags; here we want to exercise the full
// matrix regardless of release state.
options.EnabledFeatures =
[
KnownFeatures.ExperimentalPolyglotPython,
KnownFeatures.ExperimentalPolyglotGo,
KnownFeatures.ExperimentalPolyglotJava,
KnownFeatures.ExperimentalPolyglotRust,
];
});
// Override the real IAppHostServerProjectFactory so PrepareAsync returns failure
// and the (real) scaffolding / starter writer terminates without touching the
// network, the dotnet CLI, or template restore. The earlier channel-write
// side-effect still runs (when a channel was resolved), which is exactly what
// this test inspects. Last AddSingleton wins for GetRequiredService, so this
// replaces the default registration from CliTestHelper.
services.AddSingleton<IAppHostServerProjectFactory>(_ => new TestAppHostServerProjectFactory
{
CreateAsyncCallback = (path, _) =>
Task.FromResult<IAppHostServerProject>(new FakeFailingAppHostServerProject(path))
});
using var serviceProvider = services.BuildServiceProvider();
var newCommand = serviceProvider.GetRequiredService<NewCommand>();
const string outputDirectoryName = "TemplateOut";
var channelArg = string.IsNullOrEmpty(explicitChannelArg) ? "" : $" --channel {explicitChannelArg}";
// `--localhost-tld false` short-circuits the bool? confirmation prompt so the
// test is non-interactive. The value doesn't affect channel persistence.
var parseResult = newCommand.Parse(
$"new {templateId} --name TemplateOut --output ./{outputDirectoryName} --localhost-tld false{channelArg}");
_ = await parseResult.InvokeAsync().DefaultTimeout();
// We don't assert the exit code — the fake AppHostServerProject deliberately
// fails, so the run exits non-success after the on-disk write. The contract we
// test is the *side-effect*: whatever channel the writer decided to persist is
// what landed in aspire.config.json (or no channel if none was resolved /
// template doesn't pin).
var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, outputDirectoryName);
var config = AspireConfigFile.Load(outputDirectory);
return config?.Channel;
}
private static IPackagingService BuildPackagingService(string identityChannel, bool registerIdentityChannel)
{
var implicitCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(
[new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "13.3.0" }])
};
var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance);
var stableCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(
[new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "13.5.0" }])
};
var stableChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Stable,
PackageChannelQuality.Stable,
[new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)],
stableCache,
features: new TestFeatures(),
NullLogger.Instance);
var channels = new List<PackageChannel> { implicitChannel, stableChannel };
if (registerIdentityChannel &&
!string.Equals(identityChannel, PackageChannelNames.Stable, StringComparison.OrdinalIgnoreCase))
{
// Mirror PackagingService.GetChannelsAsync's shape: PR-hive channels are
// PackageChannelQuality.Both with a local-path Aspire* mapping, daily/staging
// are PackageChannelQuality.Prerelease with a remote feed URL.
var isPrChannel = identityChannel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase);
var quality = isPrChannel ? PackageChannelQuality.Both : PackageChannelQuality.Prerelease;
var feed = isPrChannel ? "/fake/pr-hive/packages" : "https://example.invalid/feed/v3/index.json";
var version = isPrChannel ? "13.4.0-pr.99999.gabc123" : "13.4.0-preview.1.99999.1";
var cache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(
[new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "test", Version = version }])
};
channels.Add(PackageChannel.CreateExplicitChannel(
identityChannel,
quality,
[
new PackageMapping("Aspire*", feed),
new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg),
],
cache,
features: new TestFeatures(),
NullLogger.Instance));
}
return new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(channels)
};
}
private static CliExecutionContext BuildExecutionContextWithIdentity(TemporaryWorkspace workspace, string identityChannel)
{
return workspace.CreateExecutionContext(identityChannel: identityChannel);
}
private IServiceCollection CreatePrDogfoodNewTemplateServices(TemporaryWorkspace workspace, string? processPath, TestDotNetCliRunner dotNetRunner)
{
return CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
hivesDirectory: new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")),
identityChannel: PrChannelName);
options.PackagingServiceFactory = sp => new PackagingService(
sp.GetRequiredService<CliExecutionContext>(),
sp.GetRequiredService<INuGetPackageCache>(),
sp.GetRequiredService<IFeatures>(),
sp.GetRequiredService<IConfiguration>(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => processPath);
options.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, prerelease, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(
[
new NuGetPackage
{
Id = TemplateNuGetConfigService.TemplatesPackageName,
Source = prerelease ? "preview-feed" : "stable-feed",
Version = prerelease ? "0.0.1-preview.1" : "0.0.1"
}
])
};
options.DotNetCliRunnerFactory = _ => dotNetRunner;
options.EnabledFeatures =
[
KnownFeatures.ExperimentalPolyglotPython,
KnownFeatures.ExperimentalPolyglotGo,
KnownFeatures.ExperimentalPolyglotJava,
KnownFeatures.ExperimentalPolyglotRust,
KnownFeatures.ShowAllTemplates,
];
});
}
private static (string ProcessPath, DirectoryInfo PackagesDirectory) CreatePrDogfoodInstallLayout(TemporaryWorkspace workspace)
{
var installPrefix = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "dogfood", PrChannelName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
var packagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", PrChannelName, "packages"));
File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{s_prVersion}.nupkg"), string.Empty);
return (processPath, packagesDirectory);
}
private static IEnumerable<string> GetPrDogfoodTemplateCoverageKeys(ITemplate template)
{
return template.SelectableAppHostLanguages.Count == 0
? [template.Name]
: template.SelectableAppHostLanguages.Select(languageId => $"{template.Name}:{languageId}");
}
public sealed record PrDogfoodNewTemplateCase(
string TemplateId,
string? LanguageId,
PrDogfoodNewTemplateContract Contract,
string[] ExtraArguments)
{
public string CoverageKey => LanguageId is null ? TemplateId : $"{TemplateId}:{LanguageId}";
public static PrDogfoodNewTemplateCase SelectableEmpty(string languageId, PrDogfoodNewTemplateContract contract, string[] extraArguments)
{
return new(KnownTemplateId.CSharpEmptyAppHost, languageId, contract, extraArguments);
}
public static PrDogfoodNewTemplateCase CliConfig(string templateId, string[]? extraArguments = null)
{
return new(templateId, LanguageId: null, PrDogfoodNewTemplateContract.AspireConfig, extraArguments ?? []);
}
public static PrDogfoodNewTemplateCase DotNet(string templateId, string[]? extraArguments = null)
{
return new(templateId, LanguageId: null, PrDogfoodNewTemplateContract.DotNetTemplate, extraArguments ?? []);
}
public override string ToString()
{
return CoverageKey;
}
}
private sealed record PrDogfoodNewTemplateExclusion(string CoverageKey, string Reason);
private sealed record DotNetTemplateInstall(string PackageName, string Version, string? NuGetConfigFile, string? NuGetSource);
public enum PrDogfoodNewTemplateContract
{
CSharpEmptyAppHost,
AspireConfig,
DotNetTemplate
}
}