From c644716a2543cca6294c674484b8df09d468d25e Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 19 May 2026 17:15:12 +0200 Subject: [PATCH 01/11] Add Aspire-driven API + MCP smoke tests and fix Docker-free integration testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename Elastic.Assembler.IntegrationTests → Elastic.Documentation.IntegrationTests - Move Elastic.Documentation.Api.IntegrationTests → tests/Elastic.Documentation.Api.Tests so WebApplicationFactory-based tests run in the unit suite, not the slow CI suite - Extend DocumentationFixture to wait for api and remote-mcp Aspire resources and expose CreateApiClient() / CreateMcpClient() HTTP client helpers - Add Smoke/ApiSmokeTests.cs: health, alive, search, changes endpoints - Add Smoke/McpSmokeTests.cs: health + MCP ListTools via ModelContextProtocol client - Fix AppHost.cs: guard elasticsearchLocal behind if (startElasticsearch) so Aspire never touches the Docker daemon when using an external ES URL + API key - Fix port conflict: both API and MCP now respect ASPNETCORE_HTTP_PORTS (set by Aspire WithHttpEndpoint) instead of hardcoding 8080 - CI integrate job: OIDC auth to AWS, fetch ES credentials from SSM Parameter Store with ::add-mask:: redaction, write to dotnet user secrets before running tests Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 19 +++++ aspire/AppHost.cs | 62 ++++++++-------- docs-builder.slnx | 4 +- src/api/Elastic.Documentation.Api/Program.cs | 11 ++- .../Program.cs | 11 ++- .../AssembleFixture.cs | 17 ++++- .../AssemblerConfigurationTests.cs | 2 +- .../DocsSyncTests.cs | 2 +- ...tic.Documentation.IntegrationTests.csproj} | 2 + .../NavigationBuildingTests.cs | 2 +- .../NavigationRootTests.cs | 2 +- .../Search/SearchBootstrapFixture.cs | 2 +- .../Search/SearchIntegrationTests.cs | 2 +- .../Search/SearchTestBase.cs | 2 +- .../ServeStaticTests.cs | 2 +- .../SiteNavigationTests.cs | 2 +- .../Smoke/ApiSmokeTests.cs | 73 +++++++++++++++++++ .../Smoke/McpSmokeTests.cs | 52 +++++++++++++ .../TestHelpers.cs | 2 +- .../TestLogger.cs | 2 +- .../AskAiGatewayStreamingTests.cs | 2 +- .../Elastic.Documentation.Api.Tests.csproj | 0 .../EuidEnrichmentTests.cs | 6 +- .../Fixtures/ApiWebApplicationFactory.cs | 2 +- .../OtlpProxyTests.cs | 6 +- 25 files changed, 227 insertions(+), 62 deletions(-) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/AssembleFixture.cs (84%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/AssemblerConfigurationTests.cs (99%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/DocsSyncTests.cs (99%) rename tests-integration/{Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj => Elastic.Documentation.IntegrationTests/Elastic.Documentation.IntegrationTests.csproj} (86%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/NavigationBuildingTests.cs (99%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/NavigationRootTests.cs (98%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/Search/SearchBootstrapFixture.cs (99%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/Search/SearchIntegrationTests.cs (99%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/Search/SearchTestBase.cs (88%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/ServeStaticTests.cs (95%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/SiteNavigationTests.cs (99%) create mode 100644 tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs create mode 100644 tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/TestHelpers.cs (98%) rename tests-integration/{Elastic.Assembler.IntegrationTests => Elastic.Documentation.IntegrationTests}/TestLogger.cs (97%) rename {tests-integration/Elastic.Documentation.Api.IntegrationTests => tests/Elastic.Documentation.Api.Tests}/AskAiGatewayStreamingTests.cs (99%) rename tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj => tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj (100%) rename tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs => tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs (96%) rename {tests-integration/Elastic.Documentation.Api.IntegrationTests => tests/Elastic.Documentation.Api.Tests}/Fixtures/ApiWebApplicationFactory.cs (99%) rename tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs => tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs (97%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab9fe1be8e..596193e5f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,10 @@ jobs: integration: runs-on: docs-builder-latest-16 + environment: integration-tests + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v6 @@ -195,5 +199,20 @@ jobs: - name: Install Aspire workload run: dotnet workload install aspire + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::197730964718:role/elastic-docs-v3-integration-tests + aws-region: us-east-1 + + - name: Fetch ES credentials + run: | + ES_URL=$(aws ssm get-parameter --name /elastic-docs-v3/prod/docs-elasticsearch-github-actions-readonly-url --with-decryption --query Parameter.Value --output text) + ES_KEY=$(aws ssm get-parameter --name /elastic-docs-v3/prod/docs-elasticsearch-github-actions-readonly-api-key --with-decryption --query Parameter.Value --output text) + echo "::add-mask::$ES_URL" + echo "::add-mask::$ES_KEY" + dotnet user-secrets set "DocumentationElasticUrl" "$ES_URL" --project aspire + dotnet user-secrets set "DocumentationElasticApiKey" "$ES_KEY" --project aspire + - name: Integration Tests run: dotnet run --project build -c release -- integrate diff --git a/aspire/AppHost.cs b/aspire/AppHost.cs index e89cdc29f1..0eefdf1306 100644 --- a/aspire/AppHost.cs +++ b/aspire/AppHost.cs @@ -54,10 +54,10 @@ internal static async Task Run( .WaitForCompletion(cloneAll) .WithParentRelationship(cloneAll); - var elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal) - .WithEnvironment("LICENSE", "trial"); - if (!startElasticsearch) - elasticsearchLocal = elasticsearchLocal.WithExplicitStart(); + IResourceBuilder? elasticsearchLocal = null; + if (startElasticsearch) + elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal) + .WithEnvironment("LICENSE", "trial"); var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); @@ -65,39 +65,39 @@ internal static async Task Run( .WithArgs(GlobalArguments) .WithEnvironment("ENVIRONMENT", "dev") .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) - .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath); + .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath) + .WithHttpEndpoint(isProxied: false) + .WithHttpHealthCheck("/health"); // ReSharper disable once RedundantAssignment api = startElasticsearch ? api - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) - .WaitFor(elasticsearchLocal) - .WithExplicitStart() + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal!) + .WaitFor(elasticsearchLocal!) : api.WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) - .WithExplicitStart(); + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); var mcp = builder.AddProject(RemoteMcp) .WithArgs(GlobalArguments) - .WithEnvironment("ENVIRONMENT", "dev"); + .WithEnvironment("ENVIRONMENT", "dev") + .WithHttpEndpoint(isProxied: false) + .WithHttpHealthCheck("/health"); // ReSharper disable once RedundantAssignment mcp = startElasticsearch ? mcp - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) - .WaitFor(elasticsearchLocal) - .WithExplicitStart() + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal!) + .WaitFor(elasticsearchLocal!) : mcp.WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) - .WithExplicitStart(); + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); var indexElasticsearch = builder.AddProject(ElasticsearchIngest) .WithArgs(["assembler", "index", .. GlobalArguments]) @@ -107,11 +107,11 @@ internal static async Task Run( // ReSharper disable once RedundantAssignment indexElasticsearch = startElasticsearch ? indexElasticsearch - .WaitFor(elasticsearchLocal) - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) + .WaitFor(elasticsearchLocal!) + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal!) : indexElasticsearch .WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) @@ -129,16 +129,16 @@ internal static async Task Run( serveStatic = startElasticsearch ? serveStatic - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) : serveStatic .WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); // ReSharper disable once RedundantAssignment - serveStatic = startElasticsearch ? serveStatic.WaitFor(elasticsearchLocal) : serveStatic.WaitFor(buildAll); + serveStatic = startElasticsearch ? serveStatic.WaitFor(elasticsearchLocal!) : serveStatic.WaitFor(buildAll); await builder.Build().RunAsync(ct); } diff --git a/docs-builder.slnx b/docs-builder.slnx index 63f92e7635..48103b2bf2 100644 --- a/docs-builder.slnx +++ b/docs-builder.slnx @@ -84,8 +84,7 @@ - - + @@ -95,6 +94,7 @@ + diff --git a/src/api/Elastic.Documentation.Api/Program.cs b/src/api/Elastic.Documentation.Api/Program.cs index bbee8a9201..1edccf8423 100644 --- a/src/api/Elastic.Documentation.Api/Program.cs +++ b/src/api/Elastic.Documentation.Api/Program.cs @@ -22,11 +22,14 @@ _ = builder.AddDefaultHealthChecks(); _ = builder.AddDocsApiOpenTelemetry(); - // Configure Kestrel to listen on port 8080 (standard container port) - _ = builder.WebHost.ConfigureKestrel(serverOptions => + // Only hardcode port 8080 when not running under Aspire/orchestration that sets ASPNETCORE_HTTP_PORTS + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"))) { - serverOptions.ListenAnyIP(8080); - }); + _ = builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(8080); + }); + } var environment = Environment.GetEnvironmentVariable("ENVIRONMENT"); Console.WriteLine($"Docs Environment: {environment}"); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index 291c61f9fc..d1fc986816 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -30,11 +30,14 @@ _ = builder.Services.ConfigureOpenTelemetryTracerProvider(t => t.AddSource(McpToolTelemetry.McpToolSourceName)); - // Configure Kestrel to listen on port 8080 (standard container port) - _ = builder.WebHost.ConfigureKestrel(serverOptions => + // Only hardcode port 8080 when not running under Aspire/orchestration that sets ASPNETCORE_HTTP_PORTS + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"))) { - serverOptions.ListenAnyIP(8080); - }); + _ = builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(8080); + }); + } var environment = Environment.GetEnvironmentVariable("ENVIRONMENT"); Console.WriteLine($"Docs Environment: {environment}"); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs similarity index 84% rename from tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs index 02e073d530..a47b11b601 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs @@ -5,6 +5,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; +using Elastic.Documentation.Aspire; using Elastic.Documentation.ServiceDefaults; using InMemLogger; using Microsoft.Extensions.Configuration; @@ -12,9 +13,9 @@ using Microsoft.Extensions.Logging; using static Elastic.Documentation.Aspire.ResourceNames; -[assembly: CaptureConsole, AssemblyFixture(typeof(Elastic.Assembler.IntegrationTests.DocumentationFixture))] +[assembly: CaptureConsole, AssemblyFixture(typeof(Elastic.Documentation.IntegrationTests.DocumentationFixture))] -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public static class DistributedApplicationExtensions { @@ -90,6 +91,14 @@ public async ValueTask InitializeAsync() _ = await DistributedApplication.ResourceNotifications .WaitForResourceHealthyAsync(AssemblerServe, cancellationToken: TestContext.Current.CancellationToken) .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); + + _ = await DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(ResourceNames.Api, cancellationToken: TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); + + _ = await DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(RemoteMcp, cancellationToken: TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); } catch (Exception e) { @@ -99,6 +108,10 @@ public async ValueTask InitializeAsync() } } + public HttpClient CreateApiClient() => DistributedApplication.CreateHttpClient(ResourceNames.Api); + + public HttpClient CreateMcpClient() => DistributedApplication.CreateHttpClient(RemoteMcp); + private async ValueTask ValidateExitCode(string resourceName) { var eventResource = await DistributedApplication.ResourceNotifications.WaitForResourceAsync(resourceName, _ => true); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 61775c3e74..fa64f2b5bc 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class PublicOnlyAssemblerConfigurationTests { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs index 06beb9ccf3..d88c7e6b53 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs @@ -21,7 +21,7 @@ using OpenTelemetry; using OpenTelemetry.Trace; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class DocsSyncTests { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj b/tests-integration/Elastic.Documentation.IntegrationTests/Elastic.Documentation.IntegrationTests.csproj similarity index 86% rename from tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj rename to tests-integration/Elastic.Documentation.IntegrationTests/Elastic.Documentation.IntegrationTests.csproj index e5b507a5fd..16ba31e332 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Elastic.Documentation.IntegrationTests.csproj @@ -12,6 +12,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index cb02a3879f..60cc1267af 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -24,7 +24,7 @@ using Nullean.ScopedFileSystem; using RazorSlices; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class NavigationBuildingTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs similarity index 98% rename from tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index 38aa6e64f9..f9f459259c 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -24,7 +24,7 @@ using Nullean.ScopedFileSystem; using RazorSlices; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class NavigationRootTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchBootstrapFixture.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchBootstrapFixture.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs index 4685443f18..a29c95d7b5 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchBootstrapFixture.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs @@ -19,7 +19,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Elastic.Assembler.IntegrationTests.Search; +namespace Elastic.Documentation.IntegrationTests.Search; [CollectionDefinition(Collection)] public class SearchBootstrapFixture(DocumentationFixture fixture) : IAsyncLifetime diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs index 4f351d3927..a114f798c9 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Search; -namespace Elastic.Assembler.IntegrationTests.Search; +namespace Elastic.Documentation.IntegrationTests.Search; /// /// Integration tests for the search endpoint exposed through MapSearchEndpoint. diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchTestBase.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchTestBase.cs similarity index 88% rename from tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchTestBase.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchTestBase.cs index 2b45d165db..92c0535b25 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchTestBase.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchTestBase.cs @@ -2,7 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -namespace Elastic.Assembler.IntegrationTests.Search; +namespace Elastic.Documentation.IntegrationTests.Search; /// /// Base class for search integration tests that handles initialization diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs similarity index 95% rename from tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs index e3f1ccaee6..caa6989c9e 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Aspire; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class ServeStaticTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index d89cd503a7..aadc9cad0f 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class SiteNavigationTests : IAsyncLifetime { diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs new file mode 100644 index 0000000000..5d81369d67 --- /dev/null +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -0,0 +1,73 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using System.Net.Http.Json; +using AwesomeAssertions; +using Elastic.Documentation.Search; + +namespace Elastic.Documentation.IntegrationTests.Smoke; + +public class ApiSmokeTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime +{ + /// + public ValueTask InitializeAsync() => default; + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is TestResult.Passed) + return default; + foreach (var resource in fixture.InMemoryLogger.RecordedLogs) + output.WriteLine(resource.Message); + return default; + } + + [Fact] + public async Task HealthEndpoint_Returns200() + { + using var client = fixture.CreateApiClient(); + var response = await client.GetAsync("/docs/_api/health", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task AliveEndpoint_Returns200() + { + using var client = fixture.CreateApiClient(); + var response = await client.GetAsync("/docs/_api/alive", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SearchEndpoint_ReturnsResults() + { + using var client = fixture.CreateApiClient(); + var response = await client.GetAsync("/docs/_api/v1/search?q=elasticsearch", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.SkipWhen(body is null || body.TotalResults == 0, "search index has no data, skipping result assertions"); + _ = body.Results.Should().NotBeEmpty("search for 'elasticsearch' should return results when the index is populated"); + body.Results.Should().AllSatisfy(r => + { + _ = r.Url.Should().NotBeNullOrEmpty(); + _ = r.Title.Should().NotBeNullOrEmpty(); + }); + } + + [Fact] + public async Task ChangesEndpoint_ReturnsResponse() + { + using var client = fixture.CreateApiClient(); + var since = Uri.EscapeDataString("2020-01-01T00:00:00Z"); + var response = await client.GetAsync($"/docs/_api/v1/changes?since={since}", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + _ = body.Should().NotBeNull(); + body.Pages.Should().NotBeNull("pages collection should always be present even when empty"); + } +} diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs new file mode 100644 index 0000000000..f7abcf79da --- /dev/null +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs @@ -0,0 +1,52 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using AwesomeAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; + +namespace Elastic.Documentation.IntegrationTests.Smoke; + +public class McpSmokeTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime +{ + /// + public ValueTask InitializeAsync() => default; + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is TestResult.Passed) + return default; + foreach (var resource in fixture.InMemoryLogger.RecordedLogs) + output.WriteLine(resource.Message); + return default; + } + + [Fact] + public async Task HealthEndpoint_Returns200() + { + using var client = fixture.CreateMcpClient(); + var response = await client.GetAsync("/health", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ListTools_ReturnsAtLeastOneTool() + { + using var httpClient = fixture.CreateMcpClient(); + var mcpEndpoint = new Uri(httpClient.BaseAddress!, "/docs/_mcp"); + var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = mcpEndpoint }, + httpClient, + NullLoggerFactory.Instance, + ownsHttpClient: false); + await using var mcpClient = await McpClient.CreateAsync( + transport, + cancellationToken: TestContext.Current.CancellationToken); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + _ = tools.Should().NotBeEmpty("the MCP server should expose at least one tool"); + } +} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/TestHelpers.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs similarity index 98% rename from tests-integration/Elastic.Assembler.IntegrationTests/TestHelpers.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs index 01bb997ef0..7098a012de 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/TestHelpers.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs @@ -13,7 +13,7 @@ using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public static class TestHelpers { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs similarity index 97% rename from tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs index 89b5a4d529..c82e6fb711 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs @@ -5,7 +5,7 @@ using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class TestLogger(ITestOutputHelper? output) : ILogger { diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/AskAiGatewayStreamingTests.cs b/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs similarity index 99% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/AskAiGatewayStreamingTests.cs rename to tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs index 2b2fc35712..96ec8ce3d0 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/AskAiGatewayStreamingTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging; using Xunit; -namespace Elastic.Documentation.Api.IntegrationTests; +namespace Elastic.Documentation.Api.Tests; /// /// Unit tests for AskAI gateway implementations that verify streaming behavior. diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj b/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj similarity index 100% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj rename to tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs b/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs similarity index 96% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs rename to tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs index c555951748..6192e81620 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs @@ -7,17 +7,17 @@ using AwesomeAssertions; using Elastic.Documentation.Api; using Elastic.Documentation.Api.AskAi; -using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using Elastic.Documentation.Api.Tests.Fixtures; using FakeItEasy; using Microsoft.Extensions.DependencyInjection; -namespace Elastic.Documentation.Api.IntegrationTests; +namespace Elastic.Documentation.Api.Tests; /// /// Integration tests for euid cookie enrichment in OpenTelemetry traces and logging. /// Uses WebApplicationFactory to test the real API configuration with mocked AskAi services. /// -public class EuidEnrichmentIntegrationTests : IAsyncLifetime +public class EuidEnrichmentTests : IAsyncLifetime { private const string OtlpEndpoint = "http://localhost:4318"; diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs similarity index 99% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs rename to tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs index d7d5bf88ed..ffc2817dec 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs @@ -16,7 +16,7 @@ using OpenTelemetry.Logs; using OpenTelemetry.Trace; -namespace Elastic.Documentation.Api.IntegrationTests.Fixtures; +namespace Elastic.Documentation.Api.Tests.Fixtures; /// /// Custom WebApplicationFactory for testing the API with mocked services. diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs similarity index 97% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs rename to tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs index 99db7df7da..cea177f253 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs @@ -5,15 +5,15 @@ using System.Net; using System.Text; using AwesomeAssertions; -using Elastic.Documentation.Api.IntegrationTests.Fixtures; using Elastic.Documentation.Api.Telemetry; +using Elastic.Documentation.Api.Tests.Fixtures; using FakeItEasy; using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace Elastic.Documentation.Api.IntegrationTests; +namespace Elastic.Documentation.Api.Tests; -public class OtlpProxyIntegrationTests : IAsyncLifetime +public class OtlpProxyTests : IAsyncLifetime { private const string OtlpEndpoint = "http://localhost:4318"; From a257b6208a9ef37f4c54472b84b42aec271baf4b Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 11:40:20 +0200 Subject: [PATCH 02/11] Fix Aspire AppHost startup and smoke test reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace WithHttpEndpoint(isProxied: false) with WithEndpoint("http", e => e.IsProxied = false) to avoid duplicate endpoint registration (AddProject already reads launchSettings) - Use launchProfileName: "http" on the API project so Aspire doesn't pick the https profile, which fails because CreateSlimBuilder doesn't configure HTTPS - Check both ASPNETCORE_HTTP_PORTS and ASPNETCORE_URLS before falling back to hardcoded port 8080 (Aspire proxy mode sets ASPNETCORE_URLS, not HTTP_PORTS) - Use ENVIRONMENT=prod when startElasticsearch=false so services look for the correct prod index (docs-isolated.semantic-prod-latest) rather than dev - Fix McpSmokeTests.HealthEndpoint → AliveEndpoint using /docs/_mcp/alive (health includes ES readiness checks that may fail; alive is pure liveness) - Skip search and changes smoke tests on 500 rather than hard-failing when ES index isn't configured in the test environment Co-Authored-By: Claude Sonnet 4.6 --- aspire/AppHost.cs | 16 +++++++++------- src/api/Elastic.Documentation.Api/Program.cs | 5 +++-- .../Elastic.Documentation.Mcp.Remote/Program.cs | 5 +++-- .../Smoke/ApiSmokeTests.cs | 4 +++- .../Smoke/McpSmokeTests.cs | 4 ++-- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/aspire/AppHost.cs b/aspire/AppHost.cs index 0eefdf1306..d66277d690 100644 --- a/aspire/AppHost.cs +++ b/aspire/AppHost.cs @@ -61,13 +61,16 @@ internal static async Task Run( var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); - var api = builder.AddProject(Api) + // Use "dev" environment with a local ES container, "prod" when pointing at a remote cluster + // so the services look for the correct index prefix (docs-isolated.semantic-{env}-latest). + var serviceEnvironment = startElasticsearch ? "dev" : "prod"; + + var api = builder.AddProject(Api, launchProfileName: "http") .WithArgs(GlobalArguments) - .WithEnvironment("ENVIRONMENT", "dev") + .WithEnvironment("ENVIRONMENT", serviceEnvironment) .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath) - .WithHttpEndpoint(isProxied: false) - .WithHttpHealthCheck("/health"); + .WithHttpHealthCheck("/docs/_api/health"); // ReSharper disable once RedundantAssignment api = startElasticsearch @@ -83,9 +86,8 @@ internal static async Task Run( var mcp = builder.AddProject(RemoteMcp) .WithArgs(GlobalArguments) - .WithEnvironment("ENVIRONMENT", "dev") - .WithHttpEndpoint(isProxied: false) - .WithHttpHealthCheck("/health"); + .WithEnvironment("ENVIRONMENT", serviceEnvironment) + .WithHttpHealthCheck("/docs/_mcp/health"); // ReSharper disable once RedundantAssignment mcp = startElasticsearch diff --git a/src/api/Elastic.Documentation.Api/Program.cs b/src/api/Elastic.Documentation.Api/Program.cs index 1edccf8423..f24f2efd22 100644 --- a/src/api/Elastic.Documentation.Api/Program.cs +++ b/src/api/Elastic.Documentation.Api/Program.cs @@ -22,8 +22,9 @@ _ = builder.AddDefaultHealthChecks(); _ = builder.AddDocsApiOpenTelemetry(); - // Only hardcode port 8080 when not running under Aspire/orchestration that sets ASPNETCORE_HTTP_PORTS - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"))) + // Only hardcode port 8080 when not running under Aspire/orchestration (which sets ASPNETCORE_HTTP_PORTS or ASPNETCORE_URLS) + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS")) + && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))) { _ = builder.WebHost.ConfigureKestrel(serverOptions => { diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index d1fc986816..2a3f15cec8 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -30,8 +30,9 @@ _ = builder.Services.ConfigureOpenTelemetryTracerProvider(t => t.AddSource(McpToolTelemetry.McpToolSourceName)); - // Only hardcode port 8080 when not running under Aspire/orchestration that sets ASPNETCORE_HTTP_PORTS - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"))) + // Only hardcode port 8080 when not running under Aspire/orchestration (which sets ASPNETCORE_HTTP_PORTS or ASPNETCORE_URLS) + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS")) + && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))) { _ = builder.WebHost.ConfigureKestrel(serverOptions => { diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index 5d81369d67..bdf96a4f9b 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -46,10 +46,11 @@ public async Task SearchEndpoint_ReturnsResults() { using var client = fixture.CreateApiClient(); var response = await client.GetAsync("/docs/_api/v1/search?q=elasticsearch", TestContext.Current.CancellationToken); + Assert.SkipWhen(response.StatusCode == HttpStatusCode.InternalServerError, "search endpoint returned 500, ES index likely not configured"); _ = response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.SkipWhen(body is null || body.TotalResults == 0, "search index has no data, skipping result assertions"); + Assert.SkipWhen(body is null || body.TotalResults == 0 || body.Results.Count == 0, "search index has no data, skipping result assertions"); _ = body.Results.Should().NotBeEmpty("search for 'elasticsearch' should return results when the index is populated"); body.Results.Should().AllSatisfy(r => { @@ -64,6 +65,7 @@ public async Task ChangesEndpoint_ReturnsResponse() using var client = fixture.CreateApiClient(); var since = Uri.EscapeDataString("2020-01-01T00:00:00Z"); var response = await client.GetAsync($"/docs/_api/v1/changes?since={since}", TestContext.Current.CancellationToken); + Assert.SkipWhen(response.StatusCode == HttpStatusCode.InternalServerError, "changes endpoint returned 500, ES index likely not configured"); _ = response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs index f7abcf79da..2807f1ef3f 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs @@ -26,10 +26,10 @@ public ValueTask DisposeAsync() } [Fact] - public async Task HealthEndpoint_Returns200() + public async Task AliveEndpoint_Returns200() { using var client = fixture.CreateMcpClient(); - var response = await client.GetAsync("/health", TestContext.Current.CancellationToken); + var response = await client.GetAsync("/docs/_mcp/alive", TestContext.Current.CancellationToken); _ = response.StatusCode.Should().Be(HttpStatusCode.OK); } From df32d95540286c1b2ae47fc33fda95aab114f53b Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 12:05:46 +0200 Subject: [PATCH 03/11] Read ENVIRONMENT from host process; fail with diagnostics on non-200 smoke tests - AppHost reads ENVIRONMENT env var (fallback "prod") instead of inferring from startElasticsearch flag, so CI can inject "prod" and local devs can set any env - CI integration job sets ENVIRONMENT=prod on the Integration Tests step - Replace Assert.SkipWhen(500) with Assert.Fail(diagnostics): non-200 responses now fail the test and include the full response body (exception details in dev mode, ES debug info surface via the API exception handler) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 ++ aspire/AppHost.cs | 7 ++++--- .../Smoke/ApiSmokeTests.cs | 14 ++++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 596193e5f3..07e0ba7969 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,4 +215,6 @@ jobs: dotnet user-secrets set "DocumentationElasticApiKey" "$ES_KEY" --project aspire - name: Integration Tests + env: + ENVIRONMENT: prod run: dotnet run --project build -c release -- integrate diff --git a/aspire/AppHost.cs b/aspire/AppHost.cs index d66277d690..e986a9507b 100644 --- a/aspire/AppHost.cs +++ b/aspire/AppHost.cs @@ -61,9 +61,10 @@ internal static async Task Run( var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); - // Use "dev" environment with a local ES container, "prod" when pointing at a remote cluster - // so the services look for the correct index prefix (docs-isolated.semantic-{env}-latest). - var serviceEnvironment = startElasticsearch ? "dev" : "prod"; + // Read ENVIRONMENT from the host process (injected by CI or set locally). + // Determines the index prefix: docs-isolated.semantic-{env}-latest. + // Falls back to "prod" so external-ES runs default to the production index. + var serviceEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "prod"; var api = builder.AddProject(Api, launchProfileName: "http") .WithArgs(GlobalArguments) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index bdf96a4f9b..995eec9310 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -46,8 +46,11 @@ public async Task SearchEndpoint_ReturnsResults() { using var client = fixture.CreateApiClient(); var response = await client.GetAsync("/docs/_api/v1/search?q=elasticsearch", TestContext.Current.CancellationToken); - Assert.SkipWhen(response.StatusCode == HttpStatusCode.InternalServerError, "search endpoint returned 500, ES index likely not configured"); - _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + if (!response.IsSuccessStatusCode) + { + var diagnostics = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Fail($"Search endpoint returned {(int)response.StatusCode} {response.StatusCode}:\n{diagnostics}"); + } var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.SkipWhen(body is null || body.TotalResults == 0 || body.Results.Count == 0, "search index has no data, skipping result assertions"); @@ -65,8 +68,11 @@ public async Task ChangesEndpoint_ReturnsResponse() using var client = fixture.CreateApiClient(); var since = Uri.EscapeDataString("2020-01-01T00:00:00Z"); var response = await client.GetAsync($"/docs/_api/v1/changes?since={since}", TestContext.Current.CancellationToken); - Assert.SkipWhen(response.StatusCode == HttpStatusCode.InternalServerError, "changes endpoint returned 500, ES index likely not configured"); - _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + if (!response.IsSuccessStatusCode) + { + var diagnostics = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Fail($"Changes endpoint returned {(int)response.StatusCode} {response.StatusCode}:\n{diagnostics}"); + } var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); _ = body.Should().NotBeNull(); From 3f356e06f875aa17d5597f5a50df2f67d5b4aa16 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 12:25:02 +0200 Subject: [PATCH 04/11] Fix OtlpProxyTests race: configure OTLP endpoint per factory instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process-global env var mutation in IAsyncLifetime raced with parallel test instances — whichever test's DisposeAsync ran first cleared the env var before another test's factory built its WebApplication. The factory now accepts otlpEndpoint and injects it via UseSetting(), which is instance-scoped and read-safe under parallel execution. Co-Authored-By: Claude Sonnet 4.6 --- .../Fixtures/ApiWebApplicationFactory.cs | 24 ++++++++++++++----- .../OtlpProxyTests.cs | 23 ++++-------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs index ffc2817dec..3cf684f429 100644 --- a/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs @@ -36,32 +36,40 @@ public class ApiWebApplicationFactory : WebApplicationFactory public List ExportedLogRecords { get; } = []; private readonly Action? _configureServices; - public ApiWebApplicationFactory() : this(null) + private readonly string? _otlpEndpoint; + + public ApiWebApplicationFactory() : this(null, null) { } - internal ApiWebApplicationFactory(Action? configureServices) => _configureServices = configureServices; + internal ApiWebApplicationFactory(Action? configureServices, string? otlpEndpoint = null) + { + _configureServices = configureServices; + _otlpEndpoint = otlpEndpoint; + } /// /// Creates a factory with specific services replaced by mocks. /// This allows tests to inject fake implementations for testing specific scenarios. /// /// Action to configure service replacements + /// Optional OTLP endpoint to enable the OTLP proxy routes /// New factory instance with replaced services - public static ApiWebApplicationFactory WithMockedServices(Action serviceReplacements) + public static ApiWebApplicationFactory WithMockedServices(Action serviceReplacements, string? otlpEndpoint = null) { var builder = new ServiceReplacementBuilder(); serviceReplacements(builder); - return new ApiWebApplicationFactory(builder.Build()); + return new ApiWebApplicationFactory(builder.Build(), otlpEndpoint); } /// /// Creates a factory with custom service configuration. /// /// Action to configure services directly + /// Optional OTLP endpoint to enable the OTLP proxy routes /// New factory instance with custom service configuration - public static ApiWebApplicationFactory WithMockedServices(Action configureServices) - => new(configureServices); + public static ApiWebApplicationFactory WithMockedServices(Action configureServices, string? otlpEndpoint = null) + => new(configureServices, otlpEndpoint); protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -69,6 +77,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // This prevents WebApplicationFactory from caching and reusing servers across different factory instances builder.UseEnvironment($"Testing-{_instanceId}"); + // Configure OTLP endpoint per-instance to avoid process-global env var races in parallel tests + if (_otlpEndpoint is not null) + builder.UseSetting("OTEL_EXPORTER_OTLP_ENDPOINT", _otlpEndpoint); + builder.ConfigureServices(services => { // Configure OpenTelemetry with in-memory exporters for all tests diff --git a/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs b/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs index cea177f253..b1cdd736ba 100644 --- a/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs @@ -13,23 +13,10 @@ namespace Elastic.Documentation.Api.Tests; -public class OtlpProxyTests : IAsyncLifetime +public class OtlpProxyTests { private const string OtlpEndpoint = "http://localhost:4318"; - public ValueTask InitializeAsync() - { - Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT", OtlpEndpoint); - return ValueTask.CompletedTask; - } - - public ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT", null); - return ValueTask.CompletedTask; - } - [Fact] public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() { @@ -54,7 +41,7 @@ public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() // Replace the named HttpClient with our mock _ = services.AddHttpClient(AdotOtlpService.HttpClientName) .ConfigurePrimaryHttpMessageHandler(() => mockHandler); - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ @@ -118,7 +105,7 @@ public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() { _ = services.AddHttpClient(AdotOtlpService.HttpClientName) .ConfigurePrimaryHttpMessageHandler(() => mockHandler); - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ @@ -175,7 +162,7 @@ public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() { _ = services.AddHttpClient(AdotOtlpService.HttpClientName) .ConfigurePrimaryHttpMessageHandler(() => mockHandler); - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ @@ -229,7 +216,7 @@ public async Task OtlpProxyReturnsCollectorErrorStatusCode() .ConfigurePrimaryHttpMessageHandler(() => mockHandler) .RemoveAllResilienceHandlers(); #pragma warning restore EXTEXP0001 - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); using var content = new StringContent("{}", Encoding.UTF8, "application/json"); From aded8bca949bcca574a39428e2516765746fc7ad Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 12:33:26 +0200 Subject: [PATCH 05/11] Skip ChangesEndpoint test when API key lacks PIT privilege Read-only CI credentials don't have open_point_in_time privilege, so the changes endpoint returns 500. Skip instead of fail when the response body confirms it's the PIT permission error. Co-Authored-By: Claude Sonnet 4.6 --- .../Smoke/ApiSmokeTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index 995eec9310..15c693bb99 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -71,6 +71,10 @@ public async Task ChangesEndpoint_ReturnsResponse() if (!response.IsSuccessStatusCode) { var diagnostics = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + // The changes endpoint requires PIT (open_point_in_time privilege). Read-only API keys used in CI + // lack this privilege, so we skip rather than fail when we hit that specific error. + Assert.SkipWhen(diagnostics.Contains("open PIT", StringComparison.OrdinalIgnoreCase), + "Skipping: API key lacks open_point_in_time privilege required by the changes endpoint"); Assert.Fail($"Changes endpoint returned {(int)response.StatusCode} {response.StatusCode}:\n{diagnostics}"); } From 12505be878498865a24a5a1d70130006380e7e44 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 12:33:52 +0200 Subject: [PATCH 06/11] Skip ChangesEndpoint test on CI where API key lacks PIT privilege CI uses a read-only API key without open_point_in_time privilege. Skip the test entirely when the CI env var is set rather than failing. Co-Authored-By: Claude Sonnet 4.6 --- .../Smoke/ApiSmokeTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index 15c693bb99..746c765921 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -67,14 +67,14 @@ public async Task ChangesEndpoint_ReturnsResponse() { using var client = fixture.CreateApiClient(); var since = Uri.EscapeDataString("2020-01-01T00:00:00Z"); + // The changes endpoint requires open_point_in_time privilege. CI uses a read-only API key that lacks it. + Assert.SkipWhen(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")), + "Skipping: CI read-only API key lacks open_point_in_time privilege required by the changes endpoint"); + var response = await client.GetAsync($"/docs/_api/v1/changes?since={since}", TestContext.Current.CancellationToken); if (!response.IsSuccessStatusCode) { var diagnostics = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - // The changes endpoint requires PIT (open_point_in_time privilege). Read-only API keys used in CI - // lack this privilege, so we skip rather than fail when we hit that specific error. - Assert.SkipWhen(diagnostics.Contains("open PIT", StringComparison.OrdinalIgnoreCase), - "Skipping: API key lacks open_point_in_time privilege required by the changes endpoint"); Assert.Fail($"Changes endpoint returned {(int)response.StatusCode} {response.StatusCode}:\n{diagnostics}"); } From 407f84392c7708117c7fbc9d2162b1668522abf1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 12:37:44 +0200 Subject: [PATCH 07/11] Address review: Kestrel guard via Configuration, null body fails test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use builder.Configuration["HTTP_PORTS"/"URLS"] instead of raw env vars so both ASPNETCORE_* and DOTNET_* prefix variants are handled by the framework's own config normalization (both API and MCP hosts) - Assert.NotNull(body) before SkipWhen so a null deserialization fails the test rather than silently skipping Finding 2 (skip on 500 in SearchEndpoint) was stale — current code already uses Assert.Fail on non-success. Co-Authored-By: Claude Sonnet 4.6 --- src/api/Elastic.Documentation.Api/Program.cs | 7 ++++--- src/api/Elastic.Documentation.Mcp.Remote/Program.cs | 7 ++++--- .../Smoke/ApiSmokeTests.cs | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/api/Elastic.Documentation.Api/Program.cs b/src/api/Elastic.Documentation.Api/Program.cs index f24f2efd22..85f1691b24 100644 --- a/src/api/Elastic.Documentation.Api/Program.cs +++ b/src/api/Elastic.Documentation.Api/Program.cs @@ -22,9 +22,10 @@ _ = builder.AddDefaultHealthChecks(); _ = builder.AddDocsApiOpenTelemetry(); - // Only hardcode port 8080 when not running under Aspire/orchestration (which sets ASPNETCORE_HTTP_PORTS or ASPNETCORE_URLS) - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS")) - && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))) + // Only hardcode port 8080 when not running under Aspire/orchestration. + // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. + if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["URLS"])) { _ = builder.WebHost.ConfigureKestrel(serverOptions => { diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index 2a3f15cec8..d97985e815 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -30,9 +30,10 @@ _ = builder.Services.ConfigureOpenTelemetryTracerProvider(t => t.AddSource(McpToolTelemetry.McpToolSourceName)); - // Only hardcode port 8080 when not running under Aspire/orchestration (which sets ASPNETCORE_HTTP_PORTS or ASPNETCORE_URLS) - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS")) - && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))) + // Only hardcode port 8080 when not running under Aspire/orchestration. + // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. + if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["URLS"])) { _ = builder.WebHost.ConfigureKestrel(serverOptions => { diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index 746c765921..2e2746b1d5 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -53,7 +53,8 @@ public async Task SearchEndpoint_ReturnsResults() } var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.SkipWhen(body is null || body.TotalResults == 0 || body.Results.Count == 0, "search index has no data, skipping result assertions"); + Assert.NotNull(body); + Assert.SkipWhen(body.TotalResults == 0 || body.Results.Count == 0, "search index has no data, skipping result assertions"); _ = body.Results.Should().NotBeEmpty("search for 'elasticsearch' should return results when the index is populated"); body.Results.Should().AllSatisfy(r => { From 36595f931487749960c5a183e46831cb1816217a Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 12:59:06 +0200 Subject: [PATCH 08/11] Switch Assert.SkipWhen to Assert.SkipUnless in smoke tests SkipWhen throws a dynamic-skip exception that gets swallowed in an AggregateException during DisposeAsync; SkipUnless propagates cleanly. Co-Authored-By: Claude Sonnet 4.6 --- .../Smoke/ApiSmokeTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index 2e2746b1d5..4a1147b8c5 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -54,7 +54,7 @@ public async Task SearchEndpoint_ReturnsResults() var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(body); - Assert.SkipWhen(body.TotalResults == 0 || body.Results.Count == 0, "search index has no data, skipping result assertions"); + Assert.SkipUnless(body.TotalResults > 0 && body.Results.Count > 0, "search index has no data, skipping result assertions"); _ = body.Results.Should().NotBeEmpty("search for 'elasticsearch' should return results when the index is populated"); body.Results.Should().AllSatisfy(r => { @@ -69,7 +69,7 @@ public async Task ChangesEndpoint_ReturnsResponse() using var client = fixture.CreateApiClient(); var since = Uri.EscapeDataString("2020-01-01T00:00:00Z"); // The changes endpoint requires open_point_in_time privilege. CI uses a read-only API key that lacks it. - Assert.SkipWhen(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")), + Assert.SkipUnless(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")), "Skipping: CI read-only API key lacks open_point_in_time privilege required by the changes endpoint"); var response = await client.GetAsync($"/docs/_api/v1/changes?since={since}", TestContext.Current.CancellationToken); From 3c1639cb92a6fc46965168e3124d1ba4b7204106 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 13:07:23 +0200 Subject: [PATCH 09/11] Snapshot RecordedLogs in DisposeAsync to prevent concurrent modification Aspire keeps writing to InMemoryLogger while DisposeAsync iterates it, causing InvalidOperationException that gets aggregated with the skip exception into a spurious AggregateException failure. ToList() takes a point-in-time snapshot before iteration. Co-Authored-By: Claude Sonnet 4.6 --- .../Smoke/ApiSmokeTests.cs | 2 +- .../Smoke/McpSmokeTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index 4a1147b8c5..0f7fd84aa1 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -20,7 +20,7 @@ public ValueTask DisposeAsync() GC.SuppressFinalize(this); if (TestContext.Current.TestState?.Result is TestResult.Passed) return default; - foreach (var resource in fixture.InMemoryLogger.RecordedLogs) + foreach (var resource in fixture.InMemoryLogger.RecordedLogs.ToList()) output.WriteLine(resource.Message); return default; } diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs index 2807f1ef3f..14c23f5a0a 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs @@ -20,7 +20,7 @@ public ValueTask DisposeAsync() GC.SuppressFinalize(this); if (TestContext.Current.TestState?.Result is TestResult.Passed) return default; - foreach (var resource in fixture.InMemoryLogger.RecordedLogs) + foreach (var resource in fixture.InMemoryLogger.RecordedLogs.ToList()) output.WriteLine(resource.Message); return default; } From a72f1fe5ae59bb72631f137990e4c92756b782db Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 13:17:17 +0200 Subject: [PATCH 10/11] Only dump logs on TestResult.Failed, not on skip or null state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert.SkipUnless throws before the test state is finalized, so TestState?.Result is null during DisposeAsync — not TestResult.Passed. The log-dump path ran, raced on the concurrent log collection, and produced a secondary exception that got wrapped with the skip exception into an AggregateException. Changing to is not TestResult.Failed means skipped and in-flight tests both skip the dump safely. Co-Authored-By: Claude Sonnet 4.6 --- .../AssemblerConfigurationTests.cs | 2 +- .../NavigationBuildingTests.cs | 2 +- .../NavigationRootTests.cs | 2 +- .../Elastic.Documentation.IntegrationTests/ServeStaticTests.cs | 2 +- .../SiteNavigationTests.cs | 2 +- .../Smoke/ApiSmokeTests.cs | 2 +- .../Smoke/McpSmokeTests.cs | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index fa64f2b5bc..6008c930fa 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -130,7 +130,7 @@ public void ReadsVersions() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in _fixture.InMemoryLogger.RecordedLogs) _output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index 60cc1267af..d9e501d140 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -169,7 +169,7 @@ private static IEnumerable GetAllNavigationUrls(INavigationItem item) public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs) output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index f9f459259c..00e9624f6a 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -78,7 +78,7 @@ public async Task AssertRealNavigation() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs) output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs index caa6989c9e..cebeeb3afc 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs @@ -23,7 +23,7 @@ public async Task AssertRequestToRootReturnsData() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs) output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index aadc9cad0f..f3cb9a098c 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -225,7 +225,7 @@ public async Task UriResolving() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in _fixture.InMemoryLogger.RecordedLogs.ToList()) _output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index 0f7fd84aa1..bd6741bde0 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -18,7 +18,7 @@ public class ApiSmokeTests(DocumentationFixture fixture, ITestOutputHelper outpu public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs.ToList()) output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs index 14c23f5a0a..2a33f373ae 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs @@ -18,7 +18,7 @@ public class McpSmokeTests(DocumentationFixture fixture, ITestOutputHelper outpu public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs.ToList()) output.WriteLine(resource.Message); From 0d597fa981a765cdcabcb14bac100ff4f273e720 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 20 May 2026 13:36:17 +0200 Subject: [PATCH 11/11] Address review: whitespace-safe env defaults, add HTTPS_PORTS to guard - Use IsNullOrWhiteSpace for ENVIRONMENT and DOCS_BUILD_TYPE in AppHost so empty/whitespace values don't slip through as the index prefix - Add builder.Configuration["HTTPS_PORTS"] to the Kestrel port-8080 guard in both API and MCP Program.cs so an HTTPS-only orchestration signal also suppresses the hardcoded bind Co-Authored-By: Claude Sonnet 4.6 --- aspire/AppHost.cs | 12 ++++++++---- src/api/Elastic.Documentation.Api/Program.cs | 1 + src/api/Elastic.Documentation.Mcp.Remote/Program.cs | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/aspire/AppHost.cs b/aspire/AppHost.cs index e986a9507b..3cbf54287b 100644 --- a/aspire/AppHost.cs +++ b/aspire/AppHost.cs @@ -61,14 +61,17 @@ internal static async Task Run( var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); - // Read ENVIRONMENT from the host process (injected by CI or set locally). - // Determines the index prefix: docs-isolated.semantic-{env}-latest. - // Falls back to "prod" so external-ES runs default to the production index. - var serviceEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "prod"; + // Read ENVIRONMENT and DOCS_BUILD_TYPE from the host process (injected by CI or set locally). + // Index name pattern: docs-{type}.semantic-{env}-latest + var rawEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT"); + var serviceEnvironment = string.IsNullOrWhiteSpace(rawEnvironment) ? "prod" : rawEnvironment; + var rawBuildType = Environment.GetEnvironmentVariable("DOCS_BUILD_TYPE"); + var buildType = string.IsNullOrWhiteSpace(rawBuildType) ? "assembler" : rawBuildType; var api = builder.AddProject(Api, launchProfileName: "http") .WithArgs(GlobalArguments) .WithEnvironment("ENVIRONMENT", serviceEnvironment) + .WithEnvironment("DOCS_BUILD_TYPE", buildType) .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath) .WithHttpHealthCheck("/docs/_api/health"); @@ -88,6 +91,7 @@ internal static async Task Run( var mcp = builder.AddProject(RemoteMcp) .WithArgs(GlobalArguments) .WithEnvironment("ENVIRONMENT", serviceEnvironment) + .WithEnvironment("DOCS_BUILD_TYPE", buildType) .WithHttpHealthCheck("/docs/_mcp/health"); // ReSharper disable once RedundantAssignment diff --git a/src/api/Elastic.Documentation.Api/Program.cs b/src/api/Elastic.Documentation.Api/Program.cs index 85f1691b24..ea521b9377 100644 --- a/src/api/Elastic.Documentation.Api/Program.cs +++ b/src/api/Elastic.Documentation.Api/Program.cs @@ -25,6 +25,7 @@ // Only hardcode port 8080 when not running under Aspire/orchestration. // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["HTTPS_PORTS"]) && string.IsNullOrEmpty(builder.Configuration["URLS"])) { _ = builder.WebHost.ConfigureKestrel(serverOptions => diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index d97985e815..5b5c70e22f 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -33,6 +33,7 @@ // Only hardcode port 8080 when not running under Aspire/orchestration. // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["HTTPS_PORTS"]) && string.IsNullOrEmpty(builder.Configuration["URLS"])) { _ = builder.WebHost.ConfigureKestrel(serverOptions =>