File: ProfileCaptureServiceTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Compression;
using System.Net;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Bundles;
using Aspire.Cli.Commands;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Layout;
using Aspire.Cli.Profiling;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.Commands;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting;
using Aspire.Otlp.Serialization;
using Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests;
 
public class ProfileCaptureServiceTests(ITestOutputHelper outputHelper)
{
    private static readonly TimeSpan s_testTimeout = TimeSpan.FromSeconds(10);
    private static readonly TimeSpan s_testPollInterval = TimeSpan.FromSeconds(1);
 
    [Fact]
    public async Task StartAsync_LaunchesPrivateDashboardWithConfiguredPortsAndCollectorEnvironment()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var fileLoggerProvider = CreateFileLoggerProvider(workspace);
        var managedPath = CreateFile(workspace, "aspire-managed");
        var options = CreateOptions(workspace);
        var processFactory = CreateRunningProcessFactory();
        var handler = new MockHttpMessageHandler(request =>
        {
            Assert.Equal(options.DashboardUrl, request.RequestUri?.GetLeftPart(UriPartial.Authority));
            return new HttpResponseMessage(HttpStatusCode.OK);
        });
        var service = CreateService(
            fileLoggerProvider,
            processFactory,
            handler,
            CreateConfiguration((BundleDiscovery.DashboardPathEnvVar, managedPath)));
 
        await using var session = await service.StartAsync(options, s_testTimeout, s_testPollInterval, CancellationToken.None);
 
        Assert.Equal(managedPath, processFactory.LastFileName);
        var arguments = processFactory.LastArguments;
        Assert.NotNull(arguments);
        Assert.Equal(
            [
                "dashboard",
                $"--{KnownAspNetCoreConfigNames.Urls}={options.DashboardUrl}",
                $"--{KnownConfigNames.DashboardOtlpGrpcEndpointUrl}={options.OtlpGrpcUrl}",
                $"--{KnownConfigNames.DashboardOtlpHttpEndpointUrl}={options.OtlpHttpUrl}",
                $"--{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=true",
                $"--{KnownConfigNames.DashboardApiEnabled}=true"
            ],
            arguments);
 
        var environmentVariables = processFactory.LastEnvironmentVariables;
        Assert.NotNull(environmentVariables);
        Assert.Equal("false", environmentVariables[KnownConfigNames.ProfilingEnabled]);
        Assert.Equal("false", environmentVariables[KnownConfigNames.Legacy.StartupProfilingEnabled]);
        Assert.Equal(string.Empty, environmentVariables[KnownConfigNames.ProfilingSessionId]);
        Assert.Equal(string.Empty, environmentVariables[KnownConfigNames.Legacy.StartupOperationId]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.ExporterOtlpHeaders]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.BspScheduleDelay]);
        Assert.NotNull(processFactory.LastProcessInvocationOptions?.StandardOutputCallback);
        Assert.NotNull(processFactory.LastProcessInvocationOptions?.StandardErrorCallback);
 
        var process = Assert.IsType<TestProcessExecution>(Assert.Single(processFactory.CreatedExecutions));
        Assert.True(process.Started);
    }
 
    [Fact]
    public async Task StartAsync_UsesBundleLayoutManagedPath_WhenOverrideIsAbsent()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var fileLoggerProvider = CreateFileLoggerProvider(workspace);
        var layoutRoot = workspace.WorkspaceRoot.CreateSubdirectory("bundle");
        var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName);
        var managedPath = Path.Combine(managedDirectory.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
        File.WriteAllText(managedPath, string.Empty);
 
        var bundleService = new TestBundleService(isBundle: true)
        {
            Layout = new LayoutConfiguration { LayoutPath = layoutRoot.FullName }
        };
        var processFactory = CreateRunningProcessFactory();
        var service = CreateService(
            fileLoggerProvider,
            processFactory,
            new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)),
            CreateConfiguration(),
            bundleService);
 
        await using var session = await service.StartAsync(CreateOptions(workspace), s_testTimeout, s_testPollInterval, CancellationToken.None);
 
        Assert.Equal(managedPath, processFactory.LastFileName);
    }
 
    [Fact]
    public async Task StartAsync_ThrowsManagedBinaryNotFound_WhenNoManagedBinaryCanBeResolved()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var fileLoggerProvider = CreateFileLoggerProvider(workspace);
        var service = CreateService(
            fileLoggerProvider,
            CreateRunningProcessFactory(),
            new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)),
            CreateConfiguration(),
            new NullBundleService());
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.StartAsync(CreateOptions(workspace), s_testTimeout, s_testPollInterval, CancellationToken.None));
 
        Assert.Equal(DashboardCommandStrings.ManagedBinaryNotFound, exception.Message);
    }
 
    [Fact]
    public async Task StartAsync_WrapsProcessFactoryFailure()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var fileLoggerProvider = CreateFileLoggerProvider(workspace);
        var managedPath = CreateFile(workspace, "aspire-managed");
        var processFactory = new TestProcessExecutionFactory
        {
            CreateExecutionWithFileNameCallback = (_, _, _, _, _) => throw new InvalidOperationException("boom")
        };
        var service = CreateService(
            fileLoggerProvider,
            processFactory,
            new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)),
            CreateConfiguration((BundleDiscovery.DashboardPathEnvVar, managedPath)));
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.StartAsync(CreateOptions(workspace), s_testTimeout, s_testPollInterval, CancellationToken.None));
 
        Assert.Contains("boom", exception.Message, StringComparison.Ordinal);
        Assert.IsType<InvalidOperationException>(exception.InnerException);
    }
 
    [Fact]
    public async Task StartAsync_WrapsProcessStartFailureAndDisposesExecution()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var fileLoggerProvider = CreateFileLoggerProvider(workspace);
        var managedPath = CreateFile(workspace, "aspire-managed");
        TestProcessExecution? process = null;
        var processFactory = new TestProcessExecutionFactory
        {
            CreateExecutionWithFileNameCallback = (fileName, args, env, _, options) =>
            {
                process = new TestProcessExecution(fileName, args, env, options, (_, _, _) => Task.FromResult((0, (string?)null)), () => 1)
                {
                    StartReturnValue = false
                };
                return process;
            }
        };
        var service = CreateService(
            fileLoggerProvider,
            processFactory,
            new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)),
            CreateConfiguration((BundleDiscovery.DashboardPathEnvVar, managedPath)));
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.StartAsync(CreateOptions(workspace), s_testTimeout, s_testPollInterval, CancellationToken.None));
 
        Assert.Contains(managedPath, exception.Message, StringComparison.Ordinal);
        Assert.NotNull(process);
        Assert.Equal(1, process.DisposeCount);
    }
 
    [Fact]
    public async Task StartAsync_DisposesDashboardProcess_WhenReadinessTimesOut()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var fileLoggerProvider = CreateFileLoggerProvider(workspace);
        var managedPath = CreateFile(workspace, "aspire-managed");
        var processFactory = CreateRunningProcessFactory();
        var timeProvider = new FakeTimeProvider();
        var service = CreateService(
            fileLoggerProvider,
            processFactory,
            new MockHttpMessageHandler(_ =>
            {
                timeProvider.Advance(s_testTimeout + TimeSpan.FromTicks(1));
                throw new HttpRequestException("not ready");
            }),
            CreateConfiguration((BundleDiscovery.DashboardPathEnvVar, managedPath)),
            timeProvider: timeProvider);
 
        var exception = await Assert.ThrowsAsync<TimeoutException>(
            () => service.StartAsync(CreateOptions(workspace), s_testTimeout, s_testPollInterval, CancellationToken.None));
 
        Assert.Equal(DashboardCommandStrings.DashboardStartTimedOut, exception.Message);
        var process = Assert.IsType<TestProcessExecution>(Assert.Single(processFactory.CreatedExecutions));
        Assert.Equal(1, process.KillCount);
        Assert.True(process.KilledEntireProcessTree);
        Assert.Equal(1, process.DisposeCount);
    }
 
    [Fact]
    public async Task WaitForDashboardAsync_ReturnsAfterTransientConnectionFailures()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var options = CreateOptions(workspace);
        var attempts = 0;
        var handler = new MockHttpMessageHandler(_ =>
        {
            if (Interlocked.Increment(ref attempts) <= 2)
            {
                throw new HttpRequestException("connection refused");
            }
 
            return new HttpResponseMessage(HttpStatusCode.OK);
        });
 
        await using var session = CreateSession(options, handler, timeProvider: new FakeTimeProvider());
 
        await session.WaitForDashboardAsync(s_testTimeout, TimeSpan.Zero, CancellationToken.None);
 
        Assert.Equal(3, attempts);
    }
 
    [Fact]
    public async Task WaitForDashboardAsync_ThrowsDashboardExited_WhenProcessExitsBeforeReady()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var process = CreateStartedProcess((_, _) => Task.FromResult(42));
        await using var session = CreateSession(
            CreateOptions(workspace),
            new MockHttpMessageHandler(new HttpRequestException("not ready")),
            process);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => session.WaitForDashboardAsync(s_testTimeout, s_testPollInterval, CancellationToken.None));
 
        Assert.Contains("42", exception.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task WaitForDashboardAsync_ThrowsTimeout_WhenDashboardNeverResponds()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var timeProvider = new FakeTimeProvider();
        await using var session = CreateSession(
            CreateOptions(workspace),
            new MockHttpMessageHandler(_ =>
            {
                timeProvider.Advance(s_testTimeout + TimeSpan.FromTicks(1));
                throw new HttpRequestException("not ready");
            }),
            timeProvider: timeProvider);
 
        var exception = await Assert.ThrowsAsync<TimeoutException>(
            () => session.WaitForDashboardAsync(s_testTimeout, s_testPollInterval, CancellationToken.None));
 
        Assert.Equal(DashboardCommandStrings.DashboardStartTimedOut, exception.Message);
    }
 
    [Fact]
    public async Task DisposeAsync_StopsWaitingForExitAfterBoundedTimeout()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var hangingExit = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
        // Simulate a dashboard process that ignores Kill and never finishes WaitForExitAsync. Without
        // a bounded disposal timeout the CLI would hang on shutdown waiting on this task.
        var process = new TestProcessExecution(
            fileName: "aspire-managed",
            args: [],
            env: null,
            options: new ProcessInvocationOptions(),
            attemptCallback: (_, _, _) => Task.FromResult((0, (string?)null)),
            attemptCounter: () => 1)
        {
            WaitForExitAsyncCallback = (_, ct) => hangingExit.Task.WaitAsync(ct)
        };
        Assert.True(await process.StartAsync(CancellationToken.None));
 
        var timeProvider = new FakeTimeProvider();
        var session = CreateSession(
            CreateOptions(workspace),
            new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)),
            process,
            timeProvider: timeProvider);
 
        var disposeTask = session.DisposeAsync().AsTask();
 
        // Advance the fake clock until DisposeAsync's WaitAsync timer fires. The yield gives
        // DisposeAsync a chance to register that timer with the FakeTimeProvider before each advance.
        for (var attempt = 0; attempt < 100 && !disposeTask.IsCompleted; attempt++)
        {
            timeProvider.Advance(TimeSpan.FromSeconds(1));
            await Task.Yield();
        }
 
        await disposeTask.WaitAsync(TimeSpan.FromSeconds(10));
 
        Assert.Equal(1, process.KillCount);
        Assert.Equal(1, process.DisposeCount);
        hangingExit.TrySetResult(0);
    }
 
    [Fact]
    public async Task ExportAsync_WritesArchiveAfterSessionSpansReachSteadyState()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nested", "profile.zip");
        var options = CreateOptions(workspace, outputPath: outputPath, sessionId: "session-a");
        var interactionService = new TestInteractionService();
        var handler = CreateTelemetryHandler(_ => JsonResponse(CreateTracesResponse(options.SessionId)));
 
        await using var session = CreateSession(
            options,
            handler,
            interactionService: interactionService,
            profileDataTimeout: TimeSpan.FromSeconds(1),
            profileDataPollInterval: TimeSpan.Zero,
            profileDataQuietPolls: 2);
 
        var exitCode = await session.ExportAsync(CancellationToken.None);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(outputPath));
        Assert.True(Directory.Exists(Path.GetDirectoryName(outputPath)));
        using var archive = ZipFile.OpenRead(outputPath);
        var entry = Assert.Single(archive.Entries);
        Assert.Equal("traces/profile.json", entry.FullName);
 
        var exportedJson = ReadEntryText(entry);
        var exportedData = JsonSerializer.Deserialize(exportedJson, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson);
        var span = Assert.Single(Assert.Single(Assert.Single(exportedData!.ResourceSpans!).ScopeSpans!).Spans!);
        Assert.Equal("profile-span-0", span.Name);
 
        var displayedMessage = Assert.Single(interactionService.DisplayedMessages);
        Assert.Equal(KnownEmojis.CheckMarkButton, displayedMessage.Emoji);
        Assert.Contains(outputPath, displayedMessage.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task ExportAsync_WritesArchiveWhenDcpSessionSpansUseDcpSessionAttribute()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var options = CreateOptions(workspace, sessionId: "session-a");
        var handler = CreateTelemetryHandler(_ => JsonResponse(CreateTracesResponse(
            options.SessionId,
            sessionTagName: "dcp.profiling.session_id",
            sessionAttributeOnResource: true)));
 
        await using var session = CreateSession(
            options,
            handler,
            profileDataTimeout: TimeSpan.FromSeconds(1),
            profileDataPollInterval: TimeSpan.Zero,
            profileDataQuietPolls: 2);
 
        var exitCode = await session.ExportAsync(CancellationToken.None);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(options.OutputPath));
    }
 
    [Fact]
    public async Task ExportAsync_ReturnsFailure_WhenNoResourceSpansAreExported()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var options = CreateOptions(workspace);
        var timeProvider = new FakeTimeProvider();
        var handler = CreateTelemetryHandler(_ => JsonResponse(new TelemetryApiResponse
        {
            Data = new OtlpTelemetryDataJson(),
            TotalCount = 0,
            ReturnedCount = 0
        }), timeProvider);
 
        await using var session = CreateSession(options, handler, timeProvider: timeProvider);
 
        var exitCode = await session.ExportAsync(CancellationToken.None);
 
        Assert.Equal(CliExitCodes.DashboardFailure, exitCode);
        Assert.False(File.Exists(options.OutputPath));
    }
 
    [Fact]
    public async Task ExportAsync_ReturnsFailure_WhenOnlyOtherSessionSpansAreExported()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var options = CreateOptions(workspace, sessionId: "session-a");
        var timeProvider = new FakeTimeProvider();
        var handler = CreateTelemetryHandler(_ => JsonResponse(CreateTracesResponse("session-b")), timeProvider);
 
        await using var session = CreateSession(options, handler, timeProvider: timeProvider);
 
        var exitCode = await session.ExportAsync(CancellationToken.None);
 
        Assert.Equal(CliExitCodes.DashboardFailure, exitCode);
        Assert.False(File.Exists(options.OutputPath));
    }
 
    [Fact]
    public async Task ExportAsync_ThrowsHttpRequestException_WhenTelemetryApiReturnsHtmlFallback()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var handler = CreateTelemetryHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("<html></html>", Encoding.UTF8, "text/html")
        });
 
        await using var session = CreateSession(CreateOptions(workspace), handler);
 
        var exception = await Assert.ThrowsAsync<HttpRequestException>(() => session.ExportAsync(CancellationToken.None));
 
        Assert.Equal(HttpStatusCode.NotFound, exception.StatusCode);
    }
 
    [Fact]
    public async Task ExportAsync_ThrowsJsonException_WhenTelemetryApiReturnsInvalidJson()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var handler = CreateTelemetryHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("{", Encoding.UTF8, "application/json")
        });
 
        await using var session = CreateSession(CreateOptions(workspace), handler);
 
        await Assert.ThrowsAsync<JsonException>(() => session.ExportAsync(CancellationToken.None));
    }
 
    [Fact]
    public void CreateDashboardEnvironment_DisablesCollectorProfilingAndExporterInheritance()
    {
        var environmentVariables = ProfileCaptureService.CreateDashboardEnvironment();
 
        Assert.Equal("false", environmentVariables[KnownConfigNames.ProfilingEnabled]);
        Assert.Equal("false", environmentVariables[KnownConfigNames.Legacy.StartupProfilingEnabled]);
        Assert.Equal(string.Empty, environmentVariables[KnownConfigNames.ProfilingSessionId]);
        Assert.Equal(string.Empty, environmentVariables[KnownConfigNames.Legacy.StartupOperationId]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.ExporterOtlpHeaders]);
        Assert.Equal(string.Empty, environmentVariables[KnownOtelConfigNames.BspScheduleDelay]);
    }
 
    [Fact]
    public void ResolveManagedPathOverride_UsesManagedDirectoryContainingExecutable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var managedDirectory = workspace.WorkspaceRoot.CreateSubdirectory("managed");
        var managedPath = Path.Combine(managedDirectory.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
        File.WriteAllText(managedPath, string.Empty);
 
        var resolvedPath = ProfileCaptureService.ResolveManagedPathOverride(
            CreateConfiguration((BundleDiscovery.ManagedPathEnvVar, managedDirectory.FullName)));
 
        Assert.Equal(managedPath, resolvedPath);
    }
 
    [Fact]
    public void ResolveManagedPathOverride_IgnoresMissingOverrides()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var missingPath = Path.Combine(workspace.WorkspaceRoot.FullName, "missing");
 
        var resolvedPath = ProfileCaptureService.ResolveManagedPathOverride(
            CreateConfiguration(
                (BundleDiscovery.DashboardPathEnvVar, missingPath),
                (BundleDiscovery.ManagedPathEnvVar, missingPath)));
 
        Assert.Null(resolvedPath);
    }
 
    private static FileLoggerProvider CreateFileLoggerProvider(TemporaryWorkspace workspace)
    {
        var logPath = Path.Combine(workspace.WorkspaceRoot.FullName, "profile-service.log");
        return new FileLoggerProvider(logPath, new TestStartupErrorWriter());
    }
 
    private static ProfileCaptureService CreateService(
        FileLoggerProvider fileLoggerProvider,
        TestProcessExecutionFactory processFactory,
        HttpMessageHandler handler,
        IConfiguration configuration,
        IBundleService? bundleService = null,
        TestInteractionService? interactionService = null,
        TimeProvider? timeProvider = null)
    {
        return new ProfileCaptureService(
            bundleService ?? new NullBundleService(),
            new LayoutProcessRunner(processFactory),
            fileLoggerProvider,
            new MockHttpClientFactory(handler),
            interactionService ?? new TestInteractionService(),
            configuration,
            timeProvider ?? TimeProvider.System,
            NullLogger<ProfileCaptureService>.Instance);
    }
 
    private static TestProcessExecutionFactory CreateRunningProcessFactory()
    {
        return new TestProcessExecutionFactory
        {
            CreateExecutionWithFileNameCallback = (fileName, args, env, _, options) => CreateRunningProcess(fileName, args, env, options)
        };
    }
 
    private static ProfileCaptureService.ProfileCaptureSession CreateSession(
        ProfileCaptureOptions options,
        HttpMessageHandler handler,
        TestProcessExecution? process = null,
        TestInteractionService? interactionService = null,
        TimeProvider? timeProvider = null,
        TimeSpan? profileDataTimeout = null,
        TimeSpan? profileDataPollInterval = null,
        int profileDataQuietPolls = 1)
    {
        if (process is null)
        {
            process = CreateRunningProcess("aspire-managed", [], env: null, new ProcessInvocationOptions());
            Assert.True(process.StartAsync(CancellationToken.None).GetAwaiter().GetResult());
        }
 
        return new ProfileCaptureService.ProfileCaptureSession(
            options,
            process,
            new MockHttpClientFactory(handler),
            interactionService ?? new TestInteractionService(),
            timeProvider ?? TimeProvider.System,
            NullLogger<ProfileCaptureService>.Instance,
            profileDataTimeout ?? s_testTimeout,
            profileDataPollInterval ?? s_testPollInterval,
            profileDataQuietPolls);
    }
 
    private static TestProcessExecution CreateStartedProcess(Func<ProcessInvocationOptions, CancellationToken, Task<int>> waitForExitAsync)
    {
        var process = new TestProcessExecution(
            fileName: "aspire-managed",
            args: [],
            env: null,
            options: new ProcessInvocationOptions(),
            attemptCallback: (_, _, _) => Task.FromResult((0, (string?)null)),
            attemptCounter: () => 1)
        {
            WaitForExitAsyncCallback = waitForExitAsync
        };
        Assert.True(process.StartAsync(CancellationToken.None).GetAwaiter().GetResult());
        return process;
    }
 
    private static TestProcessExecution CreateRunningProcess(
        string fileName,
        string[] args,
        IDictionary<string, string>? env,
        ProcessInvocationOptions options)
    {
        var exitCompletionSource = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
        return new TestProcessExecution(
            fileName,
            args,
            env,
            options,
            (_, _, _) => Task.FromResult((0, (string?)null)),
            () => 1)
        {
            WaitForExitAsyncCallback = (_, ct) => exitCompletionSource.Task.WaitAsync(ct),
            KillCallback = _ => exitCompletionSource.TrySetResult(CliExitCodes.Success)
        };
    }
 
    private static HttpMessageHandler CreateTelemetryHandler(
        Func<HttpRequestMessage, HttpResponseMessage> telemetryResponseFactory,
        FakeTimeProvider? timeoutTimeProvider = null)
    {
        return new MockHttpMessageHandler(request =>
        {
            if (request.RequestUri?.AbsolutePath.EndsWith("/api/telemetry/traces", StringComparison.Ordinal) is true)
            {
                var response = telemetryResponseFactory(request);
                timeoutTimeProvider?.Advance(s_testTimeout + TimeSpan.FromTicks(1));
                return response;
            }
 
            return new HttpResponseMessage(HttpStatusCode.NotFound);
        });
    }
 
    private static HttpResponseMessage JsonResponse(TelemetryApiResponse response)
    {
        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(
                JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse),
                Encoding.UTF8,
                "application/json")
        };
    }
 
    private static TelemetryApiResponse CreateTracesResponse(
        string sessionId,
        string sessionTagName = ProfilingTelemetry.Tags.ProfilingSessionId,
        bool sessionAttributeOnResource = false)
    {
        var sessionAttribute = new OtlpKeyValueJson
        {
            Key = sessionTagName,
            Value = new OtlpAnyValueJson { StringValue = sessionId }
        };
        var resource = TelemetryTestHelper.CreateOtlpResource("apphost", instanceId: null);
        if (sessionAttributeOnResource)
        {
            var existingAttributes = resource.Attributes ?? [];
            resource.Attributes = [.. existingAttributes, sessionAttribute];
        }
 
        return new TelemetryApiResponse
        {
            Data = new OtlpTelemetryDataJson
            {
                ResourceSpans =
                [
                    new OtlpResourceSpansJson
                    {
                        Resource = resource,
                        ScopeSpans =
                        [
                            new OtlpScopeSpansJson
                            {
                                Scope = new OtlpInstrumentationScopeJson { Name = "Aspire.Cli.Profiling" },
                                Spans =
                                [
                                    new OtlpSpanJson
                                    {
                                        TraceId = "11111111111111111111111111111111",
                                        SpanId = "2222222222222222",
                                        Name = "profile-span-0",
                                        StartTimeUnixNano = 1,
                                        EndTimeUnixNano = 2,
                                        Attributes =
                                            sessionAttributeOnResource ? [] : [sessionAttribute]
                                    }
                                ]
                            }
                        ]
                    }
                ]
            },
            TotalCount = 1,
            ReturnedCount = 1
        };
    }
 
    private static string ReadEntryText(ZipArchiveEntry entry)
    {
        using var stream = entry.Open();
        using var reader = new StreamReader(stream, Encoding.UTF8);
        return reader.ReadToEnd();
    }
 
    private static string CreateFile(TemporaryWorkspace workspace, string fileName)
    {
        var path = Path.Combine(workspace.WorkspaceRoot.FullName, fileName);
        File.WriteAllText(path, string.Empty);
        return path;
    }
 
    private static ProfileCaptureOptions CreateOptions(
        TemporaryWorkspace workspace,
        string? outputPath = null,
        string sessionId = "session-a")
    {
        return new ProfileCaptureOptions(
            outputPath ?? Path.Combine(workspace.WorkspaceRoot.FullName, "profile.zip"),
            "http://127.0.0.1:5001",
            "http://127.0.0.1:5002",
            "http://127.0.0.1:5003",
            sessionId,
            TimeSpan.Zero);
    }
 
    private static IConfiguration CreateConfiguration(params (string Key, string? Value)[] values)
    {
        return new ConfigurationBuilder()
            .AddInMemoryCollection(values.ToDictionary(value => value.Key, value => value.Value))
            .Build();
    }
}