From ad7dfdead0d89f9465714190a677b0d49b3c8103 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:07:47 +0700 Subject: [PATCH 01/89] feat(sdk): add sdk project --- allure-csharp.slnx | 3 +++ src/Allure.Net.Sdk/Allure.Net.Sdk.csproj | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/Allure.Net.Sdk/Allure.Net.Sdk.csproj diff --git a/allure-csharp.slnx b/allure-csharp.slnx index c37dcc2e..8a240974 100644 --- a/allure-csharp.slnx +++ b/allure-csharp.slnx @@ -55,6 +55,9 @@ + + + diff --git a/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj b/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj new file mode 100644 index 00000000..0fba1233 --- /dev/null +++ b/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + Allure.Sdk + Provides the API and utilities for building Allure integrations. + Allure-Color.png + enable + + + + + + + + + + + + From d6718bed45b8cc1228651da7add168a936551d1c Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:28:55 +0700 Subject: [PATCH 02/89] feat(sdk): add destinations --- src/Allure.Net.Sdk/Allure.Net.Sdk.csproj | 4 + .../Results/FileSystemResultsDestination.cs | 153 ++++++++++++++++++ .../Results/IAllureResultsDestination.cs | 80 +++++++++ .../Results/InMemoryResultsDestination.cs | 131 +++++++++++++++ .../Results/NullResultsDestination.cs | 70 ++++++++ 5 files changed, 438 insertions(+) create mode 100644 src/Allure.Net.Sdk/Results/FileSystemResultsDestination.cs create mode 100644 src/Allure.Net.Sdk/Results/IAllureResultsDestination.cs create mode 100644 src/Allure.Net.Sdk/Results/InMemoryResultsDestination.cs create mode 100644 src/Allure.Net.Sdk/Results/NullResultsDestination.cs diff --git a/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj b/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj index 0fba1233..80658733 100644 --- a/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj +++ b/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/src/Allure.Net.Sdk/Results/FileSystemResultsDestination.cs b/src/Allure.Net.Sdk/Results/FileSystemResultsDestination.cs new file mode 100644 index 00000000..2f466c62 --- /dev/null +++ b/src/Allure.Net.Sdk/Results/FileSystemResultsDestination.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Allure.Model; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Allure.Sdk.Results; + +/// +/// A destination that represents a directory in the local file system. +/// +public class FileSystemResultsDestination : IAllureResultsDestination +{ + const int DefaultCopyBufferSize = 81920; + private readonly string outputDirectory; + private readonly JsonSerializerOptions serializerOptions; + + public FileSystemResultsDestination(string directoryPath, bool indentJson) + { + this.outputDirectory = directoryPath; + + this.serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = indentJson, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + new JsonStringEnumConverter( + namingPolicy: JsonNamingPolicy.CamelCase, + allowIntegerValues: false + ), + new JsonStringEnumConverter( + namingPolicy: JsonNamingPolicy.CamelCase, + allowIntegerValues: false + ), + new JsonStringEnumConverter( + namingPolicy: JsonNamingPolicy.CamelCase, + allowIntegerValues: false + ), + new JsonStringEnumConverter( + namingPolicy: JsonNamingPolicy.CamelCase, + allowIntegerValues: false + ), + }, + }; + } + + public void WriteTestResult(TestResult testResult) + { + this.WriteAllureObject(testResult, "-result.json"); + } + + public async Task WriteTestResultAsync(TestResult testResult, CancellationToken cancellationToken) + { + await this.WriteAllureObjectAsync(testResult, "-result.json"); + } + + public void WriteContainer(TestResultScope container) + { + this.WriteAllureObject(container, "-container.json"); + } + + public async Task WriteContainerAsync(TestResultScope container, CancellationToken cancellationToken) + { + await this.WriteAllureObjectAsync(container, "-container.json"); + } + + public void WriteGlobals(Globals globals) + { + this.WriteAllureObject(globals, "-globals.json"); + } + + public async Task WriteGlobalsAsync(Globals globals, CancellationToken cancellationToken) + { + await this.WriteAllureObjectAsync(globals, "-globals.json"); + } + + public void WriteAttachment(string outputFileName, Stream content) + { + using FileStream output = this.CreateOutput(outputFileName); + content.CopyTo(output); + } + + public async Task WriteAttachmentAsync( + string outputFileName, + Stream content, + CancellationToken cancellationToken + ) + { + using FileStream output = this.CreateOutput(outputFileName); + await content.CopyToAsync(output, DefaultCopyBufferSize, cancellationToken); + } + + public void CopyAttachment(string destinationFileName, string sourceFilePath) + { + var outputFilePath = Path.Combine(outputDirectory, destinationFileName); + File.Copy(sourceFilePath, outputFilePath); + } + + public async Task CopyAttachmentAsync( + string destinationFileName, + string sourceFilePath, + CancellationToken cancellationToken + ) + { + using var source = File.OpenRead(sourceFilePath); + using var output = this.CreateOutput(destinationFileName); + await source.CopyToAsync(output, DefaultCopyBufferSize, cancellationToken); + } + + FileStream CreateOutput(string name) + { + var outputFilePath = Path.Combine(outputDirectory, name); + + this.EnsureDirectory(); + + return File.OpenWrite(outputFilePath); + } + + void WriteAllureObject(object allureObject, string suffix) + { + using var fileStream = this.CreateAllureObjectOutputStream(suffix); + JsonSerializer.Serialize(fileStream, allureObject, serializerOptions); + } + + async Task WriteAllureObjectAsync(object allureObject, string suffix) + { + using var fileStream = this.CreateAllureObjectOutputStream(suffix); + await JsonSerializer.SerializeAsync(fileStream, allureObject, serializerOptions); + } + + FileStream CreateAllureObjectOutputStream(string suffix) + { + var uuid = Guid.NewGuid(); + var outputFileName = $"{uuid:N}{suffix}"; + var outputFilePath = Path.Combine(outputDirectory, outputFileName); + + this.EnsureDirectory(); + + return File.OpenWrite(outputFilePath); + } + + void EnsureDirectory() + { + if (!Directory.Exists(this.outputDirectory)) + { + Directory.CreateDirectory(this.outputDirectory); + } + } +} \ No newline at end of file diff --git a/src/Allure.Net.Sdk/Results/IAllureResultsDestination.cs b/src/Allure.Net.Sdk/Results/IAllureResultsDestination.cs new file mode 100644 index 00000000..31631877 --- /dev/null +++ b/src/Allure.Net.Sdk/Results/IAllureResultsDestination.cs @@ -0,0 +1,80 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Allure.Model; + +namespace Allure.Sdk.Results; + +/// +/// Represents a destination of Allure results. +/// +public interface IAllureResultsDestination +{ + /// + /// Writes a test result to the destination. + /// + void WriteTestResult(TestResult testResult); + + /// + /// Writes a test result to the destination. + /// + Task WriteTestResultAsync(TestResult testResult, CancellationToken cancellationToken); + + /// + /// Writes a container to the destination. + /// + void WriteContainer(TestResultScope scope); + + /// + /// Writes a container to the destination. + /// + Task WriteContainerAsync(TestResultScope scope, CancellationToken cancellationToken); + + /// + /// Writes a globals object to the destination. + /// + void WriteGlobals(Globals globals); + + /// + /// Writes a globals object to the destination. + /// + Task WriteGlobalsAsync(Globals globals, CancellationToken cancellationToken); + + /// + /// Writes an attachment file to the destination. + /// + /// A name of the attachment file in the output location. + /// A stream thad defines the content of the attachment. + void WriteAttachment(string outputFileName, Stream content); + + /// + /// Writes an attachment file to the destination. + /// + /// A name of the attachment file in the output location. + /// A stream thad defines the content of the attachment. + /// A cancellation token. + Task WriteAttachmentAsync( + string outputFileName, + Stream content, + CancellationToken cancellationToken + ); + + /// + /// Copies an attachment file from an existing file to the destination. + /// + /// A name of the file in the output location. + /// The path of the file. + void CopyAttachment(string destinationFileName, string sourceFilePath); + + /// + /// Copies an attachment file from an existing file to the destination. + /// + /// A name of the file in the output location. + /// The path of the file. + /// A cancellation token. + Task CopyAttachmentAsync( + string destinationFileName, + string sourceFilePath, + CancellationToken cancellationToken + ); +} \ No newline at end of file diff --git a/src/Allure.Net.Sdk/Results/InMemoryResultsDestination.cs b/src/Allure.Net.Sdk/Results/InMemoryResultsDestination.cs new file mode 100644 index 00000000..6e78dad0 --- /dev/null +++ b/src/Allure.Net.Sdk/Results/InMemoryResultsDestination.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Allure.Model; + +namespace Allure.Sdk.Results; + +/// +/// An destination that stores all results in memory. Useful for testing. +/// +public class InMemoryResultsDestination : IAllureResultsDestination +{ + readonly object monitor = new(); + + /// + /// Gets a list of all written test results. + /// + public List TestResults { get; } = []; + + /// + /// Gets a list of all written containers. + /// + public List TestContainers { get; } = []; + + /// + /// Gets a list of all written globals. + /// + public List Globals { get; } = []; + + /// + /// Gets a list of all written attachment content. + /// + public Dictionary ByteAttachments { get; } = []; + + /// + /// Gets a list of all copied attachment files. + /// + public Dictionary FileAttachments { get; } = []; + + /// + public void CopyAttachment(string destinationFileName, string sourceFilePath) + { + lock (this.monitor) + { + this.FileAttachments.Add(destinationFileName, sourceFilePath); + } + } + + /// + public Task CopyAttachmentAsync(string destinationFileName, string sourceFilePath, CancellationToken cancellationToken) + { + this.CopyAttachment(destinationFileName, sourceFilePath); + return Task.CompletedTask; + } + + /// + public void WriteAttachment(string outputFileName, Stream content) + { + using MemoryStream memoryStream = new(); + content.CopyTo(memoryStream); + this.ByteAttachments.Add(outputFileName, memoryStream.ToArray()); + } + + /// + public Task WriteAttachmentAsync(string outputFileName, Stream content, CancellationToken cancellationToken) + { + this.WriteAttachment(outputFileName, content); + return Task.CompletedTask; + } + + /// + public void WriteGlobals(Globals globals) + { + lock(this.monitor) + { + this.Globals.Add(globals); + } + } + + /// + public Task WriteGlobalsAsync(Globals globals, CancellationToken cancellationToken) + { + this.WriteGlobals(globals); + return Task.CompletedTask; + } + + /// + public void WriteContainer(TestResultScope container) + { + lock(this.monitor) + { + this.TestContainers.Add(container); + } + } + + /// + public Task WriteContainerAsync(TestResultScope container, CancellationToken cancellationToken) + { + this.WriteContainer(container); + return Task.CompletedTask; + } + + /// + public void WriteTestResult(TestResult testResult) + { + lock(this.monitor) + { + this.TestResults.Add(testResult); + } + } + + /// + public Task WriteTestResultAsync(TestResult testResult, CancellationToken cancellationToken) + { + this.WriteTestResult(testResult); + return Task.CompletedTask; + } + + public void Clear() + { + lock(this.monitor) + { + this.TestContainers.Clear(); + this.TestResults.Clear(); + this.Globals.Clear(); + this.ByteAttachments.Clear(); + this.FileAttachments.Clear(); + } + } +} diff --git a/src/Allure.Net.Sdk/Results/NullResultsDestination.cs b/src/Allure.Net.Sdk/Results/NullResultsDestination.cs new file mode 100644 index 00000000..0e89e4a3 --- /dev/null +++ b/src/Allure.Net.Sdk/Results/NullResultsDestination.cs @@ -0,0 +1,70 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Allure.Model; + +namespace Allure.Sdk.Results; + +/// +/// An absent destination that ignores everything written to it. +/// +public class NullResultsDestination : IAllureResultsDestination +{ + /// + public void CopyAttachment(string destinationFileName, string sourceFilePath) + { + } + + /// + public Task CopyAttachmentAsync( + string destinationFileName, + string sourceFilePath, + CancellationToken cancellationToken + ) => + Task.CompletedTask; + + /// + public void WriteAttachment(string outputFileName, Stream content) + { + } + + /// + public Task WriteAttachmentAsync( + string outputFileName, + Stream content, + CancellationToken cancellationToken + ) => + Task.CompletedTask; + + /// + public void WriteGlobals(Globals globals) + { + } + + /// + public Task WriteGlobalsAsync(Globals globals, CancellationToken cancellationToken) => + Task.CompletedTask; + + /// + public void WriteContainer(TestResultScope container) + { + } + + /// + public Task WriteContainerAsync(TestResultScope container, CancellationToken cancellationToken) => + Task.CompletedTask; + + /// + public void WriteTestResult(TestResult testResult) + { + } + + /// + public Task WriteTestResultAsync(TestResult testResult, CancellationToken cancellationToken) => + Task.CompletedTask; + + /// + /// A cached instance of the null destination. + /// + public static NullResultsDestination Instance { get; } = new(); +} \ No newline at end of file From fbb7c040c99e04011608f341d9039053668d4f78 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:19:06 +0700 Subject: [PATCH 03/89] feat(sdk): add remaining features --- .../CompilerHints/IsExternalInit.cs | 21 + .../CompilerHints/MaybeNullWhenAttribute.cs | 15 + .../CompilerHints/NotNullWhenAttribute.cs | 14 + .../Configuration/AllureConfiguration.cs | 32 + .../Configuration/AllureLinkTemplate.cs | 6 + .../IAllureConfigurationSource.cs | 11 + .../JsonFileConfigurationSource.cs | 160 +++++ .../LambdaConfigurationSource.cs | 29 + src/Allure.Net.Sdk/Functions/IdFunctions.cs | 266 ++++++++ .../Functions/ModelFunctions.cs | 267 ++++++++ src/Allure.Net.Sdk/Internal/ReadLockScope.cs | 21 + .../ReaderWriterLockSlimExtensions.cs | 24 + .../AllureInProcessEndpointBuilder.cs | 79 +++ .../AllureRegistrationDependencies.cs | 27 + .../DefaultParameterSerializerBuilder.cs | 63 ++ .../Internal/RuleBasedParameterSerializer.cs | 73 ++ .../Runtime/AllureInProcessRuntimeEndpoint.cs | 27 + .../Internal/Runtime/AllureRuntime.cs | 33 + .../Internal/Runtime/AllureRuntimeRoute.cs | 24 + .../Runtime/AsyncLocalRuntimeContext.cs | 101 +++ .../RuntimeBoundAsyncFixtureContext.cs | 56 ++ .../Runtime/RuntimeBoundAsyncOperations.cs | 554 +++++++++++++++ .../Runtime/RuntimeBoundAsyncStepContext.cs | 67 ++ .../Runtime/RuntimeBoundLifecycleApi.cs | 118 ++++ .../Internal/Runtime/RuntimeBoundModelApi.cs | 136 ++++ .../Runtime/RuntimeBoundSyncFixtureContext.cs | 53 ++ .../Runtime/RuntimeBoundSyncOperations.cs | 630 ++++++++++++++++++ .../Runtime/RuntimeBoundSyncStepContext.cs | 55 ++ src/Allure.Net.Sdk/Internal/TypeFunctions.cs | 20 + src/Allure.Net.Sdk/Internal/WriteLockScope.cs | 21 + .../AllureRegistrationContextExtensions.cs | 57 ++ .../AllureRegistrationDefaults.cs | 50 ++ .../AllureRuntimeRegistrationContext.cs | 140 ++++ .../IAllureEndpointRegistrationContext.cs | 18 + ...ureInProcessEndpointRegistrationContext.cs | 25 + .../IAllureIntegrationRegistrationContext.cs | 27 + .../IAllureRegistrationContext.cs | 9 + .../IAllureRegistrationDependencies.cs | 15 + .../Registration/IAllureRegistrationHook.cs | 8 + .../IAllureRuntimeRegistrationContext.cs | 28 + .../IAllureRuntimeRegistrationHook.cs | 14 + .../Registration/ILateBoundReferenceView.cs | 8 + ...IParameterSerializerRegistrationContext.cs | 20 + .../Registration/LateBoundReference.cs | 26 + ...SerializerRegistrationContextExtensions.cs | 33 + .../Runtime/AllureExecutionState.cs | 298 +++++++++ .../Runtime/IAllureLifecycleApi.cs | 28 + src/Allure.Net.Sdk/Runtime/IAllureModelApi.cs | 44 ++ src/Allure.Net.Sdk/Runtime/IAllureRuntime.cs | 26 + .../Runtime/IAllureRuntimeContext.cs | 136 ++++ .../IParameterSerializationRule.cs | 8 + .../JsonParameterSerializationRule.cs | 27 + .../LambdaParameterSerializationRule.cs | 15 + .../ToStringParameterSerializationRule.cs | 14 + .../TypedParameterSerializationRule.cs | 20 + 55 files changed, 4097 insertions(+) create mode 100644 src/Allure.Net.Sdk/CompilerHints/IsExternalInit.cs create mode 100644 src/Allure.Net.Sdk/CompilerHints/MaybeNullWhenAttribute.cs create mode 100644 src/Allure.Net.Sdk/CompilerHints/NotNullWhenAttribute.cs create mode 100644 src/Allure.Net.Sdk/Configuration/AllureConfiguration.cs create mode 100644 src/Allure.Net.Sdk/Configuration/AllureLinkTemplate.cs create mode 100644 src/Allure.Net.Sdk/Configuration/IAllureConfigurationSource.cs create mode 100644 src/Allure.Net.Sdk/Configuration/JsonFileConfigurationSource.cs create mode 100644 src/Allure.Net.Sdk/Configuration/LambdaConfigurationSource.cs create mode 100644 src/Allure.Net.Sdk/Functions/IdFunctions.cs create mode 100644 src/Allure.Net.Sdk/Functions/ModelFunctions.cs create mode 100644 src/Allure.Net.Sdk/Internal/ReadLockScope.cs create mode 100644 src/Allure.Net.Sdk/Internal/ReaderWriterLockSlimExtensions.cs create mode 100644 src/Allure.Net.Sdk/Internal/Registration/AllureInProcessEndpointBuilder.cs create mode 100644 src/Allure.Net.Sdk/Internal/Registration/AllureRegistrationDependencies.cs create mode 100644 src/Allure.Net.Sdk/Internal/Registration/DefaultParameterSerializerBuilder.cs create mode 100644 src/Allure.Net.Sdk/Internal/RuleBasedParameterSerializer.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/AllureInProcessRuntimeEndpoint.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/AllureRuntime.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/AllureRuntimeRoute.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/AsyncLocalRuntimeContext.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundAsyncFixtureContext.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundAsyncOperations.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundAsyncStepContext.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundLifecycleApi.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundModelApi.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundSyncFixtureContext.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundSyncOperations.cs create mode 100644 src/Allure.Net.Sdk/Internal/Runtime/RuntimeBoundSyncStepContext.cs create mode 100644 src/Allure.Net.Sdk/Internal/TypeFunctions.cs create mode 100644 src/Allure.Net.Sdk/Internal/WriteLockScope.cs create mode 100644 src/Allure.Net.Sdk/Registration/AllureRegistrationContextExtensions.cs create mode 100644 src/Allure.Net.Sdk/Registration/AllureRegistrationDefaults.cs create mode 100644 src/Allure.Net.Sdk/Registration/AllureRuntimeRegistrationContext.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureEndpointRegistrationContext.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureInProcessEndpointRegistrationContext.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureIntegrationRegistrationContext.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureRegistrationContext.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureRegistrationDependencies.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureRegistrationHook.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureRuntimeRegistrationContext.cs create mode 100644 src/Allure.Net.Sdk/Registration/IAllureRuntimeRegistrationHook.cs create mode 100644 src/Allure.Net.Sdk/Registration/ILateBoundReferenceView.cs create mode 100644 src/Allure.Net.Sdk/Registration/IParameterSerializerRegistrationContext.cs create mode 100644 src/Allure.Net.Sdk/Registration/LateBoundReference.cs create mode 100644 src/Allure.Net.Sdk/Registration/ParameterSerializerRegistrationContextExtensions.cs create mode 100644 src/Allure.Net.Sdk/Runtime/AllureExecutionState.cs create mode 100644 src/Allure.Net.Sdk/Runtime/IAllureLifecycleApi.cs create mode 100644 src/Allure.Net.Sdk/Runtime/IAllureModelApi.cs create mode 100644 src/Allure.Net.Sdk/Runtime/IAllureRuntime.cs create mode 100644 src/Allure.Net.Sdk/Runtime/IAllureRuntimeContext.cs create mode 100644 src/Allure.Net.Sdk/Serialization/IParameterSerializationRule.cs create mode 100644 src/Allure.Net.Sdk/Serialization/JsonParameterSerializationRule.cs create mode 100644 src/Allure.Net.Sdk/Serialization/LambdaParameterSerializationRule.cs create mode 100644 src/Allure.Net.Sdk/Serialization/ToStringParameterSerializationRule.cs create mode 100644 src/Allure.Net.Sdk/Serialization/TypedParameterSerializationRule.cs diff --git a/src/Allure.Net.Sdk/CompilerHints/IsExternalInit.cs b/src/Allure.Net.Sdk/CompilerHints/IsExternalInit.cs new file mode 100644 index 00000000..4c9e70a3 --- /dev/null +++ b/src/Allure.Net.Sdk/CompilerHints/IsExternalInit.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +#pragma warning disable IDE0130 + +namespace System.Runtime.CompilerServices; + +/// +/// This class serves as an init-only setter modreq to make a library that +/// uses init only setters compile against pre-net5.0 TFMs (including .NET +/// Standard). See +/// +/// this article +/// +/// and +/// +/// this answer +/// +/// for more details. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class IsExternalInit { } diff --git a/src/Allure.Net.Sdk/CompilerHints/MaybeNullWhenAttribute.cs b/src/Allure.Net.Sdk/CompilerHints/MaybeNullWhenAttribute.cs new file mode 100644 index 00000000..67e159d8 --- /dev/null +++ b/src/Allure.Net.Sdk/CompilerHints/MaybeNullWhenAttribute.cs @@ -0,0 +1,15 @@ +namespace System.Diagnostics.CodeAnalysis; + +/// +/// This attribute hints the compiler that the parameter it's applied to +/// has the nullability described by it type, but it may additionally be null +/// when the method returns a specified value. +/// It's not included in netstandard2.0 but can be define it the project's code. +/// +/// +/// A return value that indicated the argument is not null +/// +[AttributeUsage(AttributeTargets.Parameter)] +internal sealed class MaybeNullWhenAttribute(bool returnValue) : Attribute { + public bool ReturnValue { get; } = returnValue; +} \ No newline at end of file diff --git a/src/Allure.Net.Sdk/CompilerHints/NotNullWhenAttribute.cs b/src/Allure.Net.Sdk/CompilerHints/NotNullWhenAttribute.cs new file mode 100644 index 00000000..a52915cb --- /dev/null +++ b/src/Allure.Net.Sdk/CompilerHints/NotNullWhenAttribute.cs @@ -0,0 +1,14 @@ +namespace System.Diagnostics.CodeAnalysis; + +/// +/// This attribute hints the compiler that the parameter it's applied to +/// is not null if the method returns a specified value. +/// It's not included in netstandard2.0 but can be define it the project's code. +/// +/// +/// A return value that indicated the argument is not null +/// +[AttributeUsage(AttributeTargets.Parameter)] +internal sealed class NotNullWhenAttribute(bool returnValue) : Attribute { + public bool ReturnValue { get; } = returnValue; +} \ No newline at end of file diff --git a/src/Allure.Net.Sdk/Configuration/AllureConfiguration.cs b/src/Allure.Net.Sdk/Configuration/AllureConfiguration.cs new file mode 100644 index 00000000..f43e2e5e --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/AllureConfiguration.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Immutable; +using System.IO; + +namespace Allure.Sdk.Configuration; + +public class AllureConfiguration +{ + readonly string resultsDirecory = + Path.Combine(Environment.CurrentDirectory, "allure-results"); + + public string Hostname { get; init; } = Environment.MachineName; + + public string ResultsDirectory + { + get => this.resultsDirecory; + init + { + this.resultsDirecory = Path.GetFullPath(value); + } + } + + public ImmutableDictionary LinkTemplates { get; init; } = []; + + public ImmutableList FailExceptions { get; init; } = []; + + public bool IndentOutput { get; init; } = false; + + public ImmutableDictionary GlobalLabels { get; init; } = []; + + public string? RegistrationHook { get; init; } +} diff --git a/src/Allure.Net.Sdk/Configuration/AllureLinkTemplate.cs b/src/Allure.Net.Sdk/Configuration/AllureLinkTemplate.cs new file mode 100644 index 00000000..5a7d0339 --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/AllureLinkTemplate.cs @@ -0,0 +1,6 @@ +namespace Allure.Sdk.Configuration; + +public record class AllureLinkTemplate( + string UrlTemplate, + string? NameTemplate +); diff --git a/src/Allure.Net.Sdk/Configuration/IAllureConfigurationSource.cs b/src/Allure.Net.Sdk/Configuration/IAllureConfigurationSource.cs new file mode 100644 index 00000000..894997d3 --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/IAllureConfigurationSource.cs @@ -0,0 +1,11 @@ +namespace Allure.Sdk.Configuration; + +public interface IAllureConfigurationSource + where TConfig : AllureConfiguration +{ + string Name { get; } + + bool CanLoad { get; } + + TConfig LoadConfiguration(); +} diff --git a/src/Allure.Net.Sdk/Configuration/JsonFileConfigurationSource.cs b/src/Allure.Net.Sdk/Configuration/JsonFileConfigurationSource.cs new file mode 100644 index 00000000..5e12fb1c --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/JsonFileConfigurationSource.cs @@ -0,0 +1,160 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Allure.Sdk.Configuration; + +public class JsonFileConfigurationSource(string path) : + IAllureConfigurationSource + + where TConfiguration : AllureConfiguration, new() +{ + static readonly JsonSerializerOptions serializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public string Name => $"json from {path}"; + + public bool CanLoad => path is { Length: >0 }; + + public TConfiguration LoadConfiguration() + { + var configFullPath = Path.GetFullPath(path); + if (!File.Exists(configFullPath)) + { + throw new FileNotFoundException( + $"The configuration file '{configFullPath}' does not exist." + ); + } + + using var stream = File.OpenRead(configFullPath); + var root = JsonNode.Parse(stream) as JsonObject + ?? throw new JsonException( + "The Allure configuration file must contain a JSON object." + ); + + var configurationObject = GetConfigurationObject(root); + NormalizeLegacyProperties(configurationObject); + + return configurationObject.Deserialize(serializerOptions) + ?? new(); + } + + static JsonObject GetConfigurationObject(JsonObject root) => + root.TryGetPropertyValue("allure", out var allureNode) + ? (allureNode as JsonObject + ?? throw new InvalidOperationException( + "The 'allure' property must contain a JSON object")) + : root; + + void NormalizeLegacyProperties(JsonObject configuration) + { + if (!configuration.ContainsKey("hostname") + && configuration.TryGetPropertyValue("title", out var titleNode) + && titleNode?.GetValueKind() is JsonValueKind.String) + { + configuration["hostname"] = titleNode.DeepClone(); + } + + if (!configuration.ContainsKey("resultsDirectory")) + { + if (configuration.TryGetPropertyValue("directory", out var directoryNode) + && directoryNode?.GetValueKind() is JsonValueKind.String) + { + configuration["resultsDirectory"] = directoryNode.DeepClone(); + } + } + + if (!configuration.ContainsKey("linkTemplates") + && configuration.TryGetPropertyValue("links", out var linksNode) + && linksNode?.GetValueKind() is JsonValueKind.Array) + { + configuration["linkTemplates"] = ConvertLegacyLinks(linksNode.AsArray()); + } + + configuration.Remove("title"); + configuration.Remove("directory"); + configuration.Remove("links"); + } + + JsonNode? ConvertLegacyLinks(JsonArray jsonArray) + { + var result = new JsonObject(); + + foreach (var item in jsonArray) + { + if (item is not JsonValue value + || !value.TryGetValue(out var link)) + { + throw new JsonException( + "Every item in the legacy 'links' array must be a string." + ); + } + + if (TryParseLegacyLink(link, out var type, out var urlTemplate)) + { + result[type] = new JsonObject + { + ["urlTemplate"] = urlTemplate, + }; + } + } + + return result; + } + + bool TryParseLegacyLink(string link, out string type, out string urlTemplate) + { + for (var open = link.IndexOf('{'); open >= 0; open = link.IndexOf('{', open + 1)) + { + if (open + 1 >= link.Length) + { + break; + } + + if (link[open + 1] == '{') + { + open++; + continue; + } + + var close = link.IndexOf('}', open + 1); + if (close < 0 || close == open + 1) + { + continue; + } + + type = link.Substring(open + 1, close - open - 1); + urlTemplate = $"{link.Substring(0, open)}{{0}}{link.Substring(close + 1)}"; + return true; + } + + type = ""; + urlTemplate = ""; + return false; + } +} + +public static class JsonFileConfigurationSource +{ + public static JsonFileConfigurationSource FromEnvironmentVariable( + string environmentVariableName + ) + where TConfiguration : AllureConfiguration, new() + => new( + Environment.GetEnvironmentVariable(environmentVariableName) + ); + + public static JsonFileConfigurationSource FromEnvironmentVariable() + where TConfiguration : AllureConfiguration, new() + => + FromEnvironmentVariable("ALLURE_CONFIG"); + + public static JsonFileConfigurationSource FromBaseDirectory() + where TConfiguration : AllureConfiguration, new() + => + new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "allureConfig.json")); +} diff --git a/src/Allure.Net.Sdk/Configuration/LambdaConfigurationSource.cs b/src/Allure.Net.Sdk/Configuration/LambdaConfigurationSource.cs new file mode 100644 index 00000000..32822169 --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/LambdaConfigurationSource.cs @@ -0,0 +1,29 @@ +using System; + +namespace Allure.Sdk.Configuration; + +public sealed class LambdaConfigurationSource( + string name, + Func factory +) : + IAllureConfigurationSource + + where TConfiguration : AllureConfiguration +{ + public string Name => name; + + public bool CanLoad => true; + + public TConfiguration LoadConfiguration() => factory(); +} + +public static class LambdaConfigurationSource +{ + public static LambdaConfigurationSource Create( + string name, + Func configurationFactory + ) + where TConfiguration : AllureConfiguration + => + new(name, configurationFactory); +} diff --git a/src/Allure.Net.Sdk/Functions/IdFunctions.cs b/src/Allure.Net.Sdk/Functions/IdFunctions.cs new file mode 100644 index 00000000..8fbe9b91 --- /dev/null +++ b/src/Allure.Net.Sdk/Functions/IdFunctions.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Allure.Model; + +namespace Allure.Sdk.Functions; + +/// +/// Functions to create UUIDs, full names, etc. +/// +public static class IdFunctions +{ + /// + /// Generates a UUID for a test or container. + /// + public static string CreateUUID() => Guid.NewGuid().ToString(); + + /// + /// Creates a titlePath: a path to a test class in a tree of test results. + /// + /// A type representing a test class + /// + /// A titlePath consists of: + /// + /// assembly name + /// elements of namespace + /// name of type (including its declaring types, if any) + /// type parameters (for generic type definitions) + /// type arguments (for constructed generic types) + /// + /// The type node can be renamed by applying to the class. + /// + public static List CreateTitlePath(Type type) + { + static IEnumerable ExpandNestness(Type type) + { + for (; type.IsNested; type = type.DeclaringType) + yield return type.Name; + yield return type.Name; + } + + var assemblyName = type.Assembly.GetName().Name; + var namespaceParts = (type.Namespace ?? "").Split('.').Where(s => s.Length > 0); + var typeNode = type.GetCustomAttribute()?.Name + ?? (string.Join("+", ExpandNestness(type).Reverse()) + + SerializeTypeParameterTypeList( + type.GetGenericArguments())); + + return [ + assemblyName, + .. namespaceParts, + typeNode, + ]; + } + + /// + /// Creates a titlePath: a path to a test class in a tree of test results. + /// + /// A test method + /// + /// A titlePath consists of: + /// + /// assembly name + /// elements of namespace + /// name of type (including its declaring types, if any) + /// type parameters (for generic type definitions) + /// type arguments (for constructed generic types) + /// method name with type parameters and parameter types (for parameterized method) + /// + /// The type and method nodes can be renamed by applying . + /// + public static List CreateTitlePath(MethodInfo method) + { + var titlePath = CreateTitlePath(method.DeclaringType); + if (method.GetParameters().Length > 0) + { + titlePath.Add( + method.GetCustomAttribute()?.Name + ?? GetMethodId(method) + ); + } + return titlePath; + } + + /// + /// Creates a name that uniquely identifies a given type. + /// + /// + /// A fully-qualified name of a type includes: + /// + /// assembly name + /// namespace (if any) + /// name of type (including its declaring types, if any) + /// type parameters (for generic type definitions) + /// type arguments (for constructed generic types) + /// + /// Unlike , it doesn't include assembly + /// versions and other metadata that can be subject to change. + /// Unlike , it includes assembly names to + /// the name of the type and its type arguments, which prevents collisions + /// in some scenarios. + ///
+ /// For a generic parameters (like T in class Foo<T> { }), returns just its name. + ///
+ public static string GetTypeId(Type type) => + type.IsGenericParameter ? type.Name : SerializeNonParameterType(type); + + /// + /// Creates a name that uniquely identifies a given method in its declaring type. + /// + /// + /// A method. + /// If it's a constructed generic method, its generic definition is used instead. + /// + /// + /// For a given test method, its id includes: + /// + /// name + /// type parameters (like T in void Foo<T>() { }) + /// parameter types (like System.String in void Foo(string bar) { }) + /// + /// + public static string GetMethodId(MethodInfo method) + { + if (method.IsGenericMethod && !method.IsGenericMethodDefinition) + { + method = method.GetGenericMethodDefinition(); + } + + var methodName = method.Name; + var typeParameters = method.GetGenericArguments(); + var typeParametersDecl = SerializeTypeParameterTypeList(typeParameters); + var parameterTypes = SerializeParameterTypes(method.GetParameters()); + return $"{methodName}{typeParametersDecl}({parameterTypes})"; + } + + /// + /// Creates a string that unuquely identifies a given method. + /// + /// + /// A method. + /// If it's a constructed generic method, its generic definition is used instead. + /// + /// + /// For a given test method the full name includes: + /// + /// assembly name + /// namespace (if any) + /// name of type (including its declaring types, if any) + /// type parameters of the declaring type (for generic type definitions) + /// type arguments of the declaring type (for constructed generic types) + /// the method's name + /// type parameters of the method (if any) + /// parameter types + /// + /// + public static string CreateFullName(MethodInfo method) => + $"{GetTypeId(method.DeclaringType)}.{GetMethodId(method)}"; + + /// + /// Creates a testCaseId value. testCaseId has a fixed length and depends + /// only on a given fullName. The fullName shouldn't depend on test parameters. + /// + public static string CreateTestCaseId(string fullName) => + ToMD5(fullName); + + /// + /// Creates a historyId value to be used by Allure Reporter. historyId has a + /// fixed length and depends on a fullName and parameters of a test. + /// Howewer, it doesn't depend on parametrs order as well as on parameter + /// names in general. Parameters are sorted alphabetically by their names. + /// Then, only the values are used to produce the final historyId value. + /// + /// The fullName of a test. + /// The parameters of a test. + public static string CreateHistoryId( + string fullName, + IEnumerable parameters + ) => + ToMD5( + JsonSerializer.Serialize( + new + { + fullName, + parameters = parameters.Where(p => !p.Excluded) + .OrderBy(p => p.Name) + .Select(p => p.Value) + } + ) + ); + + static string ToMD5(string input) + { + using var md5 = System.Security.Cryptography.MD5.Create(); + var inputBytes = Encoding.UTF8.GetBytes(input); + var outputBytes = md5.ComputeHash(inputBytes); + return ToHexString(outputBytes); + } + + static string ToHexString(byte[] inputBytes) + { + var sb = new StringBuilder(); + foreach (byte b in inputBytes) + { + sb.Append( + b.ToString("x2") + ); + } + return sb.ToString(); + } + + static string SerializeParameterTypes( + IEnumerable parameters + ) => + SerializeTypeList( + parameters.Select(p => p.ParameterType) + ); + + static string SerializeTypeParameterTypeList(IEnumerable types) => + types.Any() ? SerializeNonEmptyTypeParameterTypeList(types) : ""; + + static string SerializeNonEmptyTypeParameterTypeList(IEnumerable types) => + "[" + SerializeTypeList(types) + "]"; + + static string SerializeTypeList( + IEnumerable types + ) => + string.Join( + ",", + types.Select(GetTypeId) + ); + + static string SerializeNonParameterType(Type type) => + GetUniqueTypeName(type) + SerializeTypeParameterTypeList( + type.GetGenericArguments() + ); + + static string GetUniqueTypeName(Type type) => + IsSystemType(type) + ? ConstructFullName(type) + : GetTypeNameWithAssembly(type); + + static string ConstructFullName(Type type) => + type.IsNested + ? ConstructFullNameOfNestedType(type) + : ConstructFullNameOfOutmostType(type); + + static string ConstructFullNameOfNestedType(Type type) => + ConstructFullName(type.DeclaringType) + "+" + type.Name; + + static string ConstructFullNameOfOutmostType(Type type) => + string.IsNullOrEmpty(type.Namespace) + ? type.Name + : $"{type.Namespace}.{type.Name}"; + + static string GetTypeNameWithAssembly(Type type) => + $"{type.Assembly.GetName().Name}:" + ConstructFullName(type); + + static bool IsSystemType(Type type) => + type.Assembly == systemTypesAssembly; + + static readonly Assembly systemTypesAssembly = typeof(object).Assembly; +} \ No newline at end of file diff --git a/src/Allure.Net.Sdk/Functions/ModelFunctions.cs b/src/Allure.Net.Sdk/Functions/ModelFunctions.cs new file mode 100644 index 00000000..a09c4f30 --- /dev/null +++ b/src/Allure.Net.Sdk/Functions/ModelFunctions.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading; +using Allure.Abstractions; +using Allure.Model; +using Allure.Sdk.Configuration; +using Allure.Sdk.Internal; + +namespace Allure.Sdk.Functions; + +/// +/// Contains functions to help implementing Allure model-related conversions. +/// +public static class ModelFunctions +{ + /// + /// Checks if an exception type, one of its base types, or one of the + /// interfaces it implements exists in the list of known execption types. + /// + /// The list of known exception types. + /// The exception to check. + public static bool IsKnownError(IEnumerable knownErrorBases, Exception e) => + knownErrorBases + ?.Intersect( + GetExceptionClassChain(e) + ) + ?.Any() == true; + + /// + /// Returns a if a given exception represents + /// an assertion error. Otherwise, returns . + /// + /// + /// The list of exception types. Exceptions of those types (including + /// subclasses) are considered assertion errors. This list typically comes + /// from the configuration associated with the current lifecycle instance. + /// + /// The exception to convert. + /// + public static Status ResolveErrorStatus( + IEnumerable failExceptions, + Exception e + ) => + IsKnownError(failExceptions, e) + ? Status.Failed + : Status.Broken; + + /// + /// Converts an exception to the status details. + /// + /// The exception to convert. + public static StatusDetails? ToStatusDetails(Exception? e) => + e is null + ? null + : new() + { + Message = string.IsNullOrEmpty(e.Message) + ? e.GetType().Name + : e.Message, + Trace = e.ToString() + }; + + /// + /// Checks if the test result contains a suite-hierarchy label, i.e., one + /// of the parentSuite, suite, or subSuite labels. If + /// not, adds the provided default values to the list of labels. Otherwise, + /// leaves the test result as is. + /// + /// A test result to modify + /// + /// A value for the parentSuite label. If null or empty, the label + /// won't be added + /// + /// + /// A value for the suite label. If null or empty, the label won't + /// be added + /// + /// + /// A value for the subSuite label. If null or empty, the label won't + /// be added + /// + public static void EnsureSuites( + TestResult testResult, + string? parentSuite, + string? suite, + string? subSuite + ) + { + var labels = testResult.Labels; + if (labels.Any(IsSuiteLabel)) + { + return; + } + + if (!string.IsNullOrEmpty(parentSuite)) + { + labels.Add(Label.ParentSuite(parentSuite!)); + } + + if (!string.IsNullOrEmpty(suite)) + { + labels.Add(Label.Suite(suite!)); + } + + if (!string.IsNullOrEmpty(subSuite)) + { + labels.Add(Label.SubSuite(subSuite!)); + } + } + + /// + /// Returns a sequence of labels defined by the environment variables in form + /// of ALLURE_LABEL_<name>=<value> + /// + public static IEnumerable