File: EndpointsManifestTransformerTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Blazor.Tests\Aspire.Hosting.Blazor.Tests.csproj (Aspire.Hosting.Blazor.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.Text.Json;
 
namespace Aspire.Hosting.Blazor.Tests;
 
public class EndpointsManifestTransformerTests : IDisposable
{
    private readonly string _tempDir;
 
    public EndpointsManifestTransformerTests()
    {
        _tempDir = Path.Combine(Path.GetTempPath(), "BlazorHostingTests", Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(_tempDir);
    }
 
    public void Dispose()
    {
        if (Directory.Exists(_tempDir))
        {
            Directory.Delete(_tempDir, recursive: true);
        }
    }
 
    [Fact]
    public async Task PrefixEndpointsAssetFile_PrefixesAssetPaths()
    {
        var manifest = new EndpointsManifest
        {
            Endpoints =
            [
                new EndpointEntry { Route = "css/app.css", AssetFile = "css/app.css" },
                new EndpointEntry { Route = "_framework/blazor.webassembly.js", AssetFile = "_framework/blazor.webassembly.js" }
            ]
        };
 
        var manifestPath = Path.Combine(_tempDir, "endpoints.json");
        await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, ManifestJsonContext.Default.EndpointsManifest));
 
        var result = await EndpointsManifestTransformer.PrefixEndpointsAssetFileAsync(manifestPath, "store", CancellationToken.None);
 
        var transformed = JsonSerializer.Deserialize(result, ManifestJsonContext.Default.EndpointsManifest)!;
        Assert.All(transformed.Endpoints, ep => Assert.StartsWith("store/", ep.AssetFile));
    }
 
    [Fact]
    public async Task PrefixEndpointsAssetFile_AddsFallbackEndpoint_ForIdentityIndexHtml()
    {
        var manifest = new EndpointsManifest
        {
            Endpoints =
            [
                new EndpointEntry
                {
                    Route = "index.html",
                    AssetFile = "index.html",
                    ResponseHeaders = [new EndpointResponseHeader { Name = "Cache-Control", Value = "max-age=3600" }]
                }
            ]
        };
 
        var manifestPath = Path.Combine(_tempDir, "endpoints.json");
        await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, ManifestJsonContext.Default.EndpointsManifest));
 
        var result = await EndpointsManifestTransformer.PrefixEndpointsAssetFileAsync(manifestPath, "store", CancellationToken.None);
 
        var transformed = JsonSerializer.Deserialize(result, ManifestJsonContext.Default.EndpointsManifest)!;
 
        // Should have original + fallback
        Assert.Equal(2, transformed.Endpoints.Length);
 
        var fallback = transformed.Endpoints.Single(ep => ep.Route == "{**path:nonfile}");
        Assert.Equal("store/index.html", fallback.AssetFile);
        Assert.Contains(fallback.ResponseHeaders!, h => h.Name == "Cache-Control" && h.Value == "no-store");
    }
 
    [Fact]
    public async Task PrefixEndpointsAssetFile_SkipsFallback_ForCompressedIndexHtml()
    {
        var manifest = new EndpointsManifest
        {
            Endpoints =
            [
                new EndpointEntry
                {
                    Route = "index.html",
                    AssetFile = "index.html",
                    Selectors = [new EndpointSelector { Name = "Content-Encoding" }]
                }
            ]
        };
 
        var manifestPath = Path.Combine(_tempDir, "endpoints.json");
        await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, ManifestJsonContext.Default.EndpointsManifest));
 
        var result = await EndpointsManifestTransformer.PrefixEndpointsAssetFileAsync(manifestPath, "store", CancellationToken.None);
 
        var transformed = JsonSerializer.Deserialize(result, ManifestJsonContext.Default.EndpointsManifest)!;
 
        // Should have only the original (no fallback for compressed variant)
        Assert.Single(transformed.Endpoints);
        Assert.Equal("index.html", transformed.Endpoints[0].Route);
    }
 
    [Fact]
    public async Task PrefixEndpointsAssetFile_PreservesRoutes_Unchanged()
    {
        var manifest = new EndpointsManifest
        {
            Endpoints =
            [
                new EndpointEntry { Route = "css/app.css", AssetFile = "css/app.css" },
                new EndpointEntry { Route = "_framework/blazor.webassembly.js", AssetFile = "_framework/blazor.webassembly.js" }
            ]
        };
 
        var manifestPath = Path.Combine(_tempDir, "endpoints.json");
        await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, ManifestJsonContext.Default.EndpointsManifest));
 
        var result = await EndpointsManifestTransformer.PrefixEndpointsAssetFileAsync(manifestPath, "store", CancellationToken.None);
 
        var transformed = JsonSerializer.Deserialize(result, ManifestJsonContext.Default.EndpointsManifest)!;
 
        // Routes should NOT be prefixed (MapGroup handles URL prefixing)
        Assert.Contains(transformed.Endpoints, ep => ep.Route == "css/app.css");
        Assert.Contains(transformed.Endpoints, ep => ep.Route == "_framework/blazor.webassembly.js");
    }
 
    [Fact]
    public async Task MergeRuntimeManifests_MergesMultipleApps()
    {
        var app1Manifest = new DevelopmentManifest
        {
            ContentRoots = ["/app1/wwwroot"],
            Root = new AssetNode
            {
                Children = new Dictionary<string, AssetNode>
                {
                    ["css"] = new AssetNode
                    {
                        Children = new Dictionary<string, AssetNode>
                        {
                            ["app.css"] = new AssetNode
                            {
                                Asset = new AssetMatch { ContentRootIndex = 0 }
                            }
                        }
                    }
                }
            }
        };
 
        var app2Manifest = new DevelopmentManifest
        {
            ContentRoots = ["/app2/wwwroot"],
            Root = new AssetNode
            {
                Children = new Dictionary<string, AssetNode>
                {
                    ["js"] = new AssetNode
                    {
                        Children = new Dictionary<string, AssetNode>
                        {
                            ["app.js"] = new AssetNode
                            {
                                Asset = new AssetMatch { ContentRootIndex = 0 }
                            }
                        }
                    }
                }
            }
        };
 
        var app1Path = Path.Combine(_tempDir, "app1.runtime.json");
        var app2Path = Path.Combine(_tempDir, "app2.runtime.json");
        await File.WriteAllTextAsync(app1Path, JsonSerializer.Serialize(app1Manifest, ManifestJsonContext.Default.DevelopmentManifest));
        await File.WriteAllTextAsync(app2Path, JsonSerializer.Serialize(app2Manifest, ManifestJsonContext.Default.DevelopmentManifest));
 
        using var builder = Aspire.Hosting.Utils.TestDistributedApplicationBuilder.Create();
        var storeApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
        var adminApp = builder.AddBlazorWasmApp("admin", "Admin/Admin.csproj");
 
        var manifests = new List<AppManifestPaths>
        {
            new(new GatewayAppRegistration(storeApp, "store", Array.Empty<GatewayAppService>()), app1Path, app1Path),
            new(new GatewayAppRegistration(adminApp, "admin", Array.Empty<GatewayAppService>()), app2Path, app2Path)
        };
 
        var outputPath = Path.Combine(_tempDir, "merged.json");
        await EndpointsManifestTransformer.MergeRuntimeManifestsAsync(
            manifests, outputPath, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, CancellationToken.None);
 
        Assert.True(File.Exists(outputPath));
 
        var merged = JsonSerializer.Deserialize(
            await File.ReadAllTextAsync(outputPath),
            ManifestJsonContext.Default.DevelopmentManifest)!;
 
        // Should have combined content roots
        Assert.Equal(2, merged.ContentRoots.Length);
        Assert.Equal("/app1/wwwroot", merged.ContentRoots[0]);
        Assert.Equal("/app2/wwwroot", merged.ContentRoots[1]);
 
        // Should have children keyed by path prefix
        Assert.NotNull(merged.Root.Children);
        Assert.True(merged.Root.Children.ContainsKey("store"));
        Assert.True(merged.Root.Children.ContainsKey("admin"));
    }
 
    [Fact]
    public async Task MergeRuntimeManifests_OffsetsContentRootIndices()
    {
        var app1Manifest = new DevelopmentManifest
        {
            ContentRoots = ["/app1/root1", "/app1/root2"],
            Root = new AssetNode
            {
                Children = new Dictionary<string, AssetNode>
                {
                    ["file1"] = new AssetNode { Asset = new AssetMatch { ContentRootIndex = 0 } },
                    ["file2"] = new AssetNode { Asset = new AssetMatch { ContentRootIndex = 1 } }
                }
            }
        };
 
        var app2Manifest = new DevelopmentManifest
        {
            ContentRoots = ["/app2/root1"],
            Root = new AssetNode
            {
                Children = new Dictionary<string, AssetNode>
                {
                    ["file3"] = new AssetNode { Asset = new AssetMatch { ContentRootIndex = 0 } }
                }
            }
        };
 
        var app1Path = Path.Combine(_tempDir, "app1.runtime.json");
        var app2Path = Path.Combine(_tempDir, "app2.runtime.json");
        await File.WriteAllTextAsync(app1Path, JsonSerializer.Serialize(app1Manifest, ManifestJsonContext.Default.DevelopmentManifest));
        await File.WriteAllTextAsync(app2Path, JsonSerializer.Serialize(app2Manifest, ManifestJsonContext.Default.DevelopmentManifest));
 
        using var builder = Aspire.Hosting.Utils.TestDistributedApplicationBuilder.Create();
        var storeApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
        var adminApp = builder.AddBlazorWasmApp("admin", "Admin/Admin.csproj");
 
        var manifests = new List<AppManifestPaths>
        {
            new(new GatewayAppRegistration(storeApp, "store", Array.Empty<GatewayAppService>()), app1Path, app1Path),
            new(new GatewayAppRegistration(adminApp, "admin", Array.Empty<GatewayAppService>()), app2Path, app2Path)
        };
 
        var outputPath = Path.Combine(_tempDir, "merged.json");
        await EndpointsManifestTransformer.MergeRuntimeManifestsAsync(
            manifests, outputPath, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, CancellationToken.None);
 
        var merged = JsonSerializer.Deserialize(
            await File.ReadAllTextAsync(outputPath),
            ManifestJsonContext.Default.DevelopmentManifest)!;
 
        // App2's content root index 0 should be offset to 2 (app1 had 2 roots)
        Assert.Equal(3, merged.ContentRoots.Length);
        var adminChildren = merged.Root.Children!["admin"].Children!;
        Assert.Equal(2, adminChildren["file3"].Asset!.ContentRootIndex); // 0 + offset of 2
    }
 
    [Fact]
    public void AssetNode_OffsetContentRootIndices_OffsetsRecursively()
    {
        var node = new AssetNode
        {
            Asset = new AssetMatch { ContentRootIndex = 0 },
            Patterns = [new AssetPattern { ContentRootIndex = 1 }],
            Children = new Dictionary<string, AssetNode>
            {
                ["child"] = new AssetNode
                {
                    Asset = new AssetMatch { ContentRootIndex = 2 }
                }
            }
        };
 
        node.OffsetContentRootIndices(5);
 
        Assert.Equal(5, node.Asset.ContentRootIndex);
        Assert.Equal(6, node.Patterns[0].ContentRootIndex);
        Assert.Equal(7, node.Children["child"].Asset!.ContentRootIndex);
    }
}