diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln
index 53f5f4bcf..8fc16da1f 100644
--- a/Microsoft.DurableTask.sln
+++ b/Microsoft.DurableTask.sln
@@ -44,6 +44,9 @@ EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Grpc.IntegrationTests", "test\Grpc.IntegrationTests\Grpc.IntegrationTests.csproj", "{7825CFEA-2923-4C44-BA36-8E16259B9777}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{EFF7632B-821E-4CFC-B4A0-ED4B24296B17}"
+ ProjectSection(SolutionItems) = preProject
+ Directory.Packages.props = Directory.Packages.props
+ EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureFunctionsApp", "samples\AzureFunctionsApp\AzureFunctionsApp.csproj", "{848FC5BD-4A99-4A0D-9099-9597700AA7BC}"
EndProject
@@ -101,6 +104,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InProcessTestHost", "src\In
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InProcessTestHost.Tests", "test\InProcessTestHost.Tests\InProcessTestHost.Tests.csproj", "{B894780C-338F-475E-8E84-56AFA8197A06}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DtsPortableSdkEntityTests", "samples\DtsPortableSdkEntityTests\DtsPortableSdkEntityTests.csproj", "{B2BAE32E-C558-BB99-DC82-282613525497}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -271,6 +276,10 @@ Global
{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2BAE32E-C558-BB99-DC82-282613525497}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2BAE32E-C558-BB99-DC82-282613525497}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2BAE32E-C558-BB99-DC82-282613525497}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2BAE32E-C558-BB99-DC82-282613525497}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -321,6 +330,7 @@ Global
{B894780C-338F-475E-8E84-56AFA8197A06} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
{6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
+ {B2BAE32E-C558-BB99-DC82-282613525497} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
diff --git a/samples/DtsPortableSdkEntityTests/DtsPortableSdkEntityTests.csproj b/samples/DtsPortableSdkEntityTests/DtsPortableSdkEntityTests.csproj
new file mode 100644
index 000000000..2efc614a2
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/DtsPortableSdkEntityTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
diff --git a/samples/DtsPortableSdkEntityTests/Program.cs b/samples/DtsPortableSdkEntityTests/Program.cs
new file mode 100644
index 000000000..375bed00f
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/Program.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using Azure.Core;
+using Azure.Identity;
+using DtsPortableSdkEntityTests;
+using DurableTask.Core.Entities;
+using Microsoft.DurableTask;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.AzureManaged;
+using Microsoft.DurableTask.Worker;
+using Microsoft.DurableTask.Worker.AzureManaged;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+string connectionString = builder.Configuration["DTS_CONNECTION_STRING"] ??
+ // By default, use the connection string for the local development emulator
+ "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
+
+// Add all the generated orchestrations and activities automatically
+builder.Services.AddDurableTaskWorker(builder =>
+{
+ builder.AddTasks(r =>
+ {
+ // TODO consider using source generator
+
+ // register all orchestrations and activities used in the tests
+ HashSet registeredTestTypes = [];
+ foreach(var test in All.GetAllTests())
+ {
+ if (!registeredTestTypes.Contains(test.GetType()))
+ {
+ test.Register(r, builder.Services);
+ registeredTestTypes.Add(test.GetType());
+ }
+ }
+
+ // register all entities
+ BatchEntity.Register(r);
+ Counter.Register(r);
+ FaultyEntity.Register(r);
+ Launcher.Register(r);
+ Relay.Register(r);
+ SchedulerEntity.Register(r);
+ SelfSchedulingEntity.Register(r);
+ StringStore.Register(r);
+ StringStore2.Register(r);
+ StringStore3.Register(r);
+ });
+
+ builder.UseDurableTaskScheduler(connectionString);
+});
+
+// Register the client, which can be used to start orchestrations
+builder.Services.AddDurableTaskClient(builder =>
+{
+ builder.UseDurableTaskScheduler(connectionString);
+});
+
+// Configure the HTTP request pipeline
+builder.Services.AddControllers().AddJsonOptions(options =>
+{
+ options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+ options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+});
+
+// The actual listen URL can be configured in environment variables named "ASPNETCORE_URLS" or "ASPNETCORE_URLS_HTTPS"
+WebApplication app = builder.Build();
+app.MapControllers();
+app.Run();
diff --git a/samples/DtsPortableSdkEntityTests/Properties/launchSettings.json b/samples/DtsPortableSdkEntityTests/Properties/launchSettings.json
new file mode 100644
index 000000000..5acdcbf63
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5203",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7225;http://localhost:5203",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/DtsPortableSdkEntityTests/appsettings.Development.json b/samples/DtsPortableSdkEntityTests/appsettings.Development.json
new file mode 100644
index 000000000..b6f634e59
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "DetailedErrors": true,
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/DtsPortableSdkEntityTests/appsettings.json b/samples/DtsPortableSdkEntityTests/appsettings.json
new file mode 100644
index 000000000..ec04bc120
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/samples/DtsPortableSdkEntityTests/common/ProblematicObject.cs b/samples/DtsPortableSdkEntityTests/common/ProblematicObject.cs
new file mode 100644
index 000000000..be8fa030e
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/common/ProblematicObject.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.Serialization;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using Azure.Core.Serialization;
+
+namespace DtsPortableSdkEntityTests
+{
+ internal static class CustomSerialization
+ {
+ public static ProblematicObject CreateUnserializableObject()
+ {
+ return new ProblematicObject(serializable: false, deserializable: false);
+ }
+
+ public static ProblematicObject CreateUndeserializableObject()
+ {
+ return new ProblematicObject(serializable: true, deserializable: false);
+ }
+
+ ///
+ /// An object for which we can inject errors on serialization or deserialization, to test
+ // how those are handled by the framework.
+ ///
+ public class ProblematicObject
+ {
+ public ProblematicObject(bool serializable = true, bool deserializable = true)
+ {
+ this.Serializable = serializable;
+ this.Deserializable = deserializable;
+ }
+
+ public bool Serializable { get; set; }
+
+ public bool Deserializable { get; set; }
+ }
+
+ public class ProblematicObjectJsonConverter : JsonConverter
+ {
+ public override ProblematicObject Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ bool deserializable = reader.GetBoolean();
+ if (!deserializable)
+ {
+ throw new JsonException("problematic object: is not deserializable");
+ }
+ return new ProblematicObject(serializable: true, deserializable: true);
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ ProblematicObject value,
+ JsonSerializerOptions options)
+ {
+ if (!value.Serializable)
+ {
+ throw new JsonException("problematic object: is not serializable");
+ }
+ writer.WriteBooleanValue(value.Deserializable);
+ }
+ }
+ }
+}
diff --git a/samples/DtsPortableSdkEntityTests/common/Test.cs b/samples/DtsPortableSdkEntityTests/common/Test.cs
new file mode 100644
index 000000000..a6cd83da6
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/common/Test.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.DurableTask;
+
+namespace DtsPortableSdkEntityTests;
+
+internal abstract class Test
+{
+ public virtual string Name => this.GetType().Name;
+
+ public abstract Task RunAsync(TestContext context);
+
+ public virtual TimeSpan Timeout => TimeSpan.FromSeconds(30);
+
+ public virtual void Register(DurableTaskRegistry registry, IServiceCollection services)
+ {
+ }
+}
diff --git a/samples/DtsPortableSdkEntityTests/common/TestContext.cs b/samples/DtsPortableSdkEntityTests/common/TestContext.cs
new file mode 100644
index 000000000..86751b25b
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/common/TestContext.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace DtsPortableSdkEntityTests;
+
+internal class TestContext
+{
+ public TestContext(DurableTaskClient client, ILogger logger, CancellationToken cancellationToken)
+ {
+ this.Client = client;
+ this.Logger = logger;
+ this.CancellationToken = cancellationToken;
+ }
+
+ public DurableTaskClient Client { get; }
+
+ public ILogger Logger { get; }
+
+ public CancellationToken CancellationToken { get; set; }
+
+ public bool BackendSupportsImplicitEntityDeletion { get; set; } = true; // false for Azure Storage, true for Netherite, MSSQL, and DTS
+}
diff --git a/samples/DtsPortableSdkEntityTests/common/TestContextExtensions.cs b/samples/DtsPortableSdkEntityTests/common/TestContextExtensions.cs
new file mode 100644
index 000000000..0ebc97a9f
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/common/TestContextExtensions.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace DtsPortableSdkEntityTests;
+
+internal static class TestContextExtensions
+{
+ public static async Task WaitForEntityStateAsync(
+ this TestContext context,
+ EntityInstanceId entityInstanceId,
+ TimeSpan? timeout = null,
+ Func? describeWhatWeAreWaitingFor = null)
+ {
+ if (timeout == null)
+ {
+ timeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : TimeSpan.FromSeconds(30);
+ }
+
+ Stopwatch sw = Stopwatch.StartNew();
+
+ EntityMetadata? response;
+
+ do
+ {
+ response = await context.Client.Entities.GetEntityAsync(entityInstanceId, includeState: true);
+
+ if (response != null)
+ {
+ if (describeWhatWeAreWaitingFor == null)
+ {
+ break;
+ }
+ else
+ {
+ var waitForResult = describeWhatWeAreWaitingFor(response.State.ReadAs());
+
+ if (string.IsNullOrEmpty(waitForResult))
+ {
+ break;
+ }
+ else
+ {
+ context.Logger.LogInformation($"Waiting for {entityInstanceId} : {waitForResult}");
+ }
+ }
+ }
+ else
+ {
+ context.Logger.LogInformation($"Waiting for {entityInstanceId} to have state.");
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(100));
+ }
+ while (sw.Elapsed < timeout);
+
+ if (response != null)
+ {
+ string serializedState = response.State.Value;
+ context.Logger.LogInformation($"Found state: {serializedState}");
+ return response.State.ReadAs();
+ }
+ else
+ {
+ throw new TimeoutException($"Durable entity '{entityInstanceId}' still doesn't have any state!");
+ }
+ }
+}
diff --git a/samples/DtsPortableSdkEntityTests/common/TestRunner.cs b/samples/DtsPortableSdkEntityTests/common/TestRunner.cs
new file mode 100644
index 000000000..428f0004e
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/common/TestRunner.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace DtsPortableSdkEntityTests;
+
+internal static class TestRunner
+{
+ public static async Task RunAsync(TestContext context, string? prefix = null, bool listOnly = false)
+ {
+ var output = new StringBuilder();
+
+ foreach (var test in All.GetAllTests())
+ {
+ if (prefix == null || test.Name.ToLowerInvariant().StartsWith(prefix.ToLowerInvariant()))
+ {
+ if (listOnly)
+ {
+ output.AppendLine(test.Name);
+ }
+ else
+ {
+ context.Logger.LogWarning("------------ starting {testName}", test.Name);
+
+ // if debugging, time out after 60m
+ // otherwise, time out either when the http request times out or when the individual test time limit is exceeded
+ using CancellationTokenSource cancellationTokenSource
+ = Debugger.IsAttached ? new() : CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
+ cancellationTokenSource.CancelAfter(Debugger.IsAttached ? TimeSpan.FromMinutes(60) : test.Timeout);
+ context.CancellationToken = cancellationTokenSource.Token;
+
+ try
+ {
+ await test.RunAsync(context);
+ output.AppendLine($"PASSED {test.Name}");
+ }
+ catch (Exception ex)
+ {
+ context.Logger.LogError(ex, "test {testName} failed", test.Name);
+ output.AppendLine($"FAILED {test.Name} {ex.ToString()}");
+ break;
+ }
+ }
+ }
+ }
+
+ return output.ToString();
+ }
+}
diff --git a/samples/DtsPortableSdkEntityTests/common/Utils.cs b/samples/DtsPortableSdkEntityTests/common/Utils.cs
new file mode 100644
index 000000000..4da818306
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/common/Utils.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace DtsPortableSdkEntityTests;
+
+static class Utils
+{
+ public static async Task ParallelForEachAsync(this IEnumerable items, int maxConcurrency, Func action)
+ {
+ List tasks;
+ if (items is ICollection itemCollection)
+ {
+ tasks = new List(itemCollection.Count);
+ }
+ else
+ {
+ tasks = [];
+ }
+
+ using SemaphoreSlim semaphore = new(maxConcurrency);
+ foreach (T item in items)
+ {
+ tasks.Add(InvokeThrottledAction(item, action, semaphore));
+ }
+
+ await Task.WhenAll(tasks);
+ }
+
+ static async Task InvokeThrottledAction(T item, Func action, SemaphoreSlim semaphore)
+ {
+ await semaphore.WaitAsync();
+ try
+ {
+ await action(item);
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+}
diff --git a/samples/DtsPortableSdkEntityTests/controllers/BenchmarksController.cs b/samples/DtsPortableSdkEntityTests/controllers/BenchmarksController.cs
new file mode 100644
index 000000000..47075fa57
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/controllers/BenchmarksController.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using System.Security.Cryptography;
+using DurableTask.Core.Entities;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.DurableTask;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+
+namespace DtsPortableSdkEntityTests;
+
+
+[Route("benchmarks")]
+[ApiController]
+public class BenchmarksController(
+ DurableTaskClient durableTaskClient,
+ ILogger logger) : ControllerBase
+{
+ readonly DurableTaskClient durableTaskClient = durableTaskClient;
+ readonly ILogger logger = logger;
+
+ // we are planning to create some benchmarks here at some point but for now these are just very basic entity tests
+ // that allow us to read/update/delete a counter entity via a simple REST-like api
+
+ // POST http://localhost:5008/benchmarks/counter/xyz/increment
+ [HttpPost("counter/{key}/increment")]
+ public async Task CounterIncrement([FromRoute] string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ return BadRequest(new { error = "The 'key' route parameter must not be empty." });
+ }
+
+ EntityInstanceId entityId = new(nameof(Counter), key);
+
+ logger.LogInformation("Sending signal 'Increment' to {entityId}.", entityId);
+
+ Stopwatch sw = Stopwatch.StartNew();
+
+ await durableTaskClient.Entities.SignalEntityAsync(entityId, nameof(Counter.Increment));
+
+ sw.Stop();
+
+ logger.LogInformation(
+ "Sent signal 'Increment' to {entityId} in {time}ms!",
+ entityId,
+ sw.Elapsed.TotalMilliseconds);
+
+ return Ok(new
+ {
+ message = $"Sent signal 'Increment' to {entityId} in {sw.Elapsed.TotalMilliseconds:F3}ms."
+ });
+ }
+
+ // GET http://localhost:5008/benchmarks/counter/xyz
+ [HttpGet("counter/{key}")]
+ public async Task CounterGet([FromRoute] string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ return BadRequest(new { error = "The 'key' route parameter must not be empty." });
+ }
+
+ EntityInstanceId entityId = new(nameof(Counter), key);
+
+ logger.LogInformation("Reading state of {entityId}.", entityId);
+
+ EntityMetadata? entityMetadata =
+ await durableTaskClient.Entities.GetEntityAsync(entityId);
+
+ if (entityMetadata == null)
+ {
+ return NotFound(new
+ {
+ message = $"Entity {entityId} does not exist."
+ });
+ }
+ else
+ {
+ return Ok(new
+ {
+ message = $"Entity {entityId} has state {entityMetadata.State}."
+ });
+ }
+ }
+
+ // DELETE http://localhost:5008/benchmarks/counter/xyz
+ [HttpDelete("counter/{key}")]
+ public async Task CounterDelete([FromRoute] string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ return BadRequest(new { error = "The 'key' route parameter must not be empty." });
+ }
+
+ EntityInstanceId entityId = new(nameof(Counter), key);
+
+ logger.LogInformation("Deleting state of {entityId}.", entityId);
+
+ await durableTaskClient.Entities.SignalEntityAsync(entityId, "delete");
+
+ return Ok(new
+ {
+ message = $"Sent deletion signal to {entityId}."
+ });
+ }
+}
\ No newline at end of file
diff --git a/samples/DtsPortableSdkEntityTests/controllers/TestsController.cs b/samples/DtsPortableSdkEntityTests/controllers/TestsController.cs
new file mode 100644
index 000000000..880e913bf
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/controllers/TestsController.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Runtime.Serialization;
+using System.Text;
+using System.Text.RegularExpressions;
+using Azure.Core;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.DurableTask;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace DtsPortableSdkEntityTests;
+
+
+[Route("tests")]
+[ApiController]
+public class TestsController(
+ DurableTaskClient durableTaskClient,
+ ILogger logger) : ControllerBase
+{
+ readonly DurableTaskClient durableTaskClient = durableTaskClient;
+ readonly ILogger logger = logger;
+
+ // HTTPie command:
+ // http POST http://localhost:5008/tests?prefix=xyz
+ [HttpPost()]
+ public async Task RunTests([FromQuery] string? prefix)
+ {
+ var context = new TestContext(this.durableTaskClient, this.logger, CancellationToken.None);
+ string result = await TestRunner.RunAsync(context, prefix);
+ return this.Ok(result);
+ }
+
+ // HTTPie command:
+ // http GET http://localhost:5008/tests?prefix=xyz
+ [HttpGet()]
+ public async Task ListTests([FromQuery] string? prefix)
+ {
+ var context = new TestContext(this.durableTaskClient, this.logger, CancellationToken.None);
+ string result = await TestRunner.RunAsync(context, prefix, listOnly: true);
+ return this.Ok(result);
+ }
+}
diff --git a/samples/DtsPortableSdkEntityTests/demo.http b/samples/DtsPortableSdkEntityTests/demo.http
new file mode 100644
index 000000000..7f65b932e
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/demo.http
@@ -0,0 +1,16 @@
+# For more info on HTTP files go to https://aka.ms/vs/httpfile
+
+### List all tests
+GET http://localhost:5203/tests
+
+
+### List all tests with prefix 'EntityQueries'
+GET http://localhost:5203/tests?prefix=EntityQueries
+
+
+### Run all tests
+POST http://localhost:5203/tests
+
+
+### Run all tests with prefix 'EntityQueries'
+POST http://localhost:5203/tests?prefix=EntityQueries
diff --git a/samples/DtsPortableSdkEntityTests/entities/BatchEntity.cs b/samples/DtsPortableSdkEntityTests/entities/BatchEntity.cs
new file mode 100644
index 000000000..694176f09
--- /dev/null
+++ b/samples/DtsPortableSdkEntityTests/entities/BatchEntity.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Threading.Tasks.Dataflow;
+using Microsoft.DurableTask;
+using Microsoft.DurableTask.Entities;
+
+namespace DtsPortableSdkEntityTests;
+
+///
+/// An entity that records all batch positions and batch sizes
+///
+class BatchEntity : ITaskEntity
+{
+ int operationCounter;
+
+ public ValueTask