diff --git a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj index 1da5e4307e2..cbf42daf552 100644 --- a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj +++ b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj @@ -15,12 +15,6 @@ - - - - - - diff --git a/tests/Aspire.Cli.Tests/E2E/ExecTests.cs b/tests/Aspire.Cli.Tests/E2E/ExecTests.cs deleted file mode 100644 index 6a5a9d7faa3..00000000000 --- a/tests/Aspire.Cli.Tests/E2E/ExecTests.cs +++ /dev/null @@ -1,259 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting; -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Backchannel; -using Aspire.Hosting.Testing; -using Aspire.Hosting.Tests; -using Aspire.Hosting.Utils; -using Aspire.TestUtilities; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Projects; - -namespace Aspire.Cli.Tests.E2E; - -public class ExecTests(ITestOutputHelper output) -{ - private static string DatabaseMigrationsAppHostProjectPath => - Path.Combine(DatabaseMigration_AppHost.ProjectPath, "DatabaseMigration.AppHost.csproj"); - - [Fact] - [RequiresDocker] - public async Task Exec_NotFoundTargetResource_ShouldProduceLogs() - { - string[] args = [ - "--operation", "run", - "--project", DatabaseMigrationsAppHostProjectPath, - "--resource", "randomnonexistingresource", - "--command", "\"dotnet --info\"", - "--postgres" - ]; - - var app = await BuildAppAsync(args); - var logs = await ExecAndCollectLogsAsync(app); - - Assert.True(logs.Count > 0, "No logs were produced during the exec operation."); - Assert.Contains(logs, - x => x.Text.Contains("Target resource randomnonexistingresource not found in the model resources") - && x.IsErrorMessage == true); - } - - [Fact] - [RequiresDocker] - public async Task Exec_DotnetInfo_ShouldProduceLogs() - { - string[] args = [ - "--operation", "run", - "--project", DatabaseMigrationsAppHostProjectPath, - "--resource", "api", - "--command", "\"dotnet --info\"", - "--postgres" - ]; - - var app = await BuildAppAsync(args); - var logs = await ExecAndCollectLogsAsync(app); - - Assert.True(logs.Count > 0, "No logs were produced during the exec operation."); - Assert.Contains(logs, x => x.Text.Contains(".NET SDKs installed")); - Assert.Contains(logs, x => x.Text.Contains("Aspire exec exit code: 0")); // success - } - - [Fact] - [RequiresDocker] - public async Task Exec_DotnetBuildFail_ShouldProduceLogs() - { - string[] args = [ - "--operation", "run", - "--project", DatabaseMigrationsAppHostProjectPath, - "--resource", "api", - // not existing csproj, but we dont care if that succeeds or not - we are expecting - // whatever log output from the command - "--command", "\"dotnet build \"MyRandom.csproj\"\"", - "--postgres" - ]; - - /* Expected output: - dotnet build "MyRandom.csproj" - MSBUILD : error MSB1009: Project file does not exist. - Switch: MyRandom.csproj - */ - - var app = await BuildAppAsync(args); - var logs = await ExecAndCollectLogsAsync(app); - - Assert.True(logs.Count > 0, "No logs were produced during the exec operation."); - Assert.Contains(logs, x => x.Text.Contains("Project file does not exist")); - Assert.Contains(logs, x => x.Text.Contains("Aspire exec exit code: 1")); // not success - } - - [Fact] - [RequiresDocker] - public async Task Exec_NonExistingCommand_ShouldProduceLogs() - { - string[] args = [ - "--operation", "run", - "--project", DatabaseMigrationsAppHostProjectPath, - "--resource", "api", - // not existing command. Executable should fail without start basically - "--command", "\"randombuildcommand doit\"", - "--postgres" - ]; - - var app = await BuildAppAsync(args); - var logs = await ExecAndCollectLogsAsync(app); - - Assert.True(logs.Count > 0, "No logs were produced during the exec operation."); - Assert.Contains(logs, x => x.Text.Contains("Aspire exec failed to start")); - } - - [Fact] - [RequiresDocker] - public async Task Exec_DotnetHelp_ShouldProduceLogs() - { - string[] args = [ - "--operation", "run", - "--project", DatabaseMigrationsAppHostProjectPath, - "--resource", "api", - "--command", "\"dotnet --help\"", - "--postgres" - ]; - - var app = await BuildAppAsync(args); - var logs = await ExecAndCollectLogsAsync(app); - - Assert.True(logs.Count > 0, "No logs were produced during the exec operation."); - Assert.Contains(logs, x => x.Text.Contains("Usage: dotnet [sdk-options] [command] [command-options] [arguments]")); - Assert.Contains(logs, x => x.Text.Contains("Aspire exec exit code: 0")); // success - } - - [Fact] - [RequiresDocker] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/10138")] - public async Task Exec_InitializeMigrations_ShouldCreateMigrationsInWebApp() - { - // note: should also install dotnet-ef tool locally\globally - - var migrationName = "AddVersion"; - - var apiModelProjectDir = @$"{MSBuildUtils.GetRepoRoot()}\playground\DatabaseMigration\DatabaseMigration.ApiModel\DatabaseMigration.ApiModel.csproj"; - DeleteMigrations(apiModelProjectDir, migrationName); - - string[] args = [ - "--operation", "run", - "--project", DatabaseMigrationsAppHostProjectPath, - "--resource", "api", - "--command", $"\"dotnet ef migrations add AddVersion --project {apiModelProjectDir}\"", - "--postgres" - ]; - - var app = await BuildAppAsync(args); - var logs = await ExecAndCollectLogsAsync(app, timeoutSec: 60); - - Assert.True(logs.Count > 0, "No logs were produced during the exec operation."); - Assert.Contains(logs, x => x.Text.Contains("Build started")); - Assert.Contains(logs, x => x.Text.Contains("Build succeeded")); - - AssertMigrationsCreated(apiModelProjectDir, migrationName); - DeleteMigrations(apiModelProjectDir, migrationName); - } - - private async Task> ExecAndCollectLogsAsync(DistributedApplication app, int timeoutSec = 30) - { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec)); - - var appHostRpcTarget = app.Services.GetRequiredService(); - var outputStream = appHostRpcTarget.ExecAsync(cts.Token); - - var logs = new List(); - var startTask = app.StartAsync(cts.Token); - await foreach (var message in outputStream) - { - var logLevel = message.IsErrorMessage ? LogLevel.Error : LogLevel.Information; - var log = $"Received output: #{message.LineNumber} [level={logLevel}] [type={message.Type}] {message.Text}"; - - logs.Add(message); - output.WriteLine(log); - } - - await startTask; - return logs; - } - - private async Task BuildAppAsync(string[] args, Action? configureBuilder = null) - { - configureBuilder ??= (appOptions, _) => { }; - var builder = DistributedApplicationTestingBuilder.Create(args, configureBuilder, typeof(DatabaseMigration_AppHost).Assembly) - .WithTestAndResourceLogging(output); - - IResourceBuilder database; - if (args.Contains("--postgres")) - { - database = builder.AddPostgres("sql1").AddDatabase("db1"); - } - else - { - database = builder.AddSqlServer("sql1").AddDatabase("db1"); - } - - var project = builder - .AddProject("api") - .WithReference(database) - .WaitFor(database); - - return await builder.BuildAsync(); - } - - private static void DeleteMigrations(string projectDirectory, params string[] fileExpectedNames) - { - if (!Directory.Exists(projectDirectory)) - { - return; - } - - var migrationDirectory = Path.Combine(projectDirectory!, "Migrations"); - if (!Directory.Exists(migrationDirectory)) - { - return; - } - - var migrationFiles = Directory.GetFiles(migrationDirectory); - foreach (var migrationFile in migrationFiles) - { - try - { - if (fileExpectedNames.Any(migrationFile.Contains)) - { - File.Delete(migrationFile); - } - } - catch (FileNotFoundException) - { - // ignore if not exists - } - } - } - - private void AssertMigrationsCreated( - string projectDirectory, - params string[] expectedFileNames) - { - var migrationFiles = Directory.GetFiles(Path.Combine((string)projectDirectory!, "Migrations")); - Assert.NotEmpty(migrationFiles); - - var createdMigrationFiles = new List(); - foreach (var file in migrationFiles) - { - if (expectedFileNames.Any(file.Contains)) - { - createdMigrationFiles.Add(file); - output.WriteLine("ASSERT: Created migration file found: " + file); - } - } - - // At least one migration should be found with expected file names - Assert.NotEmpty(createdMigrationFiles); - } -} diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs new file mode 100644 index 00000000000..b8bfc11580e --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Backchannel; +using Aspire.Hosting.Testing; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests.Backchannel.Exec; + +public abstract class ExecTestsBase(ITestOutputHelper outputHelper) +{ + protected readonly ITestOutputHelper _outputHelper = outputHelper; + + /// + /// Performs an `exec` against the apphost, + /// collecting the logs of the `exec` resource apphost is being run against. + /// + /// Also awaits the app startup. It has to be built before running this method. + /// + internal async Task> ExecWithLogCollectionAsync( + DistributedApplication app, + int timeoutSec = 30) + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec)); + + var appHostRpcTarget = app.Services.GetRequiredService(); + var outputStream = appHostRpcTarget.ExecAsync(cts.Token); + + var logs = new List(); + var startTask = app.StartAsync(cts.Token); + await foreach (var message in outputStream) + { + var logLevel = message.IsErrorMessage ? "error" : "info"; + var log = $"Received output: #{message.LineNumber} [level={logLevel}] [type={message.Type}] {message.Text}"; + + logs.Add(message); + _outputHelper.WriteLine(log); + } + + await startTask; + return logs; + } + + internal static void AssertLogsContain(List logs, params string[] expectedLogMessages) + { + if (expectedLogMessages.Length == 0) + { + Assert.Empty(logs); + return; + } + + foreach (var expectedMessage in expectedLogMessages) + { + var logFound = logs.Any(x => x.Text.Contains(expectedMessage)); + Assert.True(logFound, $"Expected log message '{expectedMessage}' not found in logs."); + } + } + + protected IDistributedApplicationTestingBuilder PrepareBuilder(string[] args) + { + var builder = TestDistributedApplicationBuilder.Create(_outputHelper, args).WithTestAndResourceLogging(_outputHelper); + builder.Configuration[KnownConfigNames.UnixSocketPath] = UnixSocketHelper.GetBackchannelSocketPath(); + return builder; + } +} diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ProjectResourceExecTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ProjectResourceExecTests.cs new file mode 100644 index 00000000000..3a56d9dde0a --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ProjectResourceExecTests.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Testing; + +namespace Aspire.Hosting.Tests.Backchannel.Exec; + +public class ProjectResourceExecTests : ExecTestsBase +{ + public ProjectResourceExecTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public async Task Exec_NotFoundTargetResource_ShouldProduceLogs() + { + string[] args = [ + "--operation", "run", + "--resource", "randomnonexistingresource", + "--command", "\"dotnet --info\"", + ]; + + using var builder = PrepareBuilder(args); + WithTestProjectResource(builder); + + using var app = builder.Build(); + + var logs = await ExecWithLogCollectionAsync(app); + AssertLogsContain(logs, "Target resource randomnonexistingresource not found in the model resources"); + + await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + + [Fact] + public async Task Exec_DotnetBuildFail_ProducesLogs_Fail() + { + string[] args = [ + "--operation", "run", + "--resource", "test", + // not existing csproj, but we dont care if that succeeds or not - we are expecting + // whatever log output from the command + "--command", "\"dotnet build \"MyRandom.csproj\"\"", + ]; + + using var builder = PrepareBuilder(args); + WithTestProjectResource(builder); + + using var app = builder.Build(); + + var logs = await ExecWithLogCollectionAsync(app); + AssertLogsContain(logs, "Project file does not exist", "Aspire exec exit code: 1"); + + await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + + [Fact] + public async Task Exec_NonExistingCommand_ProducesLogs_Fail() + { + string[] args = [ + "--operation", "run", + "--resource", "test", + // not existing command. Executable should fail without start basically + "--command", "\"randombuildcommand doit\"", + ]; + + using var builder = PrepareBuilder(args); + WithTestProjectResource(builder); + + using var app = builder.Build(); + + var logs = await ExecWithLogCollectionAsync(app); + AssertLogsContain(logs, "Aspire exec failed to start"); + + await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + + [Fact] + public async Task Exec_DotnetInfo_ProducesLogs_Success() + { + string[] args = [ + "--operation", "run", + "--resource", "test", + "--command", "\"dotnet --info\"", + ]; + + using var builder = PrepareBuilder(args); + WithTestProjectResource(builder); + + using var app = builder.Build(); + + var logs = await ExecWithLogCollectionAsync(app); + AssertLogsContain(logs, + ".NET SDKs installed", // command logs + "Aspire exec exit code: 0" // exit code is submitted separately from the command logs + ); + + await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + + [Fact] + public async Task Exec_DotnetHelp_ProducesLogs_Success() + { + string[] args = [ + "--operation", "run", + "--resource", "test", + "--command", "\"dotnet --help\"", + ]; + + using var builder = PrepareBuilder(args); + WithTestProjectResource(builder); + + using var app = builder.Build(); + + var logs = await ExecWithLogCollectionAsync(app); + AssertLogsContain(logs, + "Usage: dotnet [sdk-options] [command] [command-options] [arguments]", // command logs + "Aspire exec exit code: 0" // exit code is submitted separately from the command logs + ); + + await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + + private static void WithTestProjectResource(IDistributedApplicationTestingBuilder builder, string name = "test") + { + builder.AddResource(new TestProjectResource(name)) + .WithInitialState(new() + { + ResourceType = "TestProjectResource", + State = new("Running", null), + Properties = [new("A", "B"), new("c", "d")], + EnvironmentVariables = [new("e", "f", true), new("g", "h", false)] + }) + .WithAnnotation(new ProjectMetadata(Directory.GetCurrentDirectory())); + } +} + +file sealed class TestProjectResource : ProjectResource +{ + public TestProjectResource(string name) : base(name) + { + } +}