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)
+ {
+ }
+}