diff --git a/DevProxy.Tests/InteractiveConsoleServiceTests.cs b/DevProxy.Tests/InteractiveConsoleServiceTests.cs new file mode 100644 index 00000000..50536e30 --- /dev/null +++ b/DevProxy.Tests/InteractiveConsoleServiceTests.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Proxy; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DevProxy.Tests; + +/// +/// Verifies that shutting down the proxy (Ctrl+C, devproxy stop) flushes an +/// active recording so recording plugins still run (dotnet/dev-proxy#1945). +/// +public sealed class InteractiveConsoleServiceTests +{ + private static (InteractiveConsoleService service, FakeProxyStateController controller) CreateService() + { + var controller = new FakeProxyStateController(); + var service = new InteractiveConsoleService( + controller, + new FakeProxyConfiguration(), + new RecordingConsole { IsInputRedirected = true }, + new FakeServer(), + new FakeLifetime(), + NullLogger.Instance); + return (service, controller); + } + + [Fact] + public async Task StopAsync_WhileRecording_StopsRecording() + { + var (service, controller) = CreateService(); + controller.ProxyState.IsRecording = true; + + await service.StopAsync(CancellationToken.None); + + Assert.Equal(1, controller.StopRecordingCalls); + } + + [Fact] + public async Task StopAsync_NotRecording_DoesNotStopRecording() + { + var (service, controller) = CreateService(); + + await service.StopAsync(CancellationToken.None); + + Assert.Equal(0, controller.StopRecordingCalls); + } + + private sealed class FakeServer : IServer + { + public IFeatureCollection Features { get; } = new FeatureCollection(); + public void Dispose() { } + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class FakeLifetime : IHostApplicationLifetime + { + public CancellationToken ApplicationStarted => CancellationToken.None; + public CancellationToken ApplicationStopping => CancellationToken.None; + public CancellationToken ApplicationStopped => CancellationToken.None; + public void StopApplication() { } + } +} diff --git a/DevProxy/Proxy/InteractiveConsoleService.cs b/DevProxy/Proxy/InteractiveConsoleService.cs index 9e4f7fb1..f963a0d6 100644 --- a/DevProxy/Proxy/InteractiveConsoleService.cs +++ b/DevProxy/Proxy/InteractiveConsoleService.cs @@ -130,6 +130,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } + /// + /// Flushes an active recording on shutdown (Ctrl+C, devproxy stop, API stop) + /// so plugins like HarGeneratorPlugin still process the recorded requests. + /// Hosted services stop in reverse registration order, so normal shutdown + /// stops the proxy engine before this service flushes the recording. + /// + public override async Task StopAsync(CancellationToken cancellationToken) + { + // Stop the hotkey loop first so a concurrent 'S' press can't race this flush. + await base.StopAsync(cancellationToken); + + if (controller.ProxyState.IsRecording) + { + await controller.StopRecordingAsync(cancellationToken); + } + } + /// /// Waits for the host to finish starting so the banner is printed after the /// engine's startup logs (and the bound port is known). Returns false