Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ead33ec
Add Testcontainers.CosmosDb integration for automatic emulator startup
Copilot Mar 25, 2026
09480a5
Address code review feedback: add clarifying comments to catch blocks
Copilot Mar 25, 2026
6df36b8
Address review feedback: async init, SkipConnectionCheck throw, resto…
Copilot Mar 25, 2026
5163459
Distill SKILL.md, fix container assignment timing, add disposeHandler…
Copilot Mar 25, 2026
0fa9387
Make DefaultConnection throw if read before InitializeAsync, apply SK…
Copilot Mar 25, 2026
844bf18
Block DefaultConnection/HttpMessageHandler until InitializeAsync comp…
Copilot Mar 25, 2026
b42e171
Remove EnsureInitialized from property getters; call InitializeAsync …
Copilot Mar 26, 2026
eff2931
Fix ManyServiceProvidersCreatedWarning by sharing a single static Htt…
Copilot Mar 26, 2026
a322cbe
Fix HttpClient.Timeout crash: share handler and factory delegate, cre…
Copilot Mar 26, 2026
3d67e46
helix.proj: remove SkipConnectionCheck from Ubuntu XL, switch Windows…
Copilot Mar 26, 2026
d2b56b1
Simplify InitializeAsync: just configured endpoint or testcontainer, …
Copilot Mar 27, 2026
b0ae4a8
Add helpful error message when Cosmos testcontainer startup fails
Copilot Mar 28, 2026
2aa85dd
Restore localhost probing before testcontainer fallback
Copilot Mar 28, 2026
8ea3913
Use xunit assembly fixture for Cosmos emulator container lifecycle
Copilot Apr 1, 2026
3a44583
Store initialization exception in CosmosEmulatorFixture for diagnostics
Copilot Apr 1, 2026
91b538a
Revert xunit assembly fixture changes from 8ea3913 and 3a44583
Copilot Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions .agents/skills/cosmos-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,38 @@ Non-relational provider with its own parallel query pipeline. Uses JSON for docu
- `ETag` for optimistic concurrency
- No cross-container joins

## Azure Cosmos DB Emulator in Docker
## Azure Cosmos DB Emulator for Tests

Cosmos tests on Helix start the emulator from the work item via `PreCommands` that run a Docker container using:
- `eng/testing/run-cosmos-container.ps1`
- `eng/testing/run-cosmos-container.sh`
### Automatic Testcontainer Startup

These scripts can be invoked locally for testing on machines that don't have the emulator installed, but have docker available.
Cosmos functional tests automatically manage the emulator lifecycle via [Testcontainers](https://testcontainers.com/modules/cosmodb/?language=dotnet) (`Testcontainers.CosmosDb` NuGet package). The initialization logic in `TestEnvironment.cs` follows this order:

The `Test__Cosmos__SkipConnectionCheck=true` env var is set to prevent tests from being skipped when the emulator failed to start.
1. **Configured endpoint**: If `Test__Cosmos__DefaultConnection` env var (or `cosmosConfig.json` / `cosmosConfig.test.json`) is set, it is used directly — no container is started.
2. **Local emulator probe**: A quick HTTPS probe is sent to `https://localhost:8081`. If a running emulator responds, it is used.
3. **Testcontainer fallback**: If neither of the above succeeds, a `CosmosDbContainer` is started with the Linux emulator image (`mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview`). The container is disposed on process exit.
4. **Graceful skip**: If Docker is unavailable and no emulator is reachable, the default endpoint is used and `IsConnectionAvailableAsync()` returns `false`, causing tests to be skipped.

### Linux Emulator Detection

`TestEnvironment.IsLinuxEmulator` is `true` when:
- A testcontainer is running (always the Linux image), **or**
- The OS is not Windows (assumes the local emulator is the Linux Docker image), **or**
- `Test__Cosmos__EmulatorType` is explicitly set to `linux`.

Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
The Linux (vnext) emulator does **not** support transactional batches, so `LinuxEmulatorSaveChangesInterceptor` forces `AutoTransactionBehavior.Never` on every `SaveChanges` call. Tests that require features absent from the Linux emulator are guarded with `[CosmosCondition(CosmosCondition.IsNotLinuxEmulator)]`.

### HttpClient Handling

When a testcontainer is active, `CosmosDbContextOptionsBuilderExtensions.ApplyConfiguration` uses the container's `HttpMessageHandler` (a URI rewriter that routes requests to the mapped container port over HTTP). When connecting to a local HTTPS emulator, it uses `DangerousAcceptAnyServerCertificateValidator` instead.

### Manual Scripts (Legacy)

The shell scripts `eng/testing/run-cosmos-container.sh` and `eng/testing/run-cosmos-container.ps1` can still be used to manually start the emulator in Docker when needed, but they are no longer invoked by Helix or CI.

### Key Files

- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/TestEnvironment.cs` — connection auto-detection and testcontainer lifecycle
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs` — test store creation, seeding, cleanup
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosDbContextOptionsBuilderExtensions.cs` — shared Cosmos options (execution strategy, timeout, HttpClient, Gateway mode)
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/LinuxEmulatorSaveChangesInterceptor.cs` — disables transactional batches for the Linux emulator
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosConditionAttribute.cs` — conditional test execution based on emulator type
1 change: 0 additions & 1 deletion .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,5 @@ jobs:
- name: Export environment variables for the agent's session
run: |
echo "Test__SqlServer__DefaultConnection=Server=localhost;Database=test;User=SA;Password=PLACEHOLDERPass$$w0rd;Connect Timeout=60;ConnectRetryCount=0;Trust Server Certificate=true" >> "$GITHUB_ENV"
echo "Test__Cosmos__EmulatorType=linux" >> "$GITHUB_ENV"
Comment thread
AndriySvyryd marked this conversation as resolved.
echo "DOTNET_ROOT=$PWD/.dotnet/" >> "$GITHUB_ENV"
echo "$PWD/.dotnet/" >> $GITHUB_PATH
13 changes: 2 additions & 11 deletions eng/helix.proj
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,6 @@
<PreCommands>$(PreCommands); SqlLocalDB start</PreCommands>
</XUnitProject>
</ItemGroup>

<ItemGroup Condition = "'$(HelixTargetQueue.StartsWith(`Windows.11`))'">
<XUnitProject Update="$(CosmosTests)">
<PreCommands>$(PreCommands); set Test__Cosmos__SkipConnectionCheck=true</PreCommands>
</XUnitProject>
</ItemGroup>
Comment thread
AndriySvyryd marked this conversation as resolved.

<!-- Start SqlServer instance for test projects which uses SqlServer on docker. Only run SqlServer tests. -->
<ItemGroup Condition = "'$(HelixTargetQueue.Contains(`helix-sqlserver`))'">
Expand All @@ -80,14 +74,11 @@
</XUnitProject>
</ItemGroup>

<!-- Start Cosmos emulator in Docker on Ubuntu and only run Cosmos tests -->
<!-- Run Cosmos tests on Ubuntu with Docker support (testcontainer auto-starts the emulator) -->
<ItemGroup Condition = "'$(HelixTargetQueue)' == 'Ubuntu.2204.Amd64.XL.Open' OR '$(HelixTargetQueue)' == 'Ubuntu.2204.Amd64.XL'">
<XUnitProject Remove="$(RepoRoot)/test/**/*.csproj"/>
<XUnitProject Remove="$(RepoRoot)/test/**/*.fsproj"/>
<XUnitProject Include="$(CosmosTests)">
<PreCommands>$(PreCommands); chmod +x $HELIX_CORRELATION_PAYLOAD/testing/run-cosmos-container.sh; $HELIX_CORRELATION_PAYLOAD/testing/run-cosmos-container.sh; export Test__Cosmos__DefaultConnection=https://localhost:8081; export Test__Cosmos__SkipConnectionCheck=true; export Test__Cosmos__EmulatorType=linux</PreCommands>
Comment thread
AndriySvyryd marked this conversation as resolved.
<PostCommands>$(PostCommands); docker stop cosmos-emulator || true; docker rm -f cosmos-emulator || true</PostCommands>
</XUnitProject>
<XUnitProject Include="$(CosmosTests)" />
</ItemGroup>

<!-- Run tests that don't need SqlServer or Cosmos on bare Ubuntu -->
Expand Down
1 change: 1 addition & 0 deletions test/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="$(OpenTelemetryExporterInMemoryVersion)" />
<PackageVersion Include="SQLitePCLRaw.provider.sqlite3" Version="$(SQLitePCLRawVersion)" />
<PackageVersion Include="SQLitePCLRaw.provider.winsqlite3" Version="$(SQLitePCLRawVersion)" />
<PackageVersion Include="Testcontainers.CosmosDb" Version="4.11.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.ResourceManager.CosmosDB" />
<PackageReference Include="Testcontainers.CosmosDb" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ public static class CosmosDbContextOptionsBuilderExtensions
{
public static CosmosDbContextOptionsBuilder ApplyConfiguration(this CosmosDbContextOptionsBuilder optionsBuilder)
{
var handlerFactory = TestEnvironment.HttpMessageHandlerFactory;

optionsBuilder
.ExecutionStrategy(d => new TestCosmosExecutionStrategy(d))
.RequestTimeout(TimeSpan.FromMinutes(20))
.HttpClientFactory(() => new HttpClient(
new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
}))
.HttpClientFactory(handlerFactory != null
? () => new HttpClient(handlerFactory())
: () => new HttpClient(
new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
}))
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
.ConnectionMode(ConnectionMode.Gateway);

return optionsBuilder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Testcontainers.CosmosDb;

namespace Microsoft.EntityFrameworkCore.TestUtilities;

Expand All @@ -22,15 +23,96 @@ public static class TestEnvironment
.Build()
.GetSection("Test:Cosmos");

public static string DefaultConnection { get; } = string.IsNullOrEmpty(Config["DefaultConnection"])
? "https://localhost:8081"
: Config["DefaultConnection"];
private static readonly Lazy<(string Connection, CosmosDbContainer Container)> _connectionInfo = new(InitializeConnection);

public static string DefaultConnection => _connectionInfo.Value.Connection;

private static CosmosDbContainer Container => _connectionInfo.Value.Container;

public static bool IsTestContainer => Container != null;
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated

internal static Func<HttpMessageHandler> HttpMessageHandlerFactory
=> Container != null ? () => Container.HttpMessageHandler : null;
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated

private static (string Connection, CosmosDbContainer Container) InitializeConnection()
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
{
// If a connection string is specified (env var, config.json...), always use that.
var configured = Config["DefaultConnection"];
if (!string.IsNullOrEmpty(configured))
{
return (configured, null);
}

// Try to connect to the default emulator endpoint.
if (TryProbeEmulator("https://localhost:8081"))
{
return ("https://localhost:8081", null);
}

// Try to start a testcontainer with the Linux emulator.
// Synchronous blocking is required here because this runs in a Lazy<T> initializer
// which cannot be async. This matches the pattern used in SQL Server's TestEnvironment.
try
{
var container = new CosmosDbBuilder("mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview")
.Build();
container.StartAsync().GetAwaiter().GetResult();

AppDomain.CurrentDomain.ProcessExit += (_, _) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd ideally use an xunit assembly fixture to start and stop the container.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot use an xunit assembly fixture to start and stop the container.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8ea3913. Implemented a custom xunit test framework (CosmosTestFrameworkCosmosTestFrameworkExecutorCosmosTestAssemblyRunner) that manages a CosmosEmulatorFixture as an assembly-wide fixture. The fixture calls TestEnvironment.InitializeAsync() before any tests run and TestEnvironment.DisposeAsync() after all tests complete. Removed the ProcessExit handler from TestEnvironment.

The Xunit.Extensions.AssemblyFixture NuGet package wasn't available in the configured feeds, so I implemented a minimal version inline using XunitTestFramework/XunitTestAssemblyRunner hooks.

{
try
{
container.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
catch
{
// Best-effort cleanup: container may already be stopped or Docker daemon
// may have exited before the process exit handler runs.
}
};

var endpoint = new UriBuilder(
Uri.UriSchemeHttp,
container.Hostname,
container.GetMappedPublicPort(CosmosDbBuilder.CosmosDbPort)).ToString();

return (endpoint, container);
}
catch
{
// Any failure (Docker not installed, daemon not running, image pull failure, etc.)
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
// falls back to the default endpoint. The connection check in CosmosTestStore will
// determine whether the emulator is actually reachable and skip tests if not.
return ("https://localhost:8081", null);
}
}

private static bool TryProbeEmulator(string endpoint)
{
try
{
using var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(3) };
// Any successful response (even 401) means the emulator is up and accepting connections.
using var response = client.GetAsync(endpoint).GetAwaiter().GetResult();
return true;
}
catch
{
// Expected: HttpRequestException (connection refused), TaskCanceledException (timeout),
// or SocketException when the emulator is not running.
return false;
}
}

public static string AuthToken { get; } = string.IsNullOrEmpty(Config["AuthToken"])
? _emulatorAuthToken
: Config["AuthToken"];

public static string ConnectionString { get; } = $"AccountEndpoint={DefaultConnection};AccountKey={AuthToken}";
public static string ConnectionString => $"AccountEndpoint={DefaultConnection};AccountKey={AuthToken}";

public static bool UseTokenCredential { get; } = string.Equals(Config["UseTokenCredential"], "true", StringComparison.OrdinalIgnoreCase);

Expand All @@ -45,12 +127,14 @@ public static class TestEnvironment
? AzureLocation.WestUS
: Enum.Parse<AzureLocation>(Config["AzureLocation"]);

public static bool IsEmulator { get; } = !UseTokenCredential && (AuthToken == _emulatorAuthToken);
public static bool IsEmulator => !UseTokenCredential && (AuthToken == _emulatorAuthToken);

public static bool SkipConnectionCheck { get; } = string.Equals(Config["SkipConnectionCheck"], "true", StringComparison.OrdinalIgnoreCase);

public static string EmulatorType { get; } = Config["EmulatorType"] ?? (!OperatingSystem.IsWindows() ? "linux" : "");
public static string EmulatorType => IsTestContainer
? "linux"
: Config["EmulatorType"] ?? (!OperatingSystem.IsWindows() ? "linux" : "");

public static bool IsLinuxEmulator { get; } = IsEmulator
public static bool IsLinuxEmulator => IsEmulator
&& EmulatorType.Equals("linux", StringComparison.OrdinalIgnoreCase);
}
2 changes: 1 addition & 1 deletion test/EFCore.Cosmos.FunctionalTests/cosmosConfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"Test": {
"Cosmos": {
"DefaultConnection": "https://localhost:8081",
"DefaultConnection": null,
"AuthToken": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
}
}
Expand Down
Loading