File: Commands\GoAppHostToolingCheckTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ComponentModel;
using System.Text.RegularExpressions;
using Aspire.Cli.DotNet;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils.EnvironmentChecker;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Tests.Commands;
 
public sealed class GoAppHostToolingCheckTests(ITestOutputHelper outputHelper)
{
    private const string GoInstallLink = "https://go.dev/doc/install";
 
    private static readonly LanguageInfo s_goLanguage = new(
        LanguageId: new LanguageId(KnownLanguageId.Go),
        DisplayName: KnownLanguageId.GoDisplayName,
        PackageName: "Aspire.Hosting.CodeGeneration.Go",
        DetectionPatterns: ["apphost.go"],
        CodeGenerator: "Go",
        AppHostFileName: "apphost.go");
 
    [Theory]
    [InlineData("go version go1.26.1 windows/amd64", "1.26.1")]
    [InlineData("go version go1.27.0 darwin/arm64", "1.27.0")]
    [InlineData("go version go1.26rc1 linux/amd64", "1.26")]
    [InlineData("go version devel go1.27-4d2f8e1a5b Tue Jan 6 10:00:00 2026 +0000 linux/amd64", "1.27")]
    public async Task CheckAsync_ReturnsPass_WhenGoMeetsMinimumVersion(string versionOutput, string expectedVersion)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        string? versionResolverPath = null;
 
