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; + } + } + } + } +}