release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,76 @@
namespace StellaOps.Plugin.Testing;
/// <summary>
/// Fake time provider for deterministic testing.
/// </summary>
public sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
private readonly object _lock = new();
/// <summary>
/// Creates a fake time provider starting at the specified time.
/// </summary>
/// <param name="startTime">Initial time.</param>
public FakeTimeProvider(DateTimeOffset startTime)
{
_now = startTime;
}
/// <inheritdoc />
public override DateTimeOffset GetUtcNow()
{
lock (_lock)
{
return _now;
}
}
/// <summary>
/// Advances time by the specified duration.
/// </summary>
/// <param name="duration">Duration to advance.</param>
public void Advance(TimeSpan duration)
{
lock (_lock)
{
_now += duration;
}
}
/// <summary>
/// Sets the current time to the specified value.
/// </summary>
/// <param name="time">New current time.</param>
public void SetTime(DateTimeOffset time)
{
lock (_lock)
{
_now = time;
}
}
/// <summary>
/// Advances time by the specified number of seconds.
/// </summary>
/// <param name="seconds">Number of seconds to advance.</param>
public void AdvanceSeconds(double seconds) => Advance(TimeSpan.FromSeconds(seconds));
/// <summary>
/// Advances time by the specified number of minutes.
/// </summary>
/// <param name="minutes">Number of minutes to advance.</param>
public void AdvanceMinutes(double minutes) => Advance(TimeSpan.FromMinutes(minutes));
/// <summary>
/// Advances time by the specified number of hours.
/// </summary>
/// <param name="hours">Number of hours to advance.</param>
public void AdvanceHours(double hours) => Advance(TimeSpan.FromHours(hours));
/// <summary>
/// Advances time by the specified number of days.
/// </summary>
/// <param name="days">Number of days to advance.</param>
public void AdvanceDays(double days) => Advance(TimeSpan.FromDays(days));
}

View File

@@ -0,0 +1,72 @@
namespace StellaOps.Plugin.Testing;
using StellaOps.Plugin.Abstractions;
using Xunit;
/// <summary>
/// xUnit test base class for plugin testing.
/// Provides automatic setup and teardown of plugin test infrastructure.
/// </summary>
/// <typeparam name="TPlugin">The plugin type to test.</typeparam>
public abstract class PluginTestBase<TPlugin> : IAsyncLifetime where TPlugin : IPlugin, new()
{
/// <summary>
/// Gets the test host.
/// </summary>
protected PluginTestHost Host { get; private set; } = null!;
/// <summary>
/// Gets the plugin under test.
/// </summary>
protected TPlugin Plugin { get; private set; } = default!;
/// <summary>
/// Gets the test context.
/// </summary>
protected TestPluginContext Context => Host.Context;
/// <summary>
/// Gets the test logger.
/// </summary>
protected TestPluginLogger Logger => Context.Logger;
/// <summary>
/// Gets the test configuration.
/// </summary>
protected TestPluginConfiguration Configuration => Context.Configuration;
/// <summary>
/// Gets the fake time provider.
/// </summary>
protected FakeTimeProvider? FakeTimeProvider => Context.TimeProvider as FakeTimeProvider;
/// <summary>
/// Gets the HTTP client factory.
/// </summary>
protected TestHttpClientFactory HttpClientFactory => (TestHttpClientFactory)Context.HttpClientFactory;
/// <summary>
/// Override to configure the test host.
/// </summary>
/// <param name="options">Test host options to configure.</param>
protected virtual void ConfigureHost(PluginTestHostOptions options) { }
/// <summary>
/// Override to provide configuration for the plugin.
/// </summary>
/// <returns>Configuration dictionary.</returns>
protected virtual Dictionary<string, object> GetConfiguration() => new();
/// <inheritdoc />
public virtual async ValueTask InitializeAsync()
{
Host = new PluginTestHost(ConfigureHost);
Plugin = await Host.LoadPluginAsync<TPlugin>(GetConfiguration());
}
/// <inheritdoc />
public virtual async ValueTask DisposeAsync()
{
await Host.DisposeAsync();
}
}

