diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 4d68eeaa5..ecf35875a 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -13,8 +13,7 @@ jobs:
build:
runs-on: ubuntu-latest
env:
- DCP_PRESERVE_EXECUTABLE_LOGS: "yes"
- DCP_DIAGNOSTICS_LOG_FOLDER: ${{ github.workspace }}/diagnostics
+ DOTNET_CONFIGURATION: Release
steps:
- uses: actions/checkout@v4
@@ -23,12 +22,30 @@ jobs:
with:
dotnet-version: 8.0.x
- uses: actions/setup-java@v4
+ name: Set up Java
with:
distribution: "microsoft"
java-version: "21"
- uses: actions/setup-node@v4
+ name: Set up Node.js
with:
node-version: "latest"
+
+ - uses: actions/cache@v4
+ name: Cache NuGet packages
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('Directory.Packages.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+ - uses: actions/cache@v4
+ name: Cache Java Docker images
+ with:
+ path: /var/lib/docker/image
+ key: ${{ runner.os }}-docker-${{ hashFiles('examples/java/CommunityToolkit.Aspire.Java.Spring.Maven/Dockerfile') }}
+ restore-keys: |
+ ${{ runner.os }}-docker-
+
- name: Install Aspire workload
run: dotnet workload install aspire
- name: Setup .NET dev certs
@@ -40,16 +57,40 @@ jobs:
npm install -g @azure/static-web-apps-cli
cd examples/swa/CommunityToolkit.Aspire.StaticWebApps.WebApp
npm ci
+
- name: Restore dependencies
run: dotnet restore
- name: Build
- run: dotnet build --no-restore
+ run: dotnet build --no-restore --configuration ${{ env.DOTNET_CONFIGURATION }}
- name: Test
- run: dotnet test --no-build --verbosity normal
- - name: Upload build artifact
- uses: actions/upload-artifact@v3
- if: failure()
+ run: dotnet test --no-build --configuration ${{ env.DOTNET_CONFIGURATION }} --collect "XPlat Code Coverage" --results-directory test-results --logger trx
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: always()
with:
- name: build-output
+ name: test-results
path: |
- ${{ github.workspace }}/diagnostics/**
+ ${{ github.workspace }}/test-results/**
+
+ test-reporting:
+ permissions:
+ contents: read
+ actions: read
+ checks: write
+ runs-on: ubuntu-latest
+ needs: build
+ if: ${{ always() }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ merge-multiple: true
+
+ - name: Test Report
+ uses: dorny/test-reporter@v1
+ if: success() || failure()
+ with:
+ name: ".NET Tests"
+ path: "*.trx"
+ reporter: dotnet-trx
diff --git a/.gitignore b/.gitignore
index e411fabb2..6b5bb1ee0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ obj
.azure
appsettings.*.json
*.orig
+test-results
+TestResults
diff --git a/Directory.Build.props b/Directory.Build.props
index b99537181..faa09f6ff 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -7,7 +7,7 @@
enable
enable
- 8.1.0
+ 8.2.0
8.0.7
1.9.0
diff --git a/examples/java/CommunityToolkit.Aspire.Java.AppHost/CommunityToolkit.Aspire.Java.AppHost.csproj b/examples/java/CommunityToolkit.Aspire.Java.AppHost/CommunityToolkit.Aspire.Java.AppHost.csproj
index 997a3d3e8..d3c2d1c39 100644
--- a/examples/java/CommunityToolkit.Aspire.Java.AppHost/CommunityToolkit.Aspire.Java.AppHost.csproj
+++ b/examples/java/CommunityToolkit.Aspire.Java.AppHost/CommunityToolkit.Aspire.Java.AppHost.csproj
@@ -24,7 +24,7 @@
-
+
diff --git a/examples/java/CommunityToolkit.Aspire.Java.Spring.Maven/src/test/java/org/aliencube/aspire/contribs/spring_maven/SpringMavenApplicationTests.java b/examples/java/CommunityToolkit.Aspire.Java.Spring.Maven/src/test/java/org/aliencube/aspire/contribs/spring_maven/SpringMavenApplicationTests.java
deleted file mode 100644
index b74b12ae0..000000000
--- a/examples/java/CommunityToolkit.Aspire.Java.Spring.Maven/src/test/java/org/aliencube/aspire/contribs/spring_maven/SpringMavenApplicationTests.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.aliencube.aspire.contribs.spring_maven;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class SpringMavenApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps/SwaAppHostingExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps/SwaAppHostingExtension.cs
index cc07ddab6..4421d253d 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps/SwaAppHostingExtension.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps/SwaAppHostingExtension.cs
@@ -10,14 +10,24 @@ public static class SwaAppHostingExtension
///
/// The to add the resource to.
/// The name of the resource.
- /// The to configure the Java application."
/// A reference to the .
/// This resource will not be included in the published manifest.
- public static IResourceBuilder AddSwaEmulator(this IDistributedApplicationBuilder builder, string name, int port = 4280)
+ public static IResourceBuilder AddSwaEmulator(this IDistributedApplicationBuilder builder, string name) =>
+ builder.AddSwaEmulator(name, new SwaResourceOptions());
+
+ ///
+ /// Adds a Static Web Apps emulator to the application.
+ ///
+ /// The to add the resource to.
+ /// The name of the resource.
+ /// The to configure the SWA CLI."
+ /// A reference to the .
+ /// This resource will not be included in the published manifest.
+ public static IResourceBuilder AddSwaEmulator(this IDistributedApplicationBuilder builder, string name, SwaResourceOptions options)
{
var resource = new SwaResource(name, Environment.CurrentDirectory);
return builder.AddResource(resource)
- .WithHttpEndpoint(isProxied: false, port: port)
+ .WithHttpEndpoint(isProxied: false, port: options.Port)
.WithArgs(ctx =>
{
ctx.Args.Add("start");
@@ -35,7 +45,10 @@ public static IResourceBuilder AddSwaEmulator(this IDistributedAppl
}
ctx.Args.Add("--port");
- ctx.Args.Add(port.ToString());
+ ctx.Args.Add(options.Port.ToString());
+
+ ctx.Args.Add("--devserver-timeout");
+ ctx.Args.Add(options.DevServerTimeout.ToString());
})
.ExcludeFromManifest();
}
@@ -58,3 +71,9 @@ public static IResourceBuilder WithAppResource(this IResourceBuilde
public static IResourceBuilder WithApiResource(this IResourceBuilder builder, IResourceBuilder apiResource) =>
builder.WithAnnotation(new(apiResource), ResourceAnnotationMutationBehavior.Replace);
}
+
+public class SwaResourceOptions
+{
+ public int Port { get; set; } = 4280;
+ public int DevServerTimeout { get; set; } = 60;
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/ResourceCreationTests.cs
index f637a2e6f..1706e3b5c 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/ResourceCreationTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/ResourceCreationTests.cs
@@ -33,7 +33,8 @@ public void TargetPort_Can_Be_Overridden()
{
var builder = DistributedApplication.CreateBuilder();
- builder.AddSwaEmulator("swa", port: 1234);
+ SwaResourceOptions options = new() { Port = 1234 };
+ builder.AddSwaEmulator("swa", options);
using var app = builder.Build();
@@ -46,7 +47,7 @@ public void TargetPort_Can_Be_Overridden()
Assert.Equal("swa", resource.Name);
var httpEndpoint = resource.GetEndpoint("http");
- Assert.Equal(1234, httpEndpoint.TargetPort);
+ Assert.Equal(options.Port, httpEndpoint.TargetPort);
}
[Fact]
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/SwaHostingComponentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/SwaHostingComponentTests.cs
index 862c3baa3..6d29253e7 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/SwaHostingComponentTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests/SwaHostingComponentTests.cs
@@ -2,18 +2,18 @@
using FluentAssertions;
using System.Net;
using System.Net.Http.Json;
-using Xunit.Abstractions;
namespace CommunityToolkit.Aspire.Hosting.Azure.StaticWebApps.Tests;
-public class SwaHostingComponentTests(ITestOutputHelper testOutput) : AspireIntegrationTest(testOutput)
+#pragma warning disable CTASPIRESRC001
+public class SwaHostingComponentTests(AspireIntegrationTestFixture fixture) : IClassFixture>
{
[Fact]
- public async Task EmulatorLaunchesOnDefaultPort()
+ public async Task CanAccessFrontendSuccessfully()
{
- var httpClient = app.CreateHttpClient("swa");
+ var httpClient = fixture.CreateHttpClient("swa");
- await ResourceNotificationService.WaitForResourceAsync("swa", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
+ await fixture.App.WaitForTextAsync("Azure Static Web Apps emulator started", "swa").WaitAsync(TimeSpan.FromSeconds(30));
var response = await httpClient.GetAsync("/");
@@ -21,11 +21,11 @@ public async Task EmulatorLaunchesOnDefaultPort()
}
[Fact]
- public async Task CanAccessApi()
+ public async Task CanAccessApiSuccessfully()
{
- var httpClient = app.CreateHttpClient("swa");
+ var httpClient = fixture.CreateHttpClient("swa");
- await ResourceNotificationService.WaitForResourceAsync("swa", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
+ await fixture.App.WaitForTextAsync("Azure Static Web Apps emulator started", "swa").WaitAsync(TimeSpan.FromSeconds(30));
var response = await httpClient.GetAsync("/api/weather");
diff --git a/tests/CommunityToolkit.Aspire.Java.Hosting.EndToEndTests/JavaHostingComponentTests.cs b/tests/CommunityToolkit.Aspire.Java.Hosting.EndToEndTests/JavaHostingComponentTests.cs
new file mode 100644
index 000000000..c8cc79076
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Java.Hosting.EndToEndTests/JavaHostingComponentTests.cs
@@ -0,0 +1,23 @@
+using System.Net;
+using CommunityToolkit.Aspire.Testing;
+using FluentAssertions;
+
+namespace CommunityToolkit.Aspire.Java.Hosting.EndToEndTests;
+
+#pragma warning disable CTASPIRESRC001
+public class JavaHostingComponentTests(AspireIntegrationTestFixture fixture) : IClassFixture>
+{
+ [Theory]
+ [InlineData("containerapp")]
+ [InlineData("executableapp")]
+ public async Task ResourceWillRespondWithOk(string resourceName)
+ {
+ var httpClient = fixture.CreateHttpClient(resourceName);
+
+ await fixture.App.WaitForTextAsync("Started SpringMavenApplication", resourceName).WaitAsync(TimeSpan.FromMinutes(5));
+
+ var response = await httpClient.GetAsync("/");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Java.Hosting.EndToEndTests/ProgramTests.cs b/tests/CommunityToolkit.Aspire.Java.Hosting.EndToEndTests/ProgramTests.cs
deleted file mode 100644
index b92fb6eb6..000000000
--- a/tests/CommunityToolkit.Aspire.Java.Hosting.EndToEndTests/ProgramTests.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Net;
-using CommunityToolkit.Aspire.Testing;
-using FluentAssertions;
-using Xunit.Abstractions;
-
-namespace CommunityToolkit.Aspire.Java.Hosting.EndToEndTests;
-
-public class ProgramTests(ITestOutputHelper testOutput) : AspireIntegrationTest(testOutput)
-{
- [Fact]
- public async Task Given_Container_Resource_When_Invoked_Then_Root_Returns_OK()
- {
- var httpClient = app.CreateHttpClient("containerapp");
-
- await ResourceNotificationService.WaitForResourceAsync("containerapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
-
- var response = await httpClient.GetAsync("/");
-
- response.StatusCode.Should().Be(HttpStatusCode.OK);
- }
-
- [Fact]
- public async Task Given_Executable_Resource_When_Invoked_Then_Root_Returns_OK()
- {
- var httpClient = app.CreateHttpClient("containerapp");
-
- await ResourceNotificationService.WaitForResourceAsync("executableapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
-
- var response = await httpClient.GetAsync("/");
-
- response.StatusCode.Should().Be(HttpStatusCode.OK);
- }
-}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Testing/AspireIntegrationTest.cs b/tests/CommunityToolkit.Aspire.Testing/AspireIntegrationTest.cs
index 9243242e1..a654cf680 100644
--- a/tests/CommunityToolkit.Aspire.Testing/AspireIntegrationTest.cs
+++ b/tests/CommunityToolkit.Aspire.Testing/AspireIntegrationTest.cs
@@ -2,30 +2,36 @@
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-using Xunit.Abstractions;
namespace CommunityToolkit.Aspire.Testing;
-public abstract class AspireIntegrationTest(ITestOutputHelper testOutput) : IAsyncLifetime
- where T : class
+public class AspireIntegrationTestFixture() : DistributedApplicationFactory(typeof(TEntryPoint), []), IAsyncLifetime where TEntryPoint : class
{
- protected DistributedApplication app = null!;
- protected ResourceNotificationService ResourceNotificationService => app.Services.GetRequiredService();
+ public ResourceNotificationService ResourceNotificationService => App.Services.GetRequiredService();
- public async Task DisposeAsync() => await app.DisposeAsync();
+ public DistributedApplication App { get; private set; } = null!;
- public async Task InitializeAsync()
+ protected override void OnBuilt(DistributedApplication application)
{
- var appHost = await DistributedApplicationTestingBuilder.CreateAsync();
+ App = application;
+ base.OnBuilt(application);
+ }
- appHost.Services
- .AddLogging(builder =>
+ protected override void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder)
+ {
+ applicationBuilder.Services.AddLogging(builder =>
{
- builder.AddXUnit(testOutput);
- builder.SetMinimumLevel(LogLevel.Trace);
+ builder.AddXUnit();
+ if (Environment.GetEnvironmentVariable("RUNNER_DEBUG") is not null or "1")
+ builder.SetMinimumLevel(LogLevel.Trace);
+ else
+ builder.SetMinimumLevel(LogLevel.Information);
})
.ConfigureHttpClientDefaults(clientBuilder => clientBuilder.AddStandardResilienceHandler());
- app = await appHost.BuildAsync();
- await app.StartAsync();
+ base.OnBuilderCreated(applicationBuilder);
}
+
+ public async Task InitializeAsync() => await StartAsync();
+
+ async Task IAsyncLifetime.DisposeAsync() => await DisposeAsync();
}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Testing/LoggerNotificationExtensions.cs b/tests/CommunityToolkit.Aspire.Testing/LoggerNotificationExtensions.cs
new file mode 100644
index 000000000..8ba5e8cc0
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Testing/LoggerNotificationExtensions.cs
@@ -0,0 +1,172 @@
+// copied from: https://github.com/dotnet/aspire/blob/2ea981718d2addc835b033fc4a52fae63b2a4a51/tests/Aspire.Hosting.Tests/Utils/LoggerNotificationExtensions.cs
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using System.Diagnostics.CodeAnalysis;
+
+namespace CommunityToolkit.Aspire.Testing;
+
+public static class LoggerNotificationExtensions
+{
+ ///
+ /// Waits for the specified text to be logged.
+ ///
+ /// The instance to watch.
+ /// The text to wait for.
+ /// An optional resource name to filter the logs for.
+ /// The cancellation token.
+ ///
+ ///
+ /// This has been copied from the Aspire.Hosting.Tests project and will likely be removed in the future.
+ ///
+ [Experimental("CTASPIRESRC001", UrlFormat = "https://aka.ms/communitytoolkit/aspire/diagnostics#{0}")]
+ public static Task WaitForTextAsync(this DistributedApplication app, string logText, string? resourceName = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ ArgumentException.ThrowIfNullOrEmpty(logText);
+
+ return WaitForTextAsync(app, (log) => log.Contains(logText), resourceName, cancellationToken);
+ }
+
+ ///
+ /// Waits for the specified text to be logged.
+ ///
+ /// The instance to watch.
+ /// Any text to wait for.
+ /// An optional resource name to filter the logs for.
+ /// The cancellation token.
+ ///
+ ///
+ /// This has been copied from the Aspire.Hosting.Tests project and will likely be removed in the future.
+ ///
+ [Experimental("CTASPIRESRC001", UrlFormat = "https://aka.ms/communitytoolkit/aspire/diagnostics#{0}")]
+ public static Task WaitForTextAsync(this DistributedApplication app, IEnumerable logTexts, string? resourceName = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ ArgumentNullException.ThrowIfNull(logTexts);
+
+ return app.WaitForTextAsync((log) => logTexts.Any(x => log.Contains(x)), resourceName, cancellationToken);
+ }
+
+ ///
+ /// Waits for the specified text to be logged.
+ ///
+ /// The instance to watch.
+ /// A predicate checking the text to wait for.
+ /// An optional resource name to filter the logs for.
+ /// The cancellation token.
+ ///
+ ///
+ /// This has been copied from the Aspire.Hosting.Tests project and will likely be removed in the future.
+ ///
+ [Experimental("CTASPIRESRC001", UrlFormat = "https://aka.ms/communitytoolkit/aspire/diagnostics#{0}")]
+ public static Task WaitForTextAsync(this DistributedApplication app, Predicate predicate, string? resourceName = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ ArgumentNullException.ThrowIfNull(predicate);
+
+ var hostApplicationLifetime = app.Services.GetRequiredService();
+
+ var watchCts = CancellationTokenSource.CreateLinkedTokenSource(hostApplicationLifetime.ApplicationStopping, cancellationToken);
+
+ var tcs = new TaskCompletionSource();
+
+ _ = Task.Run(() => WatchNotifications(app, resourceName, predicate, tcs, watchCts), watchCts.Token);
+
+ return tcs.Task;
+ }
+
+ ///
+ /// Waits for all the specified texts to be logged.
+ ///
+ /// The instance to watch.
+ /// Any text to wait for.
+ /// An optional resource name to filter the logs for.
+ /// The cancellation token.
+ ///
+ ///
+ /// This has been copied from the Aspire.Hosting.Tests project and will likely be removed in the future.
+ ///
+ [Experimental("CTASPIRESRC001", UrlFormat = "https://aka.ms/communitytoolkit/aspire/diagnostics#{0}")]
+ public static async Task WaitForAllTextAsync(this DistributedApplication app, IEnumerable logTexts, string? resourceName = null, CancellationToken cancellationToken = default)
+ {
+ var table = logTexts.ToList();
+ try
+ {
+ await app.WaitForTextAsync((log) =>
+ {
+ foreach (var text in table)
+ {
+ if (log.Contains(text))
+ {
+ table.Remove(text);
+ break;
+ }
+ }
+
+ return table.Count == 0;
+ }, resourceName, cancellationToken).ConfigureAwait(false);
+ }
+ catch (TaskCanceledException te) when (cancellationToken.IsCancellationRequested)
+ {
+ throw new TaskCanceledException($"Task was canceled before these messages were found: '{string.Join("', '", table)}'", te);
+ }
+ }
+
+ private static async Task WatchNotifications(DistributedApplication app, string? resourceName, Predicate predicate, TaskCompletionSource tcs, CancellationTokenSource cancellationTokenSource)
+ {
+ var resourceNotificationService = app.Services.GetRequiredService();
+ var resourceLoggerService = app.Services.GetRequiredService();
+ var logger = app.Services.GetRequiredService().CreateLogger(nameof(LoggerNotificationExtensions));
+
+ var loggingResourceIds = new HashSet();
+ var logWatchTasks = new List();
+
+ try
+ {
+ await foreach (var resourceEvent in resourceNotificationService.WatchAsync(cancellationTokenSource.Token).ConfigureAwait(false))
+ {
+ if (resourceName != null && !string.Equals(resourceEvent.Resource.Name, resourceName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var resourceId = resourceEvent.ResourceId;
+
+ if (loggingResourceIds.Add(resourceId))
+ {
+ // Start watching the logs for this resource ID
+ logWatchTasks.Add(WatchResourceLogs(tcs, resourceId, predicate, resourceLoggerService, cancellationTokenSource));
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected if the application stops prematurely or the text was detected.
+ tcs.TrySetCanceled();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "An error occurred while watching for resource notifications.");
+ tcs.TrySetException(ex);
+ }
+ }
+
+ private static async Task WatchResourceLogs(TaskCompletionSource tcs, string resourceId, Predicate predicate, ResourceLoggerService resourceLoggerService, CancellationTokenSource cancellationTokenSource)
+ {
+ await foreach (var logEvent in resourceLoggerService.WatchAsync(resourceId).WithCancellation(cancellationTokenSource.Token).ConfigureAwait(false))
+ {
+ foreach (var line in logEvent)
+ {
+ if (predicate(line.Content))
+ {
+ tcs.SetResult();
+ cancellationTokenSource.Cancel();
+ return;
+ }
+ }
+ }
+ }
+}