// 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));
}
[Fact]
public async Task CreateProjectFiles_InvalidatesScaffold_WhenIntegrationHostEntryPointChanges()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appPath = workspace.WorkspaceRoot.FullName;
var project = CreateProject(appPath);
var firstHost = Path.Combine(appPath, "first", "host.ts");
var secondHost = Path.Combine(appPath, "second", "host.ts");
await project.CreateProjectFilesAsync([IntegrationReference.FromNpm("@test/integration", firstHost)]);
var assetsPath = SeedRestoreArtifacts(project);
var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json");
var firstSettings = await File.ReadAllTextAsync(appSettingsPath);
var timestamp = File.GetLastWriteTimeUtc(appSettingsPath);
await project.CreateProjectFilesAsync([IntegrationReference.FromNpm("@test/integration", firstHost)]);
Assert.True(File.Exists(assetsPath));
Assert.Equal(timestamp, File.GetLastWriteTimeUtc(appSettingsPath));
Assert.Equal(firstSettings, await File.ReadAllTextAsync(appSettingsPath));
await project.CreateProjectFilesAsync([IntegrationReference.FromNpm("@test/integration", secondHost)]);
Assert.False(File.Exists(assetsPath));
using var settings = System.Text.Json.JsonDocument.Parse(await File.ReadAllTextAsync(appSettingsPath));
Assert.True(settings.RootElement.GetProperty("ASPIRE_INTEGRATION_HOSTS_ENABLED").GetBoolean());
var host = Assert.Single(settings.RootElement.GetProperty("IntegrationHosts").EnumerateArray());
Assert.Equal("@test/integration", host.GetProperty("PackageName").GetString());
Assert.Equal(secondHost, host.GetProperty("HostEntryPoint").GetString());
}
/// <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);
}
}