        var check = CreateCheck(
            workspace,
            appHostFile,
            commandResolver: command => $"/usr/bin/{command}",
            versionResolver: (path, _) =>
            {
                versionResolverPath = path;
                return Task.FromResult<string?>(versionOutput);
            });
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        Assert.Equal(EnvironmentCheckStatus.Pass, result.Status);
        Assert.Equal(GoAppHostToolingCheck.ToolsCheckName, result.Name);
        Assert.Equal(EnvironmentCheckCategories.Environment, result.Category);
        Assert.Equal($"Go AppHost tooling found (go {expectedVersion}).", result.Message);
        Assert.Equal("/usr/bin/go", versionResolverPath);
        Assert.NotNull(result.Metadata);
        Assert.Equal(KnownLanguageId.Go, result.Metadata["language"]?.GetValue<string>());
        Assert.Equal(expectedVersion, result.Metadata["version"]?.GetValue<string>());
        Assert.Equal(appHostFile.FullName, result.Metadata["appHostPath"]?.GetValue<string>());
    }
 
    [Fact]
    public async Task CheckAsync_ReturnsPass_WhenGoAppHostIsDiscoveredInWorkingDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
 
        var check = CreateCheck(workspace, appHostFile: null);
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        Assert.Equal(EnvironmentCheckStatus.Pass, result.Status);
        Assert.NotNull(result.Metadata);
        Assert.Equal(appHostFile.FullName, result.Metadata["appHostPath"]?.GetValue<string>());
    }
 
    [Fact]
    public async Task CheckAsync_Skips_WhenNoGoAppHostExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var check = CreateCheck(workspace, appHostFile: null, commandResolver: _ => null);
 
        var results = await check.CheckAsync().DefaultTimeout();
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task CheckAsync_Skips_WhenAppHostIsNotGo()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
        File.WriteAllText(appHostPath, "// apphost");
 
        var check = CreateCheck(workspace, new FileInfo(appHostPath), commandResolver: _ => null);
 
        var results = await check.CheckAsync().DefaultTimeout();
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task CheckAsync_Skips_WhenConfiguredAppHostIsNotGoAndGoAppHostIsDiscoverable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        CreateGoAppHost(workspace);
 
        // Settings select a TypeScript AppHost outside the working directory, as in `../ts/apphost.mts`.
        var configuredAppHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "..", "ts", "apphost.mts"));
        var check = CreateCheck(
            workspace,
            configuredAppHost,
            commandResolver: _ => null,
            languageDiscovery: CreateLanguageDiscovery(goEnabled: true));
 
        var results = await check.CheckAsync().DefaultTimeout();
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task CheckAsync_ReturnsFail_WhenGoAppHostSitsBesideCSharpProjectAndGoIsMissing()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHostBesideCSharpProject(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile: null,
            commandResolver: _ => null,
            languageDiscovery: CreateLanguageDiscovery(goEnabled: true));
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        Assert.Equal(EnvironmentCheckStatus.Fail, result.Status);
        Assert.Equal(GoAppHostToolingCheck.GoCommandCheckName, result.Name);
        Assert.NotNull(result.Metadata);
        Assert.Equal(appHostFile.FullName, result.Metadata["appHostPath"]?.GetValue<string>());
    }
 
    [Fact]
    public async Task CheckAsync_Skips_WhenGoFeatureIsDisabled()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        CreateGoAppHostBesideCSharpProject(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile: null,
            commandResolver: _ => null,
            languageDiscovery: CreateLanguageDiscovery(goEnabled: false));
 
        var results = await check.CheckAsync().DefaultTimeout();
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task CheckAsync_ReturnsFail_WhenGoIsMissing()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(workspace, appHostFile, commandResolver: _ => null);
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        Assert.Equal(EnvironmentCheckStatus.Fail, result.Status);
        Assert.Equal(GoAppHostToolingCheck.GoCommandCheckName, result.Name);
        Assert.Equal(EnvironmentCheckCategories.Environment, result.Category);
        Assert.Equal("Go AppHost requires 'go'.", result.Message);
        Assert.Equal("go is not installed or not found in PATH. Please install Go and try again.", result.Details);
        Assert.Equal("Install Go and rerun 'aspire doctor'.", result.Fix);
        Assert.Equal(GoInstallLink, result.Link);
        Assert.NotNull(result.Metadata);
        Assert.Equal(KnownLanguageId.Go, result.Metadata["language"]?.GetValue<string>());
    }
 
    [Fact]
    public async Task CheckAsync_ReturnsWarning_WhenGoIsOlderAndCanDownloadToolchain()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            toolchainResolver: ReturnsToolchain("auto"));
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertToolchainDownloadAllowed(result);
        Assert.NotNull(result.Metadata);
        Assert.Equal("1.24.1", result.Metadata["version"]?.GetValue<string>());
    }
 
    [Theory]
    [InlineData("auto")]
    [InlineData("go1.25.0+auto")]
    [InlineData("go1.24.1")] // A pinned toolchain is not modelled; the check validates the Go on PATH.
    public async Task CheckAsync_ReturnsWarning_WhenEnvironmentToolchainAllowsDownload(string toolchain)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            environment: new Dictionary<string, string?> { ["GOTOOLCHAIN"] = toolchain });
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertToolchainDownloadAllowed(result);
    }
 
    [Fact]
    public async Task CheckAsync_ReturnsFail_WhenEnvironmentToolchainIsLocal()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            environment: new Dictionary<string, string?> { ["GOTOOLCHAIN"] = "local" });
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertToolchainIsLocal(result);
    }
 
    // The path modes only fail when the toolchain that go.mod names is missing from PATH, and doctor does
    // not read go.mod, so they are inconclusive.
    [Theory]
    [InlineData("path")]
    [InlineData("local+path")]
    [InlineData("go1.25.0+path")]
    public async Task CheckAsync_ReturnsWarning_WhenEnvironmentToolchainOnlySwitchesToPathToolchain(string toolchain)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            environment: new Dictionary<string, string?> { ["GOTOOLCHAIN"] = toolchain });
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertToolchainOnlySwitchesToPathToolchain(result, toolchain);
    }
 
    // Without the environment variable, Go reads GOTOOLCHAIN from the user config written by `go env -w`
    // and then from $GOROOT/go.env, where Fedora's Go package sets `local`.
    [Theory]
    [InlineData(null, "local")]
    [InlineData("", "local")]
    [InlineData(null, "local\r\n")]
    public async Task CheckAsync_ReturnsFail_WhenGoEnvToolchainIsLocal(string? environmentValue, string probeOutput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            environment: new Dictionary<string, string?> { ["GOTOOLCHAIN"] = environmentValue },
            toolchainResolver: ReturnsToolchain(probeOutput));
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertToolchainIsLocal(result);
    }
 
    [Theory]
    [InlineData("path")]
    [InlineData("local+path")]
    [InlineData("go1.25.0+path")]
    public async Task CheckAsync_ReturnsWarning_WhenGoEnvToolchainOnlySwitchesToPathToolchain(string probeOutput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            toolchainResolver: ReturnsToolchain(probeOutput));
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertToolchainOnlySwitchesToPathToolchain(result, probeOutput);
    }
 
    [Theory]
    [InlineData("auto")]
    [InlineData("go1.25.0+auto")]
    [InlineData("")]
    [InlineData(null)] // The probe failed or timed out.
    public async Task CheckAsync_ReturnsWarning_WhenGoEnvToolchainAllowsDownload(string? probeOutput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            toolchainResolver: ReturnsToolchain(probeOutput));
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertToolchainDownloadAllowed(result);
    }
 
    [Fact]
    public async Task CheckAsync_ProbesToolchainInEmptyDirectoryAndDeletesIt()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        string? probedGoPath = null;
        string? probeDirectory = null;
        var probeDirectoryWasEmpty = false;
 
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.24.1 windows/amd64"),
            toolchainResolver: (goPath, workingDirectory, _) =>
            {
                probedGoPath = goPath;
                probeDirectory = workingDirectory;
                probeDirectoryWasEmpty = Directory.Exists(workingDirectory) && !Directory.EnumerateFileSystemEntries(workingDirectory).Any();
                return Task.FromResult<string?>("auto");
            });
 
        await check.CheckAsync().DefaultTimeout();
 
        // The AppHost directory holds apphost.go, so an empty probe directory cannot be it.
        Assert.Equal("/usr/bin/go", probedGoPath);
        Assert.True(probeDirectoryWasEmpty);
        Assert.False(Directory.Exists(probeDirectory));
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("local")]
    public async Task CheckAsync_ReturnsFail_WhenGoPredatesToolchainSwitching(string? toolchain)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(
            workspace,
            appHostFile,
            versionResolver: ReturnsVersion("go version go1.20.14 linux/amd64"),
            environment: new Dictionary<string, string?> { ["GOTOOLCHAIN"] = toolchain });
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertVersionResult(result, EnvironmentCheckStatus.Fail);
        Assert.Equal("Go 1.20.14 is installed, and Go versions before 1.21 cannot download a newer toolchain.", result.Details);
        Assert.Equal("Install Go 1.26 or later and rerun 'aspire doctor'.", result.Fix);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("go: command output without a version")]
    public async Task CheckAsync_ReturnsWarning_WhenGoVersionCannotBeDetermined(string? versionOutput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateGoAppHost(workspace);
        var check = CreateCheck(workspace, appHostFile, versionResolver: ReturnsVersion(versionOutput));
 
        var result = Assert.Single(await check.CheckAsync().DefaultTimeout());
 
        AssertVersionResult(result, EnvironmentCheckStatus.Warning);
        Assert.Equal("The installed Go version could not be determined.", result.Details);
        Assert.Equal("Check that 'go version' prints a version and rerun 'aspire doctor'.", result.Fix);
        Assert.NotNull(result.Metadata);
        Assert.Null(result.Metadata["version"]);
    }
 
    [Theory]
    [InlineData("go version go1.26.1 windows/amd64", "1.26.1")]
    [InlineData("go version go1.26 linux/amd64", "1.26")]
    [InlineData("go version go1.26rc1 linux/amd64", "1.26")]
    [InlineData("go version go1.27beta2 darwin/arm64", "1.27")]
    [InlineData("go version devel go1.27-4d2f8e1a5b Tue Jan 6 10:00:00 2026 +0000 linux/amd64", "1.27")]
    [InlineData("go version go1.21.13 X:boringcrypto linux/amd64\n", "1.21.13")]
    public void TryParseGoVersion_ParsesVersionOutput(string output, string expectedVersion)
    {
        Assert.True(GoAppHostToolingCheck.TryParseGoVersion(output, out var version));
        Assert.Equal(expectedVersion, version.ToString());
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("go version")]
    [InlineData("go version go1 linux/amd64")]
    [InlineData("'go' is not recognized as an internal or external command")]
    public void TryParseGoVersion_ReturnsFalse_WhenOutputHasNoVersion(string? output)
    {
        Assert.False(GoAppHostToolingCheck.TryParseGoVersion(output, out var version));
        Assert.Null(version);
    }
 
    [Fact]
    public void CreateVersionProbeStartInfo_PinsToolchainToLocal()
    {
        var startInfo = GoAppHostToolingCheck.CreateVersionProbeStartInfo("/usr/bin/go");
 
        Assert.Equal("/usr/bin/go", startInfo.FileName);
        Assert.Equal(["version"], startInfo.ArgumentList);
        Assert.Equal("local", startInfo.Environment["GOTOOLCHAIN"]);
    }
 
    [Fact]
    public void CreateToolchainProbeStartInfo_DoesNotOverrideToolchain()
    {
        var startInfo = GoAppHostToolingCheck.CreateToolchainProbeStartInfo("/usr/bin/go", "/tmp/aspire-go-doctor");
 
        Assert.Equal("/usr/bin/go", startInfo.FileName);
        Assert.Equal(["env", "GOTOOLCHAIN"], startInfo.ArgumentList);
        Assert.Equal("/tmp/aspire-go-doctor", startInfo.WorkingDirectory);
 
        // The probe reports the effective setting, so the child must inherit the caller's value untouched.
        startInfo.Environment.TryGetValue("GOTOOLCHAIN", out var toolchain);
        Assert.Equal(Environment.GetEnvironmentVariable("GOTOOLCHAIN"), toolchain);
    }
 
    [Fact]
    public async Task RunProbeAsync_ReturnsStandardOutput_WhenGoSucceeds()
    {
        var processExecutionFactory = new TestProcessExecutionFactory
        {
            AttemptCallback = (_, options) =>
            {
                // Go reports notices such as a toolchain download on standard error; they must not reach the parsed answer.
                options.StandardErrorCallback?.Invoke("go: downloading go1.26.1");
                return (0, "go version go1.26.1 windows/amd64");
            }
        };
 
        var output = await RunVersionProbeAsync(processExecutionFactory, new CapturingLogger<GoAppHostToolingCheck>());
 
        Assert.Equal($"go version go1.26.1 windows/amd64{Environment.NewLine}", output);
        var execution = Assert.IsType<TestProcessExecution>(Assert.Single(processExecutionFactory.CreatedExecutions));
        Assert.Equal("/usr/bin/go", execution.FileName);
        Assert.Equal(["version"], execution.Arguments);
        Assert.Equal("local", execution.EnvironmentVariables["GOTOOLCHAIN"]);
        Assert.Equal(1, execution.DisposeCount);
    }
 
    [Fact]
    public async Task RunProbeAsync_ReturnsNullAndLogsStandardError_WhenGoFails()
    {
        var processExecutionFactory = new TestProcessExecutionFactory
        {
            AttemptCallback = (_, options) =>
            {
                options.StandardErrorCallback?.Invoke("go: cannot find main module");
                return (2, null);
            }
        };
        var logger = new CapturingLogger<GoAppHostToolingCheck>();
 
        var output = await RunVersionProbeAsync(processExecutionFactory, logger);
 
        Assert.Null(output);
        var entry = Assert.Single(logger.Entries);
        Assert.Equal(LogLevel.Debug, entry.Level);
        Assert.Equal("'go version' failed with exit code 2: go: cannot find main module", entry.Message);
    }
 
    [Fact]
    public async Task RunProbeAsync_ReturnsNull_WhenGoDoesNotStart()
    {
        var processExecutionFactory = CreateProbeFactory(startReturnValue: false);
 
        var output = await RunVersionProbeAsync(processExecutionFactory, new CapturingLogger<GoAppHostToolingCheck>());
 
        Assert.Null(output);
    }
 
    [Theory]
    [InlineData(typeof(Win32Exception))]
    [InlineData(typeof(InvalidOperationException))]
    [InlineData(typeof(IOException))]
    public async Task RunProbeAsync_ReturnsNullAndLogs_WhenGoCannotBeStarted(Type exceptionType)
    {
        var exception = (Exception)Activator.CreateInstance(exceptionType, "go could not be started")!;
        var processExecutionFactory = CreateProbeFactory(startException: exception);
        var logger = new CapturingLogger<GoAppHostToolingCheck>();
 
        var output = await RunVersionProbeAsync(processExecutionFactory, logger);
 
        Assert.Null(output);
        var entry = Assert.Single(logger.Entries);
        Assert.Equal(LogLevel.Debug, entry.Level);
        Assert.Equal("Could not start 'go version' using '/usr/bin/go'.", entry.Message);
    }
 
    [Fact]
    public async Task RunProbeAsync_ReturnsNullAndLogs_WhenGoOutlastsTimeout()
    {
        var processExecutionFactory = CreateProbeFactory(waitForExitAsyncCallback: async (_, cancellationToken) =>
        {
            await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
            return 0;
        });
        var logger = new CapturingLogger<GoAppHostToolingCheck>();
 
        var output = await RunVersionProbeAsync(processExecutionFactory, logger, timeout: TimeSpan.FromMilliseconds(100));
 
        Assert.Null(output);
        var entry = Assert.Single(logger.Entries);
        Assert.Equal(LogLevel.Debug, entry.Level);
        Assert.Equal("'go version' timed out after 0.1s.", entry.Message);
        var execution = Assert.IsType<TestProcessExecution>(Assert.Single(processExecutionFactory.CreatedExecutions));
        Assert.Equal(1, execution.DisposeCount);
    }
 
    [Fact]
    public async Task RunProbeAsync_PropagatesCancellation_WhenCallerCancels()
    {
        using var cts = new CancellationTokenSource();
        var processExecutionFactory = CreateProbeFactory(waitForExitAsyncCallback: async (_, cancellationToken) =>
        {
            await cts.CancelAsync();
            await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
            return 0;
        });
        var logger = new CapturingLogger<GoAppHostToolingCheck>();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => RunVersionProbeAsync(processExecutionFactory, logger, cancellationToken: cts.Token));
 
        Assert.Empty(logger.Entries);
        var execution = Assert.IsType<TestProcessExecution>(Assert.Single(processExecutionFactory.CreatedExecutions));
        Assert.Equal(1, execution.DisposeCount);
    }
 
    [Theory]
    [InlineData("AtsGoCodeGenerator.cs")]
    [InlineData("GoLanguageSupport.cs")]
    public void MinimumGoVersion_MatchesGoModTemplate(string sourceFileName)
    {
        var sourcePath = Path.Combine(GetRepoRoot(), "src", "Aspire.Hosting.CodeGeneration.Go", sourceFileName);
        Assert.True(File.Exists(sourcePath), $"Expected the Go code generation source at {sourcePath}");
 
        // Both generators emit a go.mod whose directive sits on its own line: `go 1.26`.
        var directive = Regex.Match(File.ReadAllText(sourcePath), @"^\s*go (\d+\.\d+)\s*$", RegexOptions.Multiline);
 
        Assert.True(directive.Success, $"Expected a 'go <major>.<minor>' directive in the go.mod template of {sourceFileName}.");
        Assert.Equal(GoAppHostToolingCheck.MinimumGoVersion.ToString(), directive.Groups[1].Value);
    }
 
    private static FileInfo CreateGoAppHost(TemporaryWorkspace workspace)
    {
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.go");
        File.WriteAllText(appHostPath, "package main");
        return new FileInfo(appHostPath);
    }
 
    // DefaultLanguageDiscovery detects any *.csproj as C#, including an ordinary service project.
    private static FileInfo CreateGoAppHostBesideCSharpProject(TemporaryWorkspace workspace)
    {
        var serviceDirectory = workspace.WorkspaceRoot.CreateSubdirectory("api");
        File.WriteAllText(Path.Combine(serviceDirectory.FullName, "Api.csproj"), "<Project Sdk=\"Microsoft.NET.Sdk.Web\" />");
        return CreateGoAppHost(workspace);
    }
 
    private static DefaultLanguageDiscovery CreateLanguageDiscovery(bool goEnabled) =>
        new(new TestFeatures().SetFeature(KnownFeatures.ExperimentalPolyglotGo, goEnabled));
 
    private static GoAppHostToolingCheck CreateCheck(
        TemporaryWorkspace workspace,
        FileInfo? appHostFile,
        Func<string, string?>? commandResolver = null,
        Func<string, CancellationToken, Task<string?>>? versionResolver = null,
        IReadOnlyDictionary<string, string?>? environment = null,
        Func<string, string, CancellationToken, Task<string?>>? toolchainResolver = null,
        ILanguageDiscovery? languageDiscovery = null)
    {
        var projectLocator = new TestProjectLocator
        {
            GetAppHostFromSettingsAsyncCallback = _ => Task.FromResult(appHostFile)
        };
 
        return new GoAppHostToolingCheck(
            projectLocator,
            languageDiscovery ?? new TestLanguageDiscovery(s_goLanguage),
            CreateExecutionContext(workspace),
            new TestEnvironment(environment),
            commandResolver ?? (command => $"/usr/bin/{command}"),
            versionResolver ?? ReturnsVersion("go version go1.26.1 windows/amd64"),
            toolchainResolver ?? ThrowsIfToolchainIsProbed);
    }
 
    private static Task<string?> RunVersionProbeAsync(
        TestProcessExecutionFactory processExecutionFactory,
        CapturingLogger<GoAppHostToolingCheck> logger,
        TimeSpan? timeout = null,
        CancellationToken cancellationToken = default) =>
        GoAppHostToolingCheck.RunProbeAsync(
            processExecutionFactory,
            GoAppHostToolingCheck.CreateVersionProbeStartInfo("/usr/bin/go"),
            timeout ?? TimeSpan.FromMinutes(1),
            logger,
            cancellationToken).DefaultTimeout();
 
    // Scenarios that need to control how the fake process starts or waits; the default attempt callback is never used.
    private static TestProcessExecutionFactory CreateProbeFactory(
        bool startReturnValue = true,
        Exception? startException = null,
        Func<ProcessInvocationOptions, CancellationToken, Task<int>>? waitForExitAsyncCallback = null) =>
        new()
        {
            CreateExecutionWithFileNameCallback = (fileName, args, env, workingDirectory, options) =>
                new TestProcessExecution(fileName, args, env, options, (_, _, _) => Task.FromResult((0, (string?)null)), () => 1)
                {
                    StartReturnValue = startReturnValue,
                    StartException = startException,
                    WaitForExitAsyncCallback = waitForExitAsyncCallback
                }
        };
 
    private static Func<string, CancellationToken, Task<string?>> ReturnsVersion(string? versionOutput) =>
        (_, _) => Task.FromResult(versionOutput);
 
    private static Func<string, string, CancellationToken, Task<string?>> ReturnsToolchain(string? toolchainOutput) =>
        (_, _, _) => Task.FromResult(toolchainOutput);
 
    // Scenarios that never reach the `go env GOTOOLCHAIN` probe use this default so that an unexpected
    // child process fails the test.
    private static Task<string?> ThrowsIfToolchainIsProbed(string goPath, string workingDirectory, CancellationToken cancellationToken) =>
        throw new InvalidOperationException("The effective GOTOOLCHAIN must not be probed in this scenario.");
 
    private static void AssertToolchainDownloadAllowed(EnvironmentCheckResult result)
    {
        AssertVersionResult(result, EnvironmentCheckStatus.Warning);
        Assert.Equal("Go 1.24.1 is installed. Go downloads the Go 1.26 toolchain the first time the AppHost runs, which needs network access.", result.Details);
        Assert.Equal("Install Go 1.26 or later to avoid the download, then rerun 'aspire doctor'.", result.Fix);
    }
 
    private static void AssertToolchainIsLocal(EnvironmentCheckResult result)
    {
        AssertVersionResult(result, EnvironmentCheckStatus.Fail);
        Assert.Equal("Go 1.24.1 is installed, and GOTOOLCHAIN=local prevents Go from switching to a newer toolchain.", result.Details);
        Assert.Equal("Install Go 1.26 or later, or change GOTOOLCHAIN to auto, then rerun 'aspire doctor'.", result.Fix);
    }
 
    private static void AssertToolchainOnlySwitchesToPathToolchain(EnvironmentCheckResult result, string toolchain)
    {
        AssertVersionResult(result, EnvironmentCheckStatus.Warning);
        Assert.Equal($"Go 1.24.1 is installed, and GOTOOLCHAIN={toolchain} only lets Go switch to a newer toolchain that is already on PATH (for example go1.26.0).", result.Details);
        Assert.Equal("Install Go 1.26 or later, or put the go1.26.0 toolchain on PATH, then rerun 'aspire doctor'.", result.Fix);
    }
 
    private static void AssertVersionResult(EnvironmentCheckResult result, EnvironmentCheckStatus expectedStatus)
    {
        Assert.Equal(expectedStatus, result.Status);
        Assert.Equal(GoAppHostToolingCheck.VersionCheckName, result.Name);
        Assert.Equal(EnvironmentCheckCategories.Environment, result.Category);
        Assert.Equal("Go AppHost requires Go 1.26 or later.", result.Message);
        Assert.Equal(GoInstallLink, result.Link);
    }
 
    private static CliExecutionContext CreateExecutionContext(TemporaryWorkspace workspace) =>
        new(
            workingDirectory: workspace.WorkspaceRoot,
            hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-hives"),
            cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-cache"),
            sdksDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-sdks"),
            logsDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-logs"),
            logFilePath: "test.log",
            identityChannel: "local");
 
    private static string GetRepoRoot()
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "global.json")))
        {
            dir = dir.Parent;
        }
        Assert.NotNull(dir);
        return dir.FullName;
    }
}