| File: Integration\Playwright\NativeAotDashboardTests.cs | Web Access |
| Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests) |
// 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.Net; using System.Text; using Aspire.Dashboard.Otlp.Http; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; using Aspire.Tests.Shared.Telemetry; using Aspire.TestUtilities; using Aspire.Templates.Tests; using Google.Protobuf; using Microsoft.Playwright; using OpenTelemetry.Proto.Collector.Trace.V1; using OpenTelemetry.Proto.Trace.V1; using Xunit; namespace Aspire.Dashboard.Tests.Integration.Playwright; public class NativeAotDashboardTests(ITestOutputHelper outputHelper) { [Fact] [OuterloopTest("Publishes and launches a Native AOT executable with a browser.")] public async Task NativeDashboard_LoadsInteractivePageWithoutBrowserErrors() { var dashboardPath = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_DASHBOARD_PATH"); Assert.SkipWhen( string.IsNullOrEmpty(dashboardPath), "ASPIRE_NATIVE_DASHBOARD_PATH must identify the Native AOT Dashboard executable."); var dashboardBytes = File.ReadAllBytes(dashboardPath); Assert.True( dashboardBytes.AsSpan().IndexOf("Aspire.Dashboard.Resources.Columns.resources"u8) >= 0, "The Native AOT Dashboard must preserve its manifest resources."); var workingDirectory = Directory.CreateTempSubdirectory(); var startInfo = new ProcessStartInfo { FileName = dashboardPath, WorkingDirectory = workingDirectory.FullName, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; startInfo.ArgumentList.Add("--ASPNETCORE_URLS=http://127.0.0.1:0"); startInfo.ArgumentList.Add("--ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true"); startInfo.ArgumentList.Add("--Dashboard:Otlp:GrpcEndpointUrl=http://127.0.0.1:0"); startInfo.ArgumentList.Add("--Dashboard:Otlp:HttpEndpointUrl=http://127.0.0.1:0"); startInfo.ArgumentList.Add("--Dashboard:Otlp:AuthMode=Unsecured"); startInfo.Environment["ASPIRE_BUNDLE_VERSION_DIR"] = workingDirectory.FullName; using var dashboardProcess = Process.Start(startInfo) ?? throw new InvalidOperationException($"Failed to start Native AOT Dashboard at '{dashboardPath}'."); var dashboardUrlSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); var otlpUrlSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); var stdoutTask = ReadDashboardOutputAsync(dashboardProcess.StandardOutput, dashboardUrlSource, otlpUrlSource); var stderrTask = dashboardProcess.StandardError.ReadToEndAsync(); try { var dashboardUrl = await WaitForDashboardAsync(dashboardUrlSource.Task, dashboardProcess); var leasesDirectory = Path.Combine(workingDirectory.FullName, ".leases"); Assert.Single(Directory.GetFiles(leasesDirectory, "*.lease")); PlaywrightProvider.DetectAndSetInstalledPlaywrightDependenciesPath(); await using var browser = await PlaywrightProvider.CreateBrowserAsync(); await using var context = await browser.NewContextAsync(new BrowserNewContextOptions { BaseURL = dashboardUrl, ViewportSize = new ViewportSize { Width = 390, Height = 844 } }); PlaywrightFixture.ConfigureTimeouts(context); var page = await context.NewPageAsync(); var browserErrors = new ConcurrentQueue<string>(); page.Console += (_, message) => { if (message.Type == "error") { browserErrors.Enqueue(message.Text); } }; page.PageError += (_, error) => browserErrors.Enqueue(error); var otlpUrl = await otlpUrlSource.Task.WaitAsync(TimeSpan.FromSeconds(30)); await AssertSpanDetailsAsync(page, otlpUrl); var response = await page.GotoAsync("/"); Assert.NotNull(response); Assert.Equal(HttpStatusCode.OK, (HttpStatusCode)response.Status); await page.GetByRole(AriaRole.Heading, new() { Name = "Structured logs" }).WaitForAsync(); await page.GetByTitle(Dialogs.HelpDialogCategoryNavigation).ClickAsync(); await page.GetByRole(AriaRole.Menuitem, new() { Name = Layout.MainLayoutLaunchSettings }).ClickAsync(); var darkThemeLabel = page.GetByText(Dialogs.SettingsDialogDarkTheme, new() { Exact = true }).First; var darkThemeRadioId = await darkThemeLabel.GetAttributeAsync("for"); Assert.False(string.IsNullOrEmpty(darkThemeRadioId)); await page.Locator($"fluent-radio[id='{darkThemeRadioId}']").ClickAsync(); await Assertions.Expect(page.Locator("html")).ToHaveAttributeAsync("data-theme", "dark"); await page.WaitForTimeoutAsync(1_000); Assert.True(browserErrors.IsEmpty, string.Join(Environment.NewLine, browserErrors)); } finally { if (!dashboardProcess.HasExited) { dashboardProcess.Kill(entireProcessTree: true); } await dashboardProcess.WaitForExitAsync(); outputHelper.WriteLine("Dashboard stdout:"); outputHelper.WriteLine(await stdoutTask); outputHelper.WriteLine("Dashboard stderr:"); outputHelper.WriteLine(await stderrTask); var leasesDirectory = Path.Combine(workingDirectory.FullName, ".leases"); if (Directory.Exists(leasesDirectory)) { Assert.Empty(Directory.GetFiles(leasesDirectory, "*.lease")); } workingDirectory.Delete(recursive: true); } } private static async Task AssertSpanDetailsAsync(IPage page, string otlpUrl) { var startTime = DateTime.UtcNow; var span = TelemetryTestHelpers.CreateSpan("native-aot-trace", "aot-span", startTime, startTime.AddMilliseconds(10)); var request = new ExportTraceServiceRequest { ResourceSpans = { new ResourceSpans { Resource = TelemetryTestHelpers.CreateResource("native-aot-service"), ScopeSpans = { new ScopeSpans { Scope = TelemetryTestHelpers.CreateScope(), Spans = { span } } } } } }; using var client = new HttpClient { BaseAddress = new Uri(otlpUrl) }; using var content = new ByteArrayContent(request.ToByteArray()); content.Headers.TryAddWithoutValidation("content-type", OtlpHttpEndpointsBuilder.ProtobufContentType); using var response = await client.PostAsync("/v1/traces", content, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var traceId = Convert.ToHexStringLower(span.TraceId.Span); var spanId = Convert.ToHexStringLower(span.SpanId.Span); await page.GotoAsync($"/traces/detail/{traceId}"); await page.Locator(".trace-view-grid").GetByRole(AriaRole.Row).Filter(new() { HasText = span.Name }).ClickAsync(); var spanIdValue = page.Locator(".span-details-layout .grid-value").Filter(new() { HasText = spanId }); await Assertions.Expect(spanIdValue).ToHaveTextAsync(spanId); await Assertions.Expect(spanIdValue.Locator(".severity-icon")).ToBeVisibleAsync(); } private static async Task<string> WaitForDashboardAsync(Task<string> dashboardUrlTask, Process dashboardProcess) { using var client = new HttpClient(); using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); try { var processExitTask = dashboardProcess.WaitForExitAsync(timeout.Token); if (await Task.WhenAny(dashboardUrlTask, processExitTask) == processExitTask) { await processExitTask; throw new InvalidOperationException( $"Native AOT Dashboard exited before becoming ready with exit code {dashboardProcess.ExitCode}."); } var dashboardUrl = await dashboardUrlTask; while (true) { if (dashboardProcess.HasExited) { throw new InvalidOperationException( $"Native AOT Dashboard exited before becoming ready with exit code {dashboardProcess.ExitCode}."); } try { using var response = await client.GetAsync(dashboardUrl, timeout.Token); if (response.StatusCode == HttpStatusCode.OK) { return dashboardUrl; } } catch (HttpRequestException) { // Kestrel can take a moment to bind after the process starts. } await Task.Delay(TimeSpan.FromMilliseconds(250), timeout.Token); } } catch (OperationCanceledException) when (timeout.IsCancellationRequested) { throw new TimeoutException("Timed out waiting for the Native AOT Dashboard."); } } private static async Task<string> ReadDashboardOutputAsync(StreamReader reader, TaskCompletionSource<string> dashboardUrlSource, TaskCompletionSource<string> otlpUrlSource) { var output = new StringBuilder(); while (await reader.ReadLineAsync() is { } line) { output.AppendLine(line); // Kestrel reports the resolved ephemeral address as: // Now listening on: http://127.0.0.1:54321 // OTLP/HTTP listening on: http://127.0.0.1:54322 const string prefix = "Now listening on: "; const string otlpPrefix = "OTLP/HTTP listening on: "; var trimmedLine = line.TrimStart(); if (trimmedLine.StartsWith(prefix, StringComparison.Ordinal) && Uri.TryCreate(trimmedLine[prefix.Length..], UriKind.Absolute, out var dashboardUri)) { dashboardUrlSource.TrySetResult(dashboardUri.AbsoluteUri.TrimEnd('/')); } else if (trimmedLine.StartsWith(otlpPrefix, StringComparison.Ordinal) && Uri.TryCreate(trimmedLine[otlpPrefix.Length..], UriKind.Absolute, out var otlpUri)) { otlpUrlSource.TrySetResult(otlpUri.AbsoluteUri.TrimEnd('/')); } } return output.ToString(); } }