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()