// 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);
}
}