diff --git a/.dockerignore b/.dockerignore
index 3f95db71a..1d8faef32 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -24,3 +24,7 @@ frontend/.astro/
# Documentation
*.md
docs/
+
+# Test files
+tests/
+**/*.test.*
diff --git a/.github/workflows/tools-tests.yml b/.github/workflows/tools-tests.yml
new file mode 100644
index 000000000..1e08bf144
--- /dev/null
+++ b/.github/workflows/tools-tests.yml
@@ -0,0 +1,78 @@
+name: Tools Tests
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - src/tools/**
+ - tests/**
+ - .github/workflows/tools-tests.yml
+ pull_request:
+ branches: [ main ]
+ paths:
+ - src/tools/**
+ - tests/**
+ - .github/workflows/tools-tests.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ tools-generator-tests:
+ name: Tools Generator Tests
+ runs-on: ubuntu-latest
+ env:
+ TEST_RESULTS_DIR: ${{ github.workspace }}/testresults
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ with:
+ global-json-file: global.json
+
+ - name: Restore tests
+ run: |
+ dotnet restore tests/PackageJsonGenerator.Tests/PackageJsonGenerator.Tests.csproj
+ dotnet restore tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj
+
+ - name: Run PackageJsonGenerator tests
+ run: >
+ dotnet test tests/PackageJsonGenerator.Tests/PackageJsonGenerator.Tests.csproj
+ --configuration Release
+ --no-restore
+ --verbosity normal
+ --results-directory "$TEST_RESULTS_DIR"
+ --logger "trx;LogFileName=PackageJsonGenerator.Tests.trx"
+ -bl:"$TEST_RESULTS_DIR/PackageJsonGenerator.Tests.binlog"
+
+ - name: Run AtsJsonGenerator tests
+ run: >
+ dotnet test tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj
+ --configuration Release
+ --no-restore
+ --verbosity normal
+ --results-directory "$TEST_RESULTS_DIR"
+ --logger "trx;LogFileName=AtsJsonGenerator.Tests.trx"
+ -bl:"$TEST_RESULTS_DIR/AtsJsonGenerator.Tests.binlog"
+
+ - name: Upload test results
+ id: upload-test-results
+ if: always()
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ with:
+ name: logs-ToolsGenerator-ubuntu-latest
+ path: |
+ testresults/**/*.trx
+ testresults/**/*.binlog
+
+ - name: Generate test results summary
+ if: always()
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/tests/scripts/Write-TestSummary.ps1" `
+ -TestResultsFolder $env:TEST_RESULTS_DIR `
+ -SummaryOutputPath $env:GITHUB_STEP_SUMMARY `
+ -ArtifactUrl "${{ steps.upload-test-results.outputs.artifact-url }}" `
+ -SummaryTitle "Tools Generator Tests"
diff --git a/Aspire.Dev.slnx b/Aspire.Dev.slnx
index de7801a77..8aa19d99a 100644
--- a/Aspire.Dev.slnx
+++ b/Aspire.Dev.slnx
@@ -5,6 +5,10 @@
+
+
+
+
diff --git a/tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj b/tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj
new file mode 100644
index 000000000..18a3e41e2
--- /dev/null
+++ b/tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs b/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs
new file mode 100644
index 000000000..da9f4185f
--- /dev/null
+++ b/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs
@@ -0,0 +1,389 @@
+using System.Text.Json;
+using AtsJsonGenerator.Helpers;
+
+namespace AtsJsonGenerator.Tests;
+
+public sealed class AtsJsonGeneratorTests
+{
+ [Fact]
+ public void TransformFile_InfersMetadataAndDeduplicatesAgainstBaseModel()
+ {
+ using var tempDirectory = new TempDirectory();
+
+ var inputPath = Path.Combine(tempDirectory.Path, "Contoso.Tools.json");
+ var outputPath = Path.Combine(tempDirectory.Path, "output", "Contoso.Tools.json");
+ var basePath = Path.Combine(tempDirectory.Path, "base.json");
+
+ var dump = new AtsDumpRoot
+ {
+ Packages =
+ [
+ new AtsDumpPackageRef
+ {
+ Name = "Contoso.Tools",
+ Version = "2.4.0",
+ },
+ ],
+ HandleTypes =
+ [
+ new AtsDumpHandleType
+ {
+ AtsTypeId = "Contoso.Assembly/Contoso.Builder",
+ ExposeMethods = true,
+ ExposeProperties = true,
+ },
+ ],
+ Capabilities =
+ [
+ new AtsDumpCapability
+ {
+ CapabilityId = "shared-capability",
+ MethodName = "UseShared",
+ QualifiedMethodName = "Contoso.Builder.UseShared",
+ CapabilityKind = "method",
+ TargetTypeId = "Contoso.Assembly/Contoso.Builder",
+ TargetParameterName = "builder",
+ Parameters =
+ [
+ new AtsDumpParameter
+ {
+ Name = "builder",
+ Type = new AtsDumpTypeRef
+ {
+ TypeId = "Contoso.Assembly/Contoso.Builder",
+ Category = "Type",
+ },
+ },
+ ],
+ ReturnType = new AtsDumpTypeRef
+ {
+ TypeId = "void",
+ Category = "Primitive",
+ },
+ ExpandedTargetTypes =
+ [
+ new AtsDumpTypeRef
+ {
+ TypeId = "Contoso.Assembly/Contoso.Builder",
+ Category = "Type",
+ },
+ ],
+ },
+ new AtsDumpCapability
+ {
+ CapabilityId = "unique-capability",
+ MethodName = "UseUnique",
+ QualifiedMethodName = "Contoso.Builder.UseUnique",
+ CapabilityKind = "method",
+ TargetTypeId = "Contoso.Assembly/Contoso.Builder",
+ TargetParameterName = "builder",
+ Parameters =
+ [
+ new AtsDumpParameter
+ {
+ Name = "builder",
+ Type = new AtsDumpTypeRef
+ {
+ TypeId = "Contoso.Assembly/Contoso.Builder",
+ Category = "Type",
+ },
+ },
+ new AtsDumpParameter
+ {
+ Name = "name",
+ Type = new AtsDumpTypeRef
+ {
+ TypeId = "string",
+ Category = "Primitive",
+ },
+ },
+ ],
+ ReturnType = new AtsDumpTypeRef
+ {
+ TypeId = "void",
+ Category = "Primitive",
+ },
+ ExpandedTargetTypes =
+ [
+ new AtsDumpTypeRef
+ {
+ TypeId = "Contoso.Assembly/Contoso.Builder",
+ Category = "Type",
+ },
+ ],
+ },
+ ],
+ DtoTypes =
+ [
+ new AtsDumpDtoType
+ {
+ TypeId = "Contoso.Assembly/Contoso.SharedOptions",
+ Name = "SharedOptions",
+ },
+ new AtsDumpDtoType
+ {
+ TypeId = "Contoso.Assembly/Contoso.UniqueOptions",
+ Name = "UniqueOptions",
+ Properties =
+ [
+ new AtsDumpDtoProperty
+ {
+ Name = "Names",
+ Type = new AtsDumpTypeRef
+ {
+ TypeId = "string",
+ Category = "Array",
+ ElementType = new AtsDumpTypeRef
+ {
+ TypeId = "string",
+ Category = "Primitive",
+ },
+ },
+ },
+ ],
+ },
+ ],
+ EnumTypes =
+ [
+ new AtsDumpEnumType
+ {
+ TypeId = "enum:Contoso.SharedMode",
+ Name = "SharedMode",
+ Values = ["One"],
+ },
+ new AtsDumpEnumType
+ {
+ TypeId = "enum:Contoso.UniqueMode",
+ Name = "UniqueMode",
+ Values = ["Alpha", "Beta"],
+ },
+ ],
+ };
+
+ var baseModel = new TsPackageModel
+ {
+ Package = new TsPackageInfo
+ {
+ Name = "Aspire.Hosting",
+ },
+ Functions =
+ [
+ new TsFunctionModel
+ {
+ Name = "UseShared",
+ CapabilityId = "shared-capability",
+ QualifiedName = "Contoso.Builder.UseShared",
+ Kind = "method",
+ Signature = "UseShared(): void",
+ ReturnType = "void",
+ },
+ ],
+ DtoTypes =
+ [
+ new TsDtoTypeModel
+ {
+ Name = "SharedOptions",
+ FullName = "Contoso.SharedOptions",
+ },
+ ],
+ EnumTypes =
+ [
+ new TsEnumTypeModel
+ {
+ Name = "SharedMode",
+ FullName = "Contoso.SharedMode",
+ Members = ["One"],
+ },
+ ],
+ };
+
+ File.WriteAllText(inputPath, JsonSerializer.Serialize(dump));
+ File.WriteAllText(basePath, JsonSerializer.Serialize(baseModel));
+
+ var exitCode = GenerateCommand.TransformFile(
+ inputPath,
+ outputPath,
+ packageName: null,
+ version: null,
+ sourceRepo: "https://github.com/microsoft/aspire",
+ sourceCommit: "abc123",
+ basePath: basePath);
+
+ Assert.Equal(0, exitCode);
+
+ var result = JsonSerializer.Deserialize(File.ReadAllText(outputPath));
+
+ Assert.NotNull(result);
+ Assert.Equal("Contoso.Tools", result.Package.Name);
+ Assert.Equal("2.4.0", result.Package.Version);
+ Assert.Equal("https://github.com/microsoft/aspire", result.Package.SourceRepository);
+ Assert.Equal("abc123", result.Package.SourceCommit);
+
+ var function = Assert.Single(result.Functions);
+ Assert.Equal("unique-capability", function.CapabilityId);
+ Assert.Equal("UseUnique(name: string): void", function.Signature);
+
+ var handle = Assert.Single(result.HandleTypes);
+ Assert.Equal("Contoso.Builder", handle.FullName);
+ var handleCapability = Assert.Single(handle.Capabilities);
+ Assert.Equal("unique-capability", handleCapability.CapabilityId);
+
+ var dto = Assert.Single(result.DtoTypes);
+ Assert.Equal("Contoso.UniqueOptions", dto.FullName);
+ Assert.Equal("string[]", Assert.Single(dto.Fields).Type);
+
+ var enumType = Assert.Single(result.EnumTypes);
+ Assert.Equal("Contoso.UniqueMode", enumType.FullName);
+ }
+
+ [Fact]
+ public void TransformFile_ReturnsErrorWhenInputIsMissing()
+ {
+ using var tempDirectory = new TempDirectory();
+
+ var exitCode = GenerateCommand.TransformFile(
+ Path.Combine(tempDirectory.Path, "missing.json"),
+ Path.Combine(tempDirectory.Path, "output.json"),
+ packageName: "Contoso.Tools",
+ version: null,
+ sourceRepo: null,
+ sourceCommit: null);
+
+ Assert.Equal(1, exitCode);
+ }
+
+ [Fact]
+ public void TransformFile_NormalizesToLfAndSkipsRewritingUnchangedOutput()
+ {
+ using var tempDirectory = new TempDirectory();
+
+ var inputPath = Path.Combine(tempDirectory.Path, "Contoso.Tools.json");
+ var outputPath = Path.Combine(tempDirectory.Path, "output", "Contoso.Tools.json");
+
+ var dump = new AtsDumpRoot
+ {
+ Packages =
+ [
+ new AtsDumpPackageRef
+ {
+ Name = "Contoso.Tools",
+ Version = "2.4.0",
+ },
+ ],
+ Capabilities =
+ [
+ new AtsDumpCapability
+ {
+ CapabilityId = "unique-capability",
+ MethodName = "UseUnique",
+ QualifiedMethodName = "Contoso.Builder.UseUnique",
+ CapabilityKind = "method",
+ TargetTypeId = "Contoso.Assembly/Contoso.Builder",
+ TargetParameterName = "builder",
+ Parameters =
+ [
+ new AtsDumpParameter
+ {
+ Name = "builder",
+ Type = new AtsDumpTypeRef
+ {
+ TypeId = "Contoso.Assembly/Contoso.Builder",
+ Category = "Type",
+ },
+ },
+ ],
+ ReturnType = new AtsDumpTypeRef
+ {
+ TypeId = "void",
+ Category = "Primitive",
+ },
+ ExpandedTargetTypes =
+ [
+ new AtsDumpTypeRef
+ {
+ TypeId = "Contoso.Assembly/Contoso.Builder",
+ Category = "Type",
+ },
+ ],
+ },
+ ],
+ HandleTypes =
+ [
+ new AtsDumpHandleType
+ {
+ AtsTypeId = "Contoso.Assembly/Contoso.Builder",
+ ExposeMethods = true,
+ ExposeProperties = true,
+ },
+ ],
+ };
+
+ File.WriteAllText(inputPath, JsonSerializer.Serialize(dump));
+
+ var firstExitCode = GenerateCommand.TransformFile(
+ inputPath,
+ outputPath,
+ packageName: null,
+ version: null,
+ sourceRepo: "https://github.com/microsoft/aspire",
+ sourceCommit: "abc123");
+
+ Assert.Equal(0, firstExitCode);
+
+ var initialContent = File.ReadAllText(outputPath);
+ Assert.DoesNotContain("\r", initialContent);
+
+ File.WriteAllText(outputPath, initialContent.Replace("\n", "\r\n", StringComparison.Ordinal));
+ File.SetLastWriteTimeUtc(outputPath, new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ var crlfWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ var secondExitCode = GenerateCommand.TransformFile(
+ inputPath,
+ outputPath,
+ packageName: null,
+ version: null,
+ sourceRepo: "https://github.com/microsoft/aspire",
+ sourceCommit: "abc123");
+
+ Assert.Equal(0, secondExitCode);
+
+ var normalizedContent = File.ReadAllText(outputPath);
+ Assert.DoesNotContain("\r", normalizedContent);
+ Assert.NotEqual(crlfWriteTime, File.GetLastWriteTimeUtc(outputPath));
+
+ File.SetLastWriteTimeUtc(outputPath, new DateTime(2001, 1, 2, 0, 0, 0, DateTimeKind.Utc));
+ var unchangedWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ var thirdExitCode = GenerateCommand.TransformFile(
+ inputPath,
+ outputPath,
+ packageName: null,
+ version: null,
+ sourceRepo: "https://github.com/microsoft/aspire",
+ sourceCommit: "abc123");
+
+ Assert.Equal(0, thirdExitCode);
+ Assert.Equal(unchangedWriteTime, File.GetLastWriteTimeUtc(outputPath));
+ }
+
+ private sealed class TempDirectory : IDisposable
+ {
+ public TempDirectory()
+ {
+ Path = Directory.CreateTempSubdirectory("ats-json-generator-tests-").FullName;
+ }
+
+ public string Path { get; }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+ }
+}
diff --git a/tests/AtsJsonGenerator.Tests/AtsTransformerHelperTests.cs b/tests/AtsJsonGenerator.Tests/AtsTransformerHelperTests.cs
new file mode 100644
index 000000000..c1f36999f
--- /dev/null
+++ b/tests/AtsJsonGenerator.Tests/AtsTransformerHelperTests.cs
@@ -0,0 +1,35 @@
+using AtsJsonGenerator.Helpers;
+
+namespace AtsJsonGenerator.Tests;
+
+public sealed class AtsTransformerHelperTests
+{
+ [Fact]
+ public void StripAssemblyPrefix_RemovesAssemblyMetadataFromGenericArguments()
+ {
+ var typeId = "Test.Assembly/System.Collections.Generic.IReadOnlyList`1[[Contoso.Widget, Contoso.Assembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]";
+
+ var stripped = AtsTransformer.StripAssemblyPrefix(typeId);
+
+ Assert.Equal("System.Collections.Generic.IReadOnlyList`1[[Contoso.Widget]]", stripped);
+ }
+
+ [Fact]
+ public void FormatTypeRef_FormatsArrayTypes()
+ {
+ var typeRef = new AtsDumpTypeRef
+ {
+ TypeId = "string",
+ Category = "Array",
+ ElementType = new AtsDumpTypeRef
+ {
+ TypeId = "string",
+ Category = "Primitive",
+ },
+ };
+
+ var formatted = AtsTransformer.FormatTypeRef(typeRef);
+
+ Assert.Equal("string[]", formatted);
+ }
+}
diff --git a/tests/AtsJsonGenerator.Tests/GlobalUsings.cs b/tests/AtsJsonGenerator.Tests/GlobalUsings.cs
new file mode 100644
index 000000000..c802f4480
--- /dev/null
+++ b/tests/AtsJsonGenerator.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/PackageJsonGenerator.Tests/GlobalUsings.cs b/tests/PackageJsonGenerator.Tests/GlobalUsings.cs
new file mode 100644
index 000000000..c802f4480
--- /dev/null
+++ b/tests/PackageJsonGenerator.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/PackageJsonGenerator.Tests/PackageJsonGenerator.Tests.csproj b/tests/PackageJsonGenerator.Tests/PackageJsonGenerator.Tests.csproj
new file mode 100644
index 000000000..521472a3b
--- /dev/null
+++ b/tests/PackageJsonGenerator.Tests/PackageJsonGenerator.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorHelperTests.cs b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorHelperTests.cs
new file mode 100644
index 000000000..5fa15d85e
--- /dev/null
+++ b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorHelperTests.cs
@@ -0,0 +1,60 @@
+namespace PackageJsonGenerator.Tests;
+
+public sealed class PackageJsonGeneratorHelperTests
+{
+ [Fact]
+ public void BuildRawGitHubUrl_StripsGitSuffix()
+ {
+ var rawUrl = PackageJsonGenerator.BuildRawGitHubUrl(
+ "https://github.com/microsoft/aspire.git",
+ "abc123",
+ "src/Aspire.Hosting/Foo.cs");
+
+ Assert.Equal(
+ "https://raw.githubusercontent.com/microsoft/aspire/abc123/src/Aspire.Hosting/Foo.cs",
+ rawUrl);
+ }
+
+ [Fact]
+ public void BuildRawGitHubUrl_ReturnsNullForNonGitHubRepositories()
+ {
+ var rawUrl = PackageJsonGenerator.BuildRawGitHubUrl(
+ "https://example.com/org/repo",
+ "abc123",
+ "src/Foo.cs");
+
+ Assert.Null(rawUrl);
+ }
+
+ [Fact]
+ public void FindTypeDeclarationLine_PrefersClosestMatchingDeclaration()
+ {
+ var lines = new[]
+ {
+ "// class Widget",
+ "public sealed class WidgetBuilder",
+ "",
+ "public sealed class Widget",
+ "{",
+ "}",
+ "",
+ "public sealed class Widget",
+ "{",
+ "}",
+ };
+
+ var declarationLine = PackageJsonGenerator.FindTypeDeclarationLine(lines, "Widget", pdbHintLine: 8);
+
+ Assert.Equal(8, declarationLine);
+ }
+
+ [Theory]
+ [InlineData("42-42", 42)]
+ [InlineData("42-99", 42)]
+ [InlineData(null, 0)]
+ [InlineData("invalid", 0)]
+ public void ParseStartLine_ParsesExpectedValue(string? sourceLines, int expected)
+ {
+ Assert.Equal(expected, PackageJsonGenerator.ParseStartLine(sourceLines));
+ }
+}
diff --git a/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs
new file mode 100644
index 000000000..a5125143d
--- /dev/null
+++ b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs
@@ -0,0 +1,262 @@
+using System.Text.Json;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace PackageJsonGenerator.Tests;
+
+public sealed class PackageJsonGeneratorTests
+{
+ [Fact]
+ public void GeneratePackageJson_WritesSelectedTargetFrameworkMetadata()
+ {
+ using var assembly = TestAssembly.Create(
+ """
+ namespace Sample.Library;
+
+ public sealed class Widget
+ {
+ public string Name => "demo";
+ }
+ """);
+
+ var outputPath = Path.Combine(assembly.DirectoryPath, "Package.json");
+
+ PackageJsonGenerator.GeneratePackageJson(
+ assembly.AssemblyPath,
+ assembly.References,
+ outputPath,
+ versionOverride: "1.2.3",
+ packageNameOverride: "Sample.Package",
+ targetFrameworkOverride: "net8.0");
+
+ using var document = JsonDocument.Parse(File.ReadAllText(outputPath));
+ var package = document.RootElement.GetProperty("package");
+
+ Assert.Equal("Sample.Package", package.GetProperty("name").GetString());
+ Assert.Equal("1.2.3", package.GetProperty("version").GetString());
+ Assert.Equal("net8.0", package.GetProperty("targetFramework").GetString());
+ }
+
+ [Fact]
+ public void GeneratePackageJson_DoesNotMarkEnumsAsSealed()
+ {
+ using var assembly = TestAssembly.Create(
+ """
+ namespace Sample.Library;
+
+ public enum WidgetState
+ {
+ Unknown = 0,
+ Ready = 1,
+ }
+ """);
+
+ var outputPath = Path.Combine(assembly.DirectoryPath, "Package.json");
+
+ PackageJsonGenerator.GeneratePackageJson(
+ assembly.AssemblyPath,
+ assembly.References,
+ outputPath,
+ versionOverride: "1.2.3",
+ packageNameOverride: "Sample.Package",
+ targetFrameworkOverride: "net8.0");
+
+ using var document = JsonDocument.Parse(File.ReadAllText(outputPath));
+ var type = document.RootElement
+ .GetProperty("types")
+ .EnumerateArray()
+ .Single(t => t.GetProperty("name").GetString() == "WidgetState");
+
+ Assert.Equal("enum", type.GetProperty("kind").GetString());
+ Assert.False(type.TryGetProperty("isSealed", out _));
+ }
+
+ [Fact]
+ public void GeneratePackageJson_PreservesPlainTextXmlListItems()
+ {
+ using var assembly = TestAssembly.Create(
+ """
+ namespace Sample.Library;
+
+ public sealed class Widget
+ {
+ /// Does work.
+ ///
+ /// Happens when either:
+ ///
+ /// - The first condition is met.
+ /// - The second condition is met.
+ ///
+ ///
+ public void Run()
+ {
+ }
+ }
+ """);
+
+ var outputPath = Path.Combine(assembly.DirectoryPath, "Package.json");
+
+ PackageJsonGenerator.GeneratePackageJson(
+ assembly.AssemblyPath,
+ assembly.References,
+ outputPath,
+ versionOverride: "1.2.3",
+ packageNameOverride: "Sample.Package",
+ targetFrameworkOverride: "net8.0");
+
+ using var document = JsonDocument.Parse(File.ReadAllText(outputPath));
+ var method = document.RootElement
+ .GetProperty("types")
+ .EnumerateArray()
+ .Single(t => t.GetProperty("name").GetString() == "Widget")
+ .GetProperty("members")
+ .EnumerateArray()
+ .Single(m => m.GetProperty("name").GetString() == "Run");
+
+ var remarks = method.GetProperty("docs").GetProperty("remarks").EnumerateArray().ToArray();
+ var list = remarks.Single(node => node.GetProperty("kind").GetString() == "list");
+ var items = list.GetProperty("items").EnumerateArray().ToArray();
+
+ Assert.Equal(2, items.Length);
+ Assert.Equal("The first condition is met.", items[0]
+ .GetProperty("description")
+ .EnumerateArray()
+ .Single()
+ .GetProperty("text")
+ .GetString());
+
+ var secondDescription = items[1]
+ .GetProperty("description")
+ .EnumerateArray()
+ .Single();
+ Assert.Equal("para", secondDescription.GetProperty("kind").GetString());
+ Assert.Equal("The second condition is met.", secondDescription
+ .GetProperty("children")
+ .EnumerateArray()
+ .Single()
+ .GetProperty("text")
+ .GetString());
+ }
+
+ [Fact]
+ public void GeneratePackageJson_NormalizesToLfAndSkipsRewritingUnchangedOutput()
+ {
+ using var assembly = TestAssembly.Create(
+ """
+ namespace Sample.Library;
+
+ public sealed class Widget
+ {
+ public string Name => "demo";
+ }
+ """);
+
+ var outputPath = Path.Combine(assembly.DirectoryPath, "Package.json");
+
+ PackageJsonGenerator.GeneratePackageJson(
+ assembly.AssemblyPath,
+ assembly.References,
+ outputPath,
+ versionOverride: "1.2.3",
+ packageNameOverride: "Sample.Package",
+ targetFrameworkOverride: "net8.0");
+
+ var initialContent = File.ReadAllText(outputPath);
+ Assert.DoesNotContain("\r", initialContent);
+
+ File.WriteAllText(outputPath, initialContent.Replace("\n", "\r\n", StringComparison.Ordinal));
+ File.SetLastWriteTimeUtc(outputPath, new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ var crlfWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ PackageJsonGenerator.GeneratePackageJson(
+ assembly.AssemblyPath,
+ assembly.References,
+ outputPath,
+ versionOverride: "1.2.3",
+ packageNameOverride: "Sample.Package",
+ targetFrameworkOverride: "net8.0");
+
+ var normalizedContent = File.ReadAllText(outputPath);
+ Assert.DoesNotContain("\r", normalizedContent);
+ Assert.NotEqual(crlfWriteTime, File.GetLastWriteTimeUtc(outputPath));
+
+ File.SetLastWriteTimeUtc(outputPath, new DateTime(2001, 1, 2, 0, 0, 0, DateTimeKind.Utc));
+ var unchangedWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ PackageJsonGenerator.GeneratePackageJson(
+ assembly.AssemblyPath,
+ assembly.References,
+ outputPath,
+ versionOverride: "1.2.3",
+ packageNameOverride: "Sample.Package",
+ targetFrameworkOverride: "net8.0");
+
+ Assert.Equal(unchangedWriteTime, File.GetLastWriteTimeUtc(outputPath));
+ }
+
+ private sealed class TestAssembly : IDisposable
+ {
+ private TestAssembly(string directoryPath, string assemblyPath, string[] references)
+ {
+ DirectoryPath = directoryPath;
+ AssemblyPath = assemblyPath;
+ References = references;
+ }
+
+ public string DirectoryPath { get; }
+
+ public string AssemblyPath { get; }
+
+ public string[] References { get; }
+
+ public static TestAssembly Create(string source)
+ {
+ var tempDirectory = Directory.CreateTempSubdirectory("pkg-generator-tests-");
+ var assemblyPath = Path.Combine(tempDirectory.FullName, "Sample.Library.dll");
+ var pdbPath = Path.ChangeExtension(assemblyPath, ".pdb");
+ var xmlPath = Path.ChangeExtension(assemblyPath, ".xml");
+
+ var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))
+ ?.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
+ ?? throw new InvalidOperationException("Trusted platform assemblies were not available.");
+
+ var references = trustedPlatformAssemblies
+ .Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "Sample.Library",
+ syntaxTrees: [CSharpSyntaxTree.ParseText(source)],
+ references: references.Select(reference => MetadataReference.CreateFromFile(reference)),
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ using var assemblyStream = File.Create(assemblyPath);
+ using var pdbStream = File.Create(pdbPath);
+ using var xmlStream = File.Create(xmlPath);
+
+ var emitResult = compilation.Emit(
+ peStream: assemblyStream,
+ pdbStream: pdbStream,
+ xmlDocumentationStream: xmlStream,
+ options: new Microsoft.CodeAnalysis.Emit.EmitOptions(
+ debugInformationFormat: Microsoft.CodeAnalysis.Emit.DebugInformationFormat.PortablePdb));
+
+ Assert.True(
+ emitResult.Success,
+ string.Join(Environment.NewLine, emitResult.Diagnostics.Select(d => d.ToString())));
+
+ return new TestAssembly(tempDirectory.FullName, assemblyPath, references);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(DirectoryPath, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+ }
+}
diff --git a/tests/scripts/Write-TestSummary.ps1 b/tests/scripts/Write-TestSummary.ps1
new file mode 100644
index 000000000..d70147e8e
--- /dev/null
+++ b/tests/scripts/Write-TestSummary.ps1
@@ -0,0 +1,164 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$TestResultsFolder,
+
+ [Parameter(Mandatory = $false)]
+ [string]$SummaryOutputPath = $env:GITHUB_STEP_SUMMARY,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ArtifactUrl,
+
+ [Parameter(Mandatory = $false)]
+ [string]$SummaryTitle = "Test Summary"
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+function Get-IntValue {
+ param($Value)
+
+ if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
+ return 0
+ }
+
+ return [int]$Value
+}
+
+function Get-ErrorText {
+ param($FailedTest)
+
+ if ($null -eq $FailedTest.Output) {
+ return "No error details found in TRX output."
+ }
+
+ if ($null -ne $FailedTest.Output.ErrorInfo) {
+ if ($null -ne $FailedTest.Output.ErrorInfo.Message -and -not [string]::IsNullOrWhiteSpace([string]$FailedTest.Output.ErrorInfo.Message)) {
+ return [string]$FailedTest.Output.ErrorInfo.Message
+ }
+
+ if ($null -ne $FailedTest.Output.ErrorInfo.InnerText -and -not [string]::IsNullOrWhiteSpace([string]$FailedTest.Output.ErrorInfo.InnerText)) {
+ return [string]$FailedTest.Output.ErrorInfo.InnerText
+ }
+ }
+
+ return "No error details found in TRX output."
+}
+
+if (-not $TestResultsFolder -or $TestResultsFolder.Count -eq 0) {
+ Write-Host "No test results directory was provided."
+ exit 0
+}
+
+$existingTestResultsFolders = @($TestResultsFolder | Where-Object { Test-Path $_ })
+if (-not $existingTestResultsFolders -or $existingTestResultsFolders.Count -eq 0) {
+ Write-Host ("No test results directories found. Looked in: {0}" -f ($TestResultsFolder -join ', '))
+ exit 0
+}
+
+$trxFiles = @($existingTestResultsFolders | ForEach-Object {
+ Get-ChildItem -Path $_ -Filter *.trx -Recurse -ErrorAction SilentlyContinue
+} | Sort-Object FullName -Unique)
+if (-not $trxFiles -or $trxFiles.Count -eq 0) {
+ Write-Host ("No .trx files found under: {0}" -f ($existingTestResultsFolders -join ', '))
+ exit 0
+}
+
+$summary = New-Object System.Text.StringBuilder
+[void]$summary.AppendLine("# $SummaryTitle")
+[void]$summary.AppendLine()
+
+if (-not [string]::IsNullOrWhiteSpace($ArtifactUrl)) {
+ [void]$summary.AppendLine("[Download test artifact]($ArtifactUrl)")
+ [void]$summary.AppendLine()
+}
+
+[void]$summary.AppendLine("## Test Runs")
+[void]$summary.AppendLine()
+[void]$summary.AppendLine("| Test Run | Passed | Failed | Skipped | Total |")
+[void]$summary.AppendLine("|----------|--------|--------|---------|-------|")
+
+$totalPassed = 0
+$totalFailed = 0
+$totalSkipped = 0
+$totalTests = 0
+
+foreach ($trxFile in $trxFiles) {
+ try {
+ [xml]$trx = Get-Content -Path $trxFile.FullName -Raw
+ $counters = $trx.TestRun.ResultSummary.Counters
+ if ($null -eq $counters) {
+ continue
+ }
+
+ $passed = Get-IntValue $counters.passed
+ $failed = Get-IntValue $counters.failed
+ $skipped = Get-IntValue $counters.notExecuted
+ $total = Get-IntValue $counters.total
+
+ $totalPassed += $passed
+ $totalFailed += $failed
+ $totalSkipped += $skipped
+ $totalTests += $total
+
+ $testRunName = if ([string]::IsNullOrWhiteSpace($ArtifactUrl)) {
+ $trxFile.BaseName
+ }
+ else {
+ "[$($trxFile.BaseName)]($ArtifactUrl)"
+ }
+
+ [void]$summary.AppendLine("| $testRunName | $passed | $failed | $skipped | $total |")
+
+ if ($failed -gt 0 -and $null -ne $trx.TestRun.Results.UnitTestResult) {
+ [void]$summary.AppendLine()
+ [void]$summary.AppendLine(('### Failed tests in {0}' -f $trxFile.BaseName))
+
+ foreach ($failedTest in @($trx.TestRun.Results.UnitTestResult) | Where-Object { $_.outcome -eq 'Failed' }) {
+ [void]$summary.AppendLine()
+ [void]$summary.AppendLine(('{0}
' -f $failedTest.testName))
+ [void]$summary.AppendLine()
+ [void]$summary.AppendLine('```text')
+ [void]$summary.AppendLine((Get-ErrorText -FailedTest $failedTest))
+
+ if ($null -ne $failedTest.Output -and $null -ne $failedTest.Output.StdOut -and -not [string]::IsNullOrWhiteSpace([string]$failedTest.Output.StdOut)) {
+ [void]$summary.AppendLine()
+ [void]$summary.AppendLine('StdOut:')
+ [void]$summary.AppendLine([string]$failedTest.Output.StdOut)
+ }
+
+ [void]$summary.AppendLine('```')
+ [void]$summary.AppendLine()
+ [void]$summary.AppendLine(' ')
+ }
+ }
+ }
+ catch {
+ Write-Warning ('Failed to parse {0}: {1}' -f $trxFile.FullName, $_)
+ }
+}
+
+$overall = @(
+ "## Overall",
+ "",
+ "| Passed | Failed | Skipped | Total |",
+ "|--------|--------|---------|-------|",
+ "| $totalPassed | $totalFailed | $totalSkipped | $totalTests |",
+ ""
+) -join [Environment]::NewLine
+
+$summaryText = $summary.ToString()
+$titleEndIndex = $summaryText.IndexOf([Environment]::NewLine)
+$overallInsertIndex = if ($titleEndIndex -ge 0) {
+ $titleEndIndex + [Environment]::NewLine.Length
+}
+else {
+ 0
+}
+[void]$summary.Insert($overallInsertIndex, "$overall$([Environment]::NewLine)")
+
+if (-not [string]::IsNullOrWhiteSpace($SummaryOutputPath)) {
+ $summary.ToString() | Out-File -FilePath $SummaryOutputPath -Encoding utf8 -Append
+}
+
+Write-Host $summary.ToString()