File: DotnetProjectBuildArtifactManagerTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Dotnet.Tests\Aspire.Hosting.Dotnet.Tests.csproj (Aspire.Hosting.Dotnet.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Hosting.Dotnet.Tests;
 
public class DotnetProjectBuildArtifactManagerTests(ITestOutputHelper outputHelper)
{
    private static readonly DateTimeOffset s_startTime = new(2026, 8, 26, 0, 0, 0, TimeSpan.Zero);
 
    [Fact]
    public async Task InactiveBuildProjectIsPrunedOnlyAfterGracePeriod()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var timeProvider = new FakeTimeProvider(s_startTime);
        var buildDirectory = Path.Combine(workspace.Path, ".aspire", "build");
        var inactiveHash = "aaaaaaaaaaaa";
        string inactiveBuildProjectPath;
 
        using (var firstManager = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider))
        {
            inactiveBuildProjectPath = await PublishAsync(firstManager, inactiveHash);
        }
 
        using var sweeper = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider);
        await PublishAsync(sweeper, "bbbbbbbbbbbb");
        Assert.True(File.Exists(inactiveBuildProjectPath));
 
        timeProvider.Advance(DotnetProjectBuildArtifactManager.InactiveRetentionPeriod - TimeSpan.FromMinutes(1));
        await PublishAsync(sweeper, "bbbbbbbbbbbb");
        Assert.True(File.Exists(inactiveBuildProjectPath));
 
        timeProvider.Advance(TimeSpan.FromMinutes(2));
        await PublishAsync(sweeper, "bbbbbbbbbbbb");
        Assert.False(File.Exists(inactiveBuildProjectPath));
    }
 
    [Fact]
    public async Task BuildProjectWithoutStateStartsFreshGracePeriodBeforePruning()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var timeProvider = new FakeTimeProvider(s_startTime);
        var buildDirectory = Path.Combine(workspace.Path, ".aspire", "build");
        const string inactiveHash = "aaaaaaaaaaaa";
        string inactiveBuildProjectPath;
        string inactiveStatePath;
 
        using (var firstManager = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider))
        {
            inactiveBuildProjectPath = await PublishAsync(firstManager, inactiveHash);
            inactiveStatePath = firstManager.GetStatePath(inactiveHash);
        }
 
        Directory.Delete(Path.Combine(buildDirectory, ".artifacts"), recursive: true);
 
        using var sweeper = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider);
        await PublishAsync(sweeper, "bbbbbbbbbbbb");
        Assert.True(File.Exists(inactiveBuildProjectPath));
        Assert.True(File.Exists(inactiveStatePath));
 
        timeProvider.Advance(DotnetProjectBuildArtifactManager.InactiveRetentionPeriod - TimeSpan.FromMinutes(1));
        await PublishAsync(sweeper, "bbbbbbbbbbbb");
        Assert.True(File.Exists(inactiveBuildProjectPath));
 
        timeProvider.Advance(TimeSpan.FromMinutes(2));
        await PublishAsync(sweeper, "bbbbbbbbbbbb");
        Assert.False(File.Exists(inactiveBuildProjectPath));
        Assert.False(File.Exists(inactiveStatePath));
    }
 
    [Fact]
    public async Task SharedBuildProjectRemainsUntilEveryLeaseEndsAndThenStartsFreshGracePeriod()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var timeProvider = new FakeTimeProvider(s_startTime);
        var buildDirectory = Path.Combine(workspace.Path, ".aspire", "build");
        var sharedHash = "aaaaaaaaaaaa";
        var firstManager = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider);
        var secondManager = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider);
        using var sweeper = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider);
 
        try
        {
            var sharedBuildProjectPath = await PublishAsync(firstManager, sharedHash);
            Assert.Equal(sharedBuildProjectPath, await PublishAsync(secondManager, sharedHash));
 
            firstManager.Dispose();
            timeProvider.Advance(DotnetProjectBuildArtifactManager.InactiveRetentionPeriod + TimeSpan.FromDays(2));
            await PublishAsync(sweeper, "bbbbbbbbbbbb");
            Assert.True(File.Exists(sharedBuildProjectPath));
 
            secondManager.Dispose();
            await PublishAsync(sweeper, "bbbbbbbbbbbb");
            Assert.True(File.Exists(sharedBuildProjectPath));
 
            timeProvider.Advance(DotnetProjectBuildArtifactManager.InactiveRetentionPeriod + TimeSpan.FromMinutes(1));
            await PublishAsync(sweeper, "bbbbbbbbbbbb");
            Assert.False(File.Exists(sharedBuildProjectPath));
        }
        finally
        {
            firstManager.Dispose();
            secondManager.Dispose();
        }
    }
 
    [Fact]
    public async Task InvalidStateRetainsBuildProject()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var timeProvider = new FakeTimeProvider(s_startTime);
        var buildDirectory = Path.Combine(workspace.Path, ".aspire", "build");
        var hash = "aaaaaaaaaaaa";
        string buildProjectPath;
        string statePath;
 
        using (var firstManager = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider))
        {
            buildProjectPath = await PublishAsync(firstManager, hash);
            statePath = firstManager.GetStatePath(hash);
        }
 
        File.WriteAllText(statePath, "future-schema");
        timeProvider.Advance(DotnetProjectBuildArtifactManager.InactiveRetentionPeriod + TimeSpan.FromDays(2));
 
        using var sweeper = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider);
        await PublishAsync(sweeper, "bbbbbbbbbbbb");
 
        Assert.True(File.Exists(buildProjectPath));
        Assert.Equal("future-schema", File.ReadAllText(statePath));
    }
 
    [Fact]
    public async Task StateIsPublishedWithoutLeavingTemporaryFiles()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var buildDirectory = Path.Combine(workspace.Path, ".aspire", "build");
        using var manager = new DotnetProjectBuildArtifactManager(buildDirectory, TimeProvider.System);
 
        await PublishAsync(manager, "aaaaaaaaaaaa");
 
        var statePath = manager.GetStatePath("aaaaaaaaaaaa");
        Assert.Equal("0", File.ReadAllText(statePath));
        Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(statePath)!, ".*.tmp"));
    }
 
    [Fact]
    public async Task MissingBuildProjectIsRegeneratedWhileLeaseIsHeld()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var buildDirectory = Path.Combine(workspace.Path, ".aspire", "build");
        using var manager = new DotnetProjectBuildArtifactManager(buildDirectory, TimeProvider.System);
 
        var buildProjectPath = await PublishAsync(manager, "aaaaaaaaaaaa");
        File.Delete(buildProjectPath);
 
        Assert.Equal(buildProjectPath, await PublishAsync(manager, "aaaaaaaaaaaa"));
        Assert.True(File.Exists(buildProjectPath));
    }
 
    [Fact]
    public async Task OnlyOldTemporaryFilesArePruned()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var timeProvider = new FakeTimeProvider(s_startTime);
        var buildDirectory = Path.Combine(workspace.Path, ".aspire", "build");
        var stateDirectory = Path.Combine(buildDirectory, ".artifacts", "v1");
        Directory.CreateDirectory(buildDirectory);
        Directory.CreateDirectory(stateDirectory);
        var oldTemporaryPaths = new[]
        {
            Path.Combine(buildDirectory, ".old.tmp"),
            Path.Combine(stateDirectory, ".old.tmp"),
        };
        var recentTemporaryPaths = new[]
        {
            Path.Combine(buildDirectory, ".recent.tmp"),
            Path.Combine(stateDirectory, ".recent.tmp"),
        };
        foreach (var oldTemporaryPath in oldTemporaryPaths)
        {
            File.WriteAllText(oldTemporaryPath, "old");
            File.SetLastWriteTimeUtc(
                oldTemporaryPath,
                (s_startTime - DotnetProjectBuildArtifactManager.TemporaryFileRetentionPeriod - TimeSpan.FromMinutes(1)).UtcDateTime);
        }
        foreach (var recentTemporaryPath in recentTemporaryPaths)
        {
            File.WriteAllText(recentTemporaryPath, "recent");
            File.SetLastWriteTimeUtc(recentTemporaryPath, s_startTime.UtcDateTime);
        }
 
        using var manager = new DotnetProjectBuildArtifactManager(buildDirectory, timeProvider);
        await PublishAsync(manager, "aaaaaaaaaaaa");
 
        Assert.All(oldTemporaryPaths, path => Assert.False(File.Exists(path)));
        Assert.All(recentTemporaryPaths, path => Assert.True(File.Exists(path)));
    }
 
    private static Task<string> PublishAsync(DotnetProjectBuildArtifactManager manager, string hash) =>
        manager.PublishAndLeaseAsync(hash, [1, 2, 3], NullLogger.Instance, TestContext.Current.CancellationToken);
}