File: Projects\DotNetBasedAppHostServerScaffoldCacheTests.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 Aspire.Cli.Configuration;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Projects;
 
/// <summary>
/// Guards the scaffold fingerprint cache on <see cref="DotNetBasedAppHostServerProject"/>.
/// Scaffolding used to delete <c>obj/</c> on every run, which discarded NuGet's restore output and
/// forced a full restore plus a full project-reference graph walk on every in-repo
/// <c>aspire run</c>. The scaffold is now rewritten only when its generated content actually
/// changes. These tests pin that contract in both directions.
/// </summary>
public class DotNetBasedAppHostServerScaffoldCacheTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task CreateProjectFiles_PreservesRestoreArtifacts_WhenScaffoldContentUnchanged()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appPath = workspace.WorkspaceRoot.FullName;
        var project = CreateProject(appPath);
 
        IntegrationReference[] integrations = [IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")];
 
        await project.CreateProjectFilesAsync(integrations);
        var assetsPath = SeedRestoreArtifacts(project);
 
        await project.CreateProjectFilesAsync(integrations);
 
        Assert.True(File.Exists(assetsPath),
            "obj/project.assets.json was deleted even though the scaffold content did not change.");
    }
 
    [Fact]
    public async Task CreateProjectFiles_ClearsRestoreArtifacts_WhenIntegrationSetChanges()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appPath = workspace.WorkspaceRoot.FullName;
        var project = CreateProject(appPath);
 
        await project.CreateProjectFilesAsync([IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")]);
        var assetsPath = SeedRestoreArtifacts(project);
 
        // A different integration set produces a different csproj, so the previous restore no
        // longer describes this project and must not be reused. Aspire.Hosting.* names are
        // resolved as in-repo project references and dropped when missing, so use a package that
        // actually lands in the csproj as a PackageReference.
        await project.CreateProjectFilesAsync(
        [
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"),
            IntegrationReference.FromPackage("Contoso.Widgets", "1.0.0"),
        ]);
 
        Assert.False(File.Exists(assetsPath),
            "obj/project.assets.json survived even though the integration set changed.");
 
        var csproj = await File.ReadAllTextAsync(
            Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName));
        Assert.Contains("Contoso.Widgets", csproj);
    }
 
    [Fact]
    public async Task CreateProjectFiles_RewritesScaffold_WhenAScaffoldFileWasDeleted()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appPath = workspace.WorkspaceRoot.FullName;
        var project = CreateProject(appPath);
 
        IntegrationReference[] integrations = [IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")];
 
        await project.CreateProjectFilesAsync(integrations);
        SeedRestoreArtifacts(project);
 
        // Simulate a hand-deleted or half-written scaffold. The fingerprint still matches, so only
        // the existence guard can catch this.
        var programPath = Path.Combine(project.ProjectModelPath, "Program.cs");
        File.Delete(programPath);
 
        await project.CreateProjectFilesAsync(integrations);
 
        Assert.True(File.Exists(programPath), "Program.cs was not restored after being deleted.");
    }
 
    [Fact]
    public async Task CreateProjectFiles_RewritesScaffold_WhenRestoreArtifactsAreAbsent()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appPath = workspace.WorkspaceRoot.FullName;
        var project = CreateProject(appPath);
 
        IntegrationReference[] integrations = [IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")];
 
        await project.CreateProjectFilesAsync(integrations);
 
        // A half-restored obj/ with no project.assets.json is what an interrupted restore leaves
        // behind. The fingerprint matches, so only the assets check can force the clean rebuild.
        var objPath = Path.Combine(project.ProjectModelPath, "obj");
        Directory.CreateDirectory(objPath);
        var stalePath = Path.Combine(objPath, "stale.cache");
        File.WriteAllText(stalePath, "partial restore");
 
        await project.CreateProjectFilesAsync(integrations);
 
        Assert.False(File.Exists(stalePath),
            "obj/ was reused even though no project.assets.json was present to restore from.");
    }
 
    [Fact]
    public async Task CreateProjectFiles_ClearsRestoreArtifacts_WhenProjectReferencePathChanges()
    {
        // Pointing an integration at a local checkout is the in-repo dev-speed path: edits to that
        // project flow straight into the next build without a repack. The cache must not pin an
        // earlier target, so retargeting the reference has to rewrite the scaffold.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appPath = workspace.WorkspaceRoot.FullName;
        var project = CreateProject(appPath);
 
        var firstProject = Path.Combine(appPath, "v1", "Contoso.Widgets.csproj");
        var secondProject = Path.Combine(appPath, "v2", "Contoso.Widgets.csproj");
 
        await project.CreateProjectFilesAsync(
            [IntegrationReference.FromProject("Contoso.Widgets", firstProject)]);
        var assetsPath = SeedRestoreArtifacts(project);
 
        var csprojPath = Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName);
        Assert.Contains(firstProject, await File.ReadAllTextAsync(csprojPath));
 
        await project.CreateProjectFilesAsync(
            [IntegrationReference.FromProject("Contoso.Widgets", secondProject)]);
 
        Assert.False(File.Exists(assetsPath),
            "obj/project.assets.json survived even though the project reference was retargeted.");
        Assert.Contains(secondProject, await File.ReadAllTextAsync(csprojPath));
    }
 
    /// <summary>
    /// Writes the marker file the cache uses to decide whether a usable restore already exists.
    /// Real restores create far more under obj/, but project.assets.json is the file the skip path
    /// checks and the one whose loss forces a full restore.
    /// </summary>
    private static string SeedRestoreArtifacts(DotNetBasedAppHostServerProject project)
    {
        var objPath = Path.Combine(project.ProjectModelPath, "obj");
        Directory.CreateDirectory(objPath);
        var assetsPath = Path.Combine(objPath, "project.assets.json");
        File.WriteAllText(assetsPath, "{}");
        return assetsPath;
    }
 
    private static DotNetBasedAppHostServerProject CreateProject(string appPath)
    {
        // Pin ProjectModelPath inside the workspace so test artifacts don't bleed into the user's
        // ~/.aspire/hosts directory.
        var projectModelPath = Path.Combine(appPath, ".aspire_server");
 
        return new DotNetBasedAppHostServerProject(
            appPath,
            socketPath: "test.sock",
            repoRoot: appPath,
            new TestDotNetCliRunner(),
            MockPackagingServiceFactory.Create(),
            new TestProcessExecutionFactory(),
            new TestEnvironment(),
            NullLogger<DotNetBasedAppHostServerProject>.Instance,
            projectModelPath);
    }
}