diff --git a/all.sln b/all.sln index 87dd937bc..05f8cd5e6 100644 --- a/all.sln +++ b/all.sln @@ -254,6 +254,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Crypto EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Actors", "test\Dapr.IntegrationTest.Actors\Dapr.IntegrationTest.Actors.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Actors.Generators", "test\Dapr.IntegrationTest.Actors.Generators\Dapr.IntegrationTest.Actors.Generators.csproj", "{C6948155-C70C-4C03-A733-0624402AEC97}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowRetryPolicy", "examples\Workflow\WorkflowRetryPolicy\WorkflowRetryPolicy.csproj", "{6C77A2C4-0A96-4B90-AFEA-16E86A906259}" EndProject Global @@ -1490,6 +1492,18 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Debug|x64.Build.0 = Debug|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Debug|x86.Build.0 = Debug|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Release|Any CPU.Build.0 = Release|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Release|x64.ActiveCfg = Release|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Release|x64.Build.0 = Release|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Release|x86.ActiveCfg = Release|Any CPU + {C6948155-C70C-4C03-A733-0624402AEC97}.Release|x86.Build.0 = Release|Any CPU {6C77A2C4-0A96-4B90-AFEA-16E86A906259}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6C77A2C4-0A96-4B90-AFEA-16E86A906259}.Debug|Any CPU.Build.0 = Debug|Any CPU {6C77A2C4-0A96-4B90-AFEA-16E86A906259}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1625,6 +1639,7 @@ Global {01A20A89-53A1-4D5B-B563-89E157718474} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} {7B14879F-156B-417E-ACA3-0B5A69CC2F39} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {C6948155-C70C-4C03-A733-0624402AEC97} = {DD020B34-460F-455F-8D17-CF4A949F100B} {6C77A2C4-0A96-4B90-AFEA-16E86A906259} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/test/Dapr.IntegrationTest.Actors.Generators/Actors/IClientActor.cs b/test/Dapr.IntegrationTest.Actors.Generators/Actors/IClientActor.cs new file mode 100644 index 000000000..9e9e04a64 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/Actors/IClientActor.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Generators; + +namespace Dapr.IntegrationTest.Actors.Generators.Actors; + +/// +/// State record used by the client-side generated actor client. +/// +public record ClientState(string Value); + +/// +/// Actor interface decorated with to trigger +/// source generation of a strongly-typed client (ClientActorClient). +/// The methods use to map to the actual server-side +/// actor method names. +/// +[GenerateActorClient] +public interface IClientActor +{ + /// + /// Gets the current state from the remote actor. + /// + [ActorMethod(Name = "GetState")] + Task GetStateAsync(CancellationToken cancellationToken = default); + + /// + /// Sets the state on the remote actor. + /// + [ActorMethod(Name = "SetState")] + Task SetStateAsync(ClientState state, CancellationToken cancellationToken = default); + + /// + /// Calls the SayHello method on the remote actor. + /// + [ActorMethod(Name = "SayHello")] + Task SayHelloAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Calls the IncrementCallCount method on the remote actor. + /// + [ActorMethod(Name = "IncrementCallCount")] + Task IncrementCallCountAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the call count from the remote actor. + /// + [ActorMethod(Name = "GetCallCount")] + Task GetCallCountAsync(CancellationToken cancellationToken = default); +} diff --git a/test/Dapr.IntegrationTest.Actors.Generators/Actors/IPingActor.cs b/test/Dapr.IntegrationTest.Actors.Generators/Actors/IPingActor.cs new file mode 100644 index 000000000..f912943f1 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/Actors/IPingActor.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.Generators; + +/// +/// Minimal actor interface used as a readiness probe. +/// +public interface IPingActor : IActor +{ + /// + /// Pings the actor to verify that the runtime is available. + /// + /// + /// A token that cancels the underlying HTTP request, allowing the caller to impose a + /// per-attempt timeout instead of waiting for the HttpClient default (100 s). + /// + Task Ping(CancellationToken cancellationToken = default); +} diff --git a/test/Dapr.IntegrationTest.Actors.Generators/Actors/IRemoteActor.cs b/test/Dapr.IntegrationTest.Actors.Generators/Actors/IRemoteActor.cs new file mode 100644 index 000000000..3e8c968a9 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/Actors/IRemoteActor.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; + +namespace Dapr.IntegrationTest.Actors.Generators.Actors; + +/// +/// State record used by the remote actor for get/set operations. +/// +public record RemoteState(string Value); + +/// +/// Actor interface for the server-side actor that manages state. +/// Extends so the runtime readiness check can be performed. +/// +public interface IRemoteActor : IPingActor +{ + /// + /// Returns the current state of the actor. + /// + Task GetState(); + + /// + /// Sets the state of the actor. + /// + /// The new state to set. + Task SetState(RemoteState state); + + /// + /// Returns a greeting message for the specified name. + /// + /// The name to greet. + Task SayHello(string name); + + /// + /// A fire-and-forget method with no parameters and no return value. + /// + Task IncrementCallCount(); + + /// + /// Returns the current call count. + /// + Task GetCallCount(); +} diff --git a/test/Dapr.IntegrationTest.Actors.Generators/Actors/RemoteActor.cs b/test/Dapr.IntegrationTest.Actors.Generators/Actors/RemoteActor.cs new file mode 100644 index 000000000..89e69b6c4 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/Actors/RemoteActor.cs @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; +using Microsoft.Extensions.Logging; + +namespace Dapr.IntegrationTest.Actors.Generators.Actors; + +/// +/// Implementation of that manages state in memory. +/// +internal sealed partial class RemoteActor(ActorHost host, ILogger logger) : Actor(host), IRemoteActor +{ + private RemoteState currentState = new("default"); + + private int callCount; + + /// + public Task GetState() + { + logger.LogGetState(); + return Task.FromResult(this.currentState); + } + + /// + public Task SetState(RemoteState state) + { + logger.LogSetState(); + this.currentState = state; + return Task.CompletedTask; + } + + /// + public Task SayHello(string name) + { + logger.LogSayHello(name); + return Task.FromResult($"Hello, {name}!"); + } + + /// + public Task IncrementCallCount() + { + logger.LogIncrementCallCount(); + this.callCount++; + return Task.CompletedTask; + } + + /// + public Task GetCallCount() + { + logger.LogGetCallCount(); + return Task.FromResult(this.callCount); + } + + /// + public Task Ping(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} + +internal static partial class RemoteActorLogMessages +{ + [LoggerMessage(LogLevel.Information, "GetState called.")] + public static partial void LogGetState(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "SetState called.")] + public static partial void LogSetState(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "SayHello called with name: {Name}")] + public static partial void LogSayHello(this ILogger logger, string name); + + [LoggerMessage(LogLevel.Information, "IncrementCallCount called.")] + public static partial void LogIncrementCallCount(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "GetCallCount called.")] + public static partial void LogGetCallCount(this ILogger logger); +} diff --git a/test/Dapr.IntegrationTest.Actors.Generators/Dapr.IntegrationTest.Actors.Generators.csproj b/test/Dapr.IntegrationTest.Actors.Generators/Dapr.IntegrationTest.Actors.Generators.csproj new file mode 100644 index 000000000..53de431f7 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/Dapr.IntegrationTest.Actors.Generators.csproj @@ -0,0 +1,30 @@ + + + + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + diff --git a/test/Dapr.IntegrationTest.Actors.Generators/GeneratedClientTests.cs b/test/Dapr.IntegrationTest.Actors.Generators/GeneratedClientTests.cs new file mode 100644 index 000000000..2ee2deab9 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/GeneratedClientTests.cs @@ -0,0 +1,313 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.Generators.Actors; +using Dapr.IntegrationTest.Actors.Generators.Infrastructure; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors.Generators; + +/// +/// Integration tests that verify the source-generated actor client (ClientActorClient) +/// can correctly invoke actor methods on a real Dapr sidecar and actor runtime. +/// These tests replicate and extend the coverage of the E2E test suite in +/// Dapr.E2E.Test.Actors.Generators. +/// +public sealed class GeneratedClientTests +{ + /// + /// Verifies that the generated client can retrieve the default state from the remote actor. + /// This is equivalent to the first half of the E2E TestGeneratedClientAsync test. + /// + [Fact] + public async Task GeneratedClient_CanGetDefaultState() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var actorId = ActorId.CreateRandom(); + const string actorType = "RemoteActor"; + + // Use the strongly-typed proxy to wait for the runtime to be ready. + var pingProxy = proxyFactory.CreateActorProxy(actorId, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + // Create the generated client through the weakly-typed proxy. + var actorProxy = proxyFactory.Create(actorId, actorType); + var client = new ClientActorClient(actorProxy); + + var state = await client.GetStateAsync(cts.Token); + + Assert.NotNull(state); + Assert.Equal("default", state.Value); + } + + /// + /// Verifies that the generated client can set and then retrieve state from the remote actor. + /// This is equivalent to the E2E TestGeneratedClientAsync test. + /// + [Fact] + public async Task GeneratedClient_CanSetAndGetState() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var actorId = ActorId.CreateRandom(); + const string actorType = "RemoteActor"; + + var pingProxy = proxyFactory.CreateActorProxy(actorId, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var actorProxy = proxyFactory.Create(actorId, actorType); + var client = new ClientActorClient(actorProxy); + + await client.SetStateAsync(new ClientState("updated state"), cts.Token); + + var state = await client.GetStateAsync(cts.Token); + Assert.Equal("updated state", state.Value); + } + + /// + /// Verifies that the generated client correctly maps method names via + /// when the server-side method has a different name from the client-side async method. + /// + [Fact] + public async Task GeneratedClient_RespectsActorMethodNameMapping() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var actorId = ActorId.CreateRandom(); + const string actorType = "RemoteActor"; + + var pingProxy = proxyFactory.CreateActorProxy(actorId, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var actorProxy = proxyFactory.Create(actorId, actorType); + var client = new ClientActorClient(actorProxy); + + // SayHelloAsync on the client maps to SayHello on the server via [ActorMethod(Name = "SayHello")] + var result = await client.SayHelloAsync("World", cts.Token); + Assert.Equal("Hello, World!", result); + } + + /// + /// Verifies that the generated client can invoke a void (fire-and-forget) method + /// with no parameters via the IncrementCallCountAsync method. + /// + [Fact] + public async Task GeneratedClient_CanInvokeVoidMethodWithNoParameters() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var actorId = ActorId.CreateRandom(); + const string actorType = "RemoteActor"; + + var pingProxy = proxyFactory.CreateActorProxy(actorId, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var actorProxy = proxyFactory.Create(actorId, actorType); + var client = new ClientActorClient(actorProxy); + + // Call the void method multiple times. + await client.IncrementCallCountAsync(cts.Token); + await client.IncrementCallCountAsync(cts.Token); + await client.IncrementCallCountAsync(cts.Token); + + // Verify the side-effect via GetCallCount. + var count = await client.GetCallCountAsync(cts.Token); + Assert.Equal(3, count); + } + + /// + /// Verifies that multiple state updates through the generated client are correctly + /// reflected, confirming that the last write wins. + /// + [Fact] + public async Task GeneratedClient_MultipleStateUpdatesReflectLastWrite() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var actorId = ActorId.CreateRandom(); + const string actorType = "RemoteActor"; + + var pingProxy = proxyFactory.CreateActorProxy(actorId, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var actorProxy = proxyFactory.Create(actorId, actorType); + var client = new ClientActorClient(actorProxy); + + await client.SetStateAsync(new ClientState("first"), cts.Token); + await client.SetStateAsync(new ClientState("second"), cts.Token); + await client.SetStateAsync(new ClientState("third"), cts.Token); + + var state = await client.GetStateAsync(cts.Token); + Assert.Equal("third", state.Value); + } + + /// + /// Verifies that two generated clients pointing at different actor IDs maintain + /// independent state — a write to one does not affect the other. + /// + [Fact] + public async Task GeneratedClient_DifferentActorIdsHaveIndependentState() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + const string actorType = "RemoteActor"; + + var actorId1 = ActorId.CreateRandom(); + var actorId2 = ActorId.CreateRandom(); + + var pingProxy = proxyFactory.CreateActorProxy(actorId1, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var client1 = new ClientActorClient(proxyFactory.Create(actorId1, actorType)); + var client2 = new ClientActorClient(proxyFactory.Create(actorId2, actorType)); + + await client1.SetStateAsync(new ClientState("state-for-actor-1"), cts.Token); + await client2.SetStateAsync(new ClientState("state-for-actor-2"), cts.Token); + + var state1 = await client1.GetStateAsync(cts.Token); + var state2 = await client2.GetStateAsync(cts.Token); + + Assert.Equal("state-for-actor-1", state1.Value); + Assert.Equal("state-for-actor-2", state2.Value); + } + + /// + /// Verifies that the generated client correctly handles cancellation tokens by + /// passing them through to the actor proxy without errors under normal operation. + /// + [Fact] + public async Task GeneratedClient_SupportsCancellationToken() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var actorId = ActorId.CreateRandom(); + const string actorType = "RemoteActor"; + + var pingProxy = proxyFactory.CreateActorProxy(actorId, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var actorProxy = proxyFactory.Create(actorId, actorType); + var client = new ClientActorClient(actorProxy); + + // Create a separate cancellation token to ensure it's properly threaded through. + using var methodCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + + await client.SetStateAsync(new ClientState("cancellation-test"), methodCts.Token); + var state = await client.GetStateAsync(methodCts.Token); + + Assert.Equal("cancellation-test", state.Value); + } + + /// + /// Verifies that the generated client works with JSON serialization enabled, + /// confirming that complex state objects are correctly serialized and deserialized. + /// + [Fact] + public async Task GeneratedClient_WorksWithJsonSerialization() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var actorId = ActorId.CreateRandom(); + const string actorType = "RemoteActor"; + + var pingProxy = proxyFactory.CreateActorProxy(actorId, actorType); + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var actorProxy = proxyFactory.Create(actorId, actorType); + var client = new ClientActorClient(actorProxy); + + // Set a state with special characters to verify JSON handling. + var specialState = new ClientState("value with \"quotes\" and special chars: <>&"); + await client.SetStateAsync(specialState, cts.Token); + + var retrieved = await client.GetStateAsync(cts.Token); + Assert.Equal(specialState.Value, retrieved.Value); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + bool useJsonSerialization, + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-generators-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.UseJsonSerialization = useJsonSerialization; + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors.Generators/Infrastructure/ActorRuntimeHelper.cs b/test/Dapr.IntegrationTest.Actors.Generators/Infrastructure/ActorRuntimeHelper.cs new file mode 100644 index 000000000..c74b34c06 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/Infrastructure/ActorRuntimeHelper.cs @@ -0,0 +1,80 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Dapr.IntegrationTest.Actors.Generators.Infrastructure; + +/// +/// Provides helpers for waiting until the Dapr actor runtime is ready to process requests. +/// +public static class ActorRuntimeHelper +{ + // Per-attempt timeout for each Ping call. Keeping this well below the HttpClient + // default (100 s) prevents a hung placement-registration request from stalling + // the poll loop for a full 100 seconds before the next retry. + private static readonly TimeSpan PingAttemptTimeout = TimeSpan.FromSeconds(5); + + /// + /// Polls until a call succeeds, + /// indicating that the actor runtime has registered the actor type with the placement service. + /// + /// + /// The actor proxy to use for health probing. + /// + /// + /// A token that cancels the polling loop. + /// + /// + /// Thrown when is cancelled before the runtime is ready. + /// + public static async Task WaitForActorRuntimeAsync(IPingActor pingActor, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // Use a short per-attempt timeout so a hung request (e.g. while the Dapr + // placement service is still registering the actor type) does not stall the + // poll loop for the full HttpClient default of 100 seconds. + using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + attemptCts.CancelAfter(PingAttemptTimeout); + + await pingActor.Ping(attemptCts.Token); + return; + } + catch (DaprApiException) + { + // The actor runtime returned an error response – placement may not have + // registered the actor type yet. Retry after a short pause. + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The per-attempt timeout fired (not the outer cancellation token). The + // sidecar accepted the TCP connection but did not respond in time – this + // happens while Dapr is still acquiring a placement token. Retry. + } + catch (HttpRequestException) + { + // Connection-level error – the sidecar may still be starting up. + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + } +} diff --git a/test/Dapr.IntegrationTest.Actors.Generators/Infrastructure/ActorTestContext.cs b/test/Dapr.IntegrationTest.Actors.Generators/Infrastructure/ActorTestContext.cs new file mode 100644 index 000000000..142d262b0 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors.Generators/Infrastructure/ActorTestContext.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Dapr.Testcontainers.Common.Testing; +using Dapr.Testcontainers.Harnesses; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors.Generators.Infrastructure; + +/// +/// Combines a with its owning +/// so that both are disposed together +/// when the test ends. The must outlive +/// the application (placement / scheduler must stay up while the test runs). +/// +public sealed class ActorTestContext : IAsyncDisposable +{ + private readonly DaprTestEnvironment _environment; + private readonly DaprTestApplication _app; + + /// + /// Initializes a new . + /// + internal ActorTestContext(DaprTestEnvironment environment, DaprTestApplication app) + { + _environment = environment; + _app = app; + } + + /// + /// Creates a DI service scope from the running test application. + /// + public IServiceScope CreateScope() => _app.CreateScope(); + + /// + public async ValueTask DisposeAsync() + { + // Dispose the application (and its harness) before shutting down the environment + // so the Dapr sidecar can drain cleanly before placement/scheduler stop. + await _app.DisposeAsync(); + await _environment.DisposeAsync(); + } +}