File: TypeScriptApiCompat\TypeScriptApiCompatTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using TypeScriptApiCompat;
using Xunit;
 
namespace Infrastructure.Tests.TypeScriptApiCompat;
 
public sealed class TypeScriptApiCompatTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void ParserReadsAtsCiSurface()
    {
        var surface = AtsSurfaceParser.Parse("Pkg", """
            # Aspire Type System Capabilities
            # Generated by: aspire sdk dump --format ci
 
            # Handle Types
            Pkg/Thing [interface, ExposeProperties]
 
            # DTO Types
            Pkg/Options # DTO description
              name: string # property description
              count?: number
 
            # Enum Types
            enum:Pkg.Mode = One | Two
 
            # Exported Values
            Configs.Default: string = "dev" # copied value
 
            # Capabilities
            Pkg/addThing(name: string, port?: number) -> Pkg/Thing
            """);
 
        var handle = Assert.Single(surface.HandleTypes.Values);
        Assert.Equal("Pkg/Thing", handle.TypeId);
        Assert.Contains("interface", handle.Flags);
        Assert.Contains("ExposeProperties", handle.Flags);
 
        var dto = Assert.Single(surface.DtoTypes.Values);
        Assert.Equal("Pkg/Options", dto.TypeId);
        Assert.False(dto.Properties["name"].IsOptional);
        Assert.True(dto.Properties["count"].IsOptional);
 
        var enumType = Assert.Single(surface.EnumTypes.Values);
        Assert.Equal(["One", "Two"], enumType.Values);
 
        var exportedValue = Assert.Single(surface.ExportedValues.Values);
        Assert.Equal("Configs.Default", exportedValue.Path);
        Assert.Equal("\"dev\"", exportedValue.Value);
 
        var capability = Assert.Single(surface.Capabilities.Values);
        Assert.Equal("Pkg/addThing", capability.CapabilityId);
        Assert.Equal("Pkg/Thing", capability.ReturnTypeId);
        Assert.Equal("port", capability.Parameters[1].Name);
        Assert.True(capability.Parameters[1].IsOptional);
    }
 
    [Fact]
    public void ParserIgnoresSymbolsOwnedByOtherPackages()
    {
        var surface = AtsSurfaceParser.Parse("Pkg", """
            # Handle Types
            Pkg/Thing
            Other/Thing
 
            # DTO Types
            Pkg/Options
              name: string
            Other/Options
              ignored: string
 
            # Enum Types
            enum:Pkg.Mode = One
            enum:Other.Mode = One
 
            # Exported Values
            Pkg.Configs.Default: string = "dev"
            Other.Configs.Default: string = "dev"
 
            # Capabilities
            Pkg/addThing(name: Other/Thing) -> Pkg/Thing
            Other/addThing() -> Other/Thing
            """);
 
        Assert.Equal(["Pkg/Thing"], surface.HandleTypes.Keys);
        var dto = Assert.Single(surface.DtoTypes.Values);
        Assert.Equal("Pkg/Options", dto.TypeId);
        Assert.Equal(["Pkg.Mode"], surface.EnumTypes.Keys.Select(k => k["enum:".Length..]));
        Assert.Equal(["Other.Configs.Default", "Pkg.Configs.Default"], surface.ExportedValues.Keys.Order(StringComparer.Ordinal));
        var capability = Assert.Single(surface.Capabilities.Values);
        Assert.Equal("Pkg/addThing", capability.CapabilityId);
        Assert.Equal("Other/Thing", capability.Parameters[0].TypeId);
    }
 
    [Fact]
    public void ParserUsesMostSpecificKnownPackageForDottedSymbols()
    {
        var surface = AtsSurfaceParser.Parse("Aspire.Hosting", """
            # Handle Types
            Aspire.Hosting.CoreResource
            Aspire.Hosting.Redis.RedisResource
 
            # DTO Types
            Aspire.Hosting.Options
              name: string
            Aspire.Hosting.Redis.Options
              name: string
 
            # Enum Types
            enum:Aspire.Hosting.Mode = One
            enum:Aspire.Hosting.Redis.Mode = One
            """,
            ["Aspire.Hosting", "Aspire.Hosting.Redis"]);
 
        Assert.Equal(["Aspire.Hosting.CoreResource"], surface.HandleTypes.Keys);
        Assert.Equal(["Aspire.Hosting.Options"], surface.DtoTypes.Keys);
        Assert.Equal(["Aspire.Hosting.Mode"], surface.EnumTypes.Keys.Select(k => k["enum:".Length..]));
    }
 
    [Fact]
    public void ComparerClassifiesBreakingAndAdditiveChanges()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var baselineRoot = Path.Combine(workspace.Path, "baseline");
        var currentRoot = Path.Combine(workspace.Path, "current");
 
        WriteSurface(baselineRoot, "Pkg", """
            # Handle Types
            Pkg/Thing [ExposeProperties, ExposeMethods]
 
            # DTO Types
            Pkg/Options
              name: string
              optional?: string
 
            # Enum Types
            enum:Pkg.Mode = One | Two
 
            # Exported Values
            Configs.Default: string = "old"
 
            # Capabilities
            Pkg/addThing(name: string, port?: number) -> Pkg/Thing
            Pkg/addInputType(name: string | Pkg/Thing) -> void
            Pkg/addInsertedOptionalBeforeExisting(name: string, suffix?: string) -> void
            Pkg/removeMe() -> void
            """);
 
        WriteSurface(currentRoot, "Pkg", """
            # Handle Types
            Pkg/Thing [ExposeProperties]
            Pkg/NewThing
 
            # DTO Types
            Pkg/Options
              name: number
              optional: string
              newRequired: string
              newOptional?: string
 
            # Enum Types
            enum:Pkg.Mode = One
 
            # Exported Values
            Configs.Default: string = "new"
 
            # Capabilities
            Pkg/addThing(name: number, port: number, requiredName: string, optionalName?: string) -> void
            Pkg/addInputType(name: string | Pkg/Thing | Pkg/NewThing) -> void
            Pkg/addInsertedOptionalBeforeExisting(name: string, inserted?: string, suffix?: string) -> void
            Pkg/newCapability() -> void
            """);
 
        var diagnostics = AtsCompatibilityComparer.Compare(AtsSurfaceSet.Load(baselineRoot), AtsSurfaceSet.Load(currentRoot));
 
        Assert.Contains(diagnostics, d => d.Kind == "handle-flag-removed" && d.Symbol == "Pkg/Thing.ExposeMethods");
        Assert.Contains(diagnostics, d => d.Kind == "dto-property-type-changed" && d.Symbol == "Pkg/Options.name");
        Assert.Contains(diagnostics, d => d.Kind == "dto-property-required" && d.Symbol == "Pkg/Options.optional");
        Assert.Contains(diagnostics, d => d.Kind == "dto-property-added-required" && d.Symbol == "Pkg/Options.newRequired");
        Assert.Contains(diagnostics, d => d.Kind == "enum-value-removed" && d.Symbol == "enum:Pkg.Mode.Two");
        Assert.Contains(diagnostics, d => d.Kind == "exported-value-changed" && d.Symbol == "Configs.Default");
        Assert.Contains(diagnostics, d => d.Kind == "capability-removed" && d.Symbol == "Pkg/removeMe");
        Assert.Contains(diagnostics, d => d.Kind == "capability-return-type-changed" && d.Symbol == "Pkg/addThing");
        Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-type-changed" && d.Symbol == "Pkg/addThing(name)");
        Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-required" && d.Symbol == "Pkg/addThing(port)");
        Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-added-required" && d.Symbol == "Pkg/addThing(requiredName)");
        Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-order-changed" && d.Symbol == "Pkg/addInsertedOptionalBeforeExisting");
        Assert.DoesNotContain(diagnostics, d => d.Symbol is "Pkg/NewThing" or "Pkg/newCapability" or "Pkg/addThing(optionalName)" or "Pkg/Options.newOptional" or "Pkg/addInputType(name)");
    }
 
    [Fact]
    public void SuppressionsUseExactMatchesAndFailWhenUnused()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var suppressionPath = Path.Combine(workspace.Path, "Pkg.tscompat.suppression.txt");
        File.WriteAllText(suppressionPath, """
            # Intentional break
            BREAK capability-removed Pkg Pkg/removeMe -- https://github.com/microsoft/aspire/issues/16961 -- Intentional test break
            BREAK dto-removed Pkg Pkg/Stale -- https://github.com/microsoft/aspire/issues/16961 -- Stale test suppression
            """);
 
        var diagnostics = new[]
        {
            new ApiCompatDiagnostic("capability-removed", "Pkg", "Pkg/removeMe", "Capability was removed."),
            new ApiCompatDiagnostic("enum-removed", "Pkg", "enum:Pkg.Mode", "Enum was removed.")
        };
 
        var suppressions = ApiCompatSuppressionLoader.Load(workspace.Path);
        var result = ApiCompatSuppressor.ApplySuppressions(diagnostics, suppressions);
 
        var suppressed = Assert.Single(result.SuppressedDiagnostics);
        Assert.Equal("capability-removed|Pkg|Pkg/removeMe", suppressed.SuppressionKey);
 
        var unsuppressed = Assert.Single(result.UnsuppressedDiagnostics);
        Assert.Equal("enum-removed|Pkg|enum:Pkg.Mode", unsuppressed.SuppressionKey);
 
        var unused = Assert.Single(result.UnusedSuppressions);
        Assert.Equal("dto-removed|Pkg|Pkg/Stale", unused.SuppressionKey);
    }
 
    [Fact]
    public void SuppressionsIgnoreUnusedEntriesInheritedFromBaseline()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var currentSuppressionPath = Path.Combine(workspace.Path, "current", "Pkg.tscompat.suppression.txt");
        var baselineSuppressionPath = Path.Combine(workspace.Path, "baseline", "Pkg.tscompat.suppression.txt");
        Directory.CreateDirectory(Path.GetDirectoryName(currentSuppressionPath)!);
        Directory.CreateDirectory(Path.GetDirectoryName(baselineSuppressionPath)!);
 
        File.WriteAllText(currentSuppressionPath, """
            BREAK capability-removed Pkg Pkg/inheritedBreak -- https://github.com/microsoft/aspire/issues/16961 -- Inherited accepted break
            BREAK dto-removed Pkg Pkg/NewStale -- https://github.com/microsoft/aspire/issues/16961 -- Newly stale suppression
            """);
        File.WriteAllText(baselineSuppressionPath, """
            BREAK capability-removed Pkg Pkg/inheritedBreak -- https://github.com/microsoft/aspire/issues/16961 -- Inherited accepted break
            """);
 
        var currentSuppressions = ApiCompatSuppressionLoader.Load(Path.Combine(workspace.Path, "current"));
        var baselineSuppressions = ApiCompatSuppressionLoader.Load(Path.Combine(workspace.Path, "baseline"));
 
        var result = ApiCompatSuppressor.ApplySuppressions([], currentSuppressions, baselineSuppressions);
 
        var unused = Assert.Single(result.UnusedSuppressions);
        Assert.Equal("dto-removed|Pkg|Pkg/NewStale", unused.SuppressionKey);
    }
 
    [Fact]
    public void RunnerWritesReportAndReturnsFailureForUnsuppressedBreaks()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var baselineRoot = Path.Combine(workspace.Path, "baseline");
        var currentRoot = Path.Combine(workspace.Path, "current");
        var reportPath = Path.Combine(workspace.Path, "report.md");
 
        WriteSurface(baselineRoot, "Pkg", """
            # Handle Types
 
            # Capabilities
            Pkg/removeMe() -> void
            """);
 
        WriteSurface(currentRoot, "Pkg", """
            # Handle Types
 
            # Capabilities
            """);
 
        var exitCode = TypeScriptApiCompatRunner.Run(new CommandLineOptions(
            baselineRoot,
            currentRoot,
            workspace.Path,
            BaselineSuppressionsRoot: null,
            ExcludedPackagesFile: null,
            ReportPath: reportPath,
            GitHubAnnotations: false));
 
        Assert.Equal(1, exitCode);
        var report = File.ReadAllText(reportPath);
        Assert.Contains("capability-removed", report, StringComparison.Ordinal);
        Assert.Contains("Pkg/removeMe", report, StringComparison.Ordinal);
    }
 
    [Fact]
    public void RunnerIgnoresExcludedPackagesAndSuppressions()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var baselineRoot = Path.Combine(workspace.Path, "baseline");
        var currentRoot = Path.Combine(workspace.Path, "current");
        var reportPath = Path.Combine(workspace.Path, "report.md");
        var excludedPackagesPath = Path.Combine(workspace.Path, "excluded-packages.txt");
        var suppressionPath = Path.Combine(workspace.Path, "Excluded.tscompat.suppression.txt");
 
        WriteSurface(baselineRoot, "Excluded", """
            # Capabilities
            Excluded/removeMe() -> void
            """);
        Directory.CreateDirectory(currentRoot);
 
        File.WriteAllText(excludedPackagesPath, """
            # Excluded because DisablePackageBaselineValidation=true
            Excluded
            """);
        File.WriteAllText(suppressionPath, """
            BREAK capability-removed Excluded Excluded/stale -- https://github.com/microsoft/aspire/issues/16961 -- Excluded package suppression
            """);
 
        var exitCode = TypeScriptApiCompatRunner.Run(new CommandLineOptions(
            baselineRoot,
            currentRoot,
            workspace.Path,
            BaselineSuppressionsRoot: null,
            ExcludedPackagesFile: excludedPackagesPath,
            ReportPath: reportPath,
            GitHubAnnotations: false));
 
        Assert.Equal(0, exitCode);
        var report = File.ReadAllText(reportPath);
        Assert.Contains("Excluded packages", report, StringComparison.Ordinal);
        Assert.Contains("`Excluded`", report, StringComparison.Ordinal);
        Assert.DoesNotContain("package-removed", report, StringComparison.Ordinal);
        Assert.DoesNotContain("Unused suppressions", report, StringComparison.Ordinal);
    }
 
    private static void WriteSurface(string rootPath, string packageName, string content)
    {
        var apiDirectory = Path.Combine(rootPath, "src", packageName, "api");
        Directory.CreateDirectory(apiDirectory);
        File.WriteAllText(Path.Combine(apiDirectory, $"{packageName}.ats.txt"), content);
    }
}