Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 3 additions & 8 deletions .agents/skills/cosmos-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,7 @@ 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`

These scripts can be invoked locally for testing on machines that don't have the emulator installed, but have docker available.

The `Test__Cosmos__SkipConnectionCheck=true` env var is set to prevent tests from being skipped when the emulator failed to start.
- `TestEnvironment.InitializeAsync()` auto-starts a `Testcontainers.CosmosDb` container when `Test__Cosmos__DefaultConnection` is not set. Set the env var to use an existing emulator instead.
- Skip tests requiring unsupported features on the Linux emulator with `[CosmosCondition(CosmosCondition.IsNotLinuxEmulator)]`.
2 changes: 2 additions & 0 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ 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__DefaultConnection=https://localhost:8081" >> "$GITHUB_ENV"
Comment thread
AndriySvyryd marked this conversation as resolved.
echo "Test__Cosmos__EmulatorType=linux" >> "$GITHUB_ENV"
Comment thread
AndriySvyryd marked this conversation as resolved.
echo "Test__Cosmos__SkipConnectionCheck=true" >> "$GITHUB_ENV"
echo "DOTNET_ROOT=$PWD/.dotnet/" >> "$GITHUB_ENV"
echo "$PWD/.dotnet/" >> $GITHUB_PATH
13 changes: 7 additions & 6 deletions eng/helix.proj
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,12 @@
<PreCommands>$(PreCommands); SqlLocalDB start</PreCommands>
</XUnitProject>
</ItemGroup>

<ItemGroup Condition = "'$(HelixTargetQueue.StartsWith(`Windows.11`))'">
Comment thread
AndriySvyryd marked this conversation as resolved.
<XUnitProject Remove="$(CosmosTests)"/>
</ItemGroup>

<ItemGroup Condition = "'$(HelixTargetQueue.StartsWith(`Windows.Server2025.Amd64`))'">
<XUnitProject Update="$(CosmosTests)">
<PreCommands>$(PreCommands); set Test__Cosmos__SkipConnectionCheck=true</PreCommands>
</XUnitProject>
Expand All @@ -80,14 +84,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
61 changes: 0 additions & 61 deletions eng/testing/run-cosmos-container.ps1

This file was deleted.

51 changes: 0 additions & 51 deletions eng/testing/run-cosmos-container.sh

This file was deleted.

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 @@ -7,16 +7,25 @@ namespace Microsoft.EntityFrameworkCore.TestUtilities;

public static class CosmosDbContextOptionsBuilderExtensions
{
private static HttpMessageHandler? _handler;
private static Func<HttpClient>? _httpClientFactory;

public static CosmosDbContextOptionsBuilder ApplyConfiguration(this CosmosDbContextOptionsBuilder optionsBuilder)
{
if (_httpClientFactory == null)
{
_handler = TestEnvironment.HttpMessageHandler
?? new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
_httpClientFactory = () => new HttpClient(_handler, disposeHandler: false);
}
Comment thread
AndriySvyryd marked this conversation as resolved.

optionsBuilder
.ExecutionStrategy(d => new TestCosmosExecutionStrategy(d))
.RequestTimeout(TimeSpan.FromMinutes(20))
.HttpClientFactory(() => new HttpClient(
new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
}))
.HttpClientFactory(_httpClientFactory)
.ConnectionMode(ConnectionMode.Gateway);

return optionsBuilder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ private static string CreateName(string name)
? name
: name + _runId;

public string ConnectionUri { get; }
public string ConnectionUri { get; private set; }
public string AuthToken { get; }
public TokenCredential TokenCredential { get; }
public string ConnectionString { get; }
public string ConnectionString { get; private set; }

private static readonly SemaphoreSlim _connectionSemaphore = new(1, 1);

Expand Down Expand Up @@ -175,6 +175,12 @@ private static bool IsNotConfigured(Exception exception)

protected override async Task InitializeAsync(Func<DbContext> createContext, Func<DbContext, Task>? seed, Func<DbContext, Task>? clean)
{
await TestEnvironment.InitializeAsync().ConfigureAwait(false);

// Update connection details in case InitializeAsync changed them (e.g., testcontainer started).
ConnectionUri = TestEnvironment.DefaultConnection;
ConnectionString = TestEnvironment.ConnectionString;

Comment thread
AndriySvyryd marked this conversation as resolved.
_initialized = true;

if (_connectionAvailable == false)
Expand Down
122 changes: 117 additions & 5 deletions test/EFCore.Cosmos.FunctionalTests/TestUtilities/TestEnvironment.cs
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,124 @@ public static class TestEnvironment
.Build()
.GetSection("Test:Cosmos");

public static string DefaultConnection { get; } = string.IsNullOrEmpty(Config["DefaultConnection"])
private static CosmosDbContainer _container;
private static bool _initialized;
private static readonly SemaphoreSlim _initSemaphore = new(1, 1);

public static string DefaultConnection { get; private set; } = string.IsNullOrEmpty(Config["DefaultConnection"])
? "https://localhost:8081"
: Config["DefaultConnection"];

internal static HttpMessageHandler HttpMessageHandler { get; private set; }

public static async Task InitializeAsync()
{
if (_initialized)
{
return;
}

await _initSemaphore.WaitAsync().ConfigureAwait(false);

try
{
if (_initialized)
{
return;
}

// If a connection string is specified (env var, config.json...), always use that.
var configured = Config["DefaultConnection"];
if (!string.IsNullOrEmpty(configured))
{
DefaultConnection = configured;
_initialized = true;
return;
}

// Try to connect to the default emulator endpoint (e.g. Windows emulator or
// a manually-started Docker container).
if (await TryProbeEmulatorAsync("https://localhost:8081").ConfigureAwait(false))
{
DefaultConnection = "https://localhost:8081";
_initialized = true;
return;
}

// Start a testcontainer with the Linux emulator.
CosmosDbContainer container;
try
{
container = new CosmosDbBuilder("mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview")
.Build();
await container.StartAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
throw new InvalidOperationException(
"Failed to start the Cosmos DB emulator testcontainer. "
+ "Ensure that either the Cosmos DB emulator is running on localhost:8081, "
+ "or Docker is installed and running, "
+ "or set the 'Test__Cosmos__DefaultConnection' environment variable to connect to "
+ "an existing emulator or Cosmos DB instance.",
ex);
}

_container = container;

AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
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.
}
};

DefaultConnection = new UriBuilder(
Uri.UriSchemeHttp,
_container.Hostname,
_container.GetMappedPublicPort(CosmosDbBuilder.CosmosDbPort)).ToString();
HttpMessageHandler = _container.HttpMessageHandler;

_initialized = true;
}
finally
{
_initSemaphore.Release();
}
}

private static async Task<bool> TryProbeEmulatorAsync(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 = await client.GetAsync(endpoint).ConfigureAwait(false);
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 +155,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 => _container != null
? "linux"
: Config["EmulatorType"] ?? (!OperatingSystem.IsWindows() ? "linux" : "");

public static bool IsLinuxEmulator { get; } = IsEmulator
public static bool IsLinuxEmulator => IsEmulator
&& EmulatorType.Equals("linux", StringComparison.OrdinalIgnoreCase);
}
Loading
Loading