View File

@@ -0,0 +1,85 @@
namespace StellaOps.Plugin.Testing;
using StellaOps.Plugin.Abstractions;
using StellaOps.Plugin.Abstractions.Health;
/// <summary>
/// Test host for running plugins in isolation during testing.
/// </summary>
public sealed class PluginTestHost : IAsyncDisposable
{
private readonly List<IPlugin> _plugins = new();
/// <summary>
/// Gets the test context for assertions.
/// </summary>
public TestPluginContext Context { get; }
/// <summary>
/// Creates a new plugin test host.
/// </summary>
/// <param name="configure">Optional configuration action.</param>
public PluginTestHost(Action<PluginTestHostOptions>? configure = null)
{
var options = new PluginTestHostOptions();
configure?.Invoke(options);
Context = new TestPluginContext(options);
}
/// <summary>
/// Load and initialize a plugin.
/// </summary>
/// <typeparam name="T">Plugin type.</typeparam>
/// <param name="ct">Cancellation token.</param>
/// <returns>Initialized plugin instance.</returns>
public async Task<T> LoadPluginAsync<T>(CancellationToken ct = default) where T : IPlugin, new()
{
var plugin = new T();
await plugin.InitializeAsync(Context, ct);
_plugins.Add(plugin);
return plugin;
}
/// <summary>
/// Load and initialize a plugin with custom configuration.
/// </summary>
/// <typeparam name="T">Plugin type.</typeparam>
/// <param name="configuration">Configuration values to set.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Initialized plugin instance.</returns>
public async Task<T> LoadPluginAsync<T>(
Dictionary<string, object> configuration,
CancellationToken ct = default) where T : IPlugin, new()
{
foreach (var (key, value) in configuration)
{
Context.Configuration.SetValue(key, value);
}
return await LoadPluginAsync<T>(ct);
}
/// <summary>
/// Verify plugin health.
/// </summary>
/// <typeparam name="T">Plugin type.</typeparam>
/// <param name="plugin">Plugin to check.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Health check result.</returns>
public async Task<HealthCheckResult> CheckHealthAsync<T>(T plugin, CancellationToken ct = default)
where T : IPlugin
{
return await plugin.HealthCheckAsync(ct);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
foreach (var plugin in _plugins)
{
await plugin.DisposeAsync();
}
_plugins.Clear();
}
}

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Plugin.Testing;
using Microsoft.Extensions.Logging;
/// <summary>
/// Options for configuring the plugin test host.
/// </summary>
public sealed class PluginTestHostOptions
{
/// <summary>
/// Whether to enable logging output.
/// </summary>
public bool EnableLogging { get; set; } = true;
/// <summary>
/// Minimum log level to capture.
/// </summary>
public LogLevel MinLogLevel { get; set; } = LogLevel.Debug;
/// <summary>
/// Optional custom time provider for deterministic testing.
/// </summary>
public TimeProvider? TimeProvider { get; set; }
/// <summary>
/// Secret values for testing.
/// </summary>
public Dictionary<string, string> Secrets { get; } = new();
/// <summary>
/// Configuration values for testing.
/// </summary>
public Dictionary<string, object> Configuration { get; } = new();
/// <summary>
/// Start time for fake time provider (if not using custom TimeProvider).
/// </summary>
public DateTimeOffset? StartTime { get; set; }
}

View File

@@ -0,0 +1,71 @@
namespace StellaOps.Plugin.Testing;
/// <summary>
/// Sequential GUID generator for deterministic testing.
/// Generates GUIDs in a predictable sequence based on a counter.
/// </summary>
public sealed class SequentialGuidGenerator
{
private int _counter;
/// <summary>
/// Creates a new sequential GUID generator.
/// </summary>
public SequentialGuidGenerator()
{
_counter = 0;
}
/// <summary>
/// Creates a new sequential GUID generator starting at the specified counter value.
/// </summary>
/// <param name="startValue">Initial counter value.</param>
public SequentialGuidGenerator(int startValue)
{
_counter = startValue;
}
/// <summary>
/// Generates a new sequential GUID.
/// </summary>
public Guid NewGuid()
{
var counter = Interlocked.Increment(ref _counter);
var bytes = new byte[16];
// Put counter in first 4 bytes for easy debugging
BitConverter.GetBytes(counter).CopyTo(bytes, 0);
// Fill rest with deterministic pattern
bytes[4] = 0x00;
bytes[5] = 0x00;
bytes[6] = 0x40; // Version 4
bytes[7] = 0x00;
bytes[8] = 0x80; // Variant
bytes[9] = 0x00;
bytes[10] = 0x00;
bytes[11] = 0x00;
bytes[12] = 0x00;
bytes[13] = 0x00;
bytes[14] = 0x00;
bytes[15] = 0x00;
return new Guid(bytes);
}
/// <summary>
/// Resets the counter to zero.
/// </summary>
public void Reset() => _counter = 0;
/// <summary>
/// Resets the counter to the specified value.
/// </summary>
/// <param name="value">New counter value.</param>
public void Reset(int value) => _counter = value;
/// <summary>
/// Gets the current counter value.
/// </summary>
public int CurrentValue => _counter;
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>Testing utilities for Stella Ops plugins</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Plugin.Sdk\StellaOps.Plugin.Sdk.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,224 @@
namespace StellaOps.Plugin.Testing;
using System.Net;
/// <summary>
/// Test HTTP client factory with request recording and response mocking.
/// </summary>
public sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly Dictionary<string, MockHttpMessageHandler> _handlers = new(StringComparer.OrdinalIgnoreCase);
private readonly List<RecordedRequest> _requests = new();
private readonly object _lock = new();
/// <summary>
/// Gets all recorded requests across all clients.
/// </summary>
public IReadOnlyList<RecordedRequest> RecordedRequests
{
get
{
lock (_lock) return _requests.ToList();
}
}
/// <inheritdoc />
public HttpClient CreateClient(string name)
{
lock (_lock)
{
if (!_handlers.TryGetValue(name, out var handler))
{
handler = new MockHttpMessageHandler(_requests, _lock);
_handlers[name] = handler;
}
return new HttpClient(handler);
}
}
/// <summary>
/// Sets up a response for a specific URL pattern.
/// </summary>
/// <param name="clientName">Name of the HTTP client.</param>
/// <param name="urlPattern">URL pattern to match.</param>
/// <param name="response">Response to return.</param>
public void SetupResponse(string clientName, string urlPattern, HttpResponseMessage response)
{
lock (_lock)
{
if (!_handlers.TryGetValue(clientName, out var handler))
{
handler = new MockHttpMessageHandler(_requests, _lock);
_handlers[clientName] = handler;
}
handler.SetupResponse(urlPattern, response);
}
}
/// <summary>
/// Sets up a JSON response for a specific URL pattern.
/// </summary>
/// <param name="clientName">Name of the HTTP client.</param>
/// <param name="urlPattern">URL pattern to match.</param>
/// <param name="json">JSON content to return.</param>
/// <param name="statusCode">HTTP status code.</param>
public void SetupJsonResponse(
string clientName,
string urlPattern,
string json,
HttpStatusCode statusCode = HttpStatusCode.OK)
{
var response = new HttpResponseMessage(statusCode)
{
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
};
SetupResponse(clientName, urlPattern, response);
}
/// <summary>
/// Sets up an error response for a specific URL pattern.
/// </summary>
/// <param name="clientName">Name of the HTTP client.</param>
/// <param name="urlPattern">URL pattern to match.</param>
/// <param name="statusCode">HTTP status code.</param>
/// <param name="reasonPhrase">Optional reason phrase.</param>
public void SetupError(
string clientName,
string urlPattern,
HttpStatusCode statusCode,
string? reasonPhrase = null)
{
var response = new HttpResponseMessage(statusCode)
{
ReasonPhrase = reasonPhrase
};
SetupResponse(clientName, urlPattern, response);
}
/// <summary>
/// Clears all recorded requests.
/// </summary>
public void ClearRecordedRequests()
{
lock (_lock) _requests.Clear();
}
/// <summary>
/// Clears all handlers and recorded requests.
/// </summary>
public void Reset()
{
lock (_lock)
{
_handlers.Clear();
_requests.Clear();
}
}
}
/// <summary>
/// A recorded HTTP request.
/// </summary>
/// <param name="Method">HTTP method.</param>
/// <param name="Uri">Request URI.</param>
/// <param name="Headers">Request headers.</param>
/// <param name="Content">Request content.</param>
/// <param name="Timestamp">When the request was made.</param>
public sealed record RecordedRequest(
HttpMethod Method,
Uri? Uri,
Dictionary<string, IEnumerable<string>> Headers,
string? Content,
DateTimeOffset Timestamp);
internal sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly List<RecordedRequest> _requests;
private readonly object _lock;
private readonly Dictionary<string, HttpResponseMessage> _responses = new(StringComparer.OrdinalIgnoreCase);
public MockHttpMessageHandler(List<RecordedRequest> requests, object lockObj)
{
_requests = requests;
_lock = lockObj;
}
public void SetupResponse(string urlPattern, HttpResponseMessage response)
{
_responses[urlPattern] = response;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Record the request
string? content = null;
if (request.Content != null)
{
content = await request.Content.ReadAsStringAsync(cancellationToken);
}
var headers = request.Headers.ToDictionary(
h => h.Key,
h => h.Value,
StringComparer.OrdinalIgnoreCase);
var recorded = new RecordedRequest(
request.Method,
request.RequestUri,
headers,
content,
DateTimeOffset.UtcNow);
lock (_lock)
{
_requests.Add(recorded);
}
// Find matching response
var url = request.RequestUri?.ToString() ?? "";
foreach (var (pattern, response) in _responses)
{
if (url.Contains(pattern, StringComparison.OrdinalIgnoreCase) ||
pattern == "*" ||
System.Text.RegularExpressions.Regex.IsMatch(url, pattern))
{
// Clone response to allow reuse
return await CloneResponseAsync(response);
}
}
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
ReasonPhrase = $"No mock response configured for {url}"
};
}
private static async Task<HttpResponseMessage> CloneResponseAsync(HttpResponseMessage response)
{
var clone = new HttpResponseMessage(response.StatusCode)
{
ReasonPhrase = response.ReasonPhrase,
Version = response.Version
};
if (response.Content != null)
{
var content = await response.Content.ReadAsStringAsync();
clone.Content = new StringContent(
content,
System.Text.Encoding.UTF8,
response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream");
}
foreach (var header in response.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
}

View File

@@ -0,0 +1,124 @@
namespace StellaOps.Plugin.Testing;
using System.Globalization;
using System.Reflection;
using StellaOps.Plugin.Abstractions.Context;
using StellaOps.Plugin.Sdk;
/// <summary>
/// Test implementation of plugin configuration.
/// </summary>
public sealed class TestPluginConfiguration : IPluginConfiguration
{
private readonly Dictionary<string, object> _values;
private readonly Dictionary<string, string> _secrets;
/// <summary>
/// Creates a new test configuration.
/// </summary>
/// <param name="values">Initial configuration values.</param>
/// <param name="secrets">Initial secret values.</param>
public TestPluginConfiguration(
Dictionary<string, object> values,
Dictionary<string, string> secrets)
{
_values = new Dictionary<string, object>(values, StringComparer.OrdinalIgnoreCase);
_secrets = new Dictionary<string, string>(secrets, StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc />
public T? GetValue<T>(string key, T? defaultValue = default)
{
if (!_values.TryGetValue(key, out var value))
return defaultValue;
if (value == null)
return defaultValue;
try
{
var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
return (T)Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
}
catch
{
return defaultValue;
}
}
/// <summary>
/// Sets a configuration value.
/// </summary>
/// <param name="key">Configuration key.</param>
/// <param name="value">Configuration value.</param>
public void SetValue(string key, object value)
{
_values[key] = value;
}
/// <inheritdoc />
public T Bind<T>(string? sectionKey = null) where T : class, new()
{
var result = new T();
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
var prefix = string.IsNullOrEmpty(sectionKey) ? "" : sectionKey + ":";
foreach (var prop in properties)
{
if (!prop.CanWrite) continue;
var key = prefix + prop.Name;
var configAttr = prop.GetCustomAttribute<PluginConfigAttribute>();
if (configAttr?.Key != null)
key = prefix + configAttr.Key;
if (_values.TryGetValue(key, out var value))
{
try
{
var targetType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
var convertedValue = Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
prop.SetValue(result, convertedValue);
}
catch
{
// Skip properties that can't be converted
}
}
}
return result;
}
/// <inheritdoc />
public Task<string?> GetSecretAsync(string secretName, CancellationToken ct)
{
return Task.FromResult(_secrets.TryGetValue(secretName, out var secret) ? secret : null);
}
/// <inheritdoc />
public bool HasKey(string key)
{
return _values.ContainsKey(key);
}
/// <summary>
/// Sets a secret value.
/// </summary>
/// <param name="key">Secret key.</param>
/// <param name="value">Secret value.</param>
public void SetSecret(string key, string value)
{
_secrets[key] = value;
}
/// <summary>
/// Clears all configuration values.
/// </summary>
public void Clear()
{
_values.Clear();
_secrets.Clear();
}
}

View File

@@ -0,0 +1,85 @@
namespace StellaOps.Plugin.Testing;
using StellaOps.Plugin.Abstractions.Context;
/// <summary>
/// Test implementation of IPluginContext.
/// </summary>
public sealed class TestPluginContext : IPluginContext
{
/// <summary>
/// Gets the test configuration.
/// </summary>
public TestPluginConfiguration Configuration { get; }
/// <summary>
/// Gets the test logger.
/// </summary>
public TestPluginLogger Logger { get; }
/// <summary>
/// Gets the test services.
/// </summary>
public TestPluginServices Services { get; }
/// <inheritdoc />
public Guid? TenantId { get; set; }
/// <inheritdoc />
public Guid InstanceId { get; }
/// <inheritdoc />
public CancellationToken ShutdownToken { get; }
/// <inheritdoc />
public TimeProvider TimeProvider { get; }
/// <summary>
/// Gets the GUID generator for deterministic testing.
/// </summary>
public SequentialGuidGenerator GuidGenerator { get; }
/// <summary>
/// Gets the HTTP client factory for test mocking.
/// </summary>
public TestHttpClientFactory HttpClientFactory { get; }
IPluginConfiguration IPluginContext.Configuration => Configuration;
IPluginLogger IPluginContext.Logger => Logger;
IPluginServices IPluginContext.Services => Services;
/// <summary>
/// Creates a new test context.
/// </summary>
/// <param name="options">Test host options.</param>
public TestPluginContext(PluginTestHostOptions options)
{
Configuration = new TestPluginConfiguration(options.Configuration, options.Secrets);
Logger = new TestPluginLogger(options.MinLogLevel, options.EnableLogging);
Services = new TestPluginServices();
TimeProvider = options.TimeProvider
?? new FakeTimeProvider(options.StartTime ?? DateTimeOffset.UtcNow);
GuidGenerator = new SequentialGuidGenerator();
HttpClientFactory = new TestHttpClientFactory();
InstanceId = GuidGenerator.NewGuid();
ShutdownToken = CancellationToken.None;
// Register common services
Services.Register<IHttpClientFactory>(HttpClientFactory);
Services.Register(TimeProvider);
}
/// <summary>
/// Creates a new test context with a custom shutdown token.
/// </summary>
/// <param name="options">Test host options.</param>
/// <param name="shutdownToken">Shutdown cancellation token.</param>
public TestPluginContext(PluginTestHostOptions options, CancellationToken shutdownToken)
: this(options)
{
ShutdownToken = shutdownToken;
}
}

View File

@@ -0,0 +1,200 @@
namespace StellaOps.Plugin.Testing;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Abstractions.Context;
/// <summary>
/// Test logger that captures log entries for assertions.
/// </summary>
public sealed class TestPluginLogger : IPluginLogger
{
private readonly LogLevel _minLevel;
private readonly bool _enabled;
private readonly List<LogEntry> _entries = new();
private readonly object _lock = new();
private readonly Dictionary<string, object> _properties = new();
private readonly string? _operationName;
/// <summary>
/// Gets all captured log entries.
/// </summary>
public IReadOnlyList<LogEntry> Entries
{
get
{
lock (_lock) return _entries.ToList();
}
}
/// <summary>
/// Creates a new test logger.
/// </summary>
/// <param name="minLevel">Minimum log level to capture.</param>
/// <param name="enabled">Whether logging is enabled.</param>
public TestPluginLogger(LogLevel minLevel, bool enabled)
{
_minLevel = minLevel;
_enabled = enabled;
}
private TestPluginLogger(
LogLevel minLevel,
bool enabled,
List<LogEntry> entries,
object lockObj,
Dictionary<string, object> properties,
string? operationName)
{
_minLevel = minLevel;
_enabled = enabled;
_entries = entries;
_lock = lockObj;
_properties = new Dictionary<string, object>(properties);
_operationName = operationName;
}
/// <inheritdoc />
public void Log(LogLevel level, string message, params object[] args)
{
if (!_enabled || level < _minLevel) return;
var formatted = FormatMessage(message, args);
lock (_lock)
{
_entries.Add(new LogEntry(level, formatted, null, _operationName, new Dictionary<string, object>(_properties)));
}
if (_enabled)
{
var prefix = _operationName != null ? $"[{_operationName}] " : "";
Console.WriteLine($"[{level}] {prefix}{formatted}");
}
}
/// <inheritdoc />
public void Log(LogLevel level, Exception exception, string message, params object[] args)
{
if (!_enabled || level < _minLevel) return;
var formatted = FormatMessage(message, args);
lock (_lock)
{
_entries.Add(new LogEntry(level, formatted, exception, _operationName, new Dictionary<string, object>(_properties)));
}
if (_enabled)
{
var prefix = _operationName != null ? $"[{_operationName}] " : "";
Console.WriteLine($"[{level}] {prefix}{formatted}");
Console.WriteLine(exception);
}
}
/// <inheritdoc />
public void Debug(string message, params object[] args) => Log(LogLevel.Debug, message, args);
/// <inheritdoc />
public void Info(string message, params object[] args) => Log(LogLevel.Information, message, args);
/// <inheritdoc />
public void Warning(string message, params object[] args) => Log(LogLevel.Warning, message, args);
/// <inheritdoc />
public void Error(string message, params object[] args) => Log(LogLevel.Error, message, args);
/// <inheritdoc />
public void Error(Exception ex, string message, params object[] args) => Log(LogLevel.Error, ex, message, args);
/// <inheritdoc />
public IPluginLogger WithProperty(string name, object value)
{
var newProps = new Dictionary<string, object>(_properties)
{
[name] = value
};
return new TestPluginLogger(_minLevel, _enabled, _entries, _lock, newProps, _operationName);
}
/// <inheritdoc />
public IPluginLogger ForOperation(string operationName)
{
return new TestPluginLogger(_minLevel, _enabled, _entries, _lock, _properties, operationName);
}
/// <inheritdoc />
public bool IsEnabled(LogLevel level) => _enabled && level >= _minLevel;
private static string FormatMessage(string message, object[] args)
{
if (args.Length == 0) return message;
// Handle structured logging placeholders like {PluginId}
var formatted = message;
var argIndex = 0;
formatted = System.Text.RegularExpressions.Regex.Replace(
formatted,
@"\{[^}]+\}",
match =>
{
if (argIndex < args.Length)
{
var arg = args[argIndex++];
return arg?.ToString() ?? "null";
}
return match.Value;
},
System.Text.RegularExpressions.RegexOptions.None,
TimeSpan.FromSeconds(1));
return formatted;
}
/// <summary>
/// Checks if any entries were logged at the specified level.
/// </summary>
/// <param name="level">Log level to check.</param>
public bool HasLoggedAtLevel(LogLevel level) => Entries.Any(e => e.Level == level);
/// <summary>
/// Checks if any error was logged.
/// </summary>
public bool HasLoggedError() => HasLoggedAtLevel(LogLevel.Error);
/// <summary>
/// Checks if any warning was logged.
/// </summary>
public bool HasLoggedWarning() => HasLoggedAtLevel(LogLevel.Warning);
/// <summary>
/// Checks if a message containing the specified text was logged.
/// </summary>
/// <param name="text">Text to search for.</param>
public bool HasLoggedContaining(string text) =>
Entries.Any(e => e.Message.Contains(text, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Clears all captured log entries.
/// </summary>
public void Clear()
{
lock (_lock) _entries.Clear();
}
}
/// <summary>
/// A captured log entry.
/// </summary>
/// <param name="Level">Log level.</param>
/// <param name="Message">Formatted message.</param>
/// <param name="Exception">Optional exception.</param>
/// <param name="OperationName">Optional operation name.</param>
/// <param name="Properties">Scoped properties.</param>
public sealed record LogEntry(
LogLevel Level,
string Message,
Exception? Exception,
string? OperationName = null,
Dictionary<string, object>? Properties = null);

View File

@@ -0,0 +1,85 @@
namespace StellaOps.Plugin.Testing;
using StellaOps.Plugin.Abstractions.Context;
/// <summary>
/// Test implementation of plugin services.
/// </summary>
public sealed class TestPluginServices : IPluginServices
{
private readonly Dictionary<Type, object> _services = new();
private readonly Dictionary<Type, List<object>> _multiServices = new();
/// <summary>
/// Registers a service implementation.
/// </summary>
/// <typeparam name="T">Service type.</typeparam>
/// <param name="implementation">Service implementation.</param>
public void Register<T>(T implementation) where T : class
{
_services[typeof(T)] = implementation;
if (!_multiServices.TryGetValue(typeof(T), out var list))
{
list = new List<object>();
_multiServices[typeof(T)] = list;
}
list.Add(implementation);
}
/// <inheritdoc />
public T GetRequiredService<T>() where T : class
{
if (_services.TryGetValue(typeof(T), out var service))
{
return (T)service;
}
throw new InvalidOperationException($"Service {typeof(T).Name} is not registered");
}
/// <inheritdoc />
public T? GetService<T>() where T : class
{
if (_services.TryGetValue(typeof(T), out var service))
{
return (T)service;
}
return null;
}
/// <inheritdoc />
public IEnumerable<T> GetServices<T>() where T : class
{
if (_multiServices.TryGetValue(typeof(T), out var list))
{
return list.Cast<T>();
}
return Enumerable.Empty<T>();
}
/// <inheritdoc />
public IAsyncDisposable CreateScope(out IPluginServices scopedServices)
{
// For testing, just return a copy of this service provider
var scope = new TestPluginServices();
foreach (var (type, service) in _services)
{
scope._services[type] = service;
}
foreach (var (type, list) in _multiServices)
{
scope._multiServices[type] = new List<object>(list);
}
scopedServices = scope;
return new TestScope();
}
private sealed class TestScope : IAsyncDisposable
{
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}