// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
using Aspire.Cli.Layout;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Shared;
using Aspire.Hosting;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Utils;
using Aspire.Tests;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Cli.Tests.Commands;
public class StopCommandTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task StopCommand_Help_Works()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --help");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
}
[Fact]
public async Task StopCommand_RejectsPositionalResourceArgument()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop myresource");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.NotEqual(CliExitCodes.Success, exitCode);
}
[Fact]
public async Task StopCommand_WithInvalidExplicitAppHost_ReturnsFailedToFindProject()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = _ => new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
throw new ProjectLocatorException("Project file does not exist.", ProjectLocatorFailureReason.ProjectFileDoesntExist)
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --apphost missing-directory");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
}
[Fact]
public async Task StopCommand_WithExplicitAppHost_UsesProjectLocatorResolution()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectLocatorInvoked = false;
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("some-directory");
var resolvedProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Resolved.AppHost", "Resolved.AppHost.csproj"));
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.ProjectLocatorFactory = _ => new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (projectFile, _, _, _) =>
{
projectLocatorInvoked = true;
return Task.FromResult(new AppHostProjectSearchResult(resolvedProjectFile, [resolvedProjectFile]));
}
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --apphost \"{appHostDirectory.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.True(projectLocatorInvoked);
Assert.Equal(CliExitCodes.Success, exitCode);
var displayedMessage = Assert.Single(interactionService.DisplayedMessages);
Assert.Equal(
string.Format(SharedCommandStrings.AppHostNotRunningAtPath, Path.Combine("Resolved.AppHost", "Resolved.AppHost.csproj")),
displayedMessage.Message);
}
[Fact]
public async Task StopCommand_AllIncludesEachAppHostPathInMessages()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var statusMessages = new ConcurrentQueue<string>();
interactionService.ShowStatusCallback = statusMessages.Enqueue;
var monitor = new TestAuxiliaryBackchannelMonitor();
var appHostPath1 = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "AppHost.cs");
var appHostPath2 = Path.Combine(workspace.WorkspaceRoot.FullName, "App2", "AppHost.cs");
monitor.AddConnection("socket.hash1", CreateConnection(appHostPath1, int.MaxValue - 1));
monitor.AddConnection("socket.hash2", CreateConnection(appHostPath2, int.MaxValue - 2));
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --all");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var expectedPath1 = Path.Combine("App1", "AppHost.cs");
var expectedPath2 = Path.Combine("App2", "AppHost.cs");
var displayedText = GetDisplayedText(interactionService, statusMessages);
Assert.Contains(displayedText, message => message.Contains(expectedPath1, StringComparison.Ordinal));
Assert.Contains(displayedText, message => message.Contains(expectedPath2, StringComparison.Ordinal));
}
[Fact]
public async Task StopCommand_AllIncludesProcessIdWhenAppHostPathsCollide()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var statusMessages = new ConcurrentQueue<string>();
interactionService.ShowStatusCallback = statusMessages.Enqueue;
var monitor = new TestAuxiliaryBackchannelMonitor();
var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost", "App1.AppHost.csproj");
var processId1 = int.MaxValue - 3;
var processId2 = int.MaxValue - 4;
monitor.AddConnection("socket.hash1", CreateConnection(appHostPath, processId1));
monitor.AddConnection("socket.hash2", CreateConnection(appHostPath, processId2));
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --all");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var expectedPath = "App1.AppHost.csproj";
var expectedIdentifier1 = string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostIdentifierWithProcessId, expectedPath, processId1);
var expectedIdentifier2 = string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostIdentifierWithProcessId, expectedPath, processId2);
var displayedText = GetDisplayedText(interactionService, statusMessages);
Assert.Contains(displayedText, message => message.Contains(expectedIdentifier1, StringComparison.Ordinal));
Assert.Contains(displayedText, message => message.Contains(expectedIdentifier2, StringComparison.Ordinal));
}
[Fact]
public async Task StopCommand_SingleAppHostIncludesIdentifierInStatusAndSuccessMessages()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var statusMessages = new ConcurrentQueue<string>();
interactionService.ShowStatusCallback = statusMessages.Enqueue;
var monitor = new TestAuxiliaryBackchannelMonitor();
var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost", "App1.AppHost.csproj");
var connection = CreateConnection(appHostPath, int.MaxValue - 5);
connection.SocketPath = CreateMatchingSocketFile(appHostPath, workspace, 5);
monitor.AddConnection(connection.SocketPath, connection);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var expectedPath = Path.Combine("App1", "App1.AppHost", "App1.AppHost.csproj");
Assert.Contains(statusMessages, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.StoppingAppHost, expectedPath));
Assert.Contains(interactionService.DisplayedSuccess, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostStoppedSuccessfully, expectedPath));
}
[Fact]
public async Task StopCommand_WithoutAppHost_DoesNotStopNestedLinkedWorktreeInstance()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".git"));
var worktreeRoot = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".worktrees", "feature")).FullName;
TestGitWorktree.WriteLinkedWorktreeMetadata(
worktreeRoot,
Path.Combine(workspace.WorkspaceRoot.FullName, ".git"));
var interactionService = new TestInteractionService();
var statusMessages = new ConcurrentQueue<string>();
interactionService.ShowStatusCallback = statusMessages.Enqueue;
var primaryAppHost = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj");
var nestedAppHost = Path.Combine(worktreeRoot, "AppHost.csproj");
Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, workspace.WorkspaceRoot.FullName));
Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, workspace.WorkspaceRoot.FullName));
var monitor = new TestAuxiliaryBackchannelMonitor();
var primaryConnection = CreateConnection(
primaryAppHost,
int.MaxValue - 7,
isInScope: AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, workspace.WorkspaceRoot.FullName));
primaryConnection.SocketPath = CreateMatchingSocketFile(primaryAppHost, workspace, 7);
var nestedConnection = CreateConnection(
nestedAppHost,
int.MaxValue - 8,
isInScope: AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, workspace.WorkspaceRoot.FullName));
nestedConnection.SocketPath = CreateMatchingSocketFile(nestedAppHost, workspace, 8);
monitor.AddConnection(primaryConnection.SocketPath, primaryConnection);
monitor.AddConnection(nestedConnection.SocketPath, nestedConnection);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var expectedPath = Path.Combine("AppHost", "AppHost.csproj");
Assert.Contains(statusMessages, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.StoppingAppHost, expectedPath));
Assert.False(File.Exists(primaryConnection.SocketPath));
Assert.True(File.Exists(nestedConnection.SocketPath));
}
[Fact]
public async Task StopCommand_WithExplicitAppHostFileDoesNotUseProjectLocatorBeforeSocketLookup()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
throw new InvalidOperationException("Explicit file stop should not require project validation.")
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.ProjectLocatorFactory = _ => projectLocator;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var displayedMessage = Assert.Single(interactionService.DisplayedMessages);
Assert.Equal(
string.Format(SharedCommandStrings.AppHostNotRunningAtPath, Path.Combine("AppHost", "AppHost.csproj")),
displayedMessage.Message);
}
[Fact]
public async Task StopCommand_SingleAppHostInDifferentWorktreeWithoutOverrideDoesNotPromptOrStop()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var commonGitDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, ".git");
Directory.CreateDirectory(commonGitDirectory);
var worktreeRoot = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".worktrees", "feature")).FullName;
TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, commonGitDirectory);
var interactionService = new TestInteractionService();
interactionService.PromptForSelectionCallback = (_, _, _, _) =>
throw new InvalidOperationException("AppHosts in another worktree must not be offered by aspire stop without --apphost or --all.");
var statusMessages = new ConcurrentQueue<string>();
interactionService.ShowStatusCallback = statusMessages.Enqueue;
var monitor = new TestAuxiliaryBackchannelMonitor();
var appHostPath = Path.Combine(worktreeRoot, "App1", "App1.AppHost", "App1.AppHost.csproj");
var connection = CreateConnection(appHostPath, int.MaxValue - 6, isInScope: false);
connection.SocketPath = CreateMatchingSocketFile(appHostPath, workspace, 6);
monitor.AddConnection(connection.SocketPath, connection);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
Assert.Equal([SharedCommandStrings.ScanningForRunningAppHosts], statusMessages);
Assert.Empty(interactionService.DisplayedSuccess);
// The AppHost is running, just not in this worktree, so the message must not say
// "start one first" and must name the flags that can reach across worktrees.
Assert.Equal(SharedCommandStrings.AppHostNotRunningInCurrentWorktree, Assert.Single(interactionService.DisplayedErrors));
Assert.True(File.Exists(connection.SocketPath));
}
[Fact]
public async Task StopCommand_SingleAppHostOutsideWorkingDirectoryInSameWorktreePromptsAndStops()
{
// Regression coverage for `aspire stop` run from a sibling directory (repo/tests) while the
// AppHost lives elsewhere in the same checkout. Scope hides it from the direct match, but
// it is still the user's own worktree, so it must be offered.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var statusMessages = new ConcurrentQueue<string>();
interactionService.ShowStatusCallback = statusMessages.Enqueue;
var monitor = new TestAuxiliaryBackchannelMonitor();
var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost", "App1.AppHost.csproj");
var connection = CreateConnection(appHostPath, int.MaxValue - 6, isInScope: false);
connection.SocketPath = CreateMatchingSocketFile(appHostPath, workspace, 6);
monitor.AddConnection(connection.SocketPath, connection);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Collection(
interactionService.DisplayedMessages.Select(message => message.Message),
message => Assert.Equal(SharedCommandStrings.NoInScopeAppHostsShowingAll, message),
message => Assert.Equal(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.FoundRunningAppHost, appHostPath), message),
message => Assert.Equal(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.SendingStopSignal, appHostPath), message));
Assert.Contains(statusMessages, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.StoppingAppHost, appHostPath));
Assert.False(File.Exists(connection.SocketPath));
}
[Fact]
public async Task StopCommand_AllEmitsProfilingActivities()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var stoppedActivities = new ConcurrentQueue<Activity>();
var monitor = new TestAuxiliaryBackchannelMonitor();
var appHostPath1 = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj");
var appHostPath2 = Path.Combine(workspace.WorkspaceRoot.FullName, "App2", "App2.AppHost.csproj");
var processId1 = int.MaxValue - 7;
var processId2 = int.MaxValue - 8;
monitor.AddConnection("socket.hash1", CreateConnection(appHostPath1, processId1));
monitor.AddConnection("socket.hash2", CreateConnection(appHostPath2, processId2));
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.ConfigurationCallback += config =>
{
config[KnownConfigNames.ProfilingEnabled] = "true";
};
});
using var provider = services.BuildServiceProvider();
var profilingTelemetry = provider.GetRequiredService<ProfilingTelemetry>();
using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource, onActivityStopped: stoppedActivities.Enqueue);
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --all");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var stopCommandActivity = Assert.Single(stoppedActivities, activity => activity.OperationName == ProfilingTelemetry.Activities.StopCommand);
Assert.Equal(true, stopCommandActivity.GetTagItem(ProfilingTelemetry.Tags.AppHostStopAll));
Assert.Equal(2, stopCommandActivity.GetTagItem(ProfilingTelemetry.Tags.AppHostStopCount));
Assert.Equal(CliExitCodes.Success, stopCommandActivity.GetTagItem(TelemetryConstants.Tags.ProcessExitCode));
var stopAppHostActivities = stoppedActivities.Where(activity => activity.OperationName == ProfilingTelemetry.Activities.StopAppHost).ToArray();
Assert.Equal(2, stopAppHostActivities.Length);
var expectedProcessIds = new[] { processId1, processId2 }.Order().ToArray();
Assert.Equal(
expectedProcessIds,
stopAppHostActivities
.Select(activity => Assert.IsType<int>(activity.GetTagItem(TelemetryConstants.Tags.ProcessPid)))
.Order()
.ToArray());
Assert.All(stopAppHostActivities, activity => Assert.Equal(CliExitCodes.Success, activity.GetTagItem(TelemetryConstants.Tags.ProcessExitCode)));
}
[Theory]
[InlineData("stop")]
[InlineData("stop --all")]
public async Task StopCommand_NoRunningAppHosts_ReturnsSuccess(string commandLine)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var monitor = new TestAuxiliaryBackchannelMonitor();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse(commandLine);
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var displayedMessage = Assert.Single(interactionService.DisplayedMessages);
Assert.Equal(SharedCommandStrings.AppHostNotRunning, displayedMessage.Message);
}
[Fact]
public async Task StopCommand_ForceInvokesDcpCleanupForResolvedAppHost()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
throw new InvalidOperationException("Explicit file force stop should not require project validation.")
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
Assert.Contains(interactionService.DisplayedMessages, message => message.Message == string.Format(SharedCommandStrings.AppHostNotRunningAtPath, Path.Combine("AppHost", "AppHost.csproj")));
Assert.Contains(interactionService.DisplayedSuccess, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.PersistentResourcesCleaned, appHostFile.Name));
}
[Fact]
public async Task StopCommand_ForceInvokesDcpCleanupFromDiscoveredLayoutWhenBundleUnavailable()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
}, useBundleLayout: false);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
}
[Fact]
public async Task StopCommand_ForceWithoutAppHostUsesRunningAppHostPathBeforeProjectDiscovery()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var runningAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("RunningAppHost");
var runningAppHostFile = new FileInfo(Path.Combine(runningAppHostDirectory.FullName, "Running.AppHost.csproj"));
await File.WriteAllTextAsync(runningAppHostFile.FullName, "<Project />");
var discoveredAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("DiscoveredAppHost");
var discoveredAppHostFile = new FileInfo(Path.Combine(discoveredAppHostDirectory.FullName, "Discovered.AppHost.csproj"));
await File.WriteAllTextAsync(discoveredAppHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(runningAppHostFile);
var projectLocatorInvoked = false;
var monitor = new TestAuxiliaryBackchannelMonitor();
var connection = CreateConnection(runningAppHostFile.FullName, int.MaxValue - 11);
connection.SocketPath = CreateMatchingSocketFile(runningAppHostFile.FullName, workspace, 11);
monitor.AddConnection(connection.SocketPath, connection);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
{
projectLocatorInvoked = true;
return Task.FromResult(new AppHostProjectSearchResult(discoveredAppHostFile, [discoveredAppHostFile]));
}
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --force");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(projectLocatorInvoked);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
var expectedStoppedMessage = string.Format(
CultureInfo.CurrentCulture,
StopCommandStrings.AppHostStoppedSuccessfully,
Path.Combine("RunningAppHost", "Running.AppHost.csproj"));
Assert.Equal(1, interactionService.DisplayedSuccess.Count(message => message == expectedStoppedMessage));
}
[Fact]
public async Task StopCommand_ForceWithExplicitAppHostFileCleansUpWithoutProjectValidation()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var processFactory = new TestProcessExecutionFactory();
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
throw new InvalidOperationException("Explicit file force stop should not require project validation.")
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
}
[Fact]
public async Task StopCommand_ForceNonInteractiveWithoutRunningAppHostFallsBackToProjectDiscovery()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.AuxiliaryBackchannelMonitorFactory = _ => new TestAuxiliaryBackchannelMonitor();
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --force");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
Assert.Empty(interactionService.DisplayedErrors);
Assert.Contains(interactionService.DisplayedMessages, message => message.Message == SharedCommandStrings.AppHostNotRunning);
}
[Fact]
public async Task StopCommand_ForceNonInteractiveWithOnlyOutOfScopeAppHostsFallsBackToProjectDiscovery()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var monitor = new TestAuxiliaryBackchannelMonitor();
var outOfScopeAppHostFile = Path.Combine(workspace.WorkspaceRoot.FullName, "OtherWorkspace", "Other.AppHost.csproj");
monitor.AddConnection("socket.hash1", CreateConnection(outOfScopeAppHostFile, int.MaxValue - 12, isInScope: false));
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --force");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
Assert.Empty(interactionService.DisplayedErrors);
Assert.Contains(interactionService.DisplayedMessages, message => message.Message == SharedCommandStrings.AppHostNotRunning);
}
[Fact]
public async Task StopCommand_ForceReturnsFailureWhenDcpCleanupFails()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory
{
DefaultExitCode = 42,
AttemptCallback = (_, _) => (42, "cleanup failed")
};
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("cleanup failed", StringComparison.Ordinal));
}
[Fact]
public async Task StopCommand_ForceWarnsAndCleansUpForUnsupportedNonBundleAppHost()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) =>
(0, CreateAppHostInfoJson(aspireHostingVersion: "13.4.0", isUsingCliBundle: false))
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
Assert.Contains(interactionService.DisplayedMessages, message =>
message.Emoji.Equals(KnownEmojis.Warning) &&
message.Message.Contains("might not support persistent resource cleanup", StringComparison.Ordinal));
}
[Fact]
public async Task StopCommand_ForceWarnsAndCleansUpWhenAppHostVersionCannotBeDetermined()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) =>
(0, CreateAppHostInfoJson(aspireHostingVersion: null, isUsingCliBundle: false))
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
Assert.Contains(interactionService.DisplayedMessages, message =>
message.Emoji.Equals(KnownEmojis.Warning) &&
message.Message.Contains("an unknown version", StringComparison.Ordinal));
}
[Fact]
public async Task StopCommand_ForceWarnsAndCleansUpWhenAppHostInfoCannotBeInspected()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) => (1, null)
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
Assert.Contains(interactionService.DisplayedMessages, message =>
message.Emoji.Equals(KnownEmojis.Warning) &&
message.Message.Contains("Could not inspect the AppHost project", StringComparison.Ordinal));
}
[Fact]
public async Task StopCommand_ForceSkipsCompatibilityWarningForGuestAppHost()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "apphost.ts"));
await File.WriteAllTextAsync(appHostFile.FullName, "");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var appHostInfoInspected = false;
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) =>
{
appHostInfoInspected = true;
return (1, null);
}
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
Assert.False(appHostInfoInspected);
var displayedMessage = Assert.Single(interactionService.DisplayedMessages);
Assert.Equal(
string.Format(SharedCommandStrings.AppHostNotRunningAtPath, Path.Combine("AppHost", "apphost.ts")),
displayedMessage.Message);
}
[Fact]
public async Task StopCommand_ForceAllowsUnsupportedVersionWhenAppHostUsesCliBundle()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) =>
(0, CreateAppHostInfoJson(aspireHostingVersion: "13.4.0", isUsingCliBundle: true))
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
}
[Fact]
public async Task StopCommand_ForceReturnsCleanupFailureWhenDcpCannotStart()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
processFactory.CreateExecutionCallback = (args, env, _, options) =>
new TestProcessExecution(
"dcp",
args,
env,
options,
(_, _, _) => Task.FromResult((0, (string?)null)),
() => processFactory.AttemptCount)
{
StartReturnValue = false
};
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
Assert.Single(processFactory.CreatedExecutions);
var error = Assert.Single(interactionService.DisplayedErrors);
Assert.Contains("Failed to clean up persistent resources", error, StringComparison.Ordinal);
Assert.Contains("Failed to start process:", error, StringComparison.Ordinal);
}
[Fact]
public async Task StopCommand_ForceReturnsCleanupFailureWhenBundleLayoutCannotBeAcquired()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.BundleServiceFactory = _ => new TestBundleService(isBundle: true)
{
EnsureExtractedException = new InvalidOperationException("Bundle extraction failed.")
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
Assert.Empty(processFactory.CreatedExecutions);
var error = Assert.Single(interactionService.DisplayedErrors);
Assert.Contains("Failed to clean up persistent resources", error, StringComparison.Ordinal);
Assert.Contains("Bundle extraction failed.", error, StringComparison.Ordinal);
}
[Fact]
public async Task StopCommand_ForceReturnsCleanupFailureWhenDcpCleanupThrowsUnexpectedException()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.BundleServiceFactory = _ => new TestBundleService(isBundle: true)
{
EnsureExtractedException = new PlatformNotSupportedException("Platform denied cleanup.")
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
Assert.Empty(processFactory.CreatedExecutions);
var error = Assert.Single(interactionService.DisplayedErrors);
Assert.Contains("Failed to clean up persistent resources", error, StringComparison.Ordinal);
Assert.Contains("Platform denied cleanup.", error, StringComparison.Ordinal);
}
[Fact]
public async Task StopCommand_ForceReturnsFailureWhenDcpIsUnavailable()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
}, createDcpExecutable: false);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"stop --force --apphost \"{appHostFile.FullName}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
Assert.Empty(processFactory.CreatedExecutions);
Assert.Contains(StopCommandStrings.DcpCleanupUnavailable, interactionService.DisplayedErrors);
}
[Fact]
public async Task StopCommand_ForceReportsUnknownAppHostPathAfterNormalStop()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var monitor = new TestAuxiliaryBackchannelMonitor();
var connection = new TestAppHostAuxiliaryBackchannel
{
AppHostInfo = null,
IsInScope = true
};
monitor.AddConnection(connection.SocketPath, connection);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
Task.FromResult(new AppHostProjectSearchResult(null, []))
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.ProjectLocatorFactory = _ => projectLocator;
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --force");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
Assert.Contains(StopCommandStrings.CouldNotDetermineAppHostPath, interactionService.DisplayedErrors);
}
[Fact]
public async Task StopCommand_ForceUsesStoppedConnectionPathForCleanupWhenProjectDiscoveryFails()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var processFactory = new TestProcessExecutionFactory();
var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
var monitor = new TestAuxiliaryBackchannelMonitor();
var connection = CreateConnection(appHostFile.FullName, int.MaxValue - 10);
connection.SocketPath = CreateMatchingSocketFile(appHostFile.FullName, workspace, 10);
monitor.AddConnection(connection.SocketPath, connection);
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
throw new ProjectLocatorException("Multiple project files found.", ProjectLocatorFailureReason.MultipleProjectFilesFound)
};
var services = CreateStopForceServices(workspace, interactionService, processFactory, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --force");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertDcpCleanupInvocation(processFactory, expectedWorkloadId);
}
[Fact]
public async Task StopCommand_ForceAndAllAreMutuallyExclusive()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop --force --all");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
Assert.Equal(
string.Format(CultureInfo.InvariantCulture, StopCommandStrings.AllAndProjectMutuallyExclusive, "--all", "--force"),
Assert.Single(interactionService.DisplayedErrors));
}
[Fact]
public async Task StopCommand_DeletesSocketFile_AfterSuccessfulStop()
{
// Regression test for https://github.com/microsoft/aspire/issues/17587: 'aspire stop' is the command
// that leaks the socket, so it must delete the socket file once the AppHost has been confirmed stopped.
// Otherwise a later command rediscovers the stale socket and tries to connect to a dead process.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
// The AppHost is reported with a process id that does not exist, so ProcessShutdownService observes
// termination immediately and the stop reaches the socket-cleanup branch.
var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj");
var socketPath = CreateMatchingSocketFile(appHostPath, workspace, 9);
Assert.True(File.Exists(socketPath));
var connection = CreateConnection(appHostPath, int.MaxValue - 9);
connection.SocketPath = socketPath;
var monitor = new TestAuxiliaryBackchannelMonitor();
monitor.AddConnection(socketPath, connection);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("stop");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(File.Exists(socketPath));
}
private IServiceCollection CreateStopForceServices(
TemporaryWorkspace workspace,
TestInteractionService interactionService,
TestProcessExecutionFactory processFactory,
Action<CliServiceCollectionTestOptions>? configure = null,
bool createDcpExecutable = true,
bool useBundleLayout = true)
{
var layoutRoot = workspace.WorkspaceRoot.CreateSubdirectory("layout");
var dcpDirectory = layoutRoot.CreateSubdirectory("dcp");
if (createDcpExecutable)
{
File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDirectory.FullName), "");
}
var layout = new LayoutConfiguration
{
LayoutPath = layoutRoot.FullName,
Components = new LayoutComponents { Dcp = "dcp" }
};
return CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interactionService;
options.DotNetCliExecutionFactoryFactory = _ => processFactory;
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
if (useBundleLayout)
{
options.BundleServiceFactory = _ => new TestBundleService(isBundle: true) { Layout = layout };
}
else
{
options.BundleServiceFactory = _ => new NullBundleService();
options.LayoutDiscoveryFactory = _ => new FixedLayoutDiscovery(layout);
}
configure?.Invoke(options);
});
}
private static void AssertDcpCleanupInvocation(TestProcessExecutionFactory processFactory, string expectedWorkloadId)
{
var execution = Assert.Single(processFactory.CreatedExecutions.OfType<TestProcessExecution>(), execution =>
execution.Arguments.Count == 2 &&
execution.Arguments[0] == "cleanup" &&
execution.Arguments[1] == expectedWorkloadId);
Assert.EndsWith(BundleDiscovery.GetDcpExecutableName(), execution.FileName, StringComparison.Ordinal);
}
private static JsonDocument CreateAppHostInfoJson(string? aspireHostingVersion, bool isUsingCliBundle)
{
var aspireHostingVersionJson = aspireHostingVersion is null ? "null" : $"\"{aspireHostingVersion}\"";
return JsonDocument.Parse($$"""
{
"Properties": {
"IsAspireHost": "true",
"AspireHostingSDKVersion": {{aspireHostingVersionJson}},
"AspireUseCliBundle": "{{(isUsingCliBundle ? "true" : "false")}}"
},
"Items": {}
}
""");
}
private static TestAppHostAuxiliaryBackchannel CreateConnection(string appHostPath, int processId, bool isInScope = true)
{
return new TestAppHostAuxiliaryBackchannel
{
SocketPath = $"socket.{processId.ToString(CultureInfo.InvariantCulture)}",
IsInScope = isInScope,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = processId
}
};
}
private static string CreateMatchingSocketFile(string appHostPath, TemporaryWorkspace workspace, int instanceId)
{
var homeDirectory = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".home"));
var backchannelsDirectory = Path.Combine(homeDirectory.FullName, ".aspire", "cli", "bch");
Directory.CreateDirectory(backchannelsDirectory);
var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath);
var prefix = BackchannelConstants.ComputeSocketPrefix(resolvedAppHostPath, homeDirectory.FullName);
var appHostId = Path.GetFileName(prefix);
var instanceSuffix = instanceId.ToString("000", CultureInfo.InvariantCulture);
var socketPath = Path.Combine(
backchannelsDirectory,
$"{appHostId}a1b2c{instanceSuffix}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}");
File.WriteAllText(socketPath, "");
return socketPath;
}
private static string[] GetDisplayedText(TestInteractionService interactionService, ConcurrentQueue<string> statusMessages)
{
return interactionService.DisplayedMessages.Select(message => message.Message)
.Concat(interactionService.DisplayedSuccess)
.Concat(interactionService.DisplayedErrors)
.Concat(statusMessages)
.ToArray();
}
}