diff --git a/.github/workflows/itests.yml b/.github/workflows/itests.yml index 8a299bd59..3267783c0 100644 --- a/.github/workflows/itests.yml +++ b/.github/workflows/itests.yml @@ -113,7 +113,7 @@ jobs: - name: Build # disable deterministic builds, just for test run. Deterministic builds break coverage for some reason run: dotnet build --configuration release /p:GITHUB_ACTIONS=false - - name: Run Test + - name: Run General Tests id: tests continue-on-error: true # proceed if tests fail, the report step will report the failure with more details. run: | @@ -128,8 +128,24 @@ jobs: /p:CollectCoverage=true \ /p:CoverletOutputFormat=opencover \ /p:GITHUB_ACTIONS=false + - name: Run Generators Tests + id: generator-tests + continue-on-error: true # proceed if tests fail, the report step will report the failure with more details. + run: | + dotnet test ${{ github.workspace }}/test/Dapr.E2E.Test.Actors.Generators/Dapr.E2E.Test.Actors.Generators.csproj \ + --configuration Release \ + --framework ${{ matrix.framework }} \ + --no-build \ + --no-restore \ + --logger "trx;LogFilePrefix=${{ matrix.prefix }}" \ + --logger "GitHubActions;report-warnings=false" \ + --logger "console;verbosity=detailed" \ + --results-directory "${{ github.workspace }}/TestResults" \ + /p:CollectCoverage=true \ + /p:CoverletOutputFormat=opencover \ + /p:GITHUB_ACTIONS=false - name: Check test failure in PR - if: github.event_name == 'pull_request' && steps.tests.outcome != 'success' + if: github.event_name == 'pull_request' && (steps.tests.outcome != 'success' || steps.generator-tests.outcome != 'success') run: exit 1 - name: Upload test coverage uses: codecov/codecov-action@v1 diff --git a/all.sln b/all.sln index bf0b87352..be5620b64 100644 --- a/all.sln +++ b/all.sln @@ -104,6 +104,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BulkPublishEventExample", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkflowUnitTest", "examples\Workflow\WorkflowUnitTest\WorkflowUnitTest.csproj", "{8CA09061-2BEF-4506-A763-07062D2BD6AC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GeneratedActor", "GeneratedActor", "{7592AFA4-426B-42F3-AE82-957C86814482}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActorClient", "examples\GeneratedActor\ActorClient\ActorClient.csproj", "{61C24126-F39D-4BEA-96DC-FC87BA730554}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActorCommon", "examples\GeneratedActor\ActorCommon\ActorCommon.csproj", "{CB903D21-4869-42EF-BDD6-5B1CFF674337}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Actors.Generators", "src\Dapr.Actors.Generators\Dapr.Actors.Generators.csproj", "{980B5FD8-0107-41F7-8FAD-E4E8BAE8A625}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActorService", "examples\GeneratedActor\ActorService\ActorService.csproj", "{7C06FE2D-6C62-48F5-A505-F0D715C554DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Actors.Generators.Test", "test\Dapr.Actors.Generators.Test\Dapr.Actors.Generators.Test.csproj", "{AF89083D-4715-42E6-93E9-38497D12A8A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.E2E.Test.Actors.Generators", "test\Dapr.E2E.Test.Actors.Generators\Dapr.E2E.Test.Actors.Generators.csproj", "{B5CDB0DC-B26D-48F1-B934-FE5C1C991940}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cryptography", "examples\Client\Cryptography\Cryptography.csproj", "{C74FBA78-13E8-407F-A173-4555AEE41FF3}" EndProject Global @@ -250,6 +263,30 @@ Global {DDC41278-FB60-403A-B969-2AEBD7C2D83C}.Release|Any CPU.Build.0 = Release|Any CPU {8CA09061-2BEF-4506-A763-07062D2BD6AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8CA09061-2BEF-4506-A763-07062D2BD6AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61C24126-F39D-4BEA-96DC-FC87BA730554}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61C24126-F39D-4BEA-96DC-FC87BA730554}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61C24126-F39D-4BEA-96DC-FC87BA730554}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61C24126-F39D-4BEA-96DC-FC87BA730554}.Release|Any CPU.Build.0 = Release|Any CPU + {CB903D21-4869-42EF-BDD6-5B1CFF674337}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB903D21-4869-42EF-BDD6-5B1CFF674337}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB903D21-4869-42EF-BDD6-5B1CFF674337}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB903D21-4869-42EF-BDD6-5B1CFF674337}.Release|Any CPU.Build.0 = Release|Any CPU + {980B5FD8-0107-41F7-8FAD-E4E8BAE8A625}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {980B5FD8-0107-41F7-8FAD-E4E8BAE8A625}.Debug|Any CPU.Build.0 = Debug|Any CPU + {980B5FD8-0107-41F7-8FAD-E4E8BAE8A625}.Release|Any CPU.ActiveCfg = Release|Any CPU + {980B5FD8-0107-41F7-8FAD-E4E8BAE8A625}.Release|Any CPU.Build.0 = Release|Any CPU + {7C06FE2D-6C62-48F5-A505-F0D715C554DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C06FE2D-6C62-48F5-A505-F0D715C554DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C06FE2D-6C62-48F5-A505-F0D715C554DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C06FE2D-6C62-48F5-A505-F0D715C554DE}.Release|Any CPU.Build.0 = Release|Any CPU + {AF89083D-4715-42E6-93E9-38497D12A8A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF89083D-4715-42E6-93E9-38497D12A8A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF89083D-4715-42E6-93E9-38497D12A8A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF89083D-4715-42E6-93E9-38497D12A8A6}.Release|Any CPU.Build.0 = Release|Any CPU + {B5CDB0DC-B26D-48F1-B934-FE5C1C991940}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5CDB0DC-B26D-48F1-B934-FE5C1C991940}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5CDB0DC-B26D-48F1-B934-FE5C1C991940}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5CDB0DC-B26D-48F1-B934-FE5C1C991940}.Release|Any CPU.Build.0 = Release|Any CPU {C74FBA78-13E8-407F-A173-4555AEE41FF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C74FBA78-13E8-407F-A173-4555AEE41FF3}.Debug|Any CPU.Build.0 = Debug|Any CPU {C74FBA78-13E8-407F-A173-4555AEE41FF3}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -303,6 +340,13 @@ Global {4A175C27-EAFE-47E7-90F6-873B37863656} = {0EF6EA64-D7C3-420D-9890-EAE8D54A57E6} {DDC41278-FB60-403A-B969-2AEBD7C2D83C} = {0EF6EA64-D7C3-420D-9890-EAE8D54A57E6} {8CA09061-2BEF-4506-A763-07062D2BD6AC} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} + {7592AFA4-426B-42F3-AE82-957C86814482} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} + {61C24126-F39D-4BEA-96DC-FC87BA730554} = {7592AFA4-426B-42F3-AE82-957C86814482} + {CB903D21-4869-42EF-BDD6-5B1CFF674337} = {7592AFA4-426B-42F3-AE82-957C86814482} + {980B5FD8-0107-41F7-8FAD-E4E8BAE8A625} = {7592AFA4-426B-42F3-AE82-957C86814482} + {7C06FE2D-6C62-48F5-A505-F0D715C554DE} = {7592AFA4-426B-42F3-AE82-957C86814482} + {AF89083D-4715-42E6-93E9-38497D12A8A6} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {B5CDB0DC-B26D-48F1-B934-FE5C1C991940} = {DD020B34-460F-455F-8D17-CF4A949F100B} {C74FBA78-13E8-407F-A173-4555AEE41FF3} = {A7F41094-8648-446B-AECD-DCC2CC871F73} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/examples/GeneratedActor/ActorClient/ActorClient.csproj b/examples/GeneratedActor/ActorClient/ActorClient.csproj new file mode 100644 index 000000000..73b5c2027 --- /dev/null +++ b/examples/GeneratedActor/ActorClient/ActorClient.csproj @@ -0,0 +1,22 @@ + + + + Exe + net6 + 10.0 + enable + enable + + + + + + + + + + + + diff --git a/examples/GeneratedActor/ActorClient/IClientActor.cs b/examples/GeneratedActor/ActorClient/IClientActor.cs new file mode 100644 index 000000000..c5c732cb9 --- /dev/null +++ b/examples/GeneratedActor/ActorClient/IClientActor.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors.Generators; + +namespace GeneratedActor; + +internal sealed record ClientState(string Value); + +[GenerateActorClient] +internal interface IClientActor +{ + [ActorMethod(Name = "GetState")] + Task GetStateAsync(CancellationToken cancellationToken = default); + + [ActorMethod(Name = "SetState")] + Task SetStateAsync(ClientState state, CancellationToken cancellationToken = default); +} diff --git a/examples/GeneratedActor/ActorClient/Program.cs b/examples/GeneratedActor/ActorClient/Program.cs new file mode 100644 index 000000000..87f714907 --- /dev/null +++ b/examples/GeneratedActor/ActorClient/Program.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors; +using Dapr.Actors.Client; +using GeneratedActor; + +Console.WriteLine("Testing generated client..."); + +var proxy = ActorProxy.Create(ActorId.CreateRandom(), "RemoteActor"); + +using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + +var client = new ClientActorClient(proxy); + +var state = await client.GetStateAsync(cancellationTokenSource.Token); + +await client.SetStateAsync(new ClientState("Hello, World!"), cancellationTokenSource.Token); + +Console.WriteLine("Done!"); diff --git a/examples/GeneratedActor/ActorCommon/ActorCommon.csproj b/examples/GeneratedActor/ActorCommon/ActorCommon.csproj new file mode 100644 index 000000000..2cbc61e2c --- /dev/null +++ b/examples/GeneratedActor/ActorCommon/ActorCommon.csproj @@ -0,0 +1,14 @@ + + + + net6 + 10.0 + enable + enable + + + + + + + diff --git a/examples/GeneratedActor/ActorCommon/IRemoteActor.cs b/examples/GeneratedActor/ActorCommon/IRemoteActor.cs new file mode 100644 index 000000000..6d136a704 --- /dev/null +++ b/examples/GeneratedActor/ActorCommon/IRemoteActor.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors; + +namespace GeneratedActor; + +public sealed record RemoteState(string Value); + +public interface IRemoteActor : IActor +{ + Task GetState(); + + Task SetState(RemoteState state); +} diff --git a/examples/GeneratedActor/ActorService/ActorService.csproj b/examples/GeneratedActor/ActorService/ActorService.csproj new file mode 100644 index 000000000..a74104363 --- /dev/null +++ b/examples/GeneratedActor/ActorService/ActorService.csproj @@ -0,0 +1,15 @@ + + + + net6 + 10.0 + enable + enable + + + + + + + + diff --git a/examples/GeneratedActor/ActorService/Program.cs b/examples/GeneratedActor/ActorService/Program.cs new file mode 100644 index 000000000..f6e62f720 --- /dev/null +++ b/examples/GeneratedActor/ActorService/Program.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 GeneratedActor; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddActors( + options => + { + options.UseJsonSerialization = true; + options.Actors.RegisterActor(); + }); + +var app = builder.Build(); + +app.UseRouting(); + +#pragma warning disable ASP0014 +app.UseEndpoints( + endpoints => + { + endpoints.MapActorsHandlers(); + }); + +app.Run(); diff --git a/examples/GeneratedActor/ActorService/Properties/launchSettings.json b/examples/GeneratedActor/ActorService/Properties/launchSettings.json new file mode 100644 index 000000000..8fbb1f581 --- /dev/null +++ b/examples/GeneratedActor/ActorService/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:56372", + "sslPort": 0 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5226", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/GeneratedActor/ActorService/RemoteActor.cs b/examples/GeneratedActor/ActorService/RemoteActor.cs new file mode 100644 index 000000000..f04921f69 --- /dev/null +++ b/examples/GeneratedActor/ActorService/RemoteActor.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors.Runtime; + +namespace GeneratedActor; + +internal sealed class RemoteActor : Actor, IRemoteActor +{ + private readonly ILogger logger; + + private RemoteState currentState = new("default"); + + public RemoteActor(ActorHost host, ILogger logger) + : base(host) + { + this.logger = logger; + } + + public Task GetState() + { + this.logger.LogInformation("GetStateAsync called."); + + return Task.FromResult(this.currentState); + } + + public Task SetState(RemoteState state) + { + this.logger.LogInformation("SetStateAsync called."); + + this.currentState = state; + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/examples/GeneratedActor/ActorService/appsettings.Development.json b/examples/GeneratedActor/ActorService/appsettings.Development.json new file mode 100644 index 000000000..ff66ba6b2 --- /dev/null +++ b/examples/GeneratedActor/ActorService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/GeneratedActor/ActorService/appsettings.json b/examples/GeneratedActor/ActorService/appsettings.json new file mode 100644 index 000000000..4d566948d --- /dev/null +++ b/examples/GeneratedActor/ActorService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/GeneratedActor/README.md b/examples/GeneratedActor/README.md new file mode 100644 index 000000000..cd595b30e --- /dev/null +++ b/examples/GeneratedActor/README.md @@ -0,0 +1,115 @@ +# Generated Actor Client Example + +An example of generating a strongly-typed actor client. + +## Prerequisites + +- [.NET 6+](https://dotnet.microsoft.com/download) installed +- [Dapr CLI](https://docs.dapr.io/getting-started/install-dapr-cli/) +- [Initialized Dapr environment](https://docs.dapr.io/getting-started/install-dapr-selfhost/) +- [Dapr .NET SDK](https://docs.dapr.io/developing-applications/sdks/dotnet/) + +## Overview + +Two options for invoking actor methods exist in the Dapr .NET SDK, a strongly-type (remoting) option and a loosely-typed (non-remoting) option. Each has its own advantages and disadvantages. A "middle" option also exists that combines the two and gains benefits of both without some of the disadvantages of either. Using .NET Source Generators, the Dapr .NET SDK can generate a strongly-typed client implementation that uses loosely-typed method invocation under the covers. + +Strongly-typed clients are generated by: + +1. Referencing the `Dapr.Actors.Generators` NuGet package. + + ```xml + + + + + + ``` + +1. Add the `Dapr.Actors.Generators.GenerateActorClientAttribute` to the actor interface. + + ```csharp + using Dapr.Actors.Generators; + + namespace Sample; + + internal sealed record SampleState(string Value); + + [GenerateActorClient] + internal interface ISampleActor + { + [ActorMethod(Name = "GetState")] + Task GetStateAsync(CancellationToken cancellationToken = default); + + [ActorMethod(Name = "SetState")] + Task SetStateAsync(SampleState state, CancellationToken cancellationToken = default); + } + ``` + + > The `Dapr.Actors.Generators.ActorMethodAttribute` can be used to map interface methods definitions to specific actor methods should the names differ (e.g. the interface uses "Async" suffix common in .NET but the actor methods do not). + +1. A strongly-typed client will be generated that can be used to invoke actor methods. + + ```csharp + using Dapr.Actors; + using Dapr.Actors.Client; + using Sample; + + var proxy = ActorProxy.Create(ActorId.CreateRandom(), "SampleActor"); + + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var client = new SampleActorClient(proxy); + + var state = await client.GetStateAsync(cancellationTokenSource.Token); + + await client.SetStateAsync(new SampleState("Hello, World!"), cancellationTokenSource.Token); + + ``` + +## Run the example + +### Start the ActorService + +Change directory to the `ActorService` folder: + +```bash +cd examples/GeneratedActor/ActorService +``` + +To start the `ActorService`, execute the following command: + +```bash +dapr run --app-id generated-service --app-port 5226 -- dotnet run +``` + +### Run the ActorClient + +Change directory to the `ActorClient` folder: + +```bash +cd examples/GeneratedActor/ActorClient +``` + +To run the `ActorClient`, execute the following command: + +```bash +dapr run --app-id generated-client -- dotnet run +``` + +### Expected output + +You should see the following output from the `ActorClient`: + +``` +== APP == Testing generated client... +== APP == Done! +``` + +You should see also see the following output from the `ActorService`: + +``` +== APP == info: GeneratedActor.RemoteActor[0] +== APP == GetStateAsync called. +== APP == info: GeneratedActor.RemoteActor[0] +== APP == SetStateAsync called. +``` \ No newline at end of file diff --git a/src/Dapr.Actors.Generators/ActorClientGenerator.cs b/src/Dapr.Actors.Generators/ActorClientGenerator.cs new file mode 100644 index 000000000..349d80188 --- /dev/null +++ b/src/Dapr.Actors.Generators/ActorClientGenerator.cs @@ -0,0 +1,303 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dapr.Actors.Generators; + +/// +/// Generates strongly-typed actor clients that use the non-remoting actor proxy. +/// +[Generator] +public sealed class ActorClientGenerator : ISourceGenerator +{ + private const string GeneratorsNamespace = "Dapr.Actors.Generators"; + + private const string ActorMethodAttributeTypeName = "ActorMethodAttribute"; + private const string ActorMethodAttributeFullTypeName = GeneratorsNamespace + "." + ActorMethodAttributeTypeName; + + private const string GenerateActorClientAttribute = "GenerateActorClientAttribute"; + private const string GenerateActorClientAttributeFullTypeName = GeneratorsNamespace + "." + GenerateActorClientAttribute; + + private const string ActorMethodAttributeText = $@" + // + + #nullable enable + + using System; + + namespace {GeneratorsNamespace} + {{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + internal sealed class ActorMethodAttribute : Attribute + {{ + public string? Name {{ get; set; }} + }} + }}"; + + private const string GenerateActorClientAttributeText = $@" + // + + #nullable enable + + using System; + + namespace {GeneratorsNamespace} + {{ + [AttributeUsage(AttributeTargets.Interface, AllowMultiple = false, Inherited = false)] + internal sealed class GenerateActorClientAttribute : Attribute + {{ + public string? Name {{ get; set; }} + + public string? Namespace {{ get; set; }} + }} + }}"; + + private sealed class ActorInterfaceSyntaxReceiver : ISyntaxContextReceiver + { + private readonly List models = new(); + + public IEnumerable Models => this.models; + + #region ISyntaxContextReceiver Members + + public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + { + if (context.Node is not InterfaceDeclarationSyntax interfaceDeclarationSyntax + || interfaceDeclarationSyntax.AttributeLists.Count == 0) + { + return; + } + + var interfaceSymbol = context.SemanticModel.GetDeclaredSymbol(interfaceDeclarationSyntax) as INamedTypeSymbol; + + if (interfaceSymbol is null + || !interfaceSymbol.GetAttributes().Any(a => a.AttributeClass?.ToString() == GenerateActorClientAttributeFullTypeName)) + { + return; + } + + this.models.Add(interfaceSymbol); + } + + #endregion + } + + #region ISourceGenerator Members + + /// + public void Execute(GeneratorExecutionContext context) + { + if (context.SyntaxContextReceiver is not ActorInterfaceSyntaxReceiver actorInterfaceSyntaxReceiver) + { + return; + } + + var actorMethodAttributeSymbol = context.Compilation.GetTypeByMetadataName(ActorMethodAttributeFullTypeName) ?? throw new InvalidOperationException("Could not find ActorMethodAttribute."); + var generateActorClientAttributeSymbol = context.Compilation.GetTypeByMetadataName(GenerateActorClientAttributeFullTypeName) ?? throw new InvalidOperationException("Could not find GenerateActorClientAttribute."); + var cancellationTokenSymbol = context.Compilation.GetTypeByMetadataName("System.Threading.CancellationToken") ?? throw new InvalidOperationException("Could not find CancellationToken."); + + foreach (var interfaceSymbol in actorInterfaceSyntaxReceiver.Models) + { + try + { + var actorInterfaceTypeName = interfaceSymbol.Name; + var fullyQualifiedActorInterfaceTypeName = interfaceSymbol.ToString(); + + var attributeData = interfaceSymbol.GetAttributes().Single(a => a.AttributeClass?.Equals(generateActorClientAttributeSymbol, SymbolEqualityComparer.Default) == true); + + var accessibility = GetClientAccessibility(interfaceSymbol); + var clientTypeName = GetClientName(interfaceSymbol, attributeData); + var namespaceName = attributeData.NamedArguments.SingleOrDefault(kvp => kvp.Key == "Namespace").Value.Value?.ToString() ?? interfaceSymbol.ContainingNamespace.ToDisplayString(); + + var members = interfaceSymbol.GetMembers().OfType().Where(m => m.MethodKind == MethodKind.Ordinary).ToList(); + + var methodImplementations = String.Join("\n", members.Select(member => GenerateMethodImplementation(member, actorMethodAttributeSymbol, cancellationTokenSymbol))); + + var source = $@" +// + +namespace {namespaceName} +{{ + {accessibility} sealed class {clientTypeName} : {fullyQualifiedActorInterfaceTypeName} + {{ + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public {clientTypeName}(Dapr.Actors.Client.ActorProxy actorProxy) + {{ + this.actorProxy = actorProxy; + }} + + {methodImplementations} + }} +}} +"; + // Add the source code to the compilation + context.AddSource($"{namespaceName}.{clientTypeName}.g.cs", source); + } + catch (DiagnosticsException e) + { + foreach (var diagnostic in e.Diagnostics) + { + context.ReportDiagnostic(diagnostic); + } + } + } + } + + /// + public void Initialize(GeneratorInitializationContext context) + { + /* + while (!Debugger.IsAttached) + { + System.Threading.Thread.Sleep(500); + } + */ + + context.RegisterForPostInitialization( + i => + { + i.AddSource($"{ActorMethodAttributeFullTypeName}.g.cs", ActorMethodAttributeText); + i.AddSource($"{GenerateActorClientAttributeFullTypeName}.g.cs", GenerateActorClientAttributeText); + }); + + context.RegisterForSyntaxNotifications(() => new ActorInterfaceSyntaxReceiver()); + } + + #endregion + + private static string GetClientAccessibility(INamedTypeSymbol interfaceSymbol) + { + return interfaceSymbol.DeclaredAccessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "protected internal", + _ => throw new InvalidOperationException("Unexpected accessibility.") + }; + } + + private static string GetClientName(INamedTypeSymbol interfaceSymbol, AttributeData attributeData) + { + string? clientName = attributeData.NamedArguments.SingleOrDefault(kvp => kvp.Key == "Name").Value.Value?.ToString(); + + clientName ??= $"{(interfaceSymbol.Name.StartsWith("I") ? interfaceSymbol.Name.Substring(1) : interfaceSymbol.Name)}Client"; + + return clientName; + } + + private static string GenerateMethodImplementation(IMethodSymbol method, INamedTypeSymbol generateActorClientAttributeSymbol, INamedTypeSymbol cancellationTokenSymbol) + { + int cancellationTokenIndex = method.Parameters.IndexOf(p => p.Type.Equals(cancellationTokenSymbol, SymbolEqualityComparer.Default)); + var cancellationTokenParameter = cancellationTokenIndex != -1 ? method.Parameters[cancellationTokenIndex] : null; + + if (cancellationTokenParameter is not null && cancellationTokenIndex != method.Parameters.Length - 1) + { + throw new DiagnosticsException(new[] + { + Diagnostic.Create( + new DiagnosticDescriptor( + "DAPR0001", + "Invalid method signature.", + "Cancellation tokens must be the last argument.", + "Dapr.Actors.Generators", + DiagnosticSeverity.Error, + true), + cancellationTokenParameter.Locations.First()) + }); + } + + if ((method.Parameters.Length > 1 && cancellationTokenIndex == -1) + || (method.Parameters.Length > 2)) + { + throw new DiagnosticsException(new[] + { + Diagnostic.Create( + new DiagnosticDescriptor( + "DAPR0002", + "Invalid method signature.", + "Only methods with a single argument or a single argument followed by a cancellation token are supported.", + "Dapr.Actors.Generators", + DiagnosticSeverity.Error, + true), + method.Locations.First()) + }); + } + + var attributeData = method.GetAttributes().SingleOrDefault(a => a.AttributeClass?.Equals(generateActorClientAttributeSymbol, SymbolEqualityComparer.Default) == true); + + string? actualMethodName = attributeData?.NamedArguments.SingleOrDefault(kvp => kvp.Key == "Name").Value.Value?.ToString() ?? method.Name; + + var requestParameter = method.Parameters.Length > 0 && cancellationTokenIndex != 0 ? method.Parameters[0] : null; + + var returnTypeArgument = (method.ReturnType as INamedTypeSymbol)?.TypeArguments.FirstOrDefault(); + + string argumentDefinitions = String.Join(", ", method.Parameters.Select(p => $"{p.Type} {p.Name}")); + + if (cancellationTokenParameter is not null + && cancellationTokenParameter.IsOptional + && cancellationTokenParameter.HasExplicitDefaultValue + && cancellationTokenParameter.ExplicitDefaultValue is null) + { + argumentDefinitions = argumentDefinitions + " = default"; + } + + string argumentList = String.Join(", ", new[] { $@"""{actualMethodName}""" }.Concat(method.Parameters.Select(p => p.Name))); + + string templateArgs = + returnTypeArgument is not null + ? $"<{(requestParameter is not null ? $"{requestParameter.Type}, " : "")}{returnTypeArgument}>" + : ""; + + return + $@"public {method.ReturnType} {method.Name}({argumentDefinitions}) + {{ + return this.actorProxy.InvokeMethodAsync{templateArgs}({argumentList}); + }}"; + } +} + +internal static class Extensions +{ + public static int IndexOf(this IEnumerable source, Func predicate) + { + int index = 0; + + foreach (var item in source) + { + if (predicate(item)) + { + return index; + } + + index++; + } + + return -1; + } +} + +internal sealed class DiagnosticsException : Exception +{ + public DiagnosticsException(IEnumerable diagnostics) + : base(String.Join("\n", diagnostics.Select(d => d.ToString()))) + { + this.Diagnostics = diagnostics.ToArray(); + } + + public IEnumerable Diagnostics { get; } +} diff --git a/src/Dapr.Actors.Generators/Dapr.Actors.Generators.csproj b/src/Dapr.Actors.Generators/Dapr.Actors.Generators.csproj new file mode 100644 index 000000000..a69f2d1a0 --- /dev/null +++ b/src/Dapr.Actors.Generators/Dapr.Actors.Generators.csproj @@ -0,0 +1,45 @@ + + + + enable + enable + + + + true + + + + + + + + + + + + netstandard2.0 + + + + false + + + true + + + false + + + This package contains source generators for interacting with Actor services using Dapr. + $(PackageTags);Actors + + + + + + + + diff --git a/test/Dapr.Actors.Generators.Test/ActorClientGeneratorTests.cs b/test/Dapr.Actors.Generators.Test/ActorClientGeneratorTests.cs new file mode 100644 index 000000000..ce4c0accd --- /dev/null +++ b/test/Dapr.Actors.Generators.Test/ActorClientGeneratorTests.cs @@ -0,0 +1,696 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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. +// ------------------------------------------------------------------------ + +namespace Dapr.Actors.Generators; + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using VerifyCS = CSharpSourceGeneratorVerifier; + +public sealed class ActorClientGeneratorTests +{ + private const string ActorMethodAttributeText = $@" + // + + #nullable enable + + using System; + + namespace Dapr.Actors.Generators + {{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + internal sealed class ActorMethodAttribute : Attribute + {{ + public string? Name {{ get; set; }} + }} + }}"; + + private static readonly (string, SourceText) ActorMethodAttributeSource = ("Dapr.Actors.Generators/Dapr.Actors.Generators.ActorClientGenerator/Dapr.Actors.Generators.ActorMethodAttribute.g.cs", SourceText.From(ActorMethodAttributeText, Encoding.UTF8)); + + private const string GenerateActorClientAttributeText = $@" + // + + #nullable enable + + using System; + + namespace Dapr.Actors.Generators + {{ + [AttributeUsage(AttributeTargets.Interface, AllowMultiple = false, Inherited = false)] + internal sealed class GenerateActorClientAttribute : Attribute + {{ + public string? Name {{ get; set; }} + + public string? Namespace {{ get; set; }} + }} + }}"; + + private static readonly (string, SourceText) GenerateActorClientAttributeSource = ("Dapr.Actors.Generators/Dapr.Actors.Generators.ActorClientGenerator/Dapr.Actors.Generators.GenerateActorClientAttribute.g.cs", SourceText.From(GenerateActorClientAttributeText, Encoding.UTF8)); + + private static VerifyCS.Test CreateTest(string originalSource, string? generatedName = null, string? generatedSource = null) + { + var test = new VerifyCS.Test + { + TestState = + { + AdditionalReferences = { AdditionalMetadataReferences.Actors }, + Sources = { originalSource }, + GeneratedSources = + { + ActorMethodAttributeSource, + GenerateActorClientAttributeSource + }, + } + }; + + if (generatedName is not null && generatedSource is not null) + { + test.TestState.GeneratedSources.Add(($"Dapr.Actors.Generators/Dapr.Actors.Generators.ActorClientGenerator/{generatedName}", SourceText.From(generatedSource, Encoding.UTF8))); + } + + return test; + } + + [Fact] + public async Task TestMethodWithNoArgumentsOrReturnValue() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + [GenerateActorClient] + public interface ITestActor + { + Task TestMethod(); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethod() + { + return this.actorProxy.InvokeMethodAsync(""TestMethod""); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestInternalInterface() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + [GenerateActorClient] + internal interface ITestActor + { + Task TestMethod(); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + internal sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethod() + { + return this.actorProxy.InvokeMethodAsync(""TestMethod""); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestRenamedClient() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + [GenerateActorClient(Name = ""MyTestActorClient"")] + internal interface ITestActor + { + Task TestMethod(); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + internal sealed class MyTestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public MyTestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethod() + { + return this.actorProxy.InvokeMethodAsync(""TestMethod""); + } + } +} +"; + + await CreateTest(originalSource, "Test.MyTestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestCustomNamespace() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + [GenerateActorClient(Namespace = ""MyTest"")] + internal interface ITestActor + { + Task TestMethod(); + } +} +"; + + var generatedSource = @" +// + +namespace MyTest +{ + internal sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethod() + { + return this.actorProxy.InvokeMethodAsync(""TestMethod""); + } + } +} +"; + + await CreateTest(originalSource, "MyTest.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestRenamedMethod() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + [GenerateActorClient] + public interface ITestActor + { + [ActorMethod(Name = ""MyTestMethod"")] + Task TestMethod(); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethod() + { + return this.actorProxy.InvokeMethodAsync(""MyTestMethod""); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithArgumentsButNoReturnValue() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + public record TestValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethod(TestValue value); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethod(Test.TestValue value) + { + return this.actorProxy.InvokeMethodAsync(""TestMethod"", value); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithNoArgumentsButReturnValue() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + public record TestValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethodAsync() + { + return this.actorProxy.InvokeMethodAsync(""TestMethodAsync""); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithArgumentsAndReturnValue() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading.Tasks; + +namespace Test +{ + public record TestRequestValue(int Value); + + public record TestReturnValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(TestRequestValue value); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethodAsync(Test.TestRequestValue value) + { + return this.actorProxy.InvokeMethodAsync(""TestMethodAsync"", value); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithCancellationTokenArgument() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading; +using System.Threading.Tasks; + +namespace Test +{ + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(CancellationToken cancellationToken); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethodAsync(System.Threading.CancellationToken cancellationToken) + { + return this.actorProxy.InvokeMethodAsync(""TestMethodAsync"", cancellationToken); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithDefaultCancellationTokenArgument() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading; +using System.Threading.Tasks; + +namespace Test +{ + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(CancellationToken cancellationToken = default); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethodAsync(System.Threading.CancellationToken cancellationToken = default) + { + return this.actorProxy.InvokeMethodAsync(""TestMethodAsync"", cancellationToken); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithValueAndCancellationTokenArguments() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading; +using System.Threading.Tasks; + +namespace Test +{ + public record TestValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(TestValue value, CancellationToken cancellationToken); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethodAsync(Test.TestValue value, System.Threading.CancellationToken cancellationToken) + { + return this.actorProxy.InvokeMethodAsync(""TestMethodAsync"", value, cancellationToken); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithValueAndDefaultCancellationTokenArguments() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading; +using System.Threading.Tasks; + +namespace Test +{ + public record TestValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(TestValue value, CancellationToken cancellationToken = default); + } +} +"; + + var generatedSource = @" +// + +namespace Test +{ + public sealed class TestActorClient : Test.ITestActor + { + private readonly Dapr.Actors.Client.ActorProxy actorProxy; + + public TestActorClient(Dapr.Actors.Client.ActorProxy actorProxy) + { + this.actorProxy = actorProxy; + } + + public System.Threading.Tasks.Task TestMethodAsync(Test.TestValue value, System.Threading.CancellationToken cancellationToken = default) + { + return this.actorProxy.InvokeMethodAsync(""TestMethodAsync"", value, cancellationToken); + } + } +} +"; + + await CreateTest(originalSource, "Test.TestActorClient.g.cs", generatedSource).RunAsync(); + } + + [Fact] + public async Task TestMethodWithReversedArguments() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading; +using System.Threading.Tasks; + +namespace Test +{ + public record TestValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(CancellationToken cancellationToken, int value); + } +} +"; + + var test = CreateTest(originalSource); + + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("DAPR0001", DiagnosticSeverity.Error) + .WithSpan(13, 48, 13, 65) + .WithMessage("Cancellation tokens must be the last argument.")); + + await test.RunAsync(); + } + + [Fact] + public async Task TestMethodWithTooManyArguments() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading; +using System.Threading.Tasks; + +namespace Test +{ + public record TestValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(int value1, int value2); + } +} +"; + + var test = CreateTest(originalSource); + + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("DAPR0002", DiagnosticSeverity.Error) + .WithSpan(13, 14, 13, 29) + .WithMessage("Only methods with a single argument or a single argument followed by a cancellation token are supported.")); + + await test.RunAsync(); + } + + [Fact] + public async Task TestMethodWithFarTooManyArguments() + { + var originalSource = @" +using Dapr.Actors.Generators; +using System.Threading; +using System.Threading.Tasks; + +namespace Test +{ + public record TestValue(int Value); + + [GenerateActorClient] + public interface ITestActor + { + Task TestMethodAsync(int value1, int value2, CancellationToken cancellationToken); + } +} +"; + + var test = CreateTest(originalSource); + + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("DAPR0002", DiagnosticSeverity.Error) + .WithSpan(13, 14, 13, 29) + .WithMessage("Only methods with a single argument or a single argument followed by a cancellation token are supported.")); + + await test.RunAsync(); + } +} \ No newline at end of file diff --git a/test/Dapr.Actors.Generators.Test/AdditionalMetadataReferences.cs b/test/Dapr.Actors.Generators.Test/AdditionalMetadataReferences.cs new file mode 100644 index 000000000..afa557026 --- /dev/null +++ b/test/Dapr.Actors.Generators.Test/AdditionalMetadataReferences.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Microsoft.CodeAnalysis; + +namespace Dapr.Actors.Generators; + +internal static class AdditionalMetadataReferences +{ + public static readonly MetadataReference Actors = MetadataReference.CreateFromFile(typeof(Dapr.Actors.Client.ActorProxy).Assembly.Location); +} \ No newline at end of file diff --git a/test/Dapr.Actors.Generators.Test/CSharpSourceGeneratorVerifier.cs b/test/Dapr.Actors.Generators.Test/CSharpSourceGeneratorVerifier.cs new file mode 100644 index 000000000..435488c2c --- /dev/null +++ b/test/Dapr.Actors.Generators.Test/CSharpSourceGeneratorVerifier.cs @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +/// +/// From Roslyn Source Generators Cookbook: https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md#unit-testing-of-generators +/// +internal static class CSharpSourceGeneratorVerifier + where TSourceGenerator : ISourceGenerator, new() +{ + public class Test : CSharpSourceGeneratorTest + { + public Test() + { + int frameworkVersion = + #if NET6_0 + 6; + #elif NET7_0 + 7; + #elif NET8_0 + 8; + #endif + + // + // NOTE: Ordinarily we'd use the following: + // + // this.ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net60; + // + // However, Net70 and Net80 are not yet available in the current version of the Roslyn SDK. + // + + this.ReferenceAssemblies = + new ReferenceAssemblies( + $"net{frameworkVersion}.0", + new PackageIdentity( + "Microsoft.NETCore.App.Ref", + $"{frameworkVersion}.0.0"), + Path.Combine("ref", $"net{frameworkVersion}.0")); + } + + protected override CompilationOptions CreateCompilationOptions() + { + var compilationOptions = base.CreateCompilationOptions(); + + return compilationOptions + .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(GetNullableWarningsFromCompiler())); + } + + public LanguageVersion LanguageVersion { get; set; } = LanguageVersion.Default; + + private static ImmutableDictionary GetNullableWarningsFromCompiler() + { + string[] args = { "/warnaserror:nullable" }; + var commandLineArguments = CSharpCommandLineParser.Default.Parse(args, baseDirectory: Environment.CurrentDirectory, sdkDirectory: Environment.CurrentDirectory); + var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; + + return nullableWarnings; + } + + protected override ParseOptions CreateParseOptions() + { + return ((CSharpParseOptions)base.CreateParseOptions()).WithLanguageVersion(LanguageVersion); + } + } +} \ No newline at end of file diff --git a/test/Dapr.Actors.Generators.Test/Dapr.Actors.Generators.Test.csproj b/test/Dapr.Actors.Generators.Test/Dapr.Actors.Generators.Test.csproj new file mode 100644 index 000000000..212faed2d --- /dev/null +++ b/test/Dapr.Actors.Generators.Test/Dapr.Actors.Generators.Test.csproj @@ -0,0 +1,32 @@ + + + + enable + enable + + true + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/test/Dapr.Actors.Generators.Test/GlobalUsings.cs b/test/Dapr.Actors.Generators.Test/GlobalUsings.cs new file mode 100644 index 000000000..48f0c59b2 --- /dev/null +++ b/test/Dapr.Actors.Generators.Test/GlobalUsings.cs @@ -0,0 +1,14 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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. +// ------------------------------------------------------------------------ + +global using Xunit; \ No newline at end of file diff --git a/test/Dapr.E2E.Test.Actors.Generators/ActorState.cs b/test/Dapr.E2E.Test.Actors.Generators/ActorState.cs new file mode 100644 index 000000000..6965c751c --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/ActorState.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors; +using Dapr.Actors.Client; + +namespace Dapr.E2E.Test.Actors.Generators; + +internal static class ActorState +{ + public static async Task EnsureReadyAsync(ActorId actorId, string actorType, ActorProxyOptions? options = null, CancellationToken cancellationToken = default) + where TActor : IPingActor + { + var pingProxy = ActorProxy.Create(actorId, actorType, options); + + while (true) + { + try + { + await pingProxy.Ping(); + + break; + } + catch (DaprApiException) + { + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + } + } +} diff --git a/test/Dapr.E2E.Test.Actors.Generators/ActorWebApplicationFactory.cs b/test/Dapr.E2E.Test.Actors.Generators/ActorWebApplicationFactory.cs new file mode 100644 index 000000000..b5e81b8aa --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/ActorWebApplicationFactory.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors.Runtime; + +namespace Dapr.E2E.Test.Actors.Generators; + +internal sealed record ActorWebApplicationOptions(Action ConfigureActors) +{ + public ILoggerProvider? LoggerProvider { get; init; } + + public string? Url { get; init; } +} + +internal sealed class ActorWebApplicationFactory +{ + public static WebApplication Create(ActorWebApplicationOptions options) + { + var builder = WebApplication.CreateBuilder(); + + if (options.LoggerProvider is not null) + { + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(options.LoggerProvider); + } + + builder.Services.AddActors(options.ConfigureActors); + + var app = builder.Build(); + + if (options.Url is not null) + { + app.Urls.Add(options.Url); + } + + app.UseRouting(); + + #pragma warning disable ASP0014 + app.UseEndpoints( + endpoints => + { + endpoints.MapActorsHandlers(); + }); + + return app; + } +} diff --git a/test/Dapr.E2E.Test.Actors.Generators/Clients/GeneratedClientTests.cs b/test/Dapr.E2E.Test.Actors.Generators/Clients/GeneratedClientTests.cs new file mode 100644 index 000000000..6079b5df7 --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/Clients/GeneratedClientTests.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors; +using Dapr.Actors.Client; +using Xunit.Abstractions; + +namespace Dapr.E2E.Test.Actors.Generators.Clients; + +public class GeneratedClientTests +{ + private readonly ILoggerProvider testLoggerProvider; + + public GeneratedClientTests(ITestOutputHelper testOutputHelper) + { + this.testLoggerProvider = new XUnitLoggingProvider(testOutputHelper); + } + + [Fact] + public async Task TestGeneratedClientAsync() + { + var portManager = new PortManager(); + + (int appPort, int clientAppHttpPort) = portManager.ReservePorts(); + + var templateSidecarOptions = new DaprSidecarOptions("template-app") + { + LoggerFactory = new LoggerFactory(new[] { this.testLoggerProvider }), + LogLevel = "debug" + }; + + var serviceAppSidecarOptions = templateSidecarOptions with + { + AppId = "service-app", + AppPort = appPort + }; + + var clientAppSidecarOptions = templateSidecarOptions with + { + AppId = "client-app", + DaprHttpPort = clientAppHttpPort + }; + + await using var app = ActorWebApplicationFactory.Create( + new ActorWebApplicationOptions(options => + { + options.UseJsonSerialization = true; + options.Actors.RegisterActor(); + }) + { + LoggerProvider = this.testLoggerProvider, + Url = $"http://localhost:{appPort}" + }); + + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + // + // Start application... + // + + await app.StartAsync(cancellationTokenSource.Token); + + // + // Start sidecars... + // + + await using var serviceAppSidecar = DaprSidecarFactory.Create(serviceAppSidecarOptions); + + await serviceAppSidecar.StartAsync(cancellationTokenSource.Token); + + await using var clientAppSidecar = DaprSidecarFactory.Create(clientAppSidecarOptions); + + await clientAppSidecar.StartAsync(cancellationTokenSource.Token); + + // + // Ensure actor is ready... + // + + var actorId = ActorId.CreateRandom(); + var actorType = "RemoteActor"; + var actorOptions = new ActorProxyOptions { HttpEndpoint = $"http://localhost:{clientAppHttpPort}" }; + + await ActorState.EnsureReadyAsync(actorId, actorType, actorOptions, cancellationTokenSource.Token); + + // + // Start test... + // + + var actorProxy = ActorProxy.Create(actorId, actorType, actorOptions); + + var client = new ClientActorClient(actorProxy); + + var result = await client.GetStateAsync(cancellationTokenSource.Token); + + await client.SetStateAsync(new ClientState("updated state"), cancellationTokenSource.Token); + } +} diff --git a/test/Dapr.E2E.Test.Actors.Generators/Clients/IClientActor.cs b/test/Dapr.E2E.Test.Actors.Generators/Clients/IClientActor.cs new file mode 100644 index 000000000..a6cf30a76 --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/Clients/IClientActor.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors.Generators; + +namespace Dapr.E2E.Test.Actors.Generators.Clients; + +internal record ClientState(string Value); + +[GenerateActorClient] +internal interface IClientActor +{ + [ActorMethod(Name = "GetState")] + Task GetStateAsync(CancellationToken cancellationToken = default); + + [ActorMethod(Name = "SetState")] + Task SetStateAsync(ClientState state, CancellationToken cancellationToken = default); +} diff --git a/test/Dapr.E2E.Test.Actors.Generators/Clients/IRemoteActor.cs b/test/Dapr.E2E.Test.Actors.Generators/Clients/IRemoteActor.cs new file mode 100644 index 000000000..77ad6e75b --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/Clients/IRemoteActor.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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. +// ------------------------------------------------------------------------ + +namespace Dapr.E2E.Test.Actors.Generators.Clients; + +public record RemoteState(string Value); + +public interface IRemoteActor : IPingActor +{ + Task GetState(); + + Task SetState(RemoteState state); +} \ No newline at end of file diff --git a/test/Dapr.E2E.Test.Actors.Generators/Clients/RemoteActor.cs b/test/Dapr.E2E.Test.Actors.Generators/Clients/RemoteActor.cs new file mode 100644 index 000000000..9c049019d --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/Clients/RemoteActor.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors.Runtime; + +namespace Dapr.E2E.Test.Actors.Generators.Clients; + +internal sealed class RemoteActor : Actor, IRemoteActor +{ + private readonly ILogger logger; + + private RemoteState currentState = new("default"); + + public RemoteActor(ActorHost host, ILogger logger) + : base(host) + { + this.logger = logger; + } + + public Task GetState() + { + this.logger.LogInformation("GetStateAsync called."); + + return Task.FromResult(this.currentState); + } + + public Task SetState(RemoteState state) + { + this.logger.LogInformation("SetStateAsync called."); + + this.currentState = state; + + return Task.CompletedTask; + } + + public Task Ping() + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/test/Dapr.E2E.Test.Actors.Generators/Dapr.E2E.Test.Actors.Generators.csproj b/test/Dapr.E2E.Test.Actors.Generators/Dapr.E2E.Test.Actors.Generators.csproj new file mode 100644 index 000000000..8618647cb --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/Dapr.E2E.Test.Actors.Generators.csproj @@ -0,0 +1,32 @@ + + + + enable + enable + + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/Dapr.E2E.Test.Actors.Generators/DaprSidecarFactory.cs b/test/Dapr.E2E.Test.Actors.Generators/DaprSidecarFactory.cs new file mode 100644 index 000000000..56d1954d4 --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/DaprSidecarFactory.cs @@ -0,0 +1,151 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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.Diagnostics; + +namespace Dapr.E2E.Test.Actors.Generators; + +internal sealed record DaprSidecarOptions(string AppId) +{ + public int? AppPort { get; init; } + + public int? DaprGrpcPort { get; init;} + + public int? DaprHttpPort { get; init; } + + public ILoggerFactory? LoggerFactory { get; init; } + + public string? LogLevel { get; init; } +} + +internal sealed class DaprSidecar : IAsyncDisposable +{ + private const string StartupOutputString = "You're up and running! Dapr logs will appear here."; + + private readonly string appId; + private readonly Process process; + private readonly ILogger? logger; + private readonly TaskCompletionSource tcs = new(); + + public DaprSidecar(DaprSidecarOptions options) + { + string arguments = $"run --app-id {options.AppId}"; + + if (options.AppPort is not null) + { + arguments += $" --app-port {options.AppPort}"; + } + + if (options.DaprGrpcPort is not null) + { + arguments += $" --dapr-grpc-port {options.DaprGrpcPort}"; + } + + if (options.DaprHttpPort is not null) + { + arguments += $" --dapr-http-port {options.DaprHttpPort}"; + } + + if (options.LogLevel is not null) + { + arguments += $" --log-level {options.LogLevel}"; + } + + this.process = new Process + { + EnableRaisingEvents = false, // ? + StartInfo = + { + Arguments = arguments, + CreateNoWindow = true, + FileName = "dapr", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + } + }; + + if (options.LoggerFactory is not null) + { + this.logger = options.LoggerFactory.CreateLogger(options.AppId); + } + + this.process.OutputDataReceived += (_, args) => + { + if (args.Data is not null) + { + if (args.Data.Contains(StartupOutputString)) + { + this.tcs.SetResult(true); + } + + this.logger?.LogInformation(args.Data); + } + }; + + this.process.ErrorDataReceived += (_, args) => + { + if (args.Data is not null) + { + this.logger?.LogError(args.Data); + } + }; + + this.appId = options.AppId; + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + process.Start(); + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + return this.tcs.Task; + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + var stopProcess = new Process + { + StartInfo = + { + Arguments = $"stop --app-id {this.appId}", + CreateNoWindow = true, + FileName = "dapr", + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + } + }; + + stopProcess.Start(); + + await stopProcess.WaitForExitAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + await StopAsync(CancellationToken.None); + } +} + +internal sealed class DaprSidecarFactory +{ + public static DaprSidecar Create(DaprSidecarOptions options) + { + return new(options); + } +} \ No newline at end of file diff --git a/test/Dapr.E2E.Test.Actors.Generators/GlobalUsings.cs b/test/Dapr.E2E.Test.Actors.Generators/GlobalUsings.cs new file mode 100644 index 000000000..48f0c59b2 --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/GlobalUsings.cs @@ -0,0 +1,14 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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. +// ------------------------------------------------------------------------ + +global using Xunit; \ No newline at end of file diff --git a/test/Dapr.E2E.Test.Actors.Generators/IPingActor.cs b/test/Dapr.E2E.Test.Actors.Generators/IPingActor.cs new file mode 100644 index 000000000..484c4d150 --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/IPingActor.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Dapr.Actors; + +namespace Dapr.E2E.Test.Actors.Generators; + +public interface IPingActor : IActor +{ + Task Ping(); +} diff --git a/test/Dapr.E2E.Test.Actors.Generators/PortManager.cs b/test/Dapr.E2E.Test.Actors.Generators/PortManager.cs new file mode 100644 index 000000000..fcd296977 --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/PortManager.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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.Net.NetworkInformation; + +namespace Dapr.E2E.Test.Actors.Generators; + +internal sealed class PortManager +{ + private readonly ISet reservedPorts = new HashSet(); + + private readonly object reservationLock = new(); + + public int ReservePort(int rangeStart = 55000) + { + var ports = this.ReservePorts(1, rangeStart); + + return ports.First(); + } + + public (int, int) ReservePorts(int rangeStart = 55000) + { + var ports = this.ReservePorts(2, rangeStart).ToArray(); + + return (ports[0], ports[1]); + } + + public ISet ReservePorts(int count, int rangeStart = 55000) + { + lock (this.reservationLock) + { + var globalProperties = IPGlobalProperties.GetIPGlobalProperties(); + + var activePorts = + globalProperties + .GetActiveTcpListeners() + .Select(endPoint => endPoint.Port) + .ToHashSet(); + + var availablePorts = + Enumerable + .Range(rangeStart, Int32.MaxValue - rangeStart + 1) + .Where(port => !activePorts.Contains(port)) + .Where(port => !this.reservedPorts.Contains(port)); + + var newReservedPorts = availablePorts.Take(count).ToHashSet(); + + this.reservedPorts.UnionWith(newReservedPorts); + + return newReservedPorts; + } + } +} \ No newline at end of file diff --git a/test/Dapr.E2E.Test.Actors.Generators/XUnitLoggingProvider.cs b/test/Dapr.E2E.Test.Actors.Generators/XUnitLoggingProvider.cs new file mode 100644 index 000000000..641d66d80 --- /dev/null +++ b/test/Dapr.E2E.Test.Actors.Generators/XUnitLoggingProvider.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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 Xunit.Abstractions; + +namespace Dapr.E2E.Test.Actors.Generators; + +internal sealed class XUnitLoggingProvider : ILoggerProvider +{ + private readonly ITestOutputHelper output; + + public XUnitLoggingProvider(ITestOutputHelper output) + { + this.output = output; + } + + public ILogger CreateLogger(string categoryName) + { + return new XUnitLogger(categoryName, this.output); + } + + public void Dispose() + { + } + + private sealed class XUnitLogger : ILogger + { + private readonly string categoryName; + private readonly ITestOutputHelper output; + + public XUnitLogger(string categoryName, ITestOutputHelper output) + { + this.categoryName = categoryName; + this.output = output; + } + +#nullable disable + public IDisposable BeginScope(TState state) + { + return new XUnitLoggerScope(); + } +#nullable enable + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + this.output.WriteLine($"{this.categoryName}: {formatter(state, exception).TrimEnd(Environment.NewLine.ToCharArray())}"); + } + } + + private sealed class XUnitLoggerScope : IDisposable + { + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/test/Dapr.E2E.Test.App/Actors/TimerActor.cs b/test/Dapr.E2E.Test.App/Actors/TimerActor.cs index 4c6589965..14b9fef4e 100644 --- a/test/Dapr.E2E.Test.App/Actors/TimerActor.cs +++ b/test/Dapr.E2E.Test.App/Actors/TimerActor.cs @@ -39,7 +39,7 @@ public Task GetState() public async Task StartTimer(StartTimerOptions options) { var bytes = JsonSerializer.SerializeToUtf8Bytes(options, this.Host.JsonSerializerOptions); - await this.RegisterTimerAsync("test-timer", nameof(Tick), bytes, dueTime: TimeSpan.Zero, period: TimeSpan.FromMilliseconds(50)); + await this.RegisterTimerAsync("test-timer", nameof(Tick), bytes, dueTime: TimeSpan.Zero, period: TimeSpan.FromMilliseconds(100)); await this.StateManager.SetStateAsync("timer-state", new State(){ IsTimerRunning = true, }); }