feat: add diagnostic source for process lifecycle
Some checks failed
default / dotnet-default-workflow (pull_request) Failing after 56s
Some checks failed
default / dotnet-default-workflow (pull_request) Failing after 56s
The library publishes process lifecycle events through a `DiagnosticListener` named `Geekeey.Process`. Subscribe through `DiagnosticListener.AllListeners` and handle the `starting`, `started`, and `finished` events. Each event value is a mutable `Hashtable` that is shared by the lifecycle, so values added by a listener remain available to later stages.
This commit is contained in:
parent
cce841a06e
commit
a2a8b548d7
3 changed files with 272 additions and 34 deletions
164
src/process.tests/DiagnosticListenerTests.cs
Normal file
164
src/process.tests/DiagnosticListenerTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Collections;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace Geekeey.Process.Tests;
|
||||
|
||||
internal sealed class DiagnosticListenerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task I_can_observe_the_process_lifecycle_and_context_values()
|
||||
{
|
||||
// Arrange
|
||||
using var observer = new ListenerObserver("diagnostic-lifecycle");
|
||||
using var subscription = DiagnosticListener.AllListeners.Subscribe(observer);
|
||||
|
||||
// Act
|
||||
await new Command(Testing.Fixture.Program.FilePath)
|
||||
.WithArguments(["echo", "diagnostic-lifecycle"])
|
||||
.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
await Assert.That(observer.Events.Select(kvp => kvp.Key)).IsEquivalentTo(["starting", "started", "finished"]);
|
||||
await Assert.That(observer.Events[0].Value["process"]).IsTypeOf<System.Diagnostics.Process>();
|
||||
await Assert.That(observer.Events[0].Value.ContainsKey("startTime")).IsFalse();
|
||||
await Assert.That(observer.Events[1].Value["startTime"]).IsTypeOf<DateTimeOffset>();
|
||||
await Assert.That(observer.Events[2].Value["exitTime"]).IsTypeOf<DateTimeOffset>();
|
||||
await Assert.That(observer.Events[2].Value["exitCode"]).IsEqualTo(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_observe_the_process_lifecycle_when_validation_fails()
|
||||
{
|
||||
// Arrange
|
||||
using var observer = new ListenerObserver("diagnostic-validation");
|
||||
using var subscription = DiagnosticListener.AllListeners.Subscribe(observer);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.That(async () => await new Command(Testing.Fixture.Program.FilePath)
|
||||
.WithArguments(["exit", "1", "diagnostic-validation"])
|
||||
.ExecuteAsync()).Throws<CommandExecutionException>();
|
||||
|
||||
// Assert
|
||||
await Assert.That(observer.Events.Select(kvp => kvp.Key)).IsEquivalentTo(["starting", "started", "finished"]);
|
||||
await Assert.That(observer.Events[2].Value["exitTime"]).IsTypeOf<DateTimeOffset>();
|
||||
await Assert.That(observer.Events[2].Value["exitCode"]).IsEqualTo(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_add_and_remove_environment_variables_from_the_starting_context()
|
||||
{
|
||||
// Arrange
|
||||
using var observer = new ListenerObserver("GEEKEEY_ENV_");
|
||||
using var subscription = DiagnosticListener.AllListeners.Subscribe(observer);
|
||||
var output = new StringBuilder();
|
||||
observer.ContextChanged = (info, context) =>
|
||||
{
|
||||
if (info is not "starting" || context["process"] is not System.Diagnostics.Process process)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
process.StartInfo.Environment["GEEKEEY_ENV_ADD"] = "added-value";
|
||||
process.StartInfo.Environment.Remove("GEEKEEY_ENV_REMOVED");
|
||||
};
|
||||
|
||||
// Act
|
||||
await new Command(Testing.Fixture.Program.FilePath)
|
||||
.WithArguments(["env", "GEEKEEY_ENV_ADD", "GEEKEEY_ENV_REMOVED"])
|
||||
.WithEnvironment(environment => environment.Set("GEEKEEY_ENV_REMOVED", "removed-value"))
|
||||
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(output))
|
||||
.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
await Assert.That(output.ToString()).IsEqualTo($"added-value{Environment.NewLine}{Environment.NewLine}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_replace_standard_streams_from_the_started_context()
|
||||
{
|
||||
// Arrange
|
||||
using var observer = new ListenerObserver("diagnostic-streams");
|
||||
using var subscription = DiagnosticListener.AllListeners.Subscribe(observer);
|
||||
var input = new MemoryStream();
|
||||
var output = new MemoryStream([.. "some-other-value"u8]);
|
||||
var error = new MemoryStream();
|
||||
var capturedOutput = new StringBuilder();
|
||||
observer.ContextChanged = (info, context) =>
|
||||
{
|
||||
if (info == "started")
|
||||
{
|
||||
context["stdin"] = input;
|
||||
context["stdout"] = output;
|
||||
context["stderr"] = error;
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await new Command(Testing.Fixture.Program.FilePath)
|
||||
.WithArguments(["echo", "diagnostic-streams"])
|
||||
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(capturedOutput))
|
||||
.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
var context = observer.Events.Single(key => key.Key == "finished").Value;
|
||||
await Assert.That(context["stdin"]).IsSameReferenceAs(input);
|
||||
await Assert.That(context["stdout"]).IsSameReferenceAs(output);
|
||||
await Assert.That(context["stderr"]).IsSameReferenceAs(error);
|
||||
await Assert.That(capturedOutput.ToString()).IsEqualTo("some-other-value");
|
||||
}
|
||||
|
||||
private sealed class ListenerObserver : IObserver<DiagnosticListener>, IObserver<KeyValuePair<string, object?>>, IDisposable
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly List<KeyValuePair<string, Hashtable>> _events = [];
|
||||
private IDisposable? _subscription;
|
||||
|
||||
public ListenerObserver(string name)
|
||||
{
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public IReadOnlyList<KeyValuePair<string, Hashtable>> Events => _events;
|
||||
public Action<string, Hashtable>? ContextChanged { get; set; }
|
||||
|
||||
public void OnNext(DiagnosticListener value)
|
||||
{
|
||||
if (value.Name == "Geekeey.Process")
|
||||
{
|
||||
_subscription = value.Subscribe(this);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnNext(KeyValuePair<string, object?> value)
|
||||
{
|
||||
if (value.Value is not Hashtable context)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (context["process"] is not System.Diagnostics.Process process)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.StartInfo.Arguments.Contains(_name, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ContextChanged?.Invoke(value.Key, context);
|
||||
_events.Add(new KeyValuePair<string, Hashtable>(value.Key, new Hashtable(context)));
|
||||
}
|
||||
|
||||
public void OnError(Exception error) { }
|
||||
public void OnCompleted() { }
|
||||
public void Dispose()
|
||||
{
|
||||
_subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue