|
// 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.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Aspire.TestUtilities;
using Hex1b;
using Hex1b.Automation;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
/// <summary>
/// End-to-end tests verifying configuration migration from legacy CLI config formats
/// (globalsettings.json, .aspire/settings.json) to the new unified aspire.config.json format.
/// These tests simulate the upgrade path users experience when updating from older CLI versions.
/// </summary>
/// <remarks>
/// Each test bind-mounts a host-side directory as <c>/root/.aspire/</c> in the container,
/// enabling direct host-side file creation and verification.
/// </remarks>
public sealed class ConfigMigrationTests(ITestOutputHelper output)
{
/// <summary>
/// Pin to the last CLI version before the aspire.config.json consolidation.
/// When 13.2 ships with the config change, this version ensures the upgrade
/// test exercises the real legacy-to-new migration path.
/// </summary>
private const string LegacyCliVersion = "13.1.0";
/// <summary>
/// Creates a Docker test terminal with a bind-mounted <c>~/.aspire/</c> directory
/// for host-side file creation and verification.
/// </summary>
/// <returns>
/// A tuple containing the host-side path to the mounted .aspire directory
/// and the configured terminal.
/// </returns>
private (string AspireHomeDir, Hex1bTerminal Terminal) CreateMigrationTerminal(
string repoRoot,
CliE2ETestHelpers.DockerInstallMode installMode,
TemporaryWorkspace workspace,
[System.Runtime.CompilerServices.CallerMemberName] string testName = "")
{
// Create a host-side directory that will be bind-mounted as /root/.aspire/ in the container.
var aspireHomeDir = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-home");
Directory.CreateDirectory(aspireHomeDir);
var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
repoRoot, installMode, output,
workspace: workspace,
additionalVolumes: [$"{aspireHomeDir}:/root/.aspire"],
testName: testName);
return (aspireHomeDir, terminal);
}
/// <summary>
/// Throws if the file at <paramref name="filePath"/> does not contain all
/// <paramref name="expectedStrings"/>.
/// </summary>
private static void AssertFileContains(string filePath, params string[] expectedStrings)
{
if (!File.Exists(filePath))
{
throw new InvalidOperationException($"Expected file does not exist: {filePath}");
}
var content = File.ReadAllText(filePath);
foreach (var expected in expectedStrings)
{
if (!content.Contains(expected))
{
throw new InvalidOperationException(
$"File {filePath} does not contain '{expected}'. Actual content:\n{content}");
}
}
}
/// <summary>
/// Throws if the file at <paramref name="filePath"/> contains any of the
/// <paramref name="unexpectedStrings"/>.
/// </summary>
private static void AssertFileDoesNotContain(string filePath, params string[] unexpectedStrings)
{
if (!File.Exists(filePath))
{
return;
}
var content = File.ReadAllText(filePath);
foreach (var unexpected in unexpectedStrings)
{
if (content.Contains(unexpected))
{
throw new InvalidOperationException(
$"File {filePath} unexpectedly contains '{unexpected}'. Actual content:\n{content}");
}
}
}
/// <summary>
/// Verifies that a legacy ~/.aspire/globalsettings.json is automatically migrated to
/// ~/.aspire/aspire.config.json when a CLI command is run, and that the legacy file
/// is preserved for backward compatibility with older CLI versions.
/// </summary>
[Fact]
public async Task GlobalSettings_MigratedFromLegacyFormat()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);
var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace);
using var _ = terminal;
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Pre-populate legacy globalsettings.json on the host (visible in container via bind mount).
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
File.WriteAllText(legacyPath,
"""{"channel":"staging","features":{"polyglotSupportEnabled":true},"sdkVersion":"9.1.0"}""");
// Ensure no aspire.config.json exists yet.
if (File.Exists(newConfigPath))
{
File.Delete(newConfigPath);
}
// Run any CLI command to trigger global migration in Program.cs.
await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Verify aspire.config.json was created with migrated values (host-side).
AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
// Verify the legacy file was preserved (intentional for backward compat).
AssertFileContains(legacyPath, "channel");
// Verify migrated values are accessible via aspire config get.
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get channel");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
await auto.TypeAsync("aspire config delete channel -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
/// <summary>
/// Verifies that global migration does NOT overwrite an existing aspire.config.json.
/// If the user already has the new format, legacy globalsettings.json should be ignored.
/// </summary>
[Fact]
public async Task GlobalMigration_SkipsWhenNewConfigExists()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);
var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace);
using var _ = terminal;
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Pre-populate BOTH files on the host: aspire.config.json with "preview",
// globalsettings.json with "staging".
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
File.WriteAllText(newConfigPath,
"""{"channel":"preview"}""");
File.WriteAllText(legacyPath,
"""{"channel":"staging","features":{"polyglotSupportEnabled":true}}""");
// Run CLI. Migration should be skipped because aspire.config.json already exists.
await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Verify aspire.config.json still has "preview" (NOT overwritten with "staging").
AssertFileContains(newConfigPath, "preview");
AssertFileDoesNotContain(newConfigPath, "staging");
// Cleanup.
await auto.TypeAsync("aspire config delete channel -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
/// <summary>
/// Verifies that the CLI gracefully handles malformed JSON in legacy globalsettings.json
/// without crashing, and that subsequent config operations still work.
/// </summary>
[Fact]
public async Task GlobalMigration_HandlesMalformedLegacyJson()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);
var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace);
using var _ = terminal;
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Write malformed JSON to the legacy file.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
File.WriteAllText(
Path.Combine(aspireHomeDir, "globalsettings.json"),
"this is not valid json {{{");
if (File.Exists(newConfigPath))
{
File.Delete(newConfigPath);
}
// Run CLI. Should not crash despite malformed legacy file.
await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Verify CLI still works by setting and reading a value.
await auto.TypeAsync("aspire config set channel stable -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get channel");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("stable", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
await auto.TypeAsync("aspire config delete channel -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
/// <summary>
/// Verifies that legacy globalsettings.json containing JSON comments and trailing commas
/// (common when hand-edited) is correctly parsed and migrated to aspire.config.json.
/// </summary>
[Fact]
public async Task GlobalMigration_HandlesCommentsAndTrailingCommas()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);
var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace);
using var _ = terminal;
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Write legacy JSON with comments and trailing commas.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
File.WriteAllText(legacyPath,
"""
{
// User-added comment
"channel": "staging",
"features": {
"polyglotSupportEnabled": true,
}
}
""");
if (File.Exists(newConfigPath))
{
File.Delete(newConfigPath);
}
// Run CLI to trigger migration.
await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Verify migration succeeded despite comments/trailing commas (host-side).
AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
// Verify value accessible via config get.
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get channel");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
await auto.TypeAsync("aspire config delete channel -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
/// <summary>
/// Verifies that aspire config set writes nested JSON to aspire.config.json
/// (the new format) and that aspire config get correctly reads from this structure.
/// Also confirms globalsettings.json is NOT created when using the new CLI.
/// </summary>
[Fact]
public async Task ConfigSetGet_CreatesNestedJsonFormat()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);
var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace);
using var _ = terminal;
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Ensure clean state.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
foreach (var f in new[] { newConfigPath, legacyPath })
{
if (File.Exists(f))
{
File.Delete(f);
}
}
// Set nested config values.
await auto.TypeAsync("aspire config set channel preview -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config set features.polyglotSupportEnabled true -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config set features.stagingChannelEnabled false -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Verify the file has nested JSON structure (host-side).
AssertFileContains(newConfigPath, "features", "polyglotSupportEnabled", "preview");
// Verify values are readable via aspire config get.
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get channel");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("preview", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get features.polyglotSupportEnabled");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("true", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Verify globalsettings.json was NOT created.
if (File.Exists(legacyPath))
{
throw new InvalidOperationException(
"globalsettings.json should not be created by the new CLI");
}
// Cleanup.
await auto.TypeAsync("aspire config delete channel -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete features.stagingChannelEnabled -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
/// <summary>
/// Verifies that migration from globalsettings.json preserves all supported
/// value types: channel (string), features (dictionary of bools), and packages
/// (dictionary of strings).
/// </summary>
[Fact]
public async Task GlobalMigration_PreservesAllValueTypes()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);
var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace);
using var _ = terminal;
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a comprehensive legacy globalsettings.json with all value types.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
File.WriteAllText(legacyPath,
"""
{
"channel": "preview",
"sdkVersion": "9.1.0",
"features": {
"polyglotSupportEnabled": true,
"stagingChannelEnabled": false
},
"packages": {
"Aspire.Hosting.Redis": "9.1.0"
}
}
""");
if (File.Exists(newConfigPath))
{
File.Delete(newConfigPath);
}
// Trigger migration.
await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Verify all value types were migrated (host-side).
AssertFileContains(newConfigPath,
"preview",
"polyglotSupportEnabled",
"stagingChannelEnabled",
"Aspire.Hosting.Redis");
// Verify individual value via config get.
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get channel");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("preview", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
await auto.TypeAsync("aspire config delete channel -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete features.stagingChannelEnabled -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete packages -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
/// <summary>
/// Full end-to-end upgrade test: installs a released (legacy) CLI version that
/// predates the aspire.config.json consolidation, sets global config values using the
/// old format, then upgrades to the new CLI and verifies all settings are migrated.
/// </summary>
[Fact]
[OuterloopTest("Requires downloading two separate CLI versions from GitHub")]
public async Task FullUpgrade_LegacyCliToNewCli_MigratesGlobalSettings()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);
var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace);
using var _ = terminal;
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
// Step 1: Install a released CLI that uses the legacy config format.
await auto.InstallAspireCliVersionAsync(LegacyCliVersion, counter);
await auto.SourceAspireCliEnvironmentAsync(counter);
// Verify the legacy CLI is installed.
await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Step 2: Set global values using the legacy CLI.
// In versions before 13.2, this writes to ~/.aspire/globalsettings.json.
await auto.TypeAsync("aspire config set channel staging -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config set features.polyglotSupportEnabled true -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Verify values were persisted by the legacy CLI.
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get channel");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Snapshot which files exist after using the legacy CLI (for debugging).
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("ls -la ~/.aspire/");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Step 3: Install the new CLI (from this PR), overwriting the legacy CLI.
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 4: Run the new CLI to trigger global migration.
await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Verify aspire.config.json exists with migrated values (host-side).
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
// Step 6: Verify values are still accessible via the new CLI.
await auto.ClearScreenAsync(counter);
await auto.TypeAsync("aspire config get channel");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
await auto.TypeAsync("aspire config delete channel -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
}
|