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 FromConfiguration(AllureConfiguration config) =>
+ from kv in config.GlobalLabels ?? []
+ where !string.IsNullOrEmpty(kv.Key) && !string.IsNullOrEmpty(kv.Value)
+ select new Label { Name = kv.Key, Value = kv.Value };
+
+ ///
+ /// Returns a sequence of labels defined by the variables that match the
+ /// ALLURE_LABEL_<name>=<value> pattern.
+ ///
+ ///
+ /// Use to get the
+ /// process environment variables for this function.
+ ///
+ public static IEnumerable FromEnvironmentVariables(
+ IDictionary environmentVariables
+ )
+ {
+ foreach (DictionaryEntry entry in environmentVariables)
+ {
+ var key = entry.Key as string;
+ var value = entry.Value as string;
+ if (IsLabelVariable(key, value))
+ {
+ var name = key.Substring(ENV_LABEL_PATTERN.Length);
+ yield return new() { Name = name, Value = value };
+ }
+ }
+ }
+
+ static bool IsLabelVariable(
+ [NotNullWhen(true)] string? name,
+ [NotNullWhen(true)] string? value
+ ) =>
+ name is not null
+ && name.Length > ENV_LABEL_PATTERN.Length
+ && name.StartsWith(ENV_LABEL_PATTERN)
+ && !string.IsNullOrEmpty(value);
+
+ const string ENV_LABEL_PATTERN = "ALLURE_LABEL_";
+}
diff --git a/src/Allure.Net.Sdk/Functions/Ids.cs b/src/Allure.Net.Sdk/Functions/Ids.cs
new file mode 100644
index 00000000..17ef1804
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/Ids.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Allure.Model;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Generates identifiers used by Allure result objects.
+///
+public static class Ids
+{
+ ///
+ /// Generates a UUID for a test or container.
+ ///
+ public static string NewUuid() => Guid.NewGuid().ToString();
+
+ ///
+ /// Creates a fixed-length test case ID derived from a test's full name.
+ ///
+ public static string ForTestCase(string fullName) =>
+ Md5.FromString(fullName);
+
+ ///
+ /// Creates a string that uniquely identifies a given test represented by a
+ /// test case full name and a sequence of Allure parameters.
+ /// The result can be used as a history ID.
+ ///
+ /// The full name of a test.
+ /// The parameters of a test.
+ public static string ForTest(
+ string fullName,
+ IEnumerable parameters
+ ) =>
+ Md5.FromJson(new
+ {
+ fullName,
+ parameters = parameters.Where(p => !p.Excluded)
+ .OrderBy(p => p.Name)
+ .Select(p => p.Value)
+ });
+}
diff --git a/src/Allure.Net.Sdk/Functions/LinkTemplates.cs b/src/Allure.Net.Sdk/Functions/LinkTemplates.cs
new file mode 100644
index 00000000..6273120e
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/LinkTemplates.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using Allure.Model;
+using Allure.Sdk.Configuration;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Applies configured templates to Allure links.
+///
+public static class LinkTemplates
+{
+ ///
+ /// Applies the template matching the link type when the link URL is not absolute.
+ ///
+ /// The templates indexed by link type.
+ /// The link to update.
+ public static void Apply(
+ IReadOnlyDictionary templates,
+ Link link
+ )
+ {
+ if (templates.TryGetValue(link.Type ?? "link", out var template))
+ {
+ ApplyLinkTemplate(template, link);
+ }
+ }
+
+ static void ApplyLinkTemplate(AllureLinkTemplate template, Link link)
+ {
+ if (Uri.IsWellFormedUriString(link.Url, UriKind.Absolute))
+ {
+ return;
+ }
+
+ var (urlTemplate, nameTemplate) = template;
+
+ var urlInput = link.Url;
+ link.Url = string.Format(urlTemplate, urlInput);
+
+ if (nameTemplate is null || !string.IsNullOrEmpty(link.Name))
+ {
+ return;
+ }
+
+ link.Name = string.Format(nameTemplate, urlInput);
+ }
+}
diff --git a/src/Allure.Net.Sdk/Functions/Md5.cs b/src/Allure.Net.Sdk/Functions/Md5.cs
new file mode 100644
index 00000000..de22b536
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/Md5.cs
@@ -0,0 +1,44 @@
+using System.Text;
+using System.Text.Json;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Computes lowercase hexadecimal MD5 hashes for stable Allure identifiers.
+///
+public static class Md5
+{
+ ///
+ /// Computes the MD5 hash of the UTF-8 representation of a string.
+ ///
+ /// The string to hash.
+ /// The lowercase hexadecimal hash.
+ public static string FromString(string input)
+ {
+ using var md5 = System.Security.Cryptography.MD5.Create();
+ var inputBytes = Encoding.UTF8.GetBytes(input);
+ var outputBytes = md5.ComputeHash(inputBytes);
+ return ToHexString(outputBytes);
+ }
+
+ ///
+ /// Serializes a value to JSON and computes the MD5 hash of the result.
+ ///
+ /// The value to serialize and hash.
+ /// The lowercase hexadecimal hash.
+ public static string FromJson(object input) => FromString(
+ JsonSerializer.Serialize(input)
+ );
+
+ static string ToHexString(byte[] inputBytes)
+ {
+ var sb = new StringBuilder();
+ foreach (byte b in inputBytes)
+ {
+ sb.Append(
+ b.ToString("x2")
+ );
+ }
+ return sb.ToString();
+ }
+}
diff --git a/src/Allure.Net.Sdk/Functions/Parameters.cs b/src/Allure.Net.Sdk/Functions/Parameters.cs
new file mode 100644
index 00000000..34dec7ec
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/Parameters.cs
@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Allure.Abstractions;
+using Allure.Model;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Creates Allure parameters from method parameters and argument values.
+///
+public static class Parameters
+{
+ ///
+ /// Creates Allure parameters using metadata from reflected method parameters.
+ ///
+ /// The reflected parameter metadata.
+ /// The corresponding argument values.
+ /// The serializer used for argument values.
+ /// The created parameters.
+ public static IEnumerable Create(
+ IEnumerable parameters,
+ IEnumerable values,
+ IAllureParameterSerializer parameterSerializer
+ ) =>
+ Create(
+ parameters.Select(static p => p.Name),
+ parameters.Select(static p =>
+ p.GetCustomAttribute()),
+ values,
+ parameterSerializer
+ );
+
+ ///
+ /// Creates Allure parameters from separate names, attributes, and values.
+ ///
+ /// The source parameter names.
+ /// The corresponding Allure parameter attributes.
+ /// The corresponding argument values.
+ /// The serializer used for argument values.
+ /// The created parameters.
+ public static IEnumerable Create(
+ IEnumerable parameterNames,
+ IEnumerable attributes,
+ IEnumerable values,
+ IAllureParameterSerializer parameterSerializer
+ ) =>
+ parameterNames
+ .Zip(attributes, static (n, a) => (name: n, attr: a))
+ .Zip(values, static (p, v) => (p.name, p.attr, value: v))
+ .Where(static (tuple) => tuple.attr?.Ignore is not true)
+ .Select((tuple) =>
+ CreateParameter(tuple.name, tuple.attr, tuple.value, parameterSerializer));
+
+ static Parameter CreateParameter(
+ string parameterName,
+ AllureParameterAttribute? attribute,
+ object? value,
+ IAllureParameterSerializer parameterSerializer
+ ) =>
+ new()
+ {
+ Name = attribute?.Name ?? parameterName,
+ Value = parameterSerializer.Serialize(value),
+ Excluded = attribute?.Excluded == true,
+ Mode = ResolveParameterMode(attribute)
+ };
+
+ static ParameterMode? ResolveParameterMode(AllureParameterAttribute? attribute) =>
+ attribute is AllureParameterAttribute { Mode: ParameterMode mode and not ParameterMode.Default }
+ ? mode
+ : null;
+}
diff --git a/src/Allure.Net.Sdk/Functions/ReflectionNames.cs b/src/Allure.Net.Sdk/Functions/ReflectionNames.cs
new file mode 100644
index 00000000..87cb5e3c
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/ReflectionNames.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Creates stable names for reflected types and methods.
+///
+public static class ReflectionNames
+{
+ ///
+ /// Creates a stable name for a reflected type.
+ ///
+ public static string ForType(Type type) =>
+ type.IsGenericParameter ? type.Name : SerializeNonParameterType(type);
+
+ ///
+ /// Creates a stable signature containing a method's name, generic arguments,
+ /// and parameter types.
+ ///
+ public static string ForMethodSignature(MethodInfo method)
+ {
+ if (method.IsGenericMethod && !method.IsGenericMethodDefinition)
+ {
+ method = method.GetGenericMethodDefinition();
+ }
+
+ var methodName = method.Name;
+ var typeParameters = method.GetGenericArguments();
+ var typeParametersDecl = ForTypeArguments(typeParameters);
+ var parameterTypes = ForParameterTypes(method.GetParameters());
+ return $"{methodName}{typeParametersDecl}({parameterTypes})";
+ }
+
+ ///
+ /// Creates a stable fully qualified name for a reflected method.
+ ///
+ public static string ForMethod(MethodInfo method) =>
+ $"{ForType(method.DeclaringType)}.{ForMethodSignature(method)}";
+
+ ///
+ /// Creates a comma-separated list of reflected parameter type names.
+ ///
+ public static string ForParameterTypes(
+ IEnumerable parameters
+ ) =>
+ SerializeTypeList(
+ parameters.Select(p => p.ParameterType)
+ );
+
+ ///
+ /// Creates a bracketed, comma-separated list of type argument names.
+ ///
+ public static string ForTypeArguments(IEnumerable types) =>
+ types.Any() ? SerializeNonEmptyTypeArgumentList(types) : "";
+
+ static string SerializeNonEmptyTypeArgumentList(IEnumerable types) =>
+ "[" + SerializeTypeList(types) + "]";
+
+ static string SerializeTypeList(
+ IEnumerable types
+ ) =>
+ string.Join(
+ ",",
+ types.Select(ForType)
+ );
+
+ static string SerializeNonParameterType(Type type) =>
+ GetUniqueTypeName(type) + ForTypeArguments(
+ 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;
+}
diff --git a/src/Allure.Net.Sdk/Functions/StatusDetailsExtensions.cs b/src/Allure.Net.Sdk/Functions/StatusDetailsExtensions.cs
new file mode 100644
index 00000000..4f6a26f6
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/StatusDetailsExtensions.cs
@@ -0,0 +1,28 @@
+using System;
+using Allure.Model;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Provides factories for Allure status details.
+///
+public static class StatusDetailsExtensions
+{
+ extension (StatusDetails)
+ {
+ ///
+ /// Converts an exception to the status details.
+ ///
+ /// The exception to convert.
+ public static StatusDetails? FromException(Exception? e) =>
+ e is null
+ ? null
+ : new()
+ {
+ Message = string.IsNullOrEmpty(e.Message)
+ ? e.GetType().Name
+ : e.Message,
+ Trace = e.ToString()
+ };
+ }
+}
diff --git a/src/Allure.Net.Sdk/Functions/SuiteLabels.cs b/src/Allure.Net.Sdk/Functions/SuiteLabels.cs
new file mode 100644
index 00000000..991c7a09
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/SuiteLabels.cs
@@ -0,0 +1,63 @@
+using System.Linq;
+using Allure.Model;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Applies default suite-hierarchy labels to test results.
+///
+public static class SuiteLabels
+{
+ ///
+ /// Adds the provided default suite-hierarchy labels unless the test result
+ /// already contains a parentSuite , suite , or
+ /// subSuite label.
+ ///
+ /// The test result to modify.
+ ///
+ /// A value for the parentSuite label. If null or empty, the label
+ /// is not added.
+ ///
+ ///
+ /// A value for the suite label. If null or empty, the label is not
+ /// added.
+ ///
+ ///
+ /// A value for the subSuite label. If null or empty, the label is not
+ /// added.
+ ///
+ public static void Ensure(
+ 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!));
+ }
+ }
+
+ static bool IsSuiteLabel(Label label) => label.Name switch
+ {
+ LabelName.ParentSuite or LabelName.Suite or LabelName.SubSuite => true,
+ _ => false
+ };
+}
diff --git a/src/Allure.Net.Sdk/Functions/Titles.cs b/src/Allure.Net.Sdk/Functions/Titles.cs
new file mode 100644
index 00000000..e09927e5
--- /dev/null
+++ b/src/Allure.Net.Sdk/Functions/Titles.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace Allure.Sdk.Functions;
+
+///
+/// Creates title paths used to organize tests in Allure Report.
+///
+public static class Titles
+{
+ ///
+ /// Creates a title path to a test class in the test result tree.
+ ///
+ /// The type representing a test class.
+ ///
+ /// A title path 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 PathFor(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()) +
+ ReflectionNames.ForTypeArguments(
+ type.GetGenericArguments()));
+
+ return [
+ assemblyName,
+ .. namespaceParts,
+ typeNode,
+ ];
+ }
+
+ ///
+ /// Creates a title path to a test method in the test result tree.
+ ///
+ /// The test method.
+ ///
+ /// A title path 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 PathFor(MethodInfo method)
+ {
+ var titlePath = PathFor(method.DeclaringType);
+ if (method.GetParameters().Length > 0)
+ {
+ titlePath.Add(
+ method.GetCustomAttribute()?.Name
+ ?? ReflectionNames.ForMethodSignature(method)
+ );
+ }
+ return titlePath;
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/ReadLockScope.cs b/src/Allure.Net.Sdk/Internal/ReadLockScope.cs
new file mode 100644
index 00000000..8427a84d
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/ReadLockScope.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace Allure.Sdk.Internal;
+
+readonly struct ReadLockScope : IDisposable
+{
+ readonly ReaderWriterLockSlim rwLock;
+
+ internal ReadLockScope(ReaderWriterLockSlim rwLock)
+ {
+ this.rwLock = rwLock;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Dispose()
+ {
+ this.rwLock.ExitReadLock();
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/ReaderWriterLockSlimExtensions.cs b/src/Allure.Net.Sdk/Internal/ReaderWriterLockSlimExtensions.cs
new file mode 100644
index 00000000..baff4d06
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/ReaderWriterLockSlimExtensions.cs
@@ -0,0 +1,24 @@
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace Allure.Sdk.Internal;
+
+static class ReaderWriterLockSlimExtensions
+{
+ extension (ReaderWriterLockSlim rwLock)
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public ReadLockScope EnterReadScope()
+ {
+ rwLock.EnterReadLock();
+ return new ReadLockScope(rwLock);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public WriteLockScope EnterWriteScope()
+ {
+ rwLock.EnterWriteLock();
+ return new WriteLockScope(rwLock);
+ }
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Registration/AllureRegistrationDependencies.cs b/src/Allure.Net.Sdk/Internal/Registration/AllureRegistrationDependencies.cs
new file mode 100644
index 00000000..f47d757c
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Registration/AllureRegistrationDependencies.cs
@@ -0,0 +1,27 @@
+using Allure.Abstractions;
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Registration;
+
+sealed class AllureRegistrationDependencies(
+ TConfiguration configuration,
+ IAllureParameterSerializer parameterSerializer,
+ LateBoundReference> runtimeReference
+) :
+ IAllureRegistrationDependencies
+
+ where TConfiguration : AllureConfiguration
+{
+ public TConfiguration Configuration => configuration;
+
+ public IReadOnlyLateBoundReference> RuntimeReference => runtimeReference;
+
+ public IAllureParameterSerializer ParameterSerializer => parameterSerializer;
+
+ public void BindRuntime(IAllureRuntime runtime)
+ {
+ runtimeReference.Bind(runtime);
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Registration/LateBoundReference.cs b/src/Allure.Net.Sdk/Internal/Registration/LateBoundReference.cs
new file mode 100644
index 00000000..985b19ed
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Registration/LateBoundReference.cs
@@ -0,0 +1,27 @@
+using System;
+using Allure.Sdk.Registration;
+
+namespace Allure.Sdk.Internal.Registration;
+
+sealed class LateBoundReference : IReadOnlyLateBoundReference
+{
+ T? value = default;
+
+ public bool IsBound => this.value is not null;
+
+ public T Value => this.value ?? throw new InvalidOperationException(
+ $"{typeof(T).Name} has not been bound yet."
+ );
+
+ public void Bind(T value)
+ {
+ if (this.IsBound)
+ {
+ throw new InvalidOperationException(
+ $"{typeof(T).Name} is already bound."
+ );
+ }
+
+ this.value = value ?? throw new ArgumentNullException(nameof(value));
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Registration/RuleBasedParameterSerializerBuilder.cs b/src/Allure.Net.Sdk/Internal/Registration/RuleBasedParameterSerializerBuilder.cs
new file mode 100644
index 00000000..1871e3d7
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Registration/RuleBasedParameterSerializerBuilder.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Serialization;
+
+namespace Allure.Sdk.Internal.Registration;
+
+sealed class RuleBasedParameterSerializerBuilder : IParameterSerializationRulesContext
+{
+ readonly List actions;
+
+ Func currentFallback = static (o) => o.ToString();
+
+ string currentNullRepresentation = "null";
+
+ readonly List> currentJsonOptionTransformers = [];
+
+ public RuleBasedParameterSerializerBuilder()
+ {
+ this.actions = [
+ new AddRuleAction(ToStringParameterSerializationRule.Instance),
+ new AddJsonRuleAction(ResolveJsonOptions),
+ ];
+ }
+
+ public void AddRules(IEnumerable rules)
+ {
+ this.actions.AddRange(rules.Select(static (rule) => new AddRuleAction(rule)));
+ }
+
+ public void RemoveRules(Func predicate)
+ {
+ this.actions.Add(new RemoveRulesAction(predicate));
+ }
+
+ public void ReplaceRules(
+ Func predicate,
+ Func ruleFactory
+ )
+ {
+ this.actions.Add(new ReplaceRulesAction(predicate, ruleFactory));
+ }
+
+ public void TransformJsonOptions(
+ Func jsonOptionsTransformer
+ )
+ {
+ this.currentJsonOptionTransformers.Add(jsonOptionsTransformer);
+ }
+
+ public void UseFallback(Func fallback)
+ {
+ this.currentFallback = fallback;
+ }
+
+ public void UseNullRepresentation(string nullRepresentation)
+ {
+ this.currentNullRepresentation = nullRepresentation;
+ }
+
+ internal RuleBasedParameterSerializer Build()
+ {
+ List rules = [];
+ foreach (var action in this.actions)
+ {
+ action.ApplyTo(rules);
+ }
+ rules.Reverse();
+
+ return new([.. rules], this.currentFallback, this.currentNullRepresentation);
+ }
+
+ JsonSerializerOptions ResolveJsonOptions()
+ {
+ var options = JsonParameterSerializationRule.CreateDefaultJsonOptions();
+ foreach (var transform in this.currentJsonOptionTransformers)
+ {
+ options = transform(options);
+ }
+ return options;
+ }
+
+ abstract record class RuleAction
+ {
+ internal abstract void ApplyTo(List rules);
+ }
+
+ record class AddRuleAction(IParameterSerializationRule Rule) : RuleAction
+ {
+ internal override void ApplyTo(List rules)
+ {
+ rules.Add(this.Rule);
+ }
+ }
+
+ record class AddJsonRuleAction(Func OptionsFactory) : RuleAction
+ {
+ internal override void ApplyTo(List rules)
+ {
+ rules.Add(new JsonParameterSerializationRule(this.OptionsFactory()));
+ }
+ }
+
+ record class ReplaceRulesAction(
+ Func Predicate,
+ Func Factory
+ ) : RuleAction
+ {
+ internal override void ApplyTo(List rules)
+ {
+ foreach (var index in Enumerable.Range(0, rules.Count))
+ {
+ var rule = rules[index];
+ if (this.Predicate(rule))
+ {
+ rules[index] = this.Factory(rule);
+ }
+ }
+ }
+ }
+
+ record class RemoveRulesAction(
+ Func Predicate
+ ) : RuleAction
+ {
+ internal override void ApplyTo(List rules)
+ {
+ rules.RemoveAll((rule) => this.Predicate(rule));
+ }
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/RuleBasedParameterSerializer.cs b/src/Allure.Net.Sdk/Internal/RuleBasedParameterSerializer.cs
new file mode 100644
index 00000000..3be9bbd1
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/RuleBasedParameterSerializer.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using Allure.Abstractions;
+using Allure.Sdk.Serialization;
+
+namespace Allure.Sdk.Internal;
+
+sealed class RuleBasedParameterSerializer(
+ ImmutableArray rules,
+ Func fallback,
+ string nullRepresentation
+) :
+ IAllureParameterSerializer
+{
+ readonly ConcurrentDictionary fallbackedTypes = [];
+ readonly ConcurrentDictionary matchedRulesCache = [];
+
+ public ImmutableArray Rules => rules;
+
+ public Func FallbackAlgorithm => fallback;
+
+ public string NullRepresentation => nullRepresentation;
+
+ public string Serialize(object? value)
+ {
+ if (value is null)
+ {
+ return nullRepresentation;
+ }
+
+ var type = value.GetType();
+ if (this.IsMatchedToFallback(type))
+ {
+ return fallback(value);
+ }
+
+ if (this.TrySerializeFromCache(value, out var text))
+ {
+ return text ?? nullRepresentation;
+ }
+
+ foreach (var rule in rules)
+ {
+ if (rule.TrySerialize(value, out text))
+ {
+ this.matchedRulesCache[type] = rule;
+ return text ?? nullRepresentation;
+ }
+ }
+
+ this.fallbackedTypes[type] = true;
+ return fallback(value);
+ }
+
+ public bool TryGetCachedRule(Type type, [NotNullWhen(true)] out IParameterSerializationRule rule) =>
+ this.matchedRulesCache.TryGetValue(type, out rule);
+
+ public bool IsMatchedToFallback(Type type) => this.fallbackedTypes.ContainsKey(type);
+
+ bool TrySerializeFromCache(object value, [MaybeNullWhen(false)] out string text)
+ {
+ if (this.TryGetCachedRule(value.GetType(), out var cachedRule)
+ && cachedRule.TrySerialize(value, out text))
+ {
+ return true;
+ }
+
+ text = null;
+ return false;
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/AllureInProcessRuntimeEndpoint.cs b/src/Allure.Net.Sdk/Internal/Runtime/AllureInProcessRuntimeEndpoint.cs
new file mode 100644
index 00000000..23395381
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/AllureInProcessRuntimeEndpoint.cs
@@ -0,0 +1,27 @@
+using System;
+using Allure.Abstractions;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+class AllureInProcessRuntimeEndpoint(
+ string name,
+ Func availabilityPredicate,
+ AllureInProcessOperations operations,
+ IAllureParameterSerializer parameterSerializer
+) : IAllureInProcessRuntimeEndpoint
+{
+ public string Name => name;
+
+ public bool IsAvailable => availabilityPredicate();
+
+ public AllureInProcessOperations Operations => operations;
+
+ public IAllureParameterSerializer ParameterSerializer => parameterSerializer;
+
+ public AllureInProcessOperations InProcessOperations => operations;
+
+ AllureOperations IAllureRuntimeEndpoint.Operations { get; } = new(
+ operations.Sync,
+ operations.Async
+ );
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/AllureRuntimeRoute.cs b/src/Allure.Net.Sdk/Internal/Runtime/AllureRuntimeRoute.cs
new file mode 100644
index 00000000..a815f83c
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/AllureRuntimeRoute.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Immutable;
+using Allure.Abstractions;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+class AllureRuntimeRoute(
+ string routeId,
+ Func currentScopePredicate,
+ Func globalScopePredicate,
+ ImmutableHashSet suppressedRoutes,
+ IAllureRuntimeEndpoint endpoint
+) : IAllureRuntimeRoute
+{
+ public string Id => routeId;
+
+ public bool MatchesCurrentScope => currentScopePredicate();
+
+ public bool MatchesGlobalScope => globalScopePredicate();
+
+ public ImmutableHashSet SuppressedRouteIds => suppressedRoutes;
+
+ public IAllureRuntimeEndpoint Endpoint => endpoint;
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/AsyncFixtureOperationContext.cs b/src/Allure.Net.Sdk/Internal/Runtime/AsyncFixtureOperationContext.cs
new file mode 100644
index 00000000..4afd5861
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/AsyncFixtureOperationContext.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+sealed class AsyncFixtureOperationContext(IAllureRuntime runtime) :
+ FixtureOperationContext(runtime),
+ IAllureInProcessAsyncFixtureContext
+{
+ public Task AddParameterAsync(Parameter parameter, CancellationToken _)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateFixtureResult(
+ (fixtureResult) => fixtureResult.Parameters.Add(parameter)
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task SetNameAsync(string newName, CancellationToken _)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateFixtureResult(
+ (fixtureResult) => fixtureResult.Name = newName
+ );
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/AsyncStepOperationContext.cs b/src/Allure.Net.Sdk/Internal/Runtime/AsyncStepOperationContext.cs
new file mode 100644
index 00000000..c2fd982f
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/AsyncStepOperationContext.cs
@@ -0,0 +1,34 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+sealed class AsyncStepOperationContext(IAllureRuntime runtime, int level) :
+ StepOperationContext(runtime, level),
+ IAllureInProcessAsyncStepContext
+{
+ public Task AddParameterAsync(Parameter parameter, CancellationToken _)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateStepResult(
+ this.Level,
+ (stepResult) => stepResult.Parameters.Add(parameter)
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task SetNameAsync(string newName, CancellationToken _)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateStepResult(
+ this.Level,
+ (stepResult) => stepResult.Name = newName
+ );
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/FixtureOperationContext.cs b/src/Allure.Net.Sdk/Internal/Runtime/FixtureOperationContext.cs
new file mode 100644
index 00000000..e70d3490
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/FixtureOperationContext.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+abstract class FixtureOperationContext(IAllureRuntime runtime) :
+ OperationContext(runtime),
+ IAllureOperationContext
+{
+ public bool TryReadFixtureResult(
+ Func read,
+ [MaybeNullWhen(false)] out T result
+ )
+ {
+ this.EnsureInScope();
+
+ if (this.CurrentState.HasFixture)
+ {
+ result = this.Runtime.ModelApi.ReadFixtureResult(read);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ public void UpdateFixtureResult(Action update)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateFixtureResult(update);
+ }
+
+ protected override string ScopingErrorMessage =>
+ "The fixture associated with this context has already finished.";
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/OperationContext.cs b/src/Allure.Net.Sdk/Internal/Runtime/OperationContext.cs
new file mode 100644
index 00000000..3978daf8
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/OperationContext.cs
@@ -0,0 +1,33 @@
+using System;
+using Allure.Abstractions;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+abstract class OperationContext(IAllureRuntime runtime) : IAllureOperationContext, IDisposable
+{
+ bool disposed = false;
+
+ protected IAllureRuntime Runtime => runtime;
+
+ protected AllureExecutionState CurrentState => runtime.ContextApi.CurrentState;
+
+ protected abstract string ScopingErrorMessage { get; }
+
+ public IAllureParameterSerializer ParameterSerializer => runtime.ParameterSerializer;
+
+ public void Dispose()
+ {
+ this.disposed = true;
+ }
+
+ protected void EnsureInScope()
+ {
+ if (this.disposed)
+ {
+ throw new InvalidOperationException(
+ this.ScopingErrorMessage
+ );
+ }
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/RuntimeAsyncOperations.cs b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeAsyncOperations.cs
new file mode 100644
index 00000000..b339b286
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeAsyncOperations.cs
@@ -0,0 +1,490 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Functions;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+sealed class RuntimeAsyncOperations(IAllureRuntime runtime) :
+ IAllureInProcessAsyncOperations
+
+ where TConfiguration : AllureConfiguration
+{
+ readonly ImmutableList failExceptions = runtime.Configuration.FailExceptions;
+
+ readonly ImmutableDictionary linkTemplates =
+ runtime.Configuration.LinkTemplates;
+
+ AllureExecutionState CurrentState => runtime.ContextApi.CurrentState;
+
+ public async Task AddAttachmentAsync(
+ string name,
+ Stream content,
+ string? mediaType,
+ string fileExtension,
+ CancellationToken cancellationToken
+ )
+ {
+ var source = AttachmentSource.CreateName(fileExtension);
+ var attachment = new Attachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source,
+ };
+ await runtime.ResultsDestination.WriteAttachmentAsync(source, content, cancellationToken);
+ runtime.ModelApi.UpdateCurrentExecutableItem(
+ (obj) => obj.Attachments.Add(attachment)
+ );
+ }
+
+ public async Task AddGlobalAttachmentAsync(
+ string name,
+ Stream content,
+ string? mediaType,
+ string fileExtension,
+ CancellationToken cancellationToken
+ )
+ {
+ var timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+ var source = AttachmentSource.CreateName(fileExtension);
+ Globals globals = new()
+ {
+ Attachments =
+ [
+ new GlobalAttachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source,
+ Timestamp = timestamp,
+ }
+ ],
+ };
+ await runtime.ResultsDestination.WriteAttachmentAsync(source, content, cancellationToken);
+ await runtime.ResultsDestination.WriteGlobalsAsync(globals, cancellationToken);
+ }
+
+ public async Task AddAttachmentFromFileAsync(
+ string name,
+ string path,
+ string? mediaType,
+ string fileExtension,
+ CancellationToken cancellationToken
+ )
+ {
+ var source = AttachmentSource.CreateName(fileExtension);
+ var attachment = new Attachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source
+ };
+ await runtime.ResultsDestination.CopyAttachmentAsync(source, path, cancellationToken);
+ runtime.ModelApi.UpdateCurrentExecutableItem(
+ (obj) => obj.Attachments.Add(attachment)
+ );
+ }
+
+ public async Task AddGlobalAttachmentFromFileAsync(
+ string name,
+ string path,
+ string? mediaType,
+ string fileExtension,
+ CancellationToken cancellationToken
+ )
+ {
+ var timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+ var source = AttachmentSource.CreateName(fileExtension);
+ Globals globals = new()
+ {
+ Attachments =
+ [
+ new GlobalAttachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source,
+ Timestamp = timestamp
+ }
+ ]
+ };
+ await runtime.ResultsDestination.CopyAttachmentAsync(source, path, cancellationToken);
+ await runtime.ResultsDestination.WriteGlobalsAsync(globals, cancellationToken);
+ }
+
+ public Task AddLabelsAsync(IEnumerable labels, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.Labels.AddRange(labels)
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task AddLabelAsync(Label label, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.Labels.Add(label)
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task AddLinksAsync(IEnumerable links, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ {
+ foreach (var link in links)
+ {
+ LinkTemplates.Apply(linkTemplates, link);
+ testResult.Links.Add(link);
+ }
+
+ });
+ return Task.CompletedTask;
+ }
+
+ public Task AddLinkAsync(Link link, CancellationToken _)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ {
+ LinkTemplates.Apply(linkTemplates, link);
+ testResult.Links.Add(link);
+ });
+ return Task.CompletedTask;
+ }
+
+ public Task SetDescriptionAsync(string description, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.Description = description
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task SetDescriptionHtmlAsync(string descriptionHtml, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.DescriptionHtml = descriptionHtml
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task SetFixtureNameAsync(string newName, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateFixtureResult((fixtureResult) =>
+ fixtureResult.Name = newName
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task SetLabelAsync(string name, string value, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ {
+ testResult.Labels.RemoveAll((l) => l.Name == name);
+ testResult.Labels.Add(new() { Name = name, Value = value });
+ });
+ return Task.CompletedTask;
+ }
+
+ public Task SetNameAsync(string newName, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateCurrentExecutableItem(
+ (item) => item.Name = newName
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task SetTestNameAsync(string newName, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((fixtureResult) =>
+ fixtureResult.Name = newName
+ );
+ return Task.CompletedTask;
+ }
+
+ public Task StepAsync(
+ string name,
+ IEnumerable parameters,
+ Status status,
+ StatusDetails? statusDetails,
+ CancellationToken cancellationToken
+ )
+ {
+ runtime.LifecycleApi.StartStep(new()
+ {
+ Name = name,
+ Status = status,
+ StatusDetails = statusDetails,
+ Parameters = [.. parameters],
+ });
+ runtime.LifecycleApi.StopStep();
+ return Task.CompletedTask;
+ }
+
+ public async Task StepAsync(
+ string name,
+ IEnumerable parameters,
+ Func body,
+ CancellationToken cancellationToken
+ )
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ int level = this.StartStep(name, parameters);
+ try
+ {
+ using AsyncStepOperationContext context = new(runtime, level);
+ await body(context, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopStep(status, statusDetails);
+ }
+ }
+
+ public async Task StepAsync(
+ string name,
+ IEnumerable parameters,
+ Func> body,
+ CancellationToken cancellationToken
+ )
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ int level = this.StartStep(name, parameters);
+ try
+ {
+ using AsyncStepOperationContext context = new(runtime, level);
+ return await body(context, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopStep(status, statusDetails);
+ }
+ }
+
+ public async Task AddScreenDiffAsync(Stream expected, Stream actual, Stream diff, CancellationToken cancellationToken)
+ {
+ var currentDiffCount =
+ this.CurrentState.CurrentExecutableItem.Attachments.Count(
+ static (a) => a.Type == DIFF_MEDIA_TYPE
+ );
+ var name = string.Format(DIFF_NAME_PATTERN, currentDiffCount + 1);
+
+ using var stream = new MemoryStream();
+ await JsonSerializer.SerializeAsync(stream, new
+ {
+ expected = await ToDiffEntryAsync(expected, cancellationToken),
+ actual = await ToDiffEntryAsync(actual, cancellationToken),
+ diff = await ToDiffEntryAsync(diff, cancellationToken)
+ });
+
+ stream.Position = 0;
+
+ await AddAttachmentAsync(name, stream, DIFF_MEDIA_TYPE, ".json", cancellationToken);
+ }
+
+ public async Task AddScreenDiffFromFilesAsync(string expectedPath, string actualPath, string diffPath, CancellationToken cancellationToken)
+ {
+ using var expected = File.OpenRead(expectedPath);
+ using var actual = File.OpenRead(actualPath);
+ using var diff = File.OpenRead(diffPath);
+ await AddScreenDiffAsync(expected, actual, diff, cancellationToken);
+ }
+
+ public Task AddGlobalErrorAsync(GlobalError error, CancellationToken cancellationToken) =>
+ runtime.ResultsDestination.WriteGlobalsAsync(new()
+ {
+ Errors = [error],
+ }, cancellationToken);
+
+ public Task AddTestParameterAsync(Parameter parameter, CancellationToken cancellationToken)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ {
+ testResult.Parameters.Add(parameter);
+ });
+ return Task.CompletedTask;
+ }
+
+ public async Task SetUpAsync(string name, IEnumerable parameters, Func body, CancellationToken cancellationToken)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartSetUp(name, parameters);
+
+ try
+ {
+ using var context = new AsyncFixtureOperationContext(runtime);
+ await body(context, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ public async Task SetUpAsync(string name, IEnumerable parameters, Func> body, CancellationToken cancellationToken)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartSetUp(name, parameters);
+
+ try
+ {
+ using var context = new AsyncFixtureOperationContext(runtime);
+ return await body(context, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ public async Task TearDownAsync(string name, IEnumerable parameters, Func body, CancellationToken cancellationToken)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartTearDown(name, parameters);
+
+ try
+ {
+ using var context = new AsyncFixtureOperationContext(runtime);
+ await body(context, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ public async Task TearDownAsync(string name, IEnumerable parameters, Func> body, CancellationToken cancellationToken)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartTearDown(name, parameters);
+
+ try
+ {
+ using var context = new AsyncFixtureOperationContext(runtime);
+ return await body(context, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ int StartStep(string name, IEnumerable parameters)
+ {
+ runtime.LifecycleApi.StartStep(new()
+ {
+ Name = name,
+ Parameters = [.. parameters],
+ });
+ return CurrentState.StepDepth - 1;
+ }
+
+ void StopStep(Status status, StatusDetails? statusDetails)
+ {
+ var stepResult = runtime.LifecycleApi.StopStep();
+ if (stepResult.Status == default)
+ {
+ stepResult.Status = status;
+ }
+ stepResult.StatusDetails ??= statusDetails;
+ }
+
+ void StartSetUp(string name, IEnumerable parameters)
+ {
+ runtime.LifecycleApi.StartSetUpFixture(new()
+ {
+ Name = name,
+ Parameters = [.. parameters],
+ });
+ }
+
+ void StartTearDown(string name, IEnumerable parameters)
+ {
+ runtime.LifecycleApi.StartTearDownFixture(new()
+ {
+ Name = name,
+ Parameters = [.. parameters],
+ });
+ }
+
+ void StopFixture(Status status, StatusDetails? statusDetails)
+ {
+ var fixtureResult = runtime.LifecycleApi.StopFixture();
+ if (fixtureResult.Status == default)
+ {
+ fixtureResult.Status = status;
+ }
+ fixtureResult.StatusDetails ??= statusDetails;
+ }
+
+ const int DefaultCopyBufferSize = 81920;
+ const string DIFF_NAME_PATTERN = "diff-{0}";
+ const string DIFF_MEDIA_TYPE = "application/vnd.allure.image.diff";
+ const string DIFF_ENTRY_PREFIX = "data:image/png;base64,";
+
+ static async Task ToDiffEntryAsync(Stream data, CancellationToken cancellationToken)
+ {
+ using var stream = new MemoryStream();
+ await data.CopyToAsync(stream, DefaultCopyBufferSize, cancellationToken);
+ var base64Part = Convert.ToBase64String(stream.ToArray());
+ return $"{DIFF_ENTRY_PREFIX}{base64Part}";
+ }
+}
\ No newline at end of file
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/RuntimeLifecycleApi.cs b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeLifecycleApi.cs
new file mode 100644
index 00000000..ee9d45bc
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeLifecycleApi.cs
@@ -0,0 +1,118 @@
+using System;
+using Allure.Model;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+class RuntimeLifecycleApi(
+ IReadOnlyLateBoundReference runtimeReference
+) :
+ IAllureLifecycleApi
+{
+ AllureExecutionState CurrentState => runtimeReference.Value.ContextApi.CurrentState;
+
+ IAllureModelApi ModelApi => runtimeReference.Value.ModelApi;
+
+ IAllureExecutionContext ContextApi => runtimeReference.Value.ContextApi;
+
+ public void ScheduleTest(TestResult testResult)
+ {
+ var uuid = testResult.Uuid;
+ testResult.Stage = Stage.Scheduled;
+ this.ModelApi.UpdateAllScopes((scope) => scope.Children.Add(uuid));
+ this.ContextApi.Update((state) => state.SetTestResult(testResult));
+ }
+
+ public void StartTearDownFixture(FixtureResult fixtureResult) =>
+ this.StartFixture(fixtureResult, (scope) => scope.Afters.Add(fixtureResult));
+
+ public void StartSetUpFixture(FixtureResult fixtureResult) =>
+ this.StartFixture(fixtureResult, (scope) => scope.Befores.Add(fixtureResult));
+
+ public void StartTestScope(TestResultScope scope)
+ {
+ this.ContextApi.Update((state) => state.PushScope(scope));
+ }
+
+ public void StartStep(StepResult stepResult)
+ {
+ this.ModelApi.UpdateCurrentExecutableItem(
+ (parent) => parent.Steps.Add(stepResult)
+ );
+ this.ContextApi.Update((state) => state.PushStepResult(stepResult));
+ this.ModelApi.UpdateStepResult(startExecutableItem);
+ }
+
+ public void StartTest()
+ {
+ this.ModelApi.UpdateTestResult(startExecutableItem);
+ }
+
+ public void StartTest(TestResult testResult)
+ {
+ this.ScheduleTest(testResult);
+ this.ModelApi.UpdateTestResult(startExecutableItem);
+ }
+
+ public FixtureResult StopFixture()
+ {
+ this.ModelApi.UpdateFixtureResult(stopExecutableItem);
+
+ var fixtureResult = this.CurrentState.CurrentFixture;
+ this.ContextApi.Update(static (state) => state.ClearFixtureResult());
+ return fixtureResult;
+ }
+
+ public TestResultScope StopTestScope()
+ {
+ var scope = this.CurrentState.CurrentScope;
+ this.ContextApi.Update(static (state) => state.PopScope());
+ return scope;
+ }
+
+ public StepResult StopStep()
+ {
+ this.ModelApi.UpdateStepResult(stopExecutableItem);
+
+ var stepResult = this.CurrentState.CurrentStep;
+ this.ContextApi.Update(static (state) => state.PopStepResult());
+ return stepResult;
+ }
+
+ public TestResult StopTest()
+ {
+ this.ModelApi.UpdateTestResult(stopExecutableItem);
+
+ var testResult = this.CurrentState.CurrentTest;
+ this.ContextApi.Update((state) => state.ClearTestResult());
+ return testResult;
+ }
+
+ void StartFixture(FixtureResult fixtureResult, Action addFixtureToScope)
+ {
+ this.ContextApi.Update((state) => state.SetFixtureResult(fixtureResult));
+ this.ModelApi.UpdateFixtureResult(startExecutableItem);
+ this.ModelApi.UpdateScope(addFixtureToScope);
+ }
+
+ static readonly Action startExecutableItem =
+ static item =>
+ {
+ item.Stage = Stage.Running;
+ if (item.Start == default)
+ {
+ item.Start = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+ }
+ };
+
+ static readonly Action stopExecutableItem =
+ static item =>
+ {
+ item.Stage = Stage.Finished;
+ if (item.Stop == default)
+ {
+ item.Stop = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+ }
+ };
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/RuntimeModelApi.cs b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeModelApi.cs
new file mode 100644
index 00000000..fc55be7b
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeModelApi.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using Allure.Model;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+class RuntimeModelApi(
+ IReadOnlyLateBoundReference runtimeReference
+) :
+ IAllureModelApi
+{
+ AllureExecutionState CurrentState => runtimeReference.Value.ContextApi.CurrentState;
+
+ readonly ReaderWriterLockSlim @lock = new(LockRecursionPolicy.NoRecursion);
+
+ public TResult ReadCurrentExecutableItem(Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return read(this.CurrentState.CurrentExecutableItem);
+ }
+
+ public TResult ReadFixtureResult(Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return read(this.CurrentState.CurrentFixture);
+ }
+
+ public TResult ReadScope(Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return read(this.CurrentState.CurrentScope);
+ }
+
+ public TResult ReadScope(int level, Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return read(ElementAtLevel(this.CurrentState.ScopeStack, level));
+ }
+
+ public ImmutableArray ReadAllScopes(Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return this.CurrentState.ScopeStack.Select(read).ToImmutableArray();
+ }
+
+ public TResult ReadStepResult(Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return read(this.CurrentState.CurrentStep);
+ }
+
+ public TResult ReadStepResult(int level, Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return read(ElementAtLevel(this.CurrentState.StepStack, level));
+ }
+
+ public ImmutableArray ReadAllSteps(Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return [.. this.CurrentState.StepStack.Select(read)];
+ }
+
+ public TResult ReadTestResult(Func read)
+ {
+ using var _ = this.@lock.EnterReadScope();
+ return read(this.CurrentState.CurrentTest);
+ }
+
+ public void UpdateCurrentExecutableItem(Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ update(this.CurrentState.CurrentExecutableItem);
+ }
+
+ public void UpdateFixtureResult(Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ update(this.CurrentState.CurrentFixture);
+ }
+
+ public void UpdateScope(Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ update(this.CurrentState.CurrentScope);
+ }
+
+ public void UpdateScope(int level, Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ update(ElementAtLevel(this.CurrentState.ScopeStack, level));
+ }
+
+ public void UpdateAllScopes(Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ foreach (var scope in this.CurrentState.ScopeStack)
+ {
+ update(scope);
+ }
+ }
+
+ public void UpdateStepResult(Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ update(this.CurrentState.CurrentStep);
+ }
+
+ public void UpdateStepResult(int level, Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ update(ElementAtLevel(this.CurrentState.StepStack, level));
+ }
+
+ public void UpdateAllStepResults(Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ foreach (var stepResult in this.CurrentState.StepStack)
+ {
+ update(stepResult);
+ }
+ }
+
+ public void UpdateTestResult(Action update)
+ {
+ using var _ = this.@lock.EnterWriteScope();
+ update(this.CurrentState.CurrentTest);
+ }
+
+ static TElement ElementAtLevel(ImmutableStack stack, int level) =>
+ stack.ElementAt(stack.Count() - level - 1);
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/RuntimeSyncOperations.cs b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeSyncOperations.cs
new file mode 100644
index 00000000..31b099d4
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/RuntimeSyncOperations.cs
@@ -0,0 +1,499 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Functions;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+sealed class RuntimeSyncOperations(IAllureRuntime runtime) :
+ IAllureInProcessSyncOperations
+
+ where TConfiguration : AllureConfiguration
+{
+ readonly ImmutableList failExceptions = runtime.Configuration.FailExceptions;
+
+ readonly ImmutableDictionary linkTemplates =
+ runtime.Configuration.LinkTemplates;
+
+ AllureExecutionState CurrentState => runtime.ContextApi.CurrentState;
+
+ public void AddAttachment(
+ string name,
+ Stream content,
+ string? mediaType,
+ string fileExtension
+ )
+ {
+ var source = AttachmentSource.CreateName(fileExtension);
+ var attachment = new Attachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source
+ };
+ runtime.ResultsDestination.WriteAttachment(source, content);
+ runtime.ModelApi.UpdateCurrentExecutableItem(
+ (obj) => obj.Attachments.Add(attachment)
+ );
+ }
+
+ public void AddGlobalAttachment(
+ string name,
+ Stream content,
+ string? mediaType,
+ string fileExtension
+ )
+ {
+ var timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+ var source = AttachmentSource.CreateName(fileExtension);
+ Globals globals = new()
+ {
+ Attachments = [
+ new GlobalAttachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source,
+ Timestamp = timestamp,
+ }
+ ],
+ };
+ runtime.ResultsDestination.WriteAttachment(source, content);
+ runtime.ResultsDestination.WriteGlobals(globals);
+ }
+
+ public void AddAttachmentFromFile(
+ string name,
+ string path,
+ string? mediaType,
+ string fileExtension
+ )
+ {
+ var source = AttachmentSource.CreateName(fileExtension);
+ var attachment = new Attachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source
+ };
+ runtime.ResultsDestination.CopyAttachment(source, path);
+ runtime.ModelApi.UpdateCurrentExecutableItem(
+ (obj) => obj.Attachments.Add(attachment)
+ );
+ }
+
+ public void AddGlobalAttachmentFromFile(
+ string name,
+ string path,
+ string? mediaType,
+ string fileExtension
+ )
+ {
+ var timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+ var source = AttachmentSource.CreateName(fileExtension);
+ Globals globals = new()
+ {
+ Attachments =
+ [
+ new GlobalAttachment
+ {
+ Name = name,
+ Type = mediaType,
+ Source = source,
+ Timestamp = timestamp
+ }
+ ]
+ };
+ runtime.ResultsDestination.CopyAttachment(source, path);
+ runtime.ResultsDestination.WriteGlobals(globals);
+ }
+
+ public void AddLabels(params IEnumerable labels) =>
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.Labels.AddRange(labels)
+ );
+
+ public void AddLabel(Label label) =>
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.Labels.Add(label)
+ );
+
+ public void AddLinks(params IEnumerable links) =>
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ {
+ foreach (var link in links)
+ {
+ LinkTemplates.Apply(this.linkTemplates, link);
+ testResult.Links.Add(link);
+ }
+ });
+
+ public void AddLink(Link link)
+ {
+ LinkTemplates.Apply(this.linkTemplates, link);
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.Links.Add(link)
+ );
+ }
+
+ public void SetDescription(string description) =>
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.Description = description
+ );
+
+ public void SetDescriptionHtml(string descriptionHtml) =>
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ testResult.DescriptionHtml = descriptionHtml
+ );
+
+ public void SetFixtureName(string newName) =>
+ runtime.ModelApi.UpdateFixtureResult((fixtureResult) =>
+ fixtureResult.Name = newName
+ );
+
+ public void SetLabel(string name, string value) =>
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ {
+ testResult.Labels.RemoveAll((l) => l.Name == name);
+ testResult.Labels.Add(new(){ Name = name, Value = value });
+ });
+
+ public void SetName(string newName)
+ {
+ runtime.ModelApi.UpdateCurrentExecutableItem(
+ (item) => item.Name = newName
+ );
+ }
+
+ public void SetTestName(string newName) =>
+ runtime.ModelApi.UpdateTestResult((fixtureResult) =>
+ fixtureResult.Name = newName
+ );
+
+ public void Step(
+ string name,
+ IEnumerable parameters,
+ Status status,
+ StatusDetails? statusDetails
+ )
+ {
+ runtime.LifecycleApi.StartStep(new()
+ {
+ Name = name,
+ Status = status,
+ StatusDetails = statusDetails,
+ Parameters = [.. parameters],
+ });
+ runtime.LifecycleApi.StopStep();
+ }
+
+ public void Step(string name, IEnumerable parameters, Action body)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ int level = this.StartStep(name, parameters);
+ try
+ {
+ using SyncStepOperationContext context = new(runtime, level);
+ body(context);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopStep(status, statusDetails);
+ }
+ }
+
+ public TResult Step(
+ string name,
+ IEnumerable parameters,
+ Func body
+ )
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ int level = this.StartStep(name, parameters);
+ try
+ {
+ using SyncStepOperationContext context = new(runtime, level);
+ return body(context);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopStep(status, statusDetails);
+ }
+ }
+
+ public bool TryReadFixtureResult(
+ Func read,
+ [MaybeNullWhen(false)] out TResult result
+ )
+ {
+ if (CurrentState.HasFixture)
+ {
+ result = runtime.ModelApi.ReadFixtureResult(read);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ public bool TryReadStepResult(
+ Func read,
+ [MaybeNullWhen(false)] out TResult result
+ )
+ {
+ if (CurrentState.HasStep)
+ {
+ result = runtime.ModelApi.ReadStepResult(read);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ public bool TryReadTestResult(
+ Func read,
+ [MaybeNullWhen(false)] out TResult result
+ )
+ {
+ if (CurrentState.HasTest)
+ {
+ result = runtime.ModelApi.ReadTestResult(read);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ public void UpdateFixtureResult(Action update) =>
+ runtime.ModelApi.UpdateFixtureResult(update);
+
+ public void UpdateStepResult(Action update) =>
+ runtime.ModelApi.UpdateStepResult(update);
+
+ public void UpdateTestResult(Action update) =>
+ runtime.ModelApi.UpdateTestResult(update);
+
+ int StartStep(string name, IEnumerable parameters)
+ {
+ runtime.LifecycleApi.StartStep(new()
+ {
+ Name = name,
+ Parameters = [.. parameters],
+ });
+ return CurrentState.StepDepth - 1;
+ }
+
+ void StopStep(Status status, StatusDetails? statusDetails)
+ {
+ var stepResult = runtime.LifecycleApi.StopStep();
+ if (stepResult.Status == default)
+ {
+ stepResult.Status = status;
+ }
+ stepResult.StatusDetails ??= statusDetails;
+ }
+
+ public void AddScreenDiff(Stream expected, Stream actual, Stream diff)
+ {
+ var currentDiffCount =
+ this.CurrentState.CurrentExecutableItem.Attachments.Count(
+ static (a) => a.Type == DIFF_MEDIA_TYPE
+ );
+ var name = string.Format(DIFF_NAME_PATTERN, currentDiffCount + 1);
+
+ using var stream = new MemoryStream();
+ JsonSerializer.Serialize(stream, new
+ {
+ expected = ToDiffEntry(expected),
+ actual = ToDiffEntry(actual),
+ diff = ToDiffEntry(diff)
+ });
+
+ stream.Position = 0;
+
+ AddAttachment(name, stream, DIFF_MEDIA_TYPE, ".json");
+ }
+
+ public void AddScreenDiffFromFiles(string expectedPath, string actualPath, string diffPath)
+ {
+ using var expected = File.OpenRead(expectedPath);
+ using var actual = File.OpenRead(actualPath);
+ using var diff = File.OpenRead(diffPath);
+ AddScreenDiff(expected, actual, diff);
+ }
+
+ public void AddGlobalError(GlobalError error)
+ {
+ runtime.ResultsDestination.WriteGlobals(new()
+ {
+ Errors = [error],
+ });
+ }
+
+ public void AddTestParameter(Parameter parameter)
+ {
+ runtime.ModelApi.UpdateTestResult((testResult) =>
+ {
+ testResult.Parameters.Add(parameter);
+ });
+ }
+
+ public void SetUp(string name, IEnumerable parameters, Action body)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartSetUp(name, parameters);
+ try
+ {
+ using SyncFixtureOperationContext context = new(runtime);
+ body(context);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ public TResult SetUp(string name, IEnumerable parameters, Func body)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartSetUp(name, parameters);
+ try
+ {
+ using SyncFixtureOperationContext context = new(runtime);
+ return body(context);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ public void TearDown(string name, IEnumerable parameters, Action body)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartTearDown(name, parameters);
+ try
+ {
+ using SyncFixtureOperationContext context = new(runtime);
+ body(context);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ public TResult TearDown(string name, IEnumerable parameters, Func body)
+ {
+ Status status = Status.Passed;
+ StatusDetails? statusDetails = null;
+
+ this.StartTearDown(name, parameters);
+ try
+ {
+ using SyncFixtureOperationContext context = new(runtime);
+ return body(context);
+ }
+ catch (Exception e)
+ {
+ status = ErrorStatus.Resolve(this.failExceptions, e);
+ statusDetails = StatusDetails.FromException(e);
+ throw;
+ }
+ finally
+ {
+ this.StopFixture(status, statusDetails);
+ }
+ }
+
+ const int DefaultCopyBufferSize = 81920;
+ const string DIFF_NAME_PATTERN = "diff-{0}";
+ const string DIFF_MEDIA_TYPE = "application/vnd.allure.image.diff";
+ const string DIFF_ENTRY_PREFIX = "data:image/png;base64,";
+
+ static string ToDiffEntry(Stream data)
+ {
+ using var stream = new MemoryStream();
+ data.CopyTo(stream, DefaultCopyBufferSize);
+ var base64Part = Convert.ToBase64String(stream.ToArray());
+ return $"{DIFF_ENTRY_PREFIX}{base64Part}";
+ }
+
+ void StartSetUp(string name, IEnumerable parameters)
+ {
+ runtime.LifecycleApi.StartSetUpFixture(new()
+ {
+ Name = name,
+ Parameters = [.. parameters],
+ });
+ }
+
+ void StartTearDown(string name, IEnumerable parameters)
+ {
+ runtime.LifecycleApi.StartTearDownFixture(new()
+ {
+ Name = name,
+ Parameters = [.. parameters],
+ });
+ }
+
+ void StopFixture(Status status, StatusDetails? statusDetails)
+ {
+ var fixtureResult = runtime.LifecycleApi.StopFixture();
+ if (fixtureResult.Status == default)
+ {
+ fixtureResult.Status = status;
+ }
+ fixtureResult.StatusDetails ??= statusDetails;
+ }
+}
\ No newline at end of file
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/StepOperationContext.cs b/src/Allure.Net.Sdk/Internal/Runtime/StepOperationContext.cs
new file mode 100644
index 00000000..dfd4d88b
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/StepOperationContext.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+abstract class StepOperationContext(IAllureRuntime runtime, int level) :
+ OperationContext(runtime),
+ IAllureOperationContext
+{
+ protected int Level => level;
+
+ public bool TryReadStepResult(
+ Func read,
+ [MaybeNullWhen(false)] out T result
+ )
+ {
+ this.EnsureInScope();
+
+ if (this.CurrentState.HasStep)
+ {
+ result = this.Runtime.ModelApi.ReadStepResult(level, read);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ public void UpdateStepResult(Action update)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateStepResult(level, update);
+ }
+
+ protected override string ScopingErrorMessage =>
+ "The step associated with this context has already finished.";
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/SyncFixtureOperationContext.cs b/src/Allure.Net.Sdk/Internal/Runtime/SyncFixtureOperationContext.cs
new file mode 100644
index 00000000..57312f5e
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/SyncFixtureOperationContext.cs
@@ -0,0 +1,28 @@
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+sealed class SyncFixtureOperationContext(IAllureRuntime runtime) :
+ FixtureOperationContext(runtime),
+ IAllureInProcessSyncFixtureContext
+{
+ public void AddParameter(Parameter parameter)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateFixtureResult(
+ (fixtureResult) => fixtureResult.Parameters.Add(parameter)
+ );
+ }
+
+ public void SetName(string newName)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateFixtureResult(
+ (fixtureResult) => fixtureResult.Name = newName
+ );
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/Runtime/SyncStepOperationContext.cs b/src/Allure.Net.Sdk/Internal/Runtime/SyncStepOperationContext.cs
new file mode 100644
index 00000000..215dfc4b
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/Runtime/SyncStepOperationContext.cs
@@ -0,0 +1,30 @@
+using Allure.Abstractions;
+using Allure.Model;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Internal.Runtime;
+
+sealed class SyncStepOperationContext(IAllureRuntime runtime, int level) :
+ StepOperationContext(runtime, level),
+ IAllureInProcessSyncStepContext
+{
+ public void AddParameter(Parameter parameter)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateStepResult(
+ this.Level,
+ (stepResult) => stepResult.Parameters.Add(parameter)
+ );
+ }
+
+ public void SetName(string newName)
+ {
+ this.EnsureInScope();
+
+ this.Runtime.ModelApi.UpdateStepResult(
+ this.Level,
+ (stepResult) => stepResult.Name = newName
+ );
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/TypeHierarchy.cs b/src/Allure.Net.Sdk/Internal/TypeHierarchy.cs
new file mode 100644
index 00000000..e7db4b89
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/TypeHierarchy.cs
@@ -0,0 +1,20 @@
+using System;
+using System.Collections.Generic;
+
+namespace Allure.Sdk.Internal;
+
+internal static class TypeHierarchy
+{
+ internal static IEnumerable Enumerate(Type type)
+ {
+ for (var t = type; t != null; t = t.BaseType)
+ {
+ yield return t;
+ }
+
+ foreach (var iFace in type.GetInterfaces())
+ {
+ yield return iFace;
+ }
+ }
+}
diff --git a/src/Allure.Net.Sdk/Internal/WriteLockScope.cs b/src/Allure.Net.Sdk/Internal/WriteLockScope.cs
new file mode 100644
index 00000000..8028fce9
--- /dev/null
+++ b/src/Allure.Net.Sdk/Internal/WriteLockScope.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace Allure.Sdk.Internal;
+
+readonly struct WriteLockScope : IDisposable
+{
+ readonly ReaderWriterLockSlim rwLock;
+
+ internal WriteLockScope(ReaderWriterLockSlim rwLock)
+ {
+ this.rwLock = rwLock;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Dispose()
+ {
+ this.rwLock.ExitWriteLock();
+ }
+}
diff --git a/src/Allure.Net.Sdk/README.md b/src/Allure.Net.Sdk/README.md
new file mode 100644
index 00000000..6bc60406
--- /dev/null
+++ b/src/Allure.Net.Sdk/README.md
@@ -0,0 +1,648 @@
+# Allure.Net.Sdk
+
+[](https://www.nuget.org/packages/Allure.Net.Sdk)
+[](https://www.nuget.org/packages/Allure.Net.Sdk)
+
+
+> Allure.Net.Sdk provides APIs and utilities for building Allure test-framework integrations.
+
+[ ](https://allurereport.org "Allure Report")
+
+- Learn more about Allure Report at [https://allurereport.org](https://allurereport.org)
+- 📚 [Documentation](https://allurereport.org/docs/) – discover official documentation for Allure Report
+- ❓ [Questions and Support](https://github.com/orgs/allure-framework/discussions/categories/questions-support) – get help from the team and community
+- 📢 [Official announcements](https://github.com/orgs/allure-framework/discussions/categories/announcements) – stay updated with our latest news and updates
+- 💬 [General Discussion](https://github.com/orgs/allure-framework/discussions/categories/general-discussion) – engage in casual conversations, share insights and ideas with the community
+- 🖥️ [Live Demo](https://demo.allurereport.org/) — explore a live example of Allure Report in action
+
+---
+
+## Purpose
+
+`Allure.Net.Sdk` is the foundation for authors of Allure adapters and test
+framework integrations. It provides:
+
+- a configurable Allure runtime;
+- lifecycle and model APIs for building test results and fixture containers;
+- execution-state propagation for concurrent and asynchronous test runs;
+- an optional in-process endpoint that routes Allure API calls to the runtime;
+- result destinations, configuration sources, parameter serialization, and
+ common model-building functions.
+
+Application test authors normally depend on `Allure.Net`, or on a framework
+adapter built with this package, rather than using the SDK directly.
+
+## Build a runtime
+
+Create one runtime for each independently configured integration instance and
+keep its registration alive for the lifetime of that instance. Use one of the builders provided by `Allure.Net.Sdk`. Their `Build` method returns
+an `AllureRuntimeRegistration` that exposes the runtime through its
+`Runtime` property. Disposing this object cancels the registration.
+
+Choose the simplest builder that provides the extension points your integration
+needs.
+
+### Use the standard runtime and configuration
+
+If the integration does not add configuration properties or runtime services,
+use the non-generic builder:
+
+```csharp
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Runtime;
+
+var builder = new AllureRuntimeBuilder("My Test Framework");
+using var registration = builder.Build();
+IAllureRuntime runtime = registration.Runtime;
+```
+
+Before calling `Build`, the integration can use the builder to:
+
+- select configuration sources;
+- configure the results destination and parameter serialization;
+- replace the execution context, lifecycle API, or model API;
+- configure runtime and endpoint hook providers;
+- register an in-process endpoint that connects the Allure API with the runtime.
+
+#### Let framework users customize registration
+
+Framework users can customize runtime registration by implementing
+`IAllureRuntimeRegistrationHook`. For example:
+
+```csharp
+using Allure.Sdk.Registration;
+using Allure.Sdk.Registration.Hooks;
+
+public sealed class ProjectAllureRegistration :
+ IAllureRuntimeRegistrationHook
+{
+ public void SetUp(IAllureRuntimeRegistrationContext context)
+ {
+ context.ConfigureSerialization(
+ rules => rules.UseNullRepresentation("")
+ );
+ }
+}
+```
+
+By default, the builder first loads the hook named by the
+`ALLURE_RUNTIME_REGISTRATION_HOOK` environment variable, then the hook named
+by the resolved configuration's `runtimeRegistrationHook` property. Unset
+hooks are ignored. For example, a user can select a hook in
+`allureConfig.json`:
+
+```json
+{
+ "runtimeRegistrationHook": "MyTests.ProjectAllureRegistration, MyTests"
+}
+```
+
+A hook loaded by name must implement the expected hook interface and have a
+public parameterless constructor.
+
+Runtime hooks run after the initial configuration is resolved. If a hook
+replaces the configuration sources, the builder resolves configuration again
+before constructing the runtime.
+
+An integration can replace the default hook provider with
+`UseRegistrationHooks`, for example to obtain hooks from its own extension
+system:
+
+```csharp
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Registration.Hooks;
+using Allure.Sdk.Runtime;
+
+public static class MyFrameworkRegistration
+{
+ public static IAllureRuntimeRegistrationHook? CurrentHook { get; set; }
+}
+
+var builder = new AllureRuntimeBuilder("My Test Framework");
+
+builder.UseRegistrationHooks(configuration => [
+ ReflectionHooks.FromConfiguration<
+ AllureConfiguration,
+ IAllureRuntimeRegistrationHook
+ >(configuration),
+ MyFrameworkRegistration.CurrentHook,
+]);
+
+using var registration = builder.Build();
+IAllureRuntime runtime = registration.Runtime;
+```
+
+To disable registration hooks, return an empty collection:
+
+```csharp
+builder.UseRegistrationHooks(_ => []);
+```
+
+### Add integration-specific configuration
+
+Derive from `AllureConfiguration` when the integration needs additional
+settings. The integration should provide its configuration type, runtime and
+endpoint registration contexts, hook interfaces, a route builder, and a
+runtime builder:
+
+```csharp
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Registration.Hooks;
+using Allure.Sdk.Runtime;
+
+public sealed record class MyFrameworkConfiguration : AllureConfiguration
+{
+ public bool CaptureFrameworkOutput { get; init; } = true;
+}
+
+public interface IMyFrameworkRuntimeRegistrationContext :
+ IAllureRuntimeRegistrationContext;
+
+public interface IMyFrameworkRuntimeRegistrationHook :
+ IAllureRuntimeRegistrationHook<
+ MyFrameworkConfiguration,
+ IMyFrameworkRuntimeRegistrationContext
+ >;
+
+public interface IMyFrameworkEndpointRegistrationContext :
+ IAllureInProcessEndpointRegistrationContext;
+
+public interface IMyFrameworkEndpointRegistrationHook :
+ IAllureInProcessEndpointRegistrationHook<
+ MyFrameworkConfiguration,
+ IMyFrameworkEndpointRegistrationContext
+ >;
+
+sealed class MyFrameworkRouteBuilder(
+ AllureRouteBuilderArgs<
+ MyFrameworkConfiguration,
+ IAllureRuntime
+ > args
+) :
+ AllureInProcessRouteBuilder<
+ MyFrameworkConfiguration,
+ IMyFrameworkEndpointRegistrationContext,
+ IMyFrameworkEndpointRegistrationHook,
+ IAllureRuntime
+ >(args),
+ IMyFrameworkEndpointRegistrationContext
+{
+ protected override IMyFrameworkEndpointRegistrationContext
+ RegistrationContext => this;
+}
+
+public sealed class MyFrameworkRuntimeBuilder(string runtimeName) :
+ AllureRuntimeBuilder<
+ MyFrameworkConfiguration,
+ IMyFrameworkRuntimeRegistrationContext,
+ IMyFrameworkRuntimeRegistrationHook,
+ IMyFrameworkEndpointRegistrationContext,
+ IMyFrameworkEndpointRegistrationHook
+ >(runtimeName),
+ IMyFrameworkRuntimeRegistrationContext
+{
+ protected override IMyFrameworkRuntimeRegistrationContext
+ RegistrationContext => this;
+
+ protected override AllureInProcessRouteBuilder<
+ MyFrameworkConfiguration,
+ IMyFrameworkEndpointRegistrationContext,
+ IMyFrameworkEndpointRegistrationHook,
+ IAllureRuntime
+ > CreateRouteBuilder(
+ AllureRouteBuilderArgs<
+ MyFrameworkConfiguration,
+ IAllureRuntime
+ > args
+ ) => new MyFrameworkRouteBuilder(args);
+}
+```
+
+The integration constructs the standard runtime with this builder and retains
+its registration:
+
+```csharp
+using var registration =
+ new MyFrameworkRuntimeBuilder("My Test Framework").Build();
+
+IAllureRuntime runtime = registration.Runtime;
+```
+
+Framework users customize registration by implementing the hook interface
+provided by the integration:
+
+```csharp
+public sealed class ProjectAllureRegistration :
+ IMyFrameworkRuntimeRegistrationHook
+{
+ public void SetUp(
+ IMyFrameworkRuntimeRegistrationContext context
+ )
+ {
+ context.ConfigureSerialization(
+ rules => rules.UseNullRepresentation("")
+ );
+ }
+}
+```
+
+### Add integration-specific runtime services
+
+If the integration also exposes additional runtime services, derive a runtime
+from `AllureRuntime` and a builder from the six-parameter
+`AllureRuntimeBuilder`. The runtime registration context and hook from the
+previous example can be reused. Define endpoint context and hook types for the
+custom runtime, then replace the route builder and five-parameter runtime
+builder with:
+
+```csharp
+using Allure.Abstractions;
+using Allure.Sdk.Registration;
+using Allure.Sdk.Registration.Hooks;
+using Allure.Sdk.Results;
+using Allure.Sdk.Runtime;
+
+// An extra service to add.
+public sealed class FrameworkOutputCapture(bool enabled)
+{
+ public bool Enabled { get; } = enabled;
+}
+
+public sealed class MyFrameworkRuntime(
+ MyFrameworkConfiguration configuration,
+ IAllureParameterSerializer parameterSerializer,
+ IAllureResultsDestination resultsDestination,
+ IAllureExecutionContext context,
+ IAllureLifecycleApi lifecycleApi,
+ IAllureModelApi modelApi,
+ FrameworkOutputCapture outputCapture
+) : AllureRuntime(
+ configuration,
+ parameterSerializer,
+ resultsDestination,
+ context,
+ lifecycleApi,
+ modelApi
+)
+{
+ public FrameworkOutputCapture OutputCapture { get; } = outputCapture;
+}
+
+public interface IMyFrameworkRuntimeEndpointRegistrationContext :
+ IAllureInProcessEndpointRegistrationContext<
+ MyFrameworkConfiguration,
+ MyFrameworkRuntime
+ >;
+
+public interface IMyFrameworkRuntimeEndpointRegistrationHook :
+ IAllureInProcessEndpointRegistrationHook<
+ MyFrameworkConfiguration,
+ IMyFrameworkRuntimeEndpointRegistrationContext,
+ MyFrameworkRuntime
+ >;
+
+sealed class MyFrameworkRouteBuilder(
+ AllureRouteBuilderArgs<
+ MyFrameworkConfiguration,
+ MyFrameworkRuntime
+ > args
+) :
+ AllureInProcessRouteBuilder<
+ MyFrameworkConfiguration,
+ IMyFrameworkRuntimeEndpointRegistrationContext,
+ IMyFrameworkRuntimeEndpointRegistrationHook,
+ MyFrameworkRuntime
+ >(args),
+ IMyFrameworkRuntimeEndpointRegistrationContext
+{
+ protected override IMyFrameworkRuntimeEndpointRegistrationContext
+ RegistrationContext => this;
+}
+
+public sealed class MyFrameworkRuntimeBuilder(string runtimeName) :
+ AllureRuntimeBuilder<
+ MyFrameworkConfiguration,
+ IMyFrameworkRuntimeRegistrationContext,
+ IMyFrameworkRuntimeRegistrationHook,
+ IMyFrameworkRuntimeEndpointRegistrationContext,
+ IMyFrameworkRuntimeEndpointRegistrationHook,
+ MyFrameworkRuntime
+ >(runtimeName),
+ IMyFrameworkRuntimeRegistrationContext
+{
+ protected override IMyFrameworkRuntimeRegistrationContext
+ RegistrationContext => this;
+
+ protected override AllureInProcessRouteBuilder<
+ MyFrameworkConfiguration,
+ IMyFrameworkRuntimeEndpointRegistrationContext,
+ IMyFrameworkRuntimeEndpointRegistrationHook,
+ MyFrameworkRuntime
+ > CreateRouteBuilder(
+ AllureRouteBuilderArgs<
+ MyFrameworkConfiguration,
+ MyFrameworkRuntime
+ > args
+ ) => new MyFrameworkRouteBuilder(args);
+
+ protected override MyFrameworkRuntime CreateRuntimeInstance(
+ MyFrameworkConfiguration configuration,
+ IAllureParameterSerializer parameterSerializer,
+ IAllureResultsDestination destination,
+ IAllureExecutionContext context,
+ IAllureLifecycleApi lifecycleApi,
+ IAllureModelApi modelApi
+ ) => new(
+ configuration,
+ parameterSerializer,
+ destination,
+ context,
+ lifecycleApi,
+ modelApi,
+ new FrameworkOutputCapture(configuration.CaptureFrameworkOutput)
+ );
+}
+```
+
+Within `MyFrameworkRouteBuilder`, the inherited `Runtime` property has type
+`MyFrameworkRuntime`, so route construction can use integration-specific
+runtime services.
+
+The registration exposes the concrete runtime type:
+
+```csharp
+using var registration =
+ new MyFrameworkRuntimeBuilder("My Test Framework").Build();
+
+MyFrameworkRuntime runtime = registration.Runtime;
+
+if (runtime.OutputCapture.Enabled)
+{
+ // ...
+}
+```
+
+## Map the framework lifecycle
+
+Drive the runtime from framework events. A typical execution is:
+
+1. Start a `TestResultScope` for a framework container or fixture scope.
+2. Start and stop setup fixtures.
+3. Schedule or start each `TestResult`.
+4. Start and stop steps while the test is active.
+5. Stop the test and write the returned result.
+6. Start and stop teardown fixtures.
+7. Stop the scope and write the returned container.
+
+The lifecycle API maintains parent-child relationships, nesting, stages, and
+timestamps. It does **not** write stopped tests or scopes to the destination.
+The integration owns that boundary:
+
+```csharp
+using System;
+using Allure.Model;
+using Allure.Sdk.Functions;
+
+var scope = new TestResultScope
+{
+ Uuid = Ids.NewUuid(),
+ Name = "CheckoutTests",
+};
+
+runtime.LifecycleApi.StartScope(scope);
+
+var test = new TestResult
+{
+ Uuid = Ids.NewUuid(),
+ Name = "creates an order",
+ FullName = "Example.CheckoutTests.CreatesOrder",
+};
+
+test.TestCaseId = Ids.ForTestCase(test.FullName);
+runtime.LifecycleApi.StartTest(test);
+
+try
+{
+ // Invoke the framework test here.
+ runtime.ModelApi.UpdateTestResult(result => result.Status = Status.Passed);
+}
+catch (Exception exception)
+{
+ runtime.ModelApi.UpdateTestResult(result =>
+ {
+ result.Status = ErrorStatus.Resolve(
+ runtime.Configuration.FailExceptions,
+ exception
+ );
+ result.StatusDetails = StatusDetails.FromException(exception);
+ });
+ throw;
+}
+finally
+{
+ var completedTest = runtime.LifecycleApi.StopTest();
+ runtime.ResultsDestination.WriteTestResult(completedTest);
+
+ var completedScope = runtime.LifecycleApi.StopScope();
+ runtime.ResultsDestination.WriteContainer(completedScope);
+}
+```
+
+Use `ScheduleTest` before `StartTest` when the
+framework reports discovery and execution as separate events. Setup and teardown
+fixtures require an active scope. A step requires an active fixture, test, or
+parent step. Stop nested items in reverse order.
+
+`ModelApi` updates or reads the active scope, fixture, test, step, or current
+executable item. It supports concurrent access, so prefer it over retaining
+mutable model references across unrelated callbacks.
+
+## Preserve state across framework callbacks
+
+The runtime uses `IAllureExecutionContext` to identify the active scope,
+fixture, test, and step. The default `AsyncLocalExecutionContext` follows
+normal synchronous and asynchronous calls. A framework may, however, deliver
+related events on a disconnected execution flow where the original `AsyncLocal`
+value is no longer available. In such a case, choose a state storage strategy
+that follows the framework's notion of the current execution.
+
+### Use the framework's execution context
+
+If the framework manages a context object for the current test or fixture
+and that object is available from both framework callbacks and user test code,
+store the `AllureExecutionState` on it and provide an `IAllureExecutionContext`
+adapter.
+
+The following example assumes that `FrameworkContext.Current` returns an
+object dedicated to the current framework execution and that the integration
+can reserve an `"AllureState"` key on it:
+
+```csharp
+using Allure.Sdk.Registration;
+using Allure.Sdk.Runtime;
+
+public sealed class FrameworkAllureExecutionContext(
+ IReadOnlyLateBoundReference runtimeReference
+) : AllureExecutionContext(runtimeReference)
+{
+ public override AllureExecutionState CurrentState
+ {
+ get =>
+ FrameworkContext.Current["AllureState"] is AllureExecutionState state
+ ? state
+ : new();
+
+ protected set => FrameworkContext.Current["AllureState"] = value;
+ }
+}
+```
+
+Register the adapter before building the runtime:
+
+```csharp
+builder.UseContext(dependencies =>
+ new FrameworkAllureExecutionContext(dependencies.RuntimeReference)
+);
+```
+
+The framework context must isolate parallel executions, remain available for
+the whole test or fixture, and be the same object observed by event handlers
+and user code.
+
+### Capture and restore state explicitly
+
+When the framework does not provide a suitable ambient context,
+capture the state while it is available, associate it with the framework's
+execution ID, and restore it around each later callback:
+
+```csharp
+using Allure.Model;
+using Allure.Sdk.Runtime;
+
+AllureExecutionState state = runtime.ContextApi.CurrentState;
+
+await runtime.ContextApi.RunWithStateAsync(state, async activeRuntime =>
+{
+ activeRuntime.LifecycleApi.StartStep(new StepResult { Name = "HTTP request" });
+ await SendRequestAsync();
+ activeRuntime.LifecycleApi.StopStep();
+});
+```
+
+The previous caller state is restored even if the callback throws.
+`RunWithState` returns the state produced by a synchronous callback. For an
+asynchronous callback whose updated state must be retained, use
+`GetWithStateAsync` and return `activeRuntime.ContextApi.CurrentState`
+explicitly.
+
+Store a separate snapshot for each parallel framework execution and restore
+the matching snapshot for each event.
+
+## Expose the public Allure API
+
+Register an in-process endpoint when test code should be able to call
+`AllureApi`, use Allure attributes, or access `AllureInProcessApi`:
+
+```csharp
+var isIntegrationActive = true;
+
+builder.RegisterInProcessEndpoint(
+ "my-framework",
+ (_, endpoint) =>
+ {
+ endpoint.SetAvailabilityPredicate(() => isIntegrationActive);
+ endpoint.UseCurrentScopePredicate(
+ _ => FrameworkContext.Current.IsActive
+ );
+ endpoint.UseGlobalScopePredicate(
+ _ => isIntegrationActive
+ );
+ }
+);
+
+using var registration = builder.Build();
+var runtime = registration.Runtime;
+```
+
+Keep the registration alive while the integration is active. Dispose it when
+the integration shuts down so the endpoint is removed. Use `DisposeAsync` when
+the custom runtime requires asynchronous disposal.
+
+The current-scope predicate should return `true` only while the integration owns
+the active test or fixture. The global-scope predicate should return `true` only
+while the endpoint should receive global attachments and errors. Broad
+predicates can make two installed integrations match the same call, which
+causes route resolution to fail.
+
+Endpoint IDs must be stable and unique. If a more specific adapter can run
+alongside a general adapter, call `SuppressRoutes` with the general adapter's
+route ID. The endpoint uses the runtime's standard operations and serializer by
+default; `UseOperations`, endpoint-level `ConfigureSerialization`, and
+`UseParameterSerializer` are available for specialized bridges.
+
+### Out-of-process endpoints
+
+`Allure.Net.Sdk` currently provides registration support only for in-process
+endpoints. Integrations that communicate with the runtime across a process
+boundary must implement `Allure.Abstractions.IAllureRuntimeEndpoint` and `Allure.Abstractions.IAllureRuntimeRoute`,
+then install the route with `Allure.Runtime.AllureRuntimeRouter.Install`.
+
+The integration is responsible for the transport, request routing,
+serialization, availability checks, and resource cleanup. Keep the
+`IDisposable` returned by `Install` and dispose it when the integration shuts
+down to remove the route.
+
+## Customize runtime components
+
+The builder lets integrations replace individual runtime components:
+
+- `UseDestination` for a custom `IAllureResultsDestination`. The package also
+ includes `InMemoryResultsDestination` for tests and
+ `NullResultsDestination` for disabled output.
+- `ConfigureSerialization` to add, remove, replace, or reprioritize
+ `IParameterSerializationRule` instances. Rules added later take precedence.
+- `UseParameterSerializer` to replace rule-based serialization completely.
+- `UseContext`, `UseLifecycleApi`, and `UseModelApi` for integrations that need
+ a different state transport or lifecycle implementation.
+- `UseRegistrationHooks` to replace the default hook provider. See
+ [Let framework users customize registration](#let-framework-users-customize-registration).
+
+## Reuse the model functions
+
+`Allure.Sdk.Functions` contains framework-neutral helpers commonly needed by
+adapters:
+
+- `Ids` creates UUIDs, test case IDs, and parameter-sensitive history IDs.
+- `Parameters` converts reflected arguments and `AllureParameterAttribute`
+ metadata into Allure parameters.
+- `Titles` derives title paths from test types and methods.
+- `SuiteLabels` and `GlobalLabels` apply conventional hierarchy and global
+ labels.
+- `LinkTemplates` expands configured issue, TMS, and custom links.
+- `ErrorStatus` classifies configured assertion exceptions as `Failed` and
+ other exceptions as `Broken`.
+- `StatusDetails.FromException` creates failure diagnostics.
+- `AttachmentSource` creates collision-resistant attachment file names.
+
+Apply identity fields, parameters, labels, and links before writing the final
+test result. In particular, calculate `HistoryId` after all non-excluded test
+parameters are known.
+
+## Integration checklist
+
+- Use stable full names and test case IDs; include non-excluded parameters in
+ history IDs.
+- Keep framework correlation IDs separate from generated Allure UUIDs.
+- Balance every lifecycle start with the matching stop in `finally` paths.
+- Set a final status and status details before stopping an executable item.
+- Write stopped tests and scopes exactly once.
+- Generate an attachment file name, store it in the model attachment, and copy
+ or write the content under that name through `ResultsDestination`.
+- Keep Allure execution state isolated for each parallel framework execution.
+- Keep endpoint matching narrow and define suppression when adapters overlap.
+- Dispose the runtime registration when the integration shuts down.
+- Test the adapter with `InMemoryResultsDestination`, including failures,
+ skipped tests, fixtures, nested steps, attachments, and parallel execution.
diff --git a/src/Allure.Net.Sdk/Registration/AllureInProcessRouteBuilder.cs b/src/Allure.Net.Sdk/Registration/AllureInProcessRouteBuilder.cs
new file mode 100644
index 00000000..06d81c96
--- /dev/null
+++ b/src/Allure.Net.Sdk/Registration/AllureInProcessRouteBuilder.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using Allure.Abstractions;
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Internal.Runtime;
+using Allure.Sdk.Registration.Hooks;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Registration;
+
+///
+/// Provides the base implementation for builders that construct an in-process
+/// endpoint route with an integration-specific registration context and hook.
+///
+/// The runtime configuration type.
+/// The endpoint registration context type.
+/// The endpoint registration hook type.
+/// The runtime type.
+public abstract class AllureInProcessRouteBuilder :
+ IAllureInProcessEndpointIntegrationContext
+
+ where TConfiguration : AllureConfiguration
+ where TContext : IAllureInProcessEndpointRegistrationContext
+ where THook : IAllureInProcessEndpointRegistrationHook
+ where TRuntime : IAllureRuntime
+{
+ readonly string runtimeName;
+
+ readonly string routeId;
+
+ Func> currentHooksFactory =
+ (_) => [];
+
+ Func availabilityPredicate = (_) => true;
+
+ Func currentScopePredicate = (_) => true;
+
+ Func globalScopePredicate = (_) => true;
+
+ Func> currentSuppressedRouteIdsFactory = (_) => [];
+
+ Func currentOperationsFactory = (runtime) =>
+ new AllureInProcessOperations(
+ new RuntimeSyncOperations(runtime),
+ new RuntimeAsyncOperations(runtime)
+ );
+
+ bool useRuleBasedSerializer = false;
+
+ Func currentSerializerFactory;
+
+ readonly List> currentRuleBasedSerializerRegistrations;
+
+ ///
+ /// Gets the integration-specific runtime associated with this route.
+ ///
+ protected TRuntime Runtime { get; }
+
+ ///
+ /// Initializes an in-process route builder from the components resolved by
+ /// its runtime builder.
+ ///
+ /// The resolved route builder arguments.
+ public AllureInProcessRouteBuilder(AllureRouteBuilderArgs args)
+ {
+ this.useRuleBasedSerializer = args.UseRuleBasedSerializer;
+ this.runtimeName = args.RuntimeName;
+ this.routeId = args.RouteId;
+ this.Runtime = args.Runtime;
+ this.currentRuleBasedSerializerRegistrations = [.. args.RuleBasedSerializerRegistrations];
+ this.currentSerializerFactory =
+ useRuleBasedSerializer
+ ? (runtime) =>
+ AllureRegistrationDefaults.ParameterSerializer(
+ currentRuleBasedSerializerRegistrations
+ )(runtime.Configuration)
+ : (_) => this.Runtime.ParameterSerializer;
+ }
+
+ ///
+ public void UseRegistrationHooks(
+ Func> hooksFactory
+ )
+ {
+ this.currentHooksFactory = hooksFactory;
+ }
+
+ ///
+ public void SetAvailabilityPredicate(Func isAvailable)
+ {
+ this.availabilityPredicate = isAvailable;
+ }
+
+ ///
+ public void SetAvailabilityPredicate(Func isAvailable)
+ {
+ this.availabilityPredicate = (_) => isAvailable();
+ }
+
+ ///
+ public void SuppressRoutes(Func> suppressedRouteIdsFactory)
+ {
+ this.currentSuppressedRouteIdsFactory = suppressedRouteIdsFactory;
+ }
+
+ ///
+ public void SuppressRoutes(Func> suppressedRouteIdsFactory)
+ {
+ this.currentSuppressedRouteIdsFactory = (_) => suppressedRouteIdsFactory();
+ }
+
+ ///
+ public void UseCurrentScopePredicate(Func predicate)
+ {
+ this.currentScopePredicate = predicate;
+ }
+
+ ///
+ public void UseGlobalScopePredicate(Func predicate)
+ {
+ this.globalScopePredicate = predicate;
+ }
+
+ ///
+ public void UseOperations(Func operationsFactory)
+ {
+ this.currentOperationsFactory = operationsFactory;
+ }
+
+ ///
+ public void UseParameterSerializer(Func serializerFactory)
+ {
+ this.useRuleBasedSerializer = false;
+ this.currentRuleBasedSerializerRegistrations.Clear();
+ this.currentSerializerFactory = serializerFactory;
+ }
+
+ ///
+ public void UseParameterSerializer(Func serializerFactory) =>
+ this.UseParameterSerializer((_) => serializerFactory());
+
+ ///
+ public void ConfigureSerialization(Action registration)
+ {
+ if (!this.useRuleBasedSerializer)
+ {
+ this.currentSerializerFactory = (runtime) => AllureRegistrationDefaults.ParameterSerializer(
+ currentRuleBasedSerializerRegistrations
+ )(runtime.Configuration);
+ this.useRuleBasedSerializer = true;
+ }
+
+ this.currentRuleBasedSerializerRegistrations.Add(registration);
+ }
+
+ ///
+ public void ConfigureSerialization(Action registration)
+ {
+ this.ConfigureSerialization((_, ctx) => registration(ctx));
+ }
+
+ ///
+ /// Runs the configured endpoint hooks and constructs the route.
+ ///
+ /// The constructed in-process runtime route.
+ public IAllureRuntimeRoute Build()
+ {
+ this.RunHooks();
+ return new AllureRuntimeRoute(
+ routeId,
+ () => this.currentScopePredicate(this.Runtime),
+ () => this.globalScopePredicate(this.Runtime),
+ [.. this.currentSuppressedRouteIdsFactory(this.Runtime)],
+ new AllureInProcessRuntimeEndpoint(
+ this.runtimeName,
+ () => this.availabilityPredicate(this.Runtime),
+ this.currentOperationsFactory(this.Runtime),
+ this.currentSerializerFactory(this.Runtime)
+ )
+ );
+ }
+
+ ///
+ /// Gets the integration-specific context passed to endpoint registration
+ /// hooks.
+ ///
+ protected abstract TContext RegistrationContext { get; }
+
+ void RunHooks()
+ {
+ foreach (var hook in this.currentHooksFactory(this.Runtime.Configuration))
+ {
+ hook?.SetUp(this.RegistrationContext);
+ }
+ }
+}
+
+///
+/// Provides the base implementation for builders that construct an in-process
+/// endpoint route for a standard Allure runtime with an integration-specific
+/// registration context and hook.
+///
+/// The runtime configuration type.
+/// The endpoint registration context type.
+/// The endpoint registration hook type.
+/// The resolved route builder arguments.
+public abstract class AllureInProcessRouteBuilder(
+ AllureRouteBuilderArgs> args
+) :
+ AllureInProcessRouteBuilder<
+ TConfiguration,
+ TContext,
+ THook,
+ IAllureRuntime
+ >(args),
+ IAllureInProcessEndpointIntegrationContext
+
+ where TConfiguration : AllureConfiguration
+ where TContext : IAllureInProcessEndpointRegistrationContext
+ where THook : IAllureInProcessEndpointRegistrationHook;
+
+class AllureInProcessRouteBuilder(
+ AllureRouteBuilderArgs> args
+) :
+ AllureInProcessRouteBuilder<
+ AllureConfiguration,
+ IAllureInProcessEndpointRegistrationContext,
+ IAllureInProcessEndpointRegistrationHook
+ >(args),
+ IAllureInProcessEndpointIntegrationContext
+{
+ ///
+ protected override IAllureInProcessEndpointRegistrationContext RegistrationContext => this;
+}
diff --git a/src/Allure.Net.Sdk/Registration/AllureRegistrationContextExtensions.cs b/src/Allure.Net.Sdk/Registration/AllureRegistrationContextExtensions.cs
new file mode 100644
index 00000000..f95853bb
--- /dev/null
+++ b/src/Allure.Net.Sdk/Registration/AllureRegistrationContextExtensions.cs
@@ -0,0 +1,53 @@
+using System;
+using Allure.Sdk.Configuration;
+
+namespace Allure.Sdk.Registration;
+
+///
+/// Provides convenient configuration-source registration methods.
+///
+public static class AllureRegistrationContextExtensions
+{
+ ///
+ /// Provides convenient configuration-source registration methods.
+ ///
+ /// A configuration type.
+ /// A runtime registration context.
+ extension(
+ IAllureRuntimeRegistrationContext context
+ )
+ where TConfiguration : AllureConfiguration, new()
+ {
+ ///
+ /// Loads configuration from the specified source.
+ ///
+ /// A function that creates the configuration source.
+ public void UseConfigurationSource(Func> sourceFactory) =>
+ context.UseConfigurationSources(() => [sourceFactory()]);
+
+ ///
+ /// Loads configuration from the specified JSON file.
+ ///
+ public void UseConfigurationFile(string path) =>
+ context.UseConfigurationSources(
+ () => [new JsonFileConfigurationSource(path)]
+ );
+
+ ///
+ /// Loads the configuration file path from an environment variable.
+ ///
+ /// The name of the variable.
+ public void UseConfigurationPathEnvironmentVariable(string variableName) =>
+ context.UseConfigurationSources(
+ () => [JsonFileConfigurationSource.FromPathEnvironmentVariable(variableName)]
+ );
+
+ ///
+ /// Uses the provided configuration object.
+ ///
+ public void UseConfiguration(TConfiguration configuration) =>
+ context.UseConfigurationSources(
+ () => [DelegateConfigurationSource.Create("explicit", () => configuration)]
+ );
+ }
+}
diff --git a/src/Allure.Net.Sdk/Registration/AllureRegistrationDefaults.cs b/src/Allure.Net.Sdk/Registration/AllureRegistrationDefaults.cs
new file mode 100644
index 00000000..4e4b0181
--- /dev/null
+++ b/src/Allure.Net.Sdk/Registration/AllureRegistrationDefaults.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using Allure.Abstractions;
+using Allure.Sdk.Configuration;
+using Allure.Sdk.Internal.Registration;
+using Allure.Sdk.Internal.Runtime;
+using Allure.Sdk.Registration.Hooks;
+using Allure.Sdk.Results;
+using Allure.Sdk.Runtime;
+
+namespace Allure.Sdk.Registration;
+
+///
+/// Provides default factories for Allure runtime components.
+///
+public static class AllureRegistrationDefaults
+{
+ ///
+ /// Creates the default ordered configuration-source factory.
+ ///
+ public static Func>> ConfigurationSources()
+ where TConfiguration : AllureConfiguration, new()
+ =>
+ static () => [
+ JsonFileConfigurationSource.FromPathEnvironmentVariable(),
+ JsonFileConfigurationSource.FromBaseDirectory(true),
+ ];
+
+ ///
+ /// Creates the default file-system results-destination factory.
+ ///
+ public static Func Destination()
+ where TConfiguration : AllureConfiguration
+ =>
+ static (configuration) => new FileSystemResultsDestination(
+ configuration.ResultsDirectory,
+ configuration.IndentOutput
+ );
+
+ ///
+ /// Creates a rule-based parameter-serializer factory from rule registrations.
+ ///
+ public static Func ParameterSerializer(
+ IEnumerable> registrations
+ ) =>
+ (configuration) =>
+ {
+ var builder = new RuleBasedParameterSerializerBuilder();
+ foreach (var registration in registrations)
+ {
+ registration(configuration, builder);
+ }
+ return builder.Build();
+ };
+
+ ///
+ /// Creates the default asynchronous-local execution-context factory.
+ ///
+ public static Func, IAllureExecutionContext> Context()
+ where TConfiguration : AllureConfiguration
+ =>
+ static (runtime) => new AsyncLocalExecutionContext(runtime.RuntimeReference);
+
+ ///
+ /// Creates the default lifecycle API factory.
+ ///
+ public static Func, IAllureLifecycleApi> LifecycleApi