diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bff2313c..f1c89d8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,7 @@ name: Test env: SOLUTION_PATH: allure-csharp.slnx BUILD_CONFIGURATION: 'Release' + PACK_CONFIGURATION: 'Publish' on: workflow_dispatch: @@ -53,7 +54,7 @@ jobs: dotnet pack ${{ env.SOLUTION_PATH }}\ --no-restore\ --no-build\ - --configuration ${{ env.BUILD_CONFIGURATION }} + --configuration ${{ env.PACK_CONFIGURATION }} - name: Build test samples run: | @@ -77,6 +78,10 @@ jobs: --no-restore\ --no-build\ --configuration ${{ env.BUILD_CONFIGURATION }} + npx -y allure@3 run --config ./.allurerc.mjs --rerun 2 --environment="${{ env.BUILD_CONFIGURATION }}" --dump="allure-results-sdk" -- dotnet run --project ./tests/Allure.Net.Sdk.Tests\ + --no-restore\ + --no-build\ + --configuration ${{ env.BUILD_CONFIGURATION }} npx -y allure@3 run --config ./.allurerc.mjs --rerun 2 --environment="${{ env.BUILD_CONFIGURATION }}" --dump="allure-results-testing-platform" -- dotnet run --project ./tests/Allure.TestingPlatform.Tests\ --no-restore\ --no-build\ diff --git a/allure-csharp.slnx b/allure-csharp.slnx index c37dcc2e..33a6178c 100644 --- a/allure-csharp.slnx +++ b/allure-csharp.slnx @@ -55,6 +55,9 @@ + + + @@ -78,6 +81,9 @@ + + + diff --git a/src/Allure.Net.Commons/Functions/ModelFunctions.cs b/src/Allure.Net.Commons/Functions/ModelFunctions.cs index 78be7912..7a0199e8 100644 --- a/src/Allure.Net.Commons/Functions/ModelFunctions.cs +++ b/src/Allure.Net.Commons/Functions/ModelFunctions.cs @@ -19,7 +19,7 @@ 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. + /// interfaces it implements exists in the list of known excecption types. /// /// The list of known exception types. /// The exception to check. 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..0e89aa73 --- /dev/null +++ b/src/Allure.Net.Sdk/Allure.Net.Sdk.csproj @@ -0,0 +1,25 @@ + + + + netstandard2.0 + Allure.Sdk + Provides the API and utilities for building Allure integrations. + Allure-Color.png + enable + + + + + + + + + + + + + + + + + diff --git a/src/Allure.Net.Sdk/CompilerHints/IsExternalInit.cs b/src/Allure.Net.Sdk/CompilerHints/IsExternalInit.cs new file mode 100644 index 00000000..ba330159 --- /dev/null +++ b/src/Allure.Net.Sdk/CompilerHints/IsExternalInit.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; + +#pragma warning disable IDE0130 + +namespace System.Runtime.CompilerServices; + +/// +/// Provides the compiler-required modifier type for init-only setters when +/// targeting frameworks earlier than .NET 5, 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..4222cf8c --- /dev/null +++ b/src/Allure.Net.Sdk/CompilerHints/MaybeNullWhenAttribute.cs @@ -0,0 +1,14 @@ +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Indicates that the parameter may be when the method +/// returns the specified value. +/// This attribute is not included in .NET Standard 2.0, so it is defined locally. +/// +/// +/// The return value for which the parameter may be . +/// +[AttributeUsage(AttributeTargets.Parameter)] +internal sealed class MaybeNullWhenAttribute(bool returnValue) : Attribute { + public bool ReturnValue { get; } = returnValue; +} diff --git a/src/Allure.Net.Sdk/CompilerHints/NotNullWhenAttribute.cs b/src/Allure.Net.Sdk/CompilerHints/NotNullWhenAttribute.cs new file mode 100644 index 00000000..f477f7e0 --- /dev/null +++ b/src/Allure.Net.Sdk/CompilerHints/NotNullWhenAttribute.cs @@ -0,0 +1,14 @@ +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Indicates that the parameter is not when the method +/// returns the specified value. +/// This attribute is not included in .NET Standard 2.0, so it is defined locally. +/// +/// +/// The return value for which the parameter is not . +/// +[AttributeUsage(AttributeTargets.Parameter)] +internal sealed class NotNullWhenAttribute(bool returnValue) : Attribute { + public bool ReturnValue { get; } = returnValue; +} diff --git a/src/Allure.Net.Sdk/Configuration/AllureConfiguration.cs b/src/Allure.Net.Sdk/Configuration/AllureConfiguration.cs new file mode 100644 index 00000000..26bdbb90 --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/AllureConfiguration.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Immutable; +using System.IO; + +namespace Allure.Sdk.Configuration; + +/// +/// Defines the common configuration used by an Allure runtime. +/// +public record class AllureConfiguration +{ + readonly string resultsDirectory = + Path.Combine(Environment.CurrentDirectory, "allure-results"); + + /// + /// Gets the host name recorded in generated test results. + /// + public string Hostname { get; init; } = Environment.MachineName; + + /// + /// Gets the absolute path of the directory where Allure result files are written. + /// + public string ResultsDirectory + { + get => this.resultsDirectory; + init + { + this.resultsDirectory = Path.GetFullPath(value); + } + } + + /// + /// Gets the link templates indexed by link type. + /// + public ImmutableDictionary LinkTemplates { get; init; } = []; + + /// + /// Gets the exception type names that Allure treats as test failures. + /// + public ImmutableList FailExceptions { get; init; } = []; + + /// + /// Gets a value indicating whether generated JSON files are indented. + /// + public bool IndentOutput { get; init; } = false; + + /// + /// Gets the labels applied to every test result. + /// + public ImmutableDictionary GlobalLabels { get; init; } = []; + + /// + /// Gets the assembly-qualified name of the runtime registration hook type. + /// + public string? RuntimeRegistrationHook { 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..1d49c7ec --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/AllureLinkTemplate.cs @@ -0,0 +1,16 @@ +namespace Allure.Sdk.Configuration; + +/// +/// Defines templates used to expand a link URL and, optionally, its display name. +/// +/// +/// The composite format string used to build the URL. Placeholder {0} +/// represents the original link value. +/// +/// +/// The optional composite format string used to build the display name. +/// +public record class AllureLinkTemplate( + string UrlTemplate, + string? NameTemplate +); diff --git a/src/Allure.Net.Sdk/Configuration/DelegateConfigurationSource.cs b/src/Allure.Net.Sdk/Configuration/DelegateConfigurationSource.cs new file mode 100644 index 00000000..a9488721 --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/DelegateConfigurationSource.cs @@ -0,0 +1,48 @@ +using System; + +namespace Allure.Sdk.Configuration; + +/// +/// Loads configuration by invoking a delegate. +/// +/// The configuration type. +/// A human-readable name for the source. +/// The delegate that creates the configuration. +public sealed class DelegateConfigurationSource( + string name, + Func factory +) : + IAllureConfigurationSource + + where TConfiguration : AllureConfiguration +{ + /// + public string Name => name; + + /// + public bool CanLoad => true; + + /// + public TConfiguration LoadConfiguration() => factory(); +} + +/// +/// Creates delegate-backed configuration sources. +/// +public static class DelegateConfigurationSource +{ + /// + /// Creates a configuration source that invokes the specified factory. + /// + /// The configuration type. + /// A human-readable name for the source. + /// The delegate that creates the configuration. + /// The configuration source. + public static DelegateConfigurationSource Create( + string name, + Func configurationFactory + ) + where TConfiguration : AllureConfiguration + => + new(name, configurationFactory); +} diff --git a/src/Allure.Net.Sdk/Configuration/IAllureConfigurationSource.cs b/src/Allure.Net.Sdk/Configuration/IAllureConfigurationSource.cs new file mode 100644 index 00000000..27a63bfa --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/IAllureConfigurationSource.cs @@ -0,0 +1,25 @@ +namespace Allure.Sdk.Configuration; + +/// +/// Provides configuration values to an Allure runtime. +/// +/// The configuration type. +public interface IAllureConfigurationSource + where TConfiguration : AllureConfiguration +{ + /// + /// Gets a human-readable name for the source. + /// + string Name { get; } + + /// + /// Gets a value indicating whether the source can currently load configuration. + /// + bool CanLoad { get; } + + /// + /// Loads configuration from the source. + /// + /// The loaded configuration. + TConfiguration 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..91775d05 --- /dev/null +++ b/src/Allure.Net.Sdk/Configuration/JsonFileConfigurationSource.cs @@ -0,0 +1,209 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Allure.Sdk.Configuration; + +/// +/// Loads Allure configuration from a JSON file. +/// +/// The configuration type. +/// The path to the JSON configuration file. +/// +/// Whether the source should be skipped when the file does not exist. +/// +public class JsonFileConfigurationSource(string path, bool isOptional) : + IAllureConfigurationSource + + where TConfiguration : AllureConfiguration, new() +{ + static readonly JsonSerializerOptions serializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + }; + + /// + public string Name => $"JSON from {path}"; + + /// + public bool CanLoad => path is { Length: >0 } + && (!isOptional || File.Exists(Path.GetFullPath(path))); + + /// + /// Creates a mandatory configuration source that throws if the file does not exist. + /// + /// The path to the JSON configuration file. + public JsonFileConfigurationSource(string path) : this(path, false) { } + + /// + /// + /// The configured file does not exist. + /// + /// + /// The file does not contain a supported JSON configuration object. + /// + 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; + } +} + +/// +/// Creates JSON-file configuration sources using conventional paths. +/// +public static class JsonFileConfigurationSource +{ + /// + /// Creates a source whose path is read from the specified environment variable. + /// + /// The configuration type. + /// + /// The name of the environment variable containing the file path. + /// + /// The configuration source. + public static JsonFileConfigurationSource FromPathEnvironmentVariable( + string environmentVariableName + ) + where TConfiguration : AllureConfiguration, new() + => new( + Environment.GetEnvironmentVariable(environmentVariableName) + ); + + /// + /// Creates a source whose path is read from the ALLURE_CONFIG + /// environment variable. + /// + /// The configuration type. + /// The configuration source. + public static JsonFileConfigurationSource FromPathEnvironmentVariable() + where TConfiguration : AllureConfiguration, new() + => + FromPathEnvironmentVariable("ALLURE_CONFIG"); + + /// + /// Creates a source for allureConfig.json in the application base directory. + /// + /// The configuration type. + /// + /// Whether the source should be skipped when the file does not exist. + /// + /// The configuration source. + public static JsonFileConfigurationSource FromBaseDirectory(bool isOptional) + where TConfiguration : AllureConfiguration, new() + => + new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "allureConfig.json"), isOptional); +} diff --git a/src/Allure.Net.Sdk/Functions/AttachmentSource.cs b/src/Allure.Net.Sdk/Functions/AttachmentSource.cs new file mode 100644 index 00000000..af4d257a --- /dev/null +++ b/src/Allure.Net.Sdk/Functions/AttachmentSource.cs @@ -0,0 +1,19 @@ +namespace Allure.Sdk.Functions; + +/// +/// Generates source file names for Allure attachments. +/// +public static class AttachmentSource +{ + /// + /// Returns a name for an attachment file. + /// + /// An optional file extension. + public static string CreateName(string fileExtension = "") + { + fileExtension ??= ""; + var suffix = "-attachment"; + var uuid = Ids.NewUuid(); + return $"{uuid}{suffix}{fileExtension}"; + } +} diff --git a/src/Allure.Net.Sdk/Functions/ErrorStatus.cs b/src/Allure.Net.Sdk/Functions/ErrorStatus.cs new file mode 100644 index 00000000..c32daf0a --- /dev/null +++ b/src/Allure.Net.Sdk/Functions/ErrorStatus.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Allure.Model; +using Allure.Sdk.Internal; + +namespace Allure.Sdk.Functions; + +/// +/// Classifies exceptions as failed or broken Allure statuses. +/// +public static class ErrorStatus +{ + /// + /// Checks whether an exception's type, one of its base types, or one of its + /// implemented interfaces appears in the list of known exception types. + /// + /// The list of known exception types. + /// The exception to check. + public static bool IsKnown(IEnumerable knownErrorBases, Exception e) => + knownErrorBases + ?.Intersect( + GetExceptionTypeNamesClosure(e) + ) + ?.Any() == true; + + /// + /// Returns if the 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. + /// The status corresponding to the exception. + public static Status Resolve( + IEnumerable failExceptions, + Exception e + ) => + IsKnown(failExceptions, e) + ? Status.Failed + : Status.Broken; + + static IEnumerable GetExceptionTypeNamesClosure(Exception e) => + TypeHierarchy.Enumerate(e.GetType()) + .Select(static (t) => t.FullName); +} diff --git a/src/Allure.Net.Sdk/Functions/GlobalLabels.cs b/src/Allure.Net.Sdk/Functions/GlobalLabels.cs new file mode 100644 index 00000000..53b19ba0 --- /dev/null +++ b/src/Allure.Net.Sdk/Functions/GlobalLabels.cs @@ -0,0 +1,59 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Allure.Model; +using Allure.Sdk.Configuration; + +namespace Allure.Sdk.Functions; + +/// +/// Defines a set of functions to retrieve labels that affect all test results +/// of the current execution. +/// +public static class GlobalLabels +{ + /// + /// Returns a sequence of labels defined by the globalLabels + /// configuration property. + /// + public static IEnumerable