From 66ee81ad8ba2cb6cebc48f88e6255d8e6a4b1393 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 14:08:21 +0300 Subject: [PATCH 1/3] test: run the public entry points as a Native AOT test executable Add LocalStack.Client.NativeAot.Tests (xUnit v3 4.0.2 AOT, net10.0/net11.0). It is published with PublishAot and run as a native executable, so trim/AOT warnings fail the publish (TreatWarningsAsErrors) and the tests prove the existing calls work with no consumer code change: - AddLocalStack + AddAwsService + resolve, as consumers write it - every AddAwsService/AddAWSServiceLocalStack/TryAdd* overload - Session generic and Type-based interface/implementation paths, and SessionStandalone, for S3 and SQS - live S3 and SQS round trips when LOCALSTACK_NATIVE_AOT_HOST is set CI gets a native-aot-tests job (windows-latest, ubuntu-22.04); Linux starts LocalStack 4.14.0 for the live tests. The Cake test task skips the project because it is not a dotnet test target. --- .github/workflows/ci-cd.yml | 49 ++++ Directory.Packages.props | 1 + LocalStack.slnx | 1 + build/LocalStack.Build/CakeTasks/TestTask.cs | 7 + .../LocalStack.Client.NativeAot.Tests.csproj | 24 ++ .../NativeAotTests.cs | 245 ++++++++++++++++++ .../appsettings.json | 17 ++ 7 files changed, 344 insertions(+) create mode 100644 tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj create mode 100644 tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs create mode 100644 tests/LocalStack.Client.NativeAot.Tests/appsettings.json diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index acf073d..67a8cf0 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -130,6 +130,55 @@ jobs: **/TestResults/**/* retention-days: 7 + native-aot-tests: + name: "Native AOT Tests (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-22.04] + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Setup .NET SDK" + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 10.0.x + global-json-file: global.json + + - name: "Start LocalStack (Linux)" + if: runner.os == 'Linux' + shell: bash + run: | + docker run -d -p 4566:4566 localstack/localstack:4.14.0 + if ! timeout 120s bash -c 'until curl --fail --silent --max-time 1 http://localhost:4566/_localstack/health; do sleep 1; done'; then + echo "LocalStack did not become ready within 120 seconds" + exit 1 + fi + echo "LOCALSTACK_NATIVE_AOT_HOST=localhost" >> "$GITHUB_ENV" + + - name: "Publish and run net10.0 Native AOT tests" + shell: pwsh + run: | + $rid = '${{ runner.os == 'Windows' && 'win-x64' || 'linux-x64' }}' + dotnet publish tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj -c Release -f net10.0 -r $rid -o artifacts/nativeaot/net10.0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $exe = if ($IsWindows) { 'LocalStack.Client.NativeAot.Tests.exe' } else { 'LocalStack.Client.NativeAot.Tests' } + & "./artifacts/nativeaot/net10.0/$exe" + exit $LASTEXITCODE + + - name: "Publish and run net11.0 Native AOT tests" + shell: pwsh + run: | + $rid = '${{ runner.os == 'Windows' && 'win-x64' || 'linux-x64' }}' + dotnet publish tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj -c Release -f net11.0 -r $rid -o artifacts/nativeaot/net11.0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $exe = if ($IsWindows) { 'LocalStack.Client.NativeAot.Tests.exe' } else { 'LocalStack.Client.NativeAot.Tests' } + & "./artifacts/nativeaot/net11.0/$exe" + exit $LASTEXITCODE + continuous-deployment: name: "Continuous Deployment" runs-on: ubuntu-22.04 diff --git a/Directory.Packages.props b/Directory.Packages.props index b06559e..3c8e08f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -175,6 +175,7 @@ + all diff --git a/LocalStack.slnx b/LocalStack.slnx index b9d215a..b4b6a9d 100644 --- a/LocalStack.slnx +++ b/LocalStack.slnx @@ -7,6 +7,7 @@ + diff --git a/build/LocalStack.Build/CakeTasks/TestTask.cs b/build/LocalStack.Build/CakeTasks/TestTask.cs index be8e669..674878c 100644 --- a/build/LocalStack.Build/CakeTasks/TestTask.cs +++ b/build/LocalStack.Build/CakeTasks/TestTask.cs @@ -27,6 +27,13 @@ public override void Run(BuildContext context) foreach (string targetFramework in testProj.TargetFrameworks) { + if (testProj.AssemblyName == "LocalStack.Client.NativeAot.Tests") + { + ConsoleHelper.WriteWarning("Skipping Native AOT Tests (run as a published executable in CI)"); + + continue; + } + if (context.SkipFunctionalTest && testProj.AssemblyName == "LocalStack.Client.Functional.Tests") { ConsoleHelper.WriteWarning("Skipping Functional Tests"); diff --git a/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj b/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj new file mode 100644 index 0000000..31cf610 --- /dev/null +++ b/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj @@ -0,0 +1,24 @@ + + + net10.0;net11.0 + Exe + true + false + + + + + + + + + + + + + + + + + + diff --git a/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs b/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs new file mode 100644 index 0000000..e9c7067 --- /dev/null +++ b/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Threading.Tasks; +using Amazon; +using Amazon.Extensions.NETCore.Setup; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using LocalStack.Client.Contracts; +using LocalStack.Client.Enums; +using LocalStack.Client.Extensions; +using LocalStack.Client.Options; +using LocalStack.Client.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LocalStack.Client.NativeAot.Tests; + +public sealed class NativeAotTests +{ + // The Type-based overloads are part of the public contract under test. + private static readonly Type S3Interface = typeof(IAmazonS3); + private static readonly Type S3Implementation = typeof(AmazonS3Client); + private static readonly Type SqsInterface = typeof(IAmazonSQS); + private static readonly Type SqsImplementation = typeof(AmazonSQSClient); + + [Fact] + public void ExistingConsumerCallsConfigureS3() + { + using ConfigurationRoot configuration = LoadConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + services.AddAwsService(); + using ServiceProvider provider = services.BuildServiceProvider(); + var s3 = provider.GetRequiredService(); + + var client = Assert.IsType(s3); + AssertClient(client, provider.GetRequiredService(), AwsService.S3, useServiceUrl: false); + Assert.Equal("eu-central-1", s3.Config.RegionEndpoint.SystemName); + } + + [Theory] + [InlineData("add", false)] + [InlineData("add", true)] + [InlineData("add-options", false)] + [InlineData("add-options", true)] + [InlineData("alias", false)] + [InlineData("alias", true)] + [InlineData("alias-options", false)] + [InlineData("alias-options", true)] + [InlineData("try-add", false)] + [InlineData("try-add", true)] + [InlineData("try-add-options", false)] + [InlineData("try-add-options", true)] + [InlineData("try-alias", false)] + [InlineData("try-alias", true)] + [InlineData("try-alias-options", false)] + [InlineData("try-alias-options", true)] + public static void EveryRegistrationOverloadConfiguresS3(string overload, bool useServiceUrl) + { + using ConfigurationRoot configuration = LoadConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + var options = new AWSOptions { Region = RegionEndpoint.USWest2, Credentials = new AnonymousAWSCredentials() }; + const ServiceLifetime lifetime = ServiceLifetime.Transient; + + switch (overload) + { + case "add": + services.AddAwsService(lifetime, useServiceUrl); + break; + case "add-options": + services.AddAwsService(options, lifetime, useServiceUrl); + break; + case "alias": + services.AddAWSServiceLocalStack(lifetime, useServiceUrl); + break; + case "alias-options": + services.AddAWSServiceLocalStack(options, lifetime, useServiceUrl); + break; + case "try-add": + services.TryAddAwsService(lifetime, useServiceUrl); + break; + case "try-add-options": + services.TryAddAwsService(options, lifetime, useServiceUrl); + break; + case "try-alias": + services.TryAddAWSServiceLocalStack(lifetime, useServiceUrl); + break; + case "try-alias-options": + services.TryAddAWSServiceLocalStack(options, lifetime, useServiceUrl); + break; + default: + throw new ArgumentOutOfRangeException(nameof(overload)); + } + + using ServiceProvider provider = services.BuildServiceProvider(); + var client = Assert.IsType(provider.GetRequiredService()); + AssertClient(client, provider.GetRequiredService(), AwsService.S3, useServiceUrl); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public static void SessionPathsConfigureS3(bool useServiceUrl, bool useSsl, bool standalone) + { + var configOptions = new ConfigOptions(localStackHost: "native-aot-host", useSsl: useSsl, edgePort: 4566); + var config = new Config(configOptions); + ISession session = CreateSession(configOptions, standalone); + + using AmazonServiceClient genericInterface = session.CreateClientByInterface(useServiceUrl); + using AmazonServiceClient typeInterface = session.CreateClientByInterface(S3Interface, useServiceUrl); + using AmazonS3Client genericImplementation = session.CreateClientByImplementation(useServiceUrl); + using AmazonServiceClient typeImplementation = session.CreateClientByImplementation(S3Implementation, useServiceUrl); + Assert.IsType(genericInterface); + Assert.IsType(typeInterface); + Assert.IsType(genericImplementation); + Assert.IsType(typeImplementation); + AssertClient(genericInterface, config, AwsService.S3, useServiceUrl); + AssertClient(typeInterface, config, AwsService.S3, useServiceUrl); + AssertClient(genericImplementation, config, AwsService.S3, useServiceUrl); + AssertClient(typeImplementation, config, AwsService.S3, useServiceUrl); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public static void SessionPathsConfigureSqs(bool useServiceUrl, bool useSsl, bool standalone) + { + var configOptions = new ConfigOptions(localStackHost: "native-aot-host", useSsl: useSsl, edgePort: 4566); + var config = new Config(configOptions); + ISession session = CreateSession(configOptions, standalone); + + using AmazonServiceClient genericInterface = session.CreateClientByInterface(useServiceUrl); + using AmazonServiceClient typeInterface = session.CreateClientByInterface(SqsInterface, useServiceUrl); + using AmazonSQSClient genericImplementation = session.CreateClientByImplementation(useServiceUrl); + using AmazonServiceClient typeImplementation = session.CreateClientByImplementation(SqsImplementation, useServiceUrl); + Assert.IsType(genericInterface); + Assert.IsType(typeInterface); + Assert.IsType(genericImplementation); + Assert.IsType(typeImplementation); + AssertClient(genericInterface, config, AwsService.Sqs, useServiceUrl); + AssertClient(typeInterface, config, AwsService.Sqs, useServiceUrl); + AssertClient(genericImplementation, config, AwsService.Sqs, useServiceUrl); + AssertClient(typeImplementation, config, AwsService.Sqs, useServiceUrl); + } + + [Fact] + public async Task S3BucketRoundTrip() + { + using ConfigurationRoot configuration = LoadLiveConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + services.AddAwsService(); + using ServiceProvider provider = services.BuildServiceProvider(); + var s3 = provider.GetRequiredService(); + string bucket = $"native-aot-{Guid.NewGuid():N}"; + + await s3.PutBucketAsync(new PutBucketRequest { BucketName = bucket }, TestContext.Current.CancellationToken); + ListBucketsResponse response = await s3.ListBucketsAsync(new ListBucketsRequest(), TestContext.Current.CancellationToken); + Assert.Contains(response.Buckets, item => string.Equals(bucket, item.BucketName, StringComparison.Ordinal)); + } + + [Fact] + public async Task SqsQueueRoundTrip() + { + using ConfigurationRoot configuration = LoadLiveConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + services.AddAwsService(); + using ServiceProvider provider = services.BuildServiceProvider(); + var sqs = provider.GetRequiredService(); + string queue = $"native-aot-{Guid.NewGuid():N}"; + + CreateQueueResponse created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = queue }, TestContext.Current.CancellationToken); + ListQueuesResponse response = await sqs.ListQueuesAsync(new ListQueuesRequest { QueueNamePrefix = queue }, TestContext.Current.CancellationToken); + Assert.EndsWith($"/{queue}", created.QueueUrl, StringComparison.Ordinal); + Assert.Contains(response.QueueUrls, url => string.Equals(created.QueueUrl, url, StringComparison.Ordinal)); + } + + private static ConfigurationRoot LoadConfiguration() => (ConfigurationRoot)new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .AddEnvironmentVariables() + .Build(); + + private static ConfigurationRoot LoadLiveConfiguration() + { + string? host = Environment.GetEnvironmentVariable("LOCALSTACK_NATIVE_AOT_HOST"); + if (string.IsNullOrEmpty(host)) + { + Assert.Skip("LOCALSTACK_NATIVE_AOT_HOST is not set"); + } + + ConfigurationRoot configuration = LoadConfiguration(); + configuration["LocalStack:Config:LocalStackHost"] = host; + return configuration; + } + + private static ISession CreateSession(ConfigOptions configOptions, bool standalone) + { + var sessionOptions = new SessionOptions("native-aot-id", "native-aot-secret", "native-aot-token", "eu-central-1"); + return standalone + ? SessionStandalone.Init().WithSessionOptions(sessionOptions).WithConfigurationOptions(configOptions).Create() + : new Session(sessionOptions, new Config(configOptions), new SessionReflection()); + } + + private static void AssertClient(AmazonServiceClient client, IConfig config, AwsService service, bool useServiceUrl) + { + var endpoint = config.GetAwsServiceEndpoint(service); + Assert.NotNull(endpoint); + if (useServiceUrl) + { + Assert.Equal(endpoint.ServiceUrl.AbsoluteUri, client.Config.ServiceURL); + Assert.Null(client.Config.RegionEndpoint); + } + else + { + Assert.Null(client.Config.ServiceURL); + Assert.Equal("eu-central-1", client.Config.RegionEndpoint.SystemName); + } + + Assert.Equal(!config.GetConfigOptions().UseSsl, client.Config.UseHttp); + Assert.Equal(endpoint.Host, client.Config.ProxyHost); + Assert.Equal(endpoint.Port, client.Config.ProxyPort); + if (service == AwsService.S3) + { + Assert.True(Assert.IsType(client.Config).ForcePathStyle); + } + } +} diff --git a/tests/LocalStack.Client.NativeAot.Tests/appsettings.json b/tests/LocalStack.Client.NativeAot.Tests/appsettings.json new file mode 100644 index 0000000..b70ac8d --- /dev/null +++ b/tests/LocalStack.Client.NativeAot.Tests/appsettings.json @@ -0,0 +1,17 @@ +{ + "LocalStack": { + "UseLocalStack": true, + "Session": { + "AwsAccessKeyId": "native-aot-id", + "AwsAccessKey": "native-aot-secret", + "AwsSessionToken": "native-aot-token", + "RegionName": "eu-central-1" + }, + "Config": { + "LocalStackHost": "localhost", + "UseSsl": false, + "UseLegacyPorts": false, + "EdgePort": 4566 + } + } +} From 76072badee2856da9414f461503d5eace29ce81e Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 22:30:28 +0300 Subject: [PATCH 2/3] test: name the Native AOT lane tests by the Should convention --- .../NativeAotTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs b/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs index e9c7067..4aae2ac 100644 --- a/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs +++ b/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs @@ -27,7 +27,7 @@ public sealed class NativeAotTests private static readonly Type SqsImplementation = typeof(AmazonSQSClient); [Fact] - public void ExistingConsumerCallsConfigureS3() + public void AddAwsService_Should_Configure_S3_For_LocalStack_With_Existing_Consumer_Calls() { using ConfigurationRoot configuration = LoadConfiguration(); var services = new ServiceCollection(); @@ -58,7 +58,7 @@ public void ExistingConsumerCallsConfigureS3() [InlineData("try-alias", true)] [InlineData("try-alias-options", false)] [InlineData("try-alias-options", true)] - public static void EveryRegistrationOverloadConfiguresS3(string overload, bool useServiceUrl) + public static void Registration_Overloads_Should_Configure_S3_For_LocalStack(string overload, bool useServiceUrl) { using ConfigurationRoot configuration = LoadConfiguration(); var services = new ServiceCollection(); @@ -110,7 +110,7 @@ public static void EveryRegistrationOverloadConfiguresS3(string overload, bool u [InlineData(false, true, true)] [InlineData(true, false, true)] [InlineData(true, true, true)] - public static void SessionPathsConfigureS3(bool useServiceUrl, bool useSsl, bool standalone) + public static void Session_Should_Configure_S3_Clients_On_Every_Creation_Path(bool useServiceUrl, bool useSsl, bool standalone) { var configOptions = new ConfigOptions(localStackHost: "native-aot-host", useSsl: useSsl, edgePort: 4566); var config = new Config(configOptions); @@ -139,7 +139,7 @@ public static void SessionPathsConfigureS3(bool useServiceUrl, bool useSsl, bool [InlineData(false, true, true)] [InlineData(true, false, true)] [InlineData(true, true, true)] - public static void SessionPathsConfigureSqs(bool useServiceUrl, bool useSsl, bool standalone) + public static void Session_Should_Configure_Sqs_Clients_On_Every_Creation_Path(bool useServiceUrl, bool useSsl, bool standalone) { var configOptions = new ConfigOptions(localStackHost: "native-aot-host", useSsl: useSsl, edgePort: 4566); var config = new Config(configOptions); @@ -160,7 +160,7 @@ public static void SessionPathsConfigureSqs(bool useServiceUrl, bool useSsl, boo } [Fact] - public async Task S3BucketRoundTrip() + public async Task S3_Should_Create_And_List_A_Bucket_Against_LocalStack() { using ConfigurationRoot configuration = LoadLiveConfiguration(); var services = new ServiceCollection(); @@ -176,7 +176,7 @@ public async Task S3BucketRoundTrip() } [Fact] - public async Task SqsQueueRoundTrip() + public async Task Sqs_Should_Create_And_List_A_Queue_Against_LocalStack() { using ConfigurationRoot configuration = LoadLiveConfiguration(); var services = new ServiceCollection(); From a1af327f731d63a1f8c5a7dfb682670516e8d58b Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 22:30:28 +0300 Subject: [PATCH 3/3] build: drive the Native AOT lane from project properties - The lane's target frameworks come from LocalStackModernTargetFrameworks, and CI publishes and runs every framework MSBuild reports instead of listing net10.0 and net11.0 by hand. - Cake skips test projects that set LocalStackTestRunsAsPublishedExecutable (read through MSBuild with the target frameworks) instead of matching a project name. --- .github/workflows/ci-cd.yml | 25 +++++------ build/LocalStack.Build/BuildContext.cs | 44 +++++++++++++------ build/LocalStack.Build/CakeTasks/TestTask.cs | 13 +++--- build/LocalStack.Build/Models/ProjMetadata.cs | 2 +- .../LocalStack.Client.NativeAot.Tests.csproj | 3 +- 5 files changed, 51 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 67a8cf0..25a6648 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -159,25 +159,20 @@ jobs: fi echo "LOCALSTACK_NATIVE_AOT_HOST=localhost" >> "$GITHUB_ENV" - - name: "Publish and run net10.0 Native AOT tests" + - name: "Publish and run Native AOT tests" shell: pwsh run: | + $project = 'tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj' $rid = '${{ runner.os == 'Windows' && 'win-x64' || 'linux-x64' }}' - dotnet publish tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj -c Release -f net10.0 -r $rid -o artifacts/nativeaot/net10.0 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $exe = if ($IsWindows) { 'LocalStack.Client.NativeAot.Tests.exe' } else { 'LocalStack.Client.NativeAot.Tests' } - & "./artifacts/nativeaot/net10.0/$exe" - exit $LASTEXITCODE - - - name: "Publish and run net11.0 Native AOT tests" - shell: pwsh - run: | - $rid = '${{ runner.os == 'Windows' && 'win-x64' || 'linux-x64' }}' - dotnet publish tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj -c Release -f net11.0 -r $rid -o artifacts/nativeaot/net11.0 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $exe = if ($IsWindows) { 'LocalStack.Client.NativeAot.Tests.exe' } else { 'LocalStack.Client.NativeAot.Tests' } - & "./artifacts/nativeaot/net11.0/$exe" - exit $LASTEXITCODE + # Every modern target framework the project declares (from Directory.Build.props). + $frameworks = (dotnet msbuild $project -getProperty:TargetFrameworks).Split(';', [StringSplitOptions]::RemoveEmptyEntries) + foreach ($framework in $frameworks) { + dotnet publish $project -c Release -f $framework -r $rid -o "artifacts/nativeaot/$framework" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "./artifacts/nativeaot/$framework/$exe" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } continuous-deployment: name: "Continuous Deployment" diff --git a/build/LocalStack.Build/BuildContext.cs b/build/LocalStack.Build/BuildContext.cs index 35062f9..5c77e89 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -174,11 +174,13 @@ public IEnumerable GetProjMetadata() { string csProjPath = csProj.FullPath; - IEnumerable targetFrameworks = GetProjectTargetFrameworks(csProjPath); + Dictionary properties = GetProjectProperties(csProjPath, "TargetFrameworks", "TargetFramework", RunsAsPublishedExecutableProperty); + IEnumerable targetFrameworks = SplitTargetFrameworks(properties); string directoryPath = csProj.GetDirectory().FullPath; string assemblyName = GetAssemblyName(csProjPath); + bool runsAsPublishedExecutable = string.Equals(properties[RunsAsPublishedExecutableProperty], "true", StringComparison.OrdinalIgnoreCase); - var testProjMetadata = new ProjMetadata(directoryPath, csProjPath, targetFrameworks, assemblyName); + var testProjMetadata = new ProjMetadata(directoryPath, csProjPath, targetFrameworks, assemblyName, runsAsPublishedExecutable); projMetadata.Add(testProjMetadata); } @@ -385,8 +387,21 @@ private static string ToPreReleaseIdentifier(string identifier) return NuGetVersion.TryParse($"0.0.0-{sanitised}", out NuGetVersion? _) ? sanitised : $"g{sanitised}"; } - // Asks MSBuild for the evaluated frameworks, so lists built from Directory.Build.props properties resolve. - private static string[] GetProjectTargetFrameworks(string csprojPath) + // Test projects that set this property are published and run as native executables by CI, not by 'dotnet test'. + private const string RunsAsPublishedExecutableProperty = "LocalStackTestRunsAsPublishedExecutable"; + + private static string[] GetProjectTargetFrameworks(string csprojPath) => + SplitTargetFrameworks(GetProjectProperties(csprojPath, "TargetFrameworks", "TargetFramework")); + + private static string[] SplitTargetFrameworks(Dictionary properties) + { + string frameworks = string.IsNullOrWhiteSpace(properties["TargetFrameworks"]) ? properties["TargetFramework"] : properties["TargetFrameworks"]; + + return frameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + // Asks MSBuild for evaluated property values, so values built from Directory.Build.props properties resolve. + private static Dictionary GetProjectProperties(string csprojPath, params string[] propertyNames) { string dotnetHost = System.Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? throw new InvalidOperationException("DOTNET_HOST_PATH is not set; run the build through the dotnet CLI."); @@ -400,8 +415,11 @@ private static string[] GetProjectTargetFrameworks(string csprojPath) }; startInfo.ArgumentList.Add("msbuild"); startInfo.ArgumentList.Add(csprojPath); - startInfo.ArgumentList.Add("-getProperty:TargetFrameworks"); - startInfo.ArgumentList.Add("-getProperty:TargetFramework"); + + foreach (string propertyName in propertyNames) + { + startInfo.ArgumentList.Add($"-getProperty:{propertyName}"); + } using System.Diagnostics.Process process = System.Diagnostics.Process.Start(startInfo) ?? throw new InvalidOperationException($"Could not start '{dotnetHost} msbuild'."); @@ -414,16 +432,16 @@ private static string[] GetProjectTargetFrameworks(string csprojPath) throw new InvalidOperationException($"'dotnet msbuild {csprojPath}' exited with code {process.ExitCode}: {output}{error}"); } - using var document = System.Text.Json.JsonDocument.Parse(output); - System.Text.Json.JsonElement properties = document.RootElement.GetProperty("Properties"); - string? frameworks = properties.GetProperty("TargetFrameworks").GetString(); - - if (string.IsNullOrWhiteSpace(frameworks)) + // With one property MSBuild prints the bare value; with several it prints JSON. + if (propertyNames.Length == 1) { - frameworks = properties.GetProperty("TargetFramework").GetString(); + return new Dictionary(StringComparer.Ordinal) { [propertyNames[0]] = output.Trim() }; } - return (frameworks ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + using var document = System.Text.Json.JsonDocument.Parse(output); + System.Text.Json.JsonElement properties = document.RootElement.GetProperty("Properties"); + + return propertyNames.ToDictionary(name => name, name => properties.GetProperty(name).GetString() ?? string.Empty, StringComparer.Ordinal); } private string GetAssemblyName(string csprojPath) diff --git a/build/LocalStack.Build/CakeTasks/TestTask.cs b/build/LocalStack.Build/CakeTasks/TestTask.cs index 674878c..bb2a501 100644 --- a/build/LocalStack.Build/CakeTasks/TestTask.cs +++ b/build/LocalStack.Build/CakeTasks/TestTask.cs @@ -25,14 +25,15 @@ public override void Run(BuildContext context) ConsoleHelper.WriteInfo($"Target Frameworks: {targetFrameworks}"); - foreach (string targetFramework in testProj.TargetFrameworks) + if (testProj.RunsAsPublishedExecutable) { - if (testProj.AssemblyName == "LocalStack.Client.NativeAot.Tests") - { - ConsoleHelper.WriteWarning("Skipping Native AOT Tests (run as a published executable in CI)"); + ConsoleHelper.WriteWarning($"Skipping {testProj.AssemblyName}: CI publishes and runs it as a native executable"); - continue; - } + continue; + } + + foreach (string targetFramework in testProj.TargetFrameworks) + { if (context.SkipFunctionalTest && testProj.AssemblyName == "LocalStack.Client.Functional.Tests") { diff --git a/build/LocalStack.Build/Models/ProjMetadata.cs b/build/LocalStack.Build/Models/ProjMetadata.cs index 38783f2..7e7aaf0 100644 --- a/build/LocalStack.Build/Models/ProjMetadata.cs +++ b/build/LocalStack.Build/Models/ProjMetadata.cs @@ -2,4 +2,4 @@ namespace LocalStack.Build.Models; -public record ProjMetadata(string DirectoryPath, string CsProjPath, IEnumerable TargetFrameworks, string AssemblyName); \ No newline at end of file +public record ProjMetadata(string DirectoryPath, string CsProjPath, IEnumerable TargetFrameworks, string AssemblyName, bool RunsAsPublishedExecutable); \ No newline at end of file diff --git a/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj b/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj index 31cf610..6d7d62a 100644 --- a/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj +++ b/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj @@ -1,7 +1,8 @@ - net10.0;net11.0 + $(LocalStackModernTargetFrameworks) Exe + true true false