// 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 System.Text.Json.Nodes;
using Aspire.Cli.Configuration;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.Tests.Configuration;
public class ConfigurationServiceTests(ITestOutputHelper outputHelper)
{
private static (ConfigurationService Service, string SettingsFilePath) CreateService(
TemporaryWorkspace workspace,
string? existingContent = null)
{
var globalSettingsDir = workspace.CreateDirectory(".aspire-global");
var globalSettingsFile = new FileInfo(Path.Combine(globalSettingsDir.FullName, AspireConfigFile.FileName));
var settingsFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
if (existingContent is not null)
{
File.WriteAllText(settingsFilePath, existingContent);
}
var executionContext = workspace.CreateExecutionContext();
var configBuilder = new ConfigurationBuilder();
var configuration = configBuilder.Build();
var logger = NullLogger<ConfigurationService>.Instance;
var service = new ConfigurationService(configuration, executionContext, globalSettingsFile, logger);
return (service, settingsFilePath);
}
[Fact]
public async Task SetConfigurationAsync_WorksWithJsonComments()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var contentWithComments = """
{
// This is a comment about the apphost
"appHost": {
"path": "MyApp.csproj" // path to the project
}
}
""";
var (service, settingsFilePath) = CreateService(workspace, contentWithComments);
await service.SetConfigurationAsync("channel", "daily", isGlobal: false);
var result = File.ReadAllText(settingsFilePath);
Assert.Contains("daily", result);
Assert.Contains("appHost", result);
}
[Fact]
public async Task SetConfigurationAsync_WorksWithTrailingCommas()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var contentWithTrailingCommas = """
{
"appHost": {
"path": "MyApp.csproj",
},
"channel": "stable",
}
""";
var (service, settingsFilePath) = CreateService(workspace, contentWithTrailingCommas);
await service.SetConfigurationAsync("features.polyglotSupportEnabled", "true", isGlobal: false);
var result = File.ReadAllText(settingsFilePath);
Assert.Contains("polyglotSupportEnabled", result);
}
[Fact]
public async Task SetConfigurationAsync_WorksWithCommentsAndTrailingCommas()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var content = """
{
// Comment
"appHost": {
"path": "MyApp.csproj", // trailing comma
},
}
""";
var (service, settingsFilePath) = CreateService(workspace, content);
await service.SetConfigurationAsync("channel", "daily", isGlobal: false);
var result = File.ReadAllText(settingsFilePath);
Assert.Contains("daily", result);
}
[Fact]
public async Task SetConfigurationAsync_CreatesNewFile_WhenNoneExists()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// The workspace has .aspire/settings.json (the legacy sentinel) but no
// aspire.config.json. FindNearestSettingsFile will find the sentinel and
// write to it.
var legacySettingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
var (service, _) = CreateService(workspace);
await service.SetConfigurationAsync("channel", "staging", isGlobal: false);
Assert.True(File.Exists(legacySettingsPath));
var result = File.ReadAllText(legacySettingsPath);
Assert.Contains("staging", result);
}
[Fact]
public async Task SetConfigurationAsync_HandlesEmptyFile()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, settingsFilePath) = CreateService(workspace, "");
await service.SetConfigurationAsync("channel", "daily", isGlobal: false);
var result = File.ReadAllText(settingsFilePath);
Assert.Contains("daily", result);
}
[Fact]
public async Task DeleteConfigurationAsync_WorksWithJsonComments()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var contentWithComments = """
{
// Comment
"channel": "daily",
"appHost": { "path": "MyApp.csproj" }
}
""";
var (service, settingsFilePath) = CreateService(workspace, contentWithComments);
var deleted = await service.DeleteConfigurationAsync("channel", isGlobal: false);
Assert.True(deleted);
var result = File.ReadAllText(settingsFilePath);
Assert.DoesNotContain("daily", result);
Assert.Contains("appHost", result);
}
[Fact]
public async Task DeleteConfigurationAsync_ReturnsFalse_WhenFileDoesNotExist()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, _) = CreateService(workspace);
var deleted = await service.DeleteConfigurationAsync("channel", isGlobal: false);
Assert.False(deleted);
}
[Fact]
public async Task DeleteConfigurationAsync_ReturnsFalse_WhenFileIsEmpty()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, _) = CreateService(workspace, "");
var deleted = await service.DeleteConfigurationAsync("channel", isGlobal: false);
Assert.False(deleted);
}
[Fact]
public async Task GetAllConfigurationAsync_ParsesCommentsCorrectly()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var contentWithComments = """
{
// This config has comments
"channel": "daily",
"features": {
"polyglotSupportEnabled": true // enabled for testing
}
}
""";
var (service, _) = CreateService(workspace, contentWithComments);
var config = await service.GetAllConfigurationAsync();
Assert.Contains("channel", config.Keys);
Assert.Equal("daily", config["channel"]);
}
[Fact]
public async Task GetConfigurationFromDirectoryAsync_WithContinueSearchWhenKeyMissing_WalksUpWhenNearestConfigDoesNotContainKey()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, _) = CreateService(
workspace,
"""
{
"sdk": {
"version": "10.0.0-preview.5.26311.1"
}
}
""");
var srcDirectory = workspace.WorkspaceRoot.CreateSubdirectory("src");
await File.WriteAllTextAsync(
Path.Combine(srcDirectory.FullName, AspireConfigFile.FileName),
"""
{
"packages": {
"Aspire.Hosting.Redis": ""
}
}
""");
var value = await service.GetConfigurationFromDirectoryAsync("sdk.version", srcDirectory, continueSearchWhenKeyMissing: true);
Assert.Equal("10.0.0-preview.5.26311.1", value);
}
[Fact]
public async Task GetConfigurationFromDirectoryAsync_WhenNearestConfigDoesNotContainKey_DoesNotReadParentConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, _) = CreateService(
workspace,
"""
{
"channel": "daily"
}
""");
var srcDirectory = workspace.WorkspaceRoot.CreateSubdirectory("src");
await File.WriteAllTextAsync(
Path.Combine(srcDirectory.FullName, AspireConfigFile.FileName),
"""
{
"language": "csharp"
}
""");
var value = await service.GetConfigurationFromDirectoryAsync("channel", srcDirectory);
Assert.Null(value);
}
[Fact]
public async Task GetConfigurationFromDirectoryAsync_FindsNearestParentConfigWhenStartDirectoryHasNoConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, _) = CreateService(
workspace,
"""
{
"channel": "daily"
}
""");
var srcDirectory = workspace.WorkspaceRoot.CreateSubdirectory("src");
var value = await service.GetConfigurationFromDirectoryAsync("channel", srcDirectory);
Assert.Equal("daily", value);
}
[Fact]
public async Task SetConfigurationAsync_SetsNestedValues()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, settingsFilePath) = CreateService(workspace, "{}");
await service.SetConfigurationAsync("appHost.path", "MyApp/MyApp.csproj", isGlobal: false);
var result = File.ReadAllText(settingsFilePath);
Assert.Contains("appHost", result);
Assert.Contains("MyApp/MyApp.csproj", result);
}
[Fact]
public async Task SetConfigurationAsync_WritesBooleanStringAsJsonString()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, settingsFilePath) = CreateService(workspace, "{}");
await service.SetConfigurationAsync("features.polyglotSupportEnabled", "true", isGlobal: false);
// Value is written as a JSON string "true", not a JSON boolean true.
// The FlexibleBooleanConverter handles parsing "true" -> bool on read.
var json = JsonNode.Parse(File.ReadAllText(settingsFilePath));
var node = json!["features"]!["polyglotSupportEnabled"];
Assert.Equal(JsonValueKind.String, node!.GetValueKind());
Assert.Equal("true", node.GetValue<string>());
// Verify round-trip through AspireConfigFile.Load still works
var config = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName);
Assert.NotNull(config?.Features);
Assert.True(config.Features["polyglotSupportEnabled"]);
}
[Fact]
public async Task SetConfigurationAsync_ChannelWithBooleanLikeValue_StaysAsString()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, settingsFilePath) = CreateService(workspace, "{}");
// "true" is a valid channel value and must remain a string in JSON
// to avoid corrupting the string-typed Channel property.
await service.SetConfigurationAsync("channel", "true", isGlobal: false);
// Must be a JSON string "true", not a JSON boolean true
var json = JsonNode.Parse(File.ReadAllText(settingsFilePath));
var node = json!["channel"];
Assert.Equal(JsonValueKind.String, node!.GetValueKind());
Assert.Equal("true", node.GetValue<string>());
// Verify it round-trips correctly through AspireConfigFile.Load
var config = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName);
Assert.NotNull(config);
Assert.Equal("true", config.Channel);
}
[Fact]
public async Task SetConfigurationAsync_WritesStringValueAsString()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (service, settingsFilePath) = CreateService(workspace, "{}");
await service.SetConfigurationAsync("channel", "daily", isGlobal: false);
var json = JsonNode.Parse(File.ReadAllText(settingsFilePath));
var node = json!["channel"];
Assert.Equal(JsonValueKind.String, node!.GetValueKind());
Assert.Equal("daily", node.GetValue<string>());
}
}