// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Text.Json;
using Aspire.Cli.Configuration;
using Aspire.Cli.Git;
using Aspire.Cli.Resources;
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.Logging;
using Microsoft.Extensions.Time.Testing;
namespace Aspire.Cli.Tests.Configuration;
public class ConfigSourceServiceGitTests(ITestOutputHelper outputHelper) : IDisposable
{
private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace).AddXunit(outputHelper));
public void Dispose() => _loggerFactory.Dispose();
private GitCommandRunner CreateRunner(ICliHostEnvironment hostEnvironment) =>
new(hostEnvironment, _loggerFactory.CreateLogger<GitCommandRunner>());
private IGitClient CreateClient(ICliHostEnvironment hostEnvironment) =>
new GitClient(CreateRunner(hostEnvironment));
[Theory]
[InlineData(nameof(GitFailure.Unavailable), false)]
[InlineData(nameof(GitFailure.Unavailable), true)]
[InlineData(nameof(GitFailure.AuthenticationFailed), false)]
[InlineData(nameof(GitFailure.AuthenticationFailed), true)]
[InlineData(nameof(GitFailure.CommandFailed), false)]
[InlineData(nameof(GitFailure.CommandFailed), true)]
[InlineData(nameof(GitFailure.RepositoryUnavailable), false)]
[InlineData(nameof(GitFailure.RepositoryUnavailable), true)]
public async Task SharedGitFailures_PreserveConfigurationSourceMessages(string failure, bool check)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var kind = Enum.Parse<GitFailure>(failure);
var commands = new TestGitCommandRunner
{
RunCallback = (_, _) => throw new GitCommandException(kind, 128)
};
var client = new GitClient(commands);
var source = new ConfigSource { Repository = workspace.Path, Path = AspireConfigFile.FileName };
var expected = kind switch
{
GitFailure.Unavailable => ConfigSourceStrings.GitNotFound,
GitFailure.AuthenticationFailed => ConfigSourceStrings.AuthenticationFailed,
_ => ConfigSourceStrings.RepositoryUnavailable
};
if (check)
{
var interaction = await CheckFailureAsync(workspace, client, source, TimeProvider.System, TestContext.Current.CancellationToken);
Assert.Equal(string.Format(ConfigSourceStrings.CheckFailed, expected), Assert.Single(interaction.DisplayedMessages).Message);
Assert.Empty(interaction.DisplayedRawText);
}
else
{
var error = await Assert.ThrowsAsync<ConfigSourceException>(() => client.AcquireAsync(source, workspace.Path, TestContext.Current.CancellationToken));
Assert.Equal(expected, error.Message);
Assert.Null(error.InnerException);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SharedGitTimeout_BudgetCoversAllCommands(bool check)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var time = new FakeTimeProvider();
var commands = new TestGitCommandRunner
{
RunCallback = (command, token) =>
{
time.Advance(TimeSpan.FromSeconds(10));
token.ThrowIfCancellationRequested();
return Task.FromResult(command.Arguments[0] == "ls-remote"
? $"{TestGitCommandRunner.DefaultCommitSha}\tHEAD\n"
: TestGitCommandRunner.DefaultCommitSha);
}
};
var client = new GitClient(commands);
var source = new ConfigSource { Repository = workspace.Path, Path = AspireConfigFile.FileName };
if (check)
{
var interaction = await CheckFailureAsync(workspace, client, source, time, TestContext.Current.CancellationToken);
Assert.Equal(string.Format(ConfigSourceStrings.CheckFailed, ConfigSourceStrings.GitTimeout), Assert.Single(interaction.DisplayedMessages).Message);
Assert.Empty(interaction.DisplayedRawText);
}
else
{
var error = await Assert.ThrowsAsync<ConfigSourceException>(() => client.AcquireAsync(source, workspace.Path, TestContext.Current.CancellationToken, time));
Assert.Equal(ConfigSourceStrings.GitTimeout, error.Message);
Assert.Null(error.GitOutput);
}
Assert.Equal(3, commands.Commands.Count);
}
[Theory]
[InlineData(false, false, "fetch")]
[InlineData(false, true, "fetch")]
[InlineData(true, false, "fetch")]
[InlineData(true, true, "fetch")]
[InlineData(false, false, "ls-remote")]
[InlineData(false, true, "ls-remote")]
[InlineData(true, false, "ls-remote")]
[InlineData(true, true, "ls-remote")]
public async Task SharedGitCancellation_DisplaysPartialOutputOnlyForTimeout(bool check, bool userCancellation, string cancelledCommand)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var time = new FakeTimeProvider();
var commands = new TestGitCommandRunner
{
RunCallback = (command, token) =>
{
if (command.Arguments[0] == cancelledCommand || command.Arguments[1] == cancelledCommand)
{
if (userCancellation)
{
cancellation.Cancel();
}
else
{
time.Advance(TimeSpan.FromSeconds(30));
}
throw new GitProcessCanceledException("partial-private-remote-diagnostic", token);
}
return Task.FromResult(command.Arguments[0] == "ls-remote"
? $"{TestGitCommandRunner.DefaultCommitSha}\tHEAD\n"
: string.Empty);
}
};
var client = new GitClient(commands);
var source = new ConfigSource { Repository = workspace.Path, Path = AspireConfigFile.FileName };
Task RunAsync() => check
? CheckFailureAsync(workspace, client, source, time, cancellation.Token)
: client.AcquireAsync(source, workspace.Path, cancellation.Token, time);
if (userCancellation)
{
var error = await Assert.ThrowsAnyAsync<OperationCanceledException>(RunAsync);
Assert.Equal(new OperationCanceledException(cancellation.Token).Message, error.Message);
}
else if (check)
{
var interaction = await CheckFailureAsync(workspace, client, source, time, cancellation.Token);
Assert.Equal(string.Format(ConfigSourceStrings.CheckFailed, ConfigSourceStrings.GitTimeout), Assert.Single(interaction.DisplayedMessages).Message);
Assert.Equal("partial-private-remote-diagnostic", Assert.Single(interaction.DisplayedRawText).Text);
}
else
{
var error = await Assert.ThrowsAsync<ConfigSourceException>(RunAsync);
Assert.Equal(ConfigSourceStrings.GitTimeout, error.Message);
Assert.Equal("partial-private-remote-diagnostic", error.GitOutput);
}
}
private static async Task<TestInteractionService> CheckFailureAsync(
TemporaryWorkspace workspace, IGitClient git, ConfigSource source, TimeProvider clock, CancellationToken token)
{
await TestConfigSourceServices.SaveBindingAsync(new ConfigSource
{
Repository = source.Repository,
Path = source.Path,
Ref = source.Ref,
AppliedVersion = "previous-version"
}, workspace.WorkspaceRoot, token);
var interaction = new TestInteractionService();
using var service = TestConfigSourceServices.Create(workspace.CreateExecutionContext(), git: git, clock: clock, interaction: interaction);
await service.CheckProjectAsync(workspace.WorkspaceRoot, force: false, token);
service.NotifyIfUpdateAvailable();
return interaction;
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(true, true)]
public async Task RunProcess_DescendantHoldingOutputCannotBlockShutdown(bool parentExits, bool captureTimeoutOutput)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var pidFile = Path.Combine(workspace.Path, "child.pid");
var startInfo = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "powershell.exe" : "/bin/sh",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (OperatingSystem.IsWindows())
{
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-Command");
// The child inherits both output pipes. Either leave its parent running for
// cancellation, or exit the parent to exercise the bounded post-exit drain.
startInfo.ArgumentList.Add(
"[Console]::Error.Write('Enter passphrase: '); [Console]::Out.Write(('x' * 1048576)); " +
"$child = Start-Process powershell.exe -NoNewWindow -PassThru -ArgumentList '-NoProfile -Command Start-Sleep -Seconds 60'; " +
$"[IO.File]::WriteAllText('{pidFile.Replace("'", "''")}', [string]$child.Id); " +
(parentExits ? "exit 0" : "Start-Sleep -Seconds 60"));
}
else
{
startInfo.ArgumentList.Add("-c");
startInfo.ArgumentList.Add("printf 'Enter passphrase: ' >&2; printf '%1048576s' '' | tr ' ' x; sleep 60 & echo $! > \"$1\"; " + (parentExits ? "exit 0" : "wait"));
startInfo.ArgumentList.Add("test-parent");
startInfo.ArgumentList.Add(pidFile);
}
using var timeoutSource = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(milliseconds: 30_000);
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken, timeoutSource.Token);
var stopwatch = Stopwatch.StartNew();
void Log(string message) => outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] [{stopwatch.Elapsed}] {message}");
Log($"Starting captured process '{startInfo.FileName}'; parentExits={parentExits}, captureTimeoutOutput={captureTimeoutOutput}.");
var run = CreateRunner(TestHelpers.CreateNonInteractiveHostEnvironment()).RunProcessAsync(
startInfo, captureTimeoutOutput, GitCommandMode.Unattended, cancellation.Token);
Log($"Captured process launched; task status={run.Status}. Waiting for descendant PID in '{pidFile}'.");
Process? child = null;
try
{
var pid = await ProcessTestHelpers.WaitForProcessIdAsync(pidFile, cancellation.Token);
child = Process.GetProcessById(pid);
Log($"Descendant PID={pid}; waiting for {(parentExits ? "post-exit pipe drain" : "cancellation")}.");
if (parentExits)
{
var error = await Assert.ThrowsAsync<GitCommandException>(() => run.WaitAsync(TimeSpan.FromSeconds(10)));
Assert.Equal(GitFailure.RepositoryUnavailable, error.Failure);
Log("Post-exit pipe drain returned the expected failure.");
}
else
{
Log("Requesting cancellation.");
cancellation.Cancel();
Log("Cancellation requested; waiting for captured process to return.");
var error = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => run.WaitAsync(TimeSpan.FromSeconds(10)));
Log($"Captured process returned {error.GetType().Name}; waiting for descendant {pid} to exit.");
if (captureTimeoutOutput)
{
var interrupted = Assert.IsType<GitProcessCanceledException>(error);
const string prompt = "Enter passphrase: ";
Assert.InRange(interrupted.Output.Length, 64 * 1024 + prompt.Length + 1, 1048576 + prompt.Length);
Assert.Equal(new string('x', interrupted.Output.Length - prompt.Length) + prompt, interrupted.Output);
Assert.Equal(new OperationCanceledException(cancellation.Token).Message, interrupted.Message);
}
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(child.HasExited);
Log($"Descendant {pid} exited.");
}
}
finally
{
Log($"Starting cleanup; captured task status={run.Status}.");
cancellation.Cancel();
Log("Cleanup cancellation completed.");
if (child is not null)
{
if (!child.HasExited)
{
Log($"Killing descendant tree {child.Id}.");
child.Kill(entireProcessTree: true);
Log($"Kill returned for descendant {child.Id}; waiting for exit.");
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Log($"Descendant {child.Id} cleanup completed.");
}
child.Dispose();
}
var cleanupError = await Record.ExceptionAsync(() => run.WaitAsync(TimeSpan.FromSeconds(10)));
Log($"Cleanup finished; captured task status={run.Status}, capture exception={cleanupError?.GetType().Name ?? "none"}.");
}
}
[Fact]
public void CreateStartInfo_AdvisoryFetchNeverOwnsTerminal()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var client = CreateRunner(TestHelpers.CreateInteractiveHostEnvironment());
var startInfo = client.CreateStartInfo(workspace.Path, ["fetch"], GitCommandMode.Unattended);
Assert.Equal("0", startInfo.Environment["GIT_TERMINAL_PROMPT"]);
Assert.Equal("never", startInfo.Environment["GCM_INTERACTIVE"]);
Assert.True(startInfo.CreateNoWindow);
Assert.True(startInfo.RedirectStandardOutput);
Assert.True(startInfo.RedirectStandardError);
Assert.False(startInfo.RedirectStandardInput);
}
[Theory]
[InlineData(false, null, null, true)]
[InlineData(true, null, null, false)]
[InlineData(false, "true", null, false)]
[InlineData(false, null, "true", false)]
public void CreateStartInfo_CredentialPromptsFollowHostInteractivity(
bool nonInteractive, string? configuredNonInteractive, string? ci, bool expectedInteractive)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var configuration = new ConfigurationManager();
configuration["ASPIRE_NON_INTERACTIVE"] = configuredNonInteractive;
configuration["CI"] = ci;
var hostEnvironment = new CliHostEnvironment(configuration, nonInteractive, isOutputRedirected: true);
var client = CreateRunner(hostEnvironment);
var startInfo = client.CreateStartInfo(workspace.Path, ["fetch", "--depth", "1", "--", "https://example.com/configuration", "HEAD"], GitCommandMode.Interactive);
Assert.Equal(expectedInteractive ? "1" : "0", startInfo.Environment["GIT_TERMINAL_PROMPT"]);
Assert.Equal(!expectedInteractive, startInfo.CreateNoWindow);
Assert.False(startInfo.RedirectStandardInput);
Assert.Equal(!expectedInteractive, startInfo.RedirectStandardOutput);
Assert.Equal(!expectedInteractive, startInfo.RedirectStandardError);
Assert.Equal(!expectedInteractive, startInfo.StandardOutputEncoding is not null);
Assert.Equal(!expectedInteractive, startInfo.StandardErrorEncoding is not null);
if (!expectedInteractive)
{
Assert.Equal("never", startInfo.Environment["GCM_INTERACTIVE"]);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CreateStartInfo_ReferenceResolutionCapturesRefsWithoutHidingInteractivePrompts(bool interactive)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var runner = CreateRunner(interactive
? TestHelpers.CreateInteractiveHostEnvironment()
: TestHelpers.CreateNonInteractiveHostEnvironment());
var startInfo = runner.CreateStartInfo(workspace.Path, ["ls-remote", "--", "repository", "HEAD"], GitCommandMode.Interactive);
Assert.False(startInfo.RedirectStandardInput);
Assert.True(startInfo.RedirectStandardOutput);
Assert.Equal(!interactive, startInfo.RedirectStandardError);
Assert.Equal(!interactive, startInfo.StandardErrorEncoding is not null);
Assert.Equal(interactive ? "1" : "0", startInfo.Environment["GIT_TERMINAL_PROMPT"]);
}
[Theory]
[InlineData("init")]
[InlineData("rev-parse")]
[InlineData("ls-tree")]
[InlineData("cat-file")]
public void CreateStartInfo_LocalQueriesRemainCapturedWhenInteractive(string command)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var client = CreateRunner(TestHelpers.CreateInteractiveHostEnvironment());
var startInfo = client.CreateStartInfo(workspace.Path, [command], GitCommandMode.Interactive);
Assert.False(startInfo.RedirectStandardInput);
Assert.True(startInfo.RedirectStandardOutput);
Assert.True(startInfo.RedirectStandardError);
Assert.Equal(System.Text.Encoding.UTF8, startInfo.StandardOutputEncoding);
Assert.Equal(System.Text.Encoding.UTF8, startInfo.StandardErrorEncoding);
}
[Theory]
[InlineData("aspire.config.json", false)]
[InlineData("aspire.config.json", true)]
[InlineData("teams/platform config.json", false)]
[InlineData("teams/platform config.json", true)]
public async Task Acquire_ReadsDefaultBranchSnapshotWithoutCheckingOut(string path, bool interactive)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var custom = new string('x', 70 * 1024) + "\u65e5\u672c\u8a9e";
await CreateRepositoryAsync(workspace, path, """
{"DOCS":{"llmsTxtUrl":"https://example.com/llms.txt","API":{"custom":"preserved"}},"docs:api:sitemapUrl":"https://example.com/sitemap.xml",
""" + "\"features\":{\"test\":true},\"custom\":\"" + custom + "\"}");
var cache = workspace.CreateDirectory("cache");
var client = CreateClient(interactive
? TestHelpers.CreateInteractiveHostEnvironment()
: TestHelpers.CreateNonInteractiveHostEnvironment());
var source = new ConfigSource { Repository = workspace.Path, Path = path };
source.Validate();
var (commit, version, configuration) = await client.AcquireAsync(
source, cache.FullName, TestContext.Current.CancellationToken);
Assert.Matches("^[a-f0-9]{40,64}$", commit);
Assert.Equal((await RunGitOutputAsync(workspace.Path, ["rev-parse", $"HEAD:{path}"])).Trim(), version);
Assert.True(configuration["features"]!["test"]!.GetValue<bool>());
Assert.Equal(custom, configuration["custom"]!.GetValue<string>());
Assert.Equal("https://example.com/llms.txt", configuration["DOCS"]!["llmsTxtUrl"]!.GetValue<string>());
Assert.Equal("https://example.com/sitemap.xml", configuration["DOCS"]!["API"]!["sitemapUrl"]!.GetValue<string>());
Assert.Equal("preserved", configuration["DOCS"]!["API"]!["custom"]!.GetValue<string>());
Assert.Equal(["DOCS", "features", "custom"], configuration.Select(property => property.Key));
Assert.False(File.Exists(Path.Combine(cache.FullName, path)));
}
[Fact]
public async Task Materialize_AppliesConfigurationFromAbsoluteLocalRepositoryWithSpaces()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string path = "teams/platform.json";
await CreateRepositoryAsync(workspace, path, """{"docs":{"llmsTxtUrl":"https://docs.example/local/llms.txt"}}""");
var repository = workspace.CreateDirectory("local repository");
await RunGitOutputAsync(workspace.Path, ["clone", "--", workspace.Path, repository.FullName]);
var source = new ConfigSource { Repository = repository.FullName, Path = path };
var project = workspace.CreateDirectory("project");
using var service = TestConfigSourceServices.Create(workspace.CreateExecutionContext(),
git: CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment()));
await service.MaterializeAsync(source, project, TestContext.Current.CancellationToken);
var config = AspireConfigFile.Load(project.FullName)!;
Assert.Equal("https://docs.example/local/llms.txt", config.Docs!.LlmsTxtUrl);
Assert.Equal(repository.FullName, config.ConfigSource!.Repository);
Assert.Equal(path, config.ConfigSource.Path);
Assert.Equal((await RunGitOutputAsync(repository.FullName, ["rev-parse", $"HEAD:{path}"])).Trim(),
config.ConfigSource.AppliedVersion);
}
[Theory]
[InlineData("""{"DOCS":{"llmsTxtUrl":"https://example.test/llms.txt"},"docs":{"api":{"sitemapUrl":"https://example.test/sitemap.xml"}}}""")]
[InlineData("""{"docs":{"api":{"sitemapUrl":"https://example.test/sitemap.xml"}},"DOCS":{"llmsTxtUrl":"https://example.test/llms.txt"}}""")]
[InlineData("""{"docs":{"llmsTxtUrl":"https://example.test/llms.txt","API":{"custom":"excluded"},"api":{"sitemapUrl":"https://example.test/sitemap.xml"}}}""")]
[InlineData("""{"DOCS":null,"docs":{"llmsTxtUrl":"https://example.test/llms.txt","api":{"sitemapUrl":"https://example.test/sitemap.xml"}}}""")]
[InlineData("""{"docs":{"llmsTxtUrl":"https://example.test/llms.txt","api":{"sitemapUrl":"https://example.test/sitemap.xml"}},"docs:llmsTxtUrl":"https://flat.example/llms.txt","docs:api:sitemapUrl":"https://flat.example/sitemap.xml"}""")]
[InlineData("""{"docs:llmsTxtUrl":"https://example.test/llms.txt","docs:api":{"sitemapUrl":"https://example.test/sitemap.xml","custom:key":"preserved"}}""")]
[InlineData("""{"DOCS":{"llmsTxtUrl":null,"API":{"SITEMAPURL":null}},"docs:api:sitemapUrl":"https://example.test/sitemap.xml","docs:llmsTxtUrl":"https://example.test/llms.txt"}""")]
[InlineData("""{"docs:llmsTxtUrl":"https://example.test/llms.txt","DOCS:LLMSTXTURL":"https://flat.example/llms.txt","docs:api:sitemapUrl":"https://example.test/sitemap.xml"}""")]
public async Task Materialize_ReadsAllowedLeavesAcrossCaseInsensitiveSections(string content)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, content);
var source = new ConfigSource { Repository = workspace.Path, Path = AspireConfigFile.FileName };
var project = workspace.CreateDirectory("project");
using var service = TestConfigSourceServices.Create(workspace.CreateExecutionContext(),
git: CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment()));
await service.MaterializeAsync(source, project, TestContext.Current.CancellationToken);
var config = AspireConfigFile.Load(project.FullName)!;
Assert.Equal("https://example.test/llms.txt", config.Docs!.LlmsTxtUrl);
Assert.Equal("https://example.test/sitemap.xml", config.Docs.Api!.SitemapUrl);
}
[Fact]
public async Task Acquire_PreservesLiteralDictionaryKeys()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string content = """
{
"features": { "feature:name": true },
"profiles": {
"dev:local": {
"environmentVariables": { "Logging:LogLevel:Default": "Debug" }
}
},
"custom": { "literal:key": "preserved" },
"docs": { "api:sitemapUrl": "https://example.test/sitemap.xml" }
}
""";
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, content);
var client = CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment());
var (_, _, configuration) = await client.AcquireAsync(
new() { Repository = workspace.Path, Path = AspireConfigFile.FileName },
workspace.CreateDirectory("cache").FullName, TestContext.Current.CancellationToken);
var config = configuration.Deserialize(JsonSourceGenerationContext.Default.AspireConfigFile)!;
Assert.Equal(new Dictionary<string, bool> { ["feature:name"] = true }, config.Features);
Assert.Equal(new Dictionary<string, string> { ["Logging:LogLevel:Default"] = "Debug" },
Assert.Single(config.Profiles!).Value.EnvironmentVariables);
Assert.Null(config.Docs!.Api);
await Verify(configuration.ToJsonString(), "json");
}
[Theory]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{")]
[InlineData("""{"features":{"test":"not-a-boolean"}}""")]
[InlineData("""{"features:test":"not-a-boolean"}""")]
[InlineData("""{"features":{"test":true},"features":{"test":false}}""")]
[InlineData("""{"DOCS":{"llmsTxtUrl":"https://example.test/first"},"docs":{"LLMSTXTURL":"https://example.test/second"}}""")]
[InlineData("""{"docs":{"API":{"sitemapUrl":"https://example.test/first"},"api":{"SITEMAPURL":"https://example.test/second"}}}""")]
[InlineData("""{"docs":{"llmsTxtUrl":"https://example.test/first"},"DOCS":{"LLMSTXTURL":"https://example.test/second"},"docs:llmsTxtUrl":"https://example.test/flat"}""")]
[InlineData("""{"docs":{"api:sitemapUrl":"https://example.test/first"},"DOCS":{"API:SITEMAPURL":"https://example.test/second"}}""")]
[InlineData("""{"features":{"feature:name":true,"feature":{"name":false}}}""")]
[InlineData("""{"configSource:repository":"https://example.com/nested"}""")]
[InlineData("""{"configSource":{"repository":"https://example.com/nested","path":"config.json"}}""")]
public async Task Acquire_RejectsInvalidConfiguration(string content)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, content);
await Assert.ThrowsAsync<ConfigSourceException>(() => CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment()).AcquireAsync(
new() { Repository = workspace.Path, Path = AspireConfigFile.FileName }, workspace.CreateDirectory("cache").FullName, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Acquire_DistinguishesMissingFile()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, "other.json", "{}");
var error = await Assert.ThrowsAsync<ConfigSourceException>(() => CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment()).AcquireAsync(
new() { Repository = workspace.Path, Path = AspireConfigFile.FileName }, workspace.CreateDirectory("cache").FullName, TestContext.Current.CancellationToken));
Assert.Equal(ConfigSourceStrings.MissingFile, error.Message);
}
[Theory]
[InlineData(4 * 1024 * 1024, true)]
[InlineData(4 * 1024 * 1024 + 1, false)]
public async Task Acquire_EnforcesExactConfigurationSizeLimit(int bytes, bool valid)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, "{}".PadRight(bytes));
var client = CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment());
var cache = workspace.CreateDirectory("cache");
var source = new ConfigSource { Repository = workspace.Path, Path = AspireConfigFile.FileName };
if (valid)
{
var (_, _, configuration) = await client.AcquireAsync(source, cache.FullName, TestContext.Current.CancellationToken);
Assert.Empty(configuration);
}
else
{
var error = await Assert.ThrowsAsync<ConfigSourceException>(() =>
client.AcquireAsync(source, cache.FullName, TestContext.Current.CancellationToken));
Assert.Equal(ConfigSourceStrings.InvalidConfiguration, error.Message);
}
}
[Theory]
[InlineData("config/preview")]
[InlineData("refs/heads/config/preview")]
public async Task Acquire_ReadsSelectedBranchInsteadOfDefaultBranch(string reference)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, """{"custom":"default"}""");
var originalBranch = (await RunGitOutputAsync(workspace.Path, ["branch", "--show-current"])).Trim();
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "checkout", "-b", "config/preview");
await File.WriteAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName), """{"custom":"preview"}""");
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "commit", "-am", "Preview configuration");
var expectedCommit = (await RunGitOutputAsync(workspace.Path, ["rev-parse", "HEAD"])).Trim();
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "checkout", originalBranch);
var client = CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment());
var (commit, _, configuration) = await client.AcquireAsync(
new() { Repository = workspace.Path, Path = AspireConfigFile.FileName, Ref = reference },
workspace.CreateDirectory("cache").FullName, TestContext.Current.CancellationToken);
Assert.Equal(expectedCommit, commit);
Assert.Equal("preview", configuration["custom"]!.GetValue<string>());
}
[Fact]
public async Task Acquire_ReadsPinnedCommitOutsideShallowDefaultBranchTip()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, """{"custom":"pinned"}""");
var expectedCommit = (await RunGitOutputAsync(workspace.Path, ["rev-parse", "HEAD"])).Trim();
await File.WriteAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName), """{"custom":"latest"}""");
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "commit", "-am", "New configuration");
var client = CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment());
var (commit, _, configuration) = await client.AcquireAsync(
new() { Repository = workspace.Path, Path = AspireConfigFile.FileName, Ref = expectedCommit },
workspace.CreateDirectory("cache").FullName, TestContext.Current.CancellationToken);
Assert.Equal(expectedCommit, commit);
Assert.Equal("pinned", configuration["custom"]!.GetValue<string>());
}
[Fact]
public async Task Check_IdentifiesFileChangesWithoutMaterializingJsonAndHonorsPins()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, "{}");
var source = new ConfigSource { Repository = workspace.Path, Path = AspireConfigFile.FileName };
var runner = CreateRunner(TestHelpers.CreateNonInteractiveHostEnvironment());
var commands = new TestGitCommandRunner
{
RunCallback = (command, token) => runner.RunAsync(command.WorkingDirectory, command.Arguments, command.Mode, token)
};
var client = new GitClient(commands);
var original = await client.CheckAsync(source, workspace.CreateDirectory("first-check").FullName, TestContext.Current.CancellationToken);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "unrelated.txt"), "Unrelated change");
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "add", "unrelated.txt");
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "commit", "-m", "Unrelated commit");
var unchanged = await client.CheckAsync(source, workspace.CreateDirectory("unchanged-check").FullName, TestContext.Current.CancellationToken);
Assert.NotEqual(original.Commit, unchanged.Commit);
Assert.Equal(original.Version, unchanged.Version);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName), "invalid JSON is not read during a version check");
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "commit", "-am", "Change configuration");
var directory = workspace.CreateDirectory("changed-check");
var changed = await client.CheckAsync(source, directory.FullName, TestContext.Current.CancellationToken);
var pinned = await client.CheckAsync(new() { Repository = source.Repository, Path = source.Path, Ref = original.Commit },
workspace.CreateDirectory("pinned-check").FullName, TestContext.Current.CancellationToken);
Assert.NotEqual(original.Version, changed.Version);
Assert.Equal(original, pinned);
Assert.Equal(
Enumerable.Repeat(new[] { "ls-remote", "init", "fetch", "rev-parse", "ls-tree" }, 3).SelectMany(sequence => sequence)
.Concat(["init", "fetch", "rev-parse", "ls-tree"]),
commands.Commands.Select(command => command.Arguments[0].StartsWith("--git-dir=", StringComparison.Ordinal)
? command.Arguments[1] : command.Arguments[0]));
}
[Theory]
[InlineData("missing-branch", false)]
[InlineData("missing-branch", true)]
[InlineData("0000000000000000000000000000000000000000", false)]
[InlineData("0000000000000000000000000000000000000000", true)]
public async Task Acquire_UnavailableRefFailsWithoutFallingBack(string reference, bool interactive)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, "{}");
var client = CreateClient(interactive
? TestHelpers.CreateInteractiveHostEnvironment()
: TestHelpers.CreateNonInteractiveHostEnvironment());
var error = await Assert.ThrowsAsync<ConfigSourceException>(() => client.AcquireAsync(
new() { Repository = workspace.Path, Path = AspireConfigFile.FileName, Ref = reference },
workspace.CreateDirectory("cache").FullName, TestContext.Current.CancellationToken));
Assert.Equal(ConfigSourceStrings.RepositoryUnavailable, error.Message);
}
[Fact]
public async Task Acquire_RejectsSymlinkConfiguration()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, "target.json", "{}");
await File.WriteAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName), "target.json");
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "add", AspireConfigFile.FileName);
// Write a symlink-mode tree entry directly so the test does not require Windows
// developer mode or elevated filesystem symlink privileges.
var entry = await RunGitOutputAsync(workspace.Path, ["ls-files", "--stage", AspireConfigFile.FileName]);
var objectId = entry.Split(' ', StringSplitOptions.RemoveEmptyEntries)[1];
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "update-index", "--cacheinfo", $"120000,{objectId},{AspireConfigFile.FileName}");
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "commit", "-m", "Add symlink configuration");
var error = await Assert.ThrowsAsync<ConfigSourceException>(() => CreateClient(TestHelpers.CreateNonInteractiveHostEnvironment()).AcquireAsync(
new() { Repository = workspace.Path, Path = AspireConfigFile.FileName }, workspace.CreateDirectory("cache").FullName, TestContext.Current.CancellationToken));
Assert.Equal(ConfigSourceStrings.MissingFile, error.Message);
}
[Theory]
[InlineData("sha1", "default", false)]
[InlineData("sha1", "branch", false)]
[InlineData("sha1", "commit", false)]
[InlineData("sha256", "default", false)]
[InlineData("sha256", "branch", false)]
[InlineData("sha256", "commit", false)]
[InlineData("sha1", "branch", true)]
[InlineData("sha256", "default", true)]
public async Task AcquireAndCheck_MatchRepositoryObjectFormat(string objectFormat, string selector, bool interactive)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateRepositoryAsync(workspace, AspireConfigFile.FileName, """{"custom":"original"}""", objectFormat);
var originalCommit = (await RunGitOutputAsync(workspace.Path, ["rev-parse", "HEAD"])).Trim();
var originalVersion = (await RunGitOutputAsync(workspace.Path, ["rev-parse", $"HEAD:{AspireConfigFile.FileName}"])).Trim();
var branch = (await RunGitOutputAsync(workspace.Path, ["branch", "--show-current"])).Trim();
var source = new ConfigSource
{
Repository = workspace.Path,
Path = AspireConfigFile.FileName,
Ref = selector switch
{
"branch" => branch,
"commit" => originalCommit,
_ => null
}
};
var runner = CreateRunner(interactive
? TestHelpers.CreateInteractiveHostEnvironment()
: TestHelpers.CreateNonInteractiveHostEnvironment());
var commands = new TestGitCommandRunner
{
RunCallback = (command, cancellationToken) => runner.RunAsync(command.WorkingDirectory, command.Arguments, command.Mode, cancellationToken)
};
var client = new GitClient(commands);
// SHA-256 object names are longer; keep this fixture's cache below Windows MAX_PATH.
using var service = TestConfigSourceServices.Create(
workspace.CreateExecutionContext(aspireHomeDirectory: workspace.CreateDirectory("h")), git: client);
var token = TestContext.Current.CancellationToken;
var acquired = await service.AcquireAsync(source, force: true, token);
var checkedVersion = await client.CheckAsync(source, workspace.CreateDirectory("first-check").FullName, token);
Assert.Equal(originalCommit, acquired.Metadata.Commit);
Assert.Equal(originalVersion, acquired.Metadata.Version);
Assert.Equal("original", acquired.Configuration["custom"]!.GetValue<string>());
Assert.Equal((originalCommit, originalVersion), checkedVersion);
Assert.Equal(objectFormat == "sha256" ? 64 : 40, acquired.Metadata.Version.Length);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName), """{"custom":"updated"}""", token);
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "commit", "-am", "Update configuration");
var expectedCommit = selector == "commit" ? originalCommit : (await RunGitOutputAsync(workspace.Path, ["rev-parse", "HEAD"])).Trim();
var expectedVersion = selector == "commit" ? originalVersion : (await RunGitOutputAsync(workspace.Path, ["rev-parse", $"HEAD:{AspireConfigFile.FileName}"])).Trim();
var refreshed = await service.AcquireAsync(source, force: true, token);
var updatedCheck = await client.CheckAsync(source, workspace.CreateDirectory("updated-check").FullName, token);
Assert.Equal(expectedCommit, refreshed.Metadata.Commit);
Assert.Equal(expectedVersion, refreshed.Metadata.Version);
Assert.Equal(selector == "commit" ? "original" : "updated", refreshed.Configuration["custom"]!.GetValue<string>());
Assert.Equal((expectedCommit, expectedVersion), updatedCheck);
Assert.Equal(Enumerable.Repeat($"--object-format={objectFormat}", 4),
commands.Commands.Where(command => command.Arguments[0] == "init").Select(command => command.Arguments[2]));
GitCommandMode[] expectedResolutionModes = selector == "commit" ? []
: [GitCommandMode.Interactive, GitCommandMode.Unattended, GitCommandMode.Interactive, GitCommandMode.Unattended];
Assert.Equal(expectedResolutionModes,
commands.Commands.Where(command => command.Arguments[0] == "ls-remote").Select(command => command.Mode));
}
private async Task CreateRepositoryAsync(TemporaryWorkspace workspace, string path, string json, string objectFormat = "sha1")
{
await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "init", $"--object-format={objectFormat}");
await GitTestHelper.ConfigureGitIdentityAsync(workspace.Path, outputHelper);
var configPath = Path.Combine(workspace.Path, path.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
await File.WriteAllTextAsync(configPath, json);
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "add", "--", path);
await GitTestHelper.RunGitAsync(workspace.Path, outputHelper, "commit", "-m", "Add configuration");
}
private static async Task<string> RunGitOutputAsync(string directory, string[] arguments)
{
var result = await System.Diagnostics.Process.RunAndCaptureTextAsync(new("git", arguments)
{
WorkingDirectory = directory,
RedirectStandardOutput = true,
RedirectStandardError = true
}, TestContext.Current.CancellationToken);
Assert.Equal(0, result.ExitStatus.ExitCode);
return result.StandardOutput;
}
}