diff --git a/README.md b/README.md index e82dc38..c600afd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The Testcontainers Dapr module for NodeJS enables local development and testing providing a DaprContainer that sets up a Dapr sidecar instance. This container provides an in-memory implementation of Dapr APIs by default, facilitating testing without requiring a full Dapr installation or external dependencies. -Usage examples can be found in [`src/DaprContainer.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/DaprContainer.test.ts), [`src/CryptographyHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/CryptographyHarness.test.ts), [`src/PubSubHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/PubSubHarness.test.ts), [`src/SecretStoreHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/SecretStoreHarness.test.ts), [`src/StateManagementHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/StateManagementHarness.test.ts), and [`src/WorkflowHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/WorkflowHarness.test.ts). +Usage examples can be found in [`src/DaprContainer.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/DaprContainer.test.ts), [`src/ActorHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/ActorHarness.test.ts), [`src/ConversationHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/ConversationHarness.test.ts), [`src/CryptographyHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/CryptographyHarness.test.ts), [`src/PubSubHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/PubSubHarness.test.ts), [`src/SecretStoreHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/SecretStoreHarness.test.ts), [`src/StateManagementHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/StateManagementHarness.test.ts), and [`src/WorkflowHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/WorkflowHarness.test.ts). ## Using the library @@ -93,6 +93,65 @@ const state = await client.waitForWorkflowCompletion(instanceId); await harness.stop(); ``` +## Dapr Actor Testing + +You can use `ActorHarness` or `.withActors()` on `DaprContainer` to test Dapr Actors backed by the Redis actor state store, placement service, and scheduler service: + +```typescript +import { ActorHarness } from "@dapr/testcontainer-node"; +import { AbstractActor, ActorId } from "@dapr/dapr"; +import { TestContainers } from "testcontainers"; + +interface ICounterActor { + increment(amount: number): Promise; + getCount(): Promise; +} + +class CounterActor extends AbstractActor implements ICounterActor { + async increment(amount = 1): Promise { + const stateManager = this.getStateManager(); + const [hasValue, current] = await stateManager.tryGetState("counter"); + const count = hasValue && current !== null && current !== undefined ? current : 0; + const next = count + amount; + await stateManager.setState("counter", next); + await stateManager.saveState(); + return next; + } + + async getCount(): Promise { + const stateManager = this.getStateManager(); + const [hasValue, count] = await stateManager.tryGetState("counter"); + return hasValue && count !== null && count !== undefined ? count : 0; + } +} + +const appPort = 8090; +await TestContainers.exposeHostPorts(appPort); + +const harness = new ActorHarness({ + appPort, + appChannelAddress: "host.testcontainers.internal", +}); + +// Register actor on server +const server = harness.createDaprServer({ + serverPort: appPort.toString(), + serverHost: "0.0.0.0", +}); +await server.actor.registerActor(CounterActor); +await server.actor.init(); +await server.daprServer.start("0.0.0.0", appPort.toString()); + +// Start harness (starts placement, scheduler, redis, and daprd sidecar) +await harness.start(); + +// Create actor proxy and invoke methods +const proxy = harness.createActorProxy(CounterActor, "counter-1"); +const count = await proxy.increment(5); // 5 + +await harness.stop(); +``` + ## Dapr Conversation Testing `ConversationHarness` starts Dapr and a CPU-only Ollama container, pulls the small `smollm2:135m` model by default, and configures a `conversation.ollama` component: diff --git a/src/ActorHarness.test.ts b/src/ActorHarness.test.ts new file mode 100644 index 0000000..41c3224 --- /dev/null +++ b/src/ActorHarness.test.ts @@ -0,0 +1,365 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { AbstractActor, ActorId, ActorProxyBuilder, DaprClient } from "@dapr/dapr"; +import ActorRuntime from "@dapr/dapr/actors/runtime/ActorRuntime"; +import { Network, TestContainers } from "testcontainers"; +import { ActorHarness } from "./ActorHarness"; +import { Configuration } from "./Configuration"; +import { DaprComponentNames } from "./Constants"; +import { DaprContainer } from "./DaprContainer"; +import { RedisContainer } from "./RedisContainer"; + +interface IDemoCounterActor { + sayHello(name: string): Promise; + increment(amount?: number): Promise; + getCount(): Promise; + setScore(score: number): Promise; + getScore(): Promise; + clearScore(): Promise; +} + +class DemoCounterActor extends AbstractActor implements IDemoCounterActor { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async increment(amount = 1): Promise { + const stateManager = this.getStateManager(); + const [hasValue, current] = await stateManager.tryGetState("counter"); + const count = hasValue && current !== null && current !== undefined ? current : 0; + const next = count + amount; + await stateManager.setState("counter", next); + await stateManager.saveState(); + return next; + } + + async getCount(): Promise { + const stateManager = this.getStateManager(); + const [hasValue, count] = await stateManager.tryGetState("counter"); + return hasValue && count !== null && count !== undefined ? count : 0; + } + + async setScore(score: number): Promise { + const stateManager = this.getStateManager(); + await stateManager.setState("score", score); + await stateManager.saveState(); + } + + async getScore(): Promise { + const stateManager = this.getStateManager(); + const [hasValue, score] = await stateManager.tryGetState("score"); + return hasValue ? score : null; + } + + async clearScore(): Promise { + const stateManager = this.getStateManager(); + await stateManager.removeState("score"); + await stateManager.saveState(); + } +} + +describe("ActorHarness and Actor Support", () => { + afterEach(() => { + try { + ActorRuntime.resetForTesting(); + } catch { + // Ignore + } + }); + + it("should configure DaprContainer with actors and redis state store", () => { + const dapr = new DaprContainer().withActors({ + stateStoreName: "custom-actor-state", + keyPrefix: "app-actor", + enableActorStateStore: true, + actorStateTTL: true, + }); + + expect(dapr.isActorsEnabled()).toBe(true); + expect(dapr.getActorOptions()).toEqual({ + stateStoreName: "custom-actor-state", + keyPrefix: "app-actor", + enableActorStateStore: true, + actorStateTTL: true, + }); + const components = dapr.getComponents(); + expect(components).toEqual([]); + }); + + it("should configure ActorHarness with default options", () => { + const harness = new ActorHarness(); + const dapr = harness.getDaprContainer(); + expect(dapr.isActorsEnabled()).toBe(true); + expect(dapr.getAppName()).toBe("actor-app"); + expect(harness.getStateStoreName()).toBe(DaprComponentNames.StateManagementComponentName); + }); + + it("should configure ActorHarness with custom options", () => { + const customRedis = new RedisContainer(); + const customConfig = new Configuration("customActorConfig", undefined, undefined, [ + { name: "ActorStateTTL", enabled: true }, + ]); + const harness = new ActorHarness({ + appId: "custom-actor-app", + appPort: 9005, + appChannelAddress: "127.0.0.1", + daprLogLevel: "debug", + daprApiLoggingEnabled: true, + redisContainer: customRedis, + redisHost: "custom-redis:6379", + redisPassword: "secret-actor-password", + actorStateStore: true, + actorStateTTL: true, + keyPrefix: "actor-prefix", + stateStoreName: "my-actor-store", + configuration: customConfig, + }); + + const dapr = harness.getDaprContainer(); + expect(dapr.isActorsEnabled()).toBe(true); + expect(dapr.getAppName()).toBe("custom-actor-app"); + expect(dapr.getAppPort()).toBe(9005); + expect(dapr.getAppChannelAddress()).toBe("127.0.0.1"); + expect(dapr.getRedisContainer()).toBe(customRedis); + expect(dapr.getConfiguration()).toBe(customConfig); + expect(harness.getStateStoreName()).toBe("my-actor-store"); + }); + + it("should throw when accessing started container properties before start()", () => { + const harness = new ActorHarness(); + expect(() => harness.getStartedDaprContainer()).toThrow("ActorHarness has not been started."); + expect(() => harness.getHost()).toThrow("ActorHarness has not been started."); + expect(() => harness.getHttpPort()).toThrow("ActorHarness has not been started."); + expect(() => harness.getGrpcPort()).toThrow("ActorHarness has not been started."); + expect(() => harness.getHttpEndpoint()).toThrow("ActorHarness has not been started."); + expect(() => harness.getGrpcEndpoint()).toThrow("ActorHarness has not been started."); + expect(() => harness.createDaprClient()).toThrow("ActorHarness has not been started."); + expect(() => harness.createActorProxyBuilder(DemoCounterActor)).toThrow("ActorHarness has not been started."); + expect(() => harness.createActorProxy(DemoCounterActor, "test-id")).toThrow("ActorHarness has not been started."); + }); + + it("should align the container app port with the server port when none was configured", () => { + const harness = new ActorHarness(); + + harness.createDaprServer({ serverPort: "8123", serverHost: "127.0.0.1" }); + + expect(harness.getDaprContainer().getAppPort()).toBe(8123); + expect(() => harness.createDaprServer({ serverPort: "8124", serverHost: "127.0.0.1" })).toThrow( + "ActorHarness appPort (8123) must match DaprServer serverPort (8124)." + ); + }); + + it("should run actor method invocation and state persistence end-to-end using ActorHarness", async () => { + const appPort = 8091; + await TestContainers.exposeHostPorts(appPort); + + const network = await new Network().start(); + const harness = new ActorHarness({ + appId: "actor-test-app", + appPort, + appChannelAddress: "host.testcontainers.internal", + daprLogLevel: "info", + network, + }); + + const server = harness.createDaprServer({ + serverPort: appPort.toString(), + serverHost: "0.0.0.0", + }); + await server.actor.registerActor(DemoCounterActor); + await server.actor.init(); + await server.daprServer.start("0.0.0.0", appPort.toString()); + + try { + await harness.start(); + + expect(harness.getHost()).toBeDefined(); + expect(harness.getHttpPort()).toBeGreaterThan(0); + expect(harness.getGrpcPort()).toBeGreaterThan(0); + expect(harness.getHttpEndpoint()).toContain(harness.getHost()); + expect(harness.getGrpcEndpoint()).toContain(harness.getGrpcPort().toString()); + + const registeredActors = await server.actor.getRegisteredActors(); + expect(registeredActors).toContain("DemoCounterActor"); + + const proxy1 = harness.createActorProxy(DemoCounterActor, "counter-1"); + const hello = await proxy1.sayHello("Dapr Actors"); + expect(hello).toBe("Hello, Dapr Actors!"); + + // Test actor state increment + const count1 = await proxy1.increment(5); + expect(count1).toBe(5); + const count2 = await proxy1.increment(3); + expect(count2).toBe(8); + expect(await proxy1.getCount()).toBe(8); + + // Verify actor state isolation with a second actor instance + const proxy2 = harness.createActorProxy(DemoCounterActor, new ActorId("counter-2")); + expect(await proxy2.getCount()).toBe(0); + await proxy2.increment(10); + expect(await proxy2.getCount()).toBe(10); + // Counter-1 remains untouched + expect(await proxy1.getCount()).toBe(8); + + // Test state CRUD within actor + await proxy1.setScore(100); + expect(await proxy1.getScore()).toBe(100); + await proxy1.clearScore(); + expect(await proxy1.getScore()).toBeNull(); + } finally { + await harness.stop(); + await network.stop(); + } + }, 120_000); + + it("should support ActorProxyBuilder and custom state store name", async () => { + const appPort = 8092; + await TestContainers.exposeHostPorts(appPort); + + const network = await new Network().start(); + const harness = new ActorHarness({ + appId: "actor-custom-store-app", + appPort, + appChannelAddress: "host.testcontainers.internal", + stateStoreName: "custom-actor-store", + keyPrefix: "actor-test", + daprLogLevel: "info", + network, + }); + + const server = harness.createDaprServer({ + serverPort: appPort.toString(), + serverHost: "0.0.0.0", + }); + await server.actor.registerActor(DemoCounterActor); + await server.actor.init(); + await server.daprServer.start("0.0.0.0", appPort.toString()); + + try { + await harness.start(); + expect(harness.getStateStoreName()).toBe("custom-actor-store"); + + const builder = harness.createActorProxyBuilder(DemoCounterActor); + expect(builder).toBeInstanceOf(ActorProxyBuilder); + + const proxy = builder.build(new ActorId("custom-actor-1")); + const greeting = await proxy.sayHello("Custom Store"); + expect(greeting).toBe("Hello, Custom Store!"); + + const initialCount = await proxy.increment(42); + expect(initialCount).toBe(42); + expect(await proxy.getCount()).toBe(42); + } finally { + await harness.stop(); + await network.stop(); + } + }, 120_000); + + it("should work with Symbol.asyncDispose", async () => { + const appPort = 8093; + await TestContainers.exposeHostPorts(appPort); + + const network = await new Network().start(); + try { + await using harness = new ActorHarness({ + appId: "actor-dispose-app", + appPort, + appChannelAddress: "host.testcontainers.internal", + daprLogLevel: "info", + network, + }); + + const server = harness.createDaprServer({ + serverPort: appPort.toString(), + serverHost: "0.0.0.0", + }); + await server.actor.registerActor(DemoCounterActor); + await server.actor.init(); + await server.daprServer.start("0.0.0.0", appPort.toString()); + + await harness.start(); + + const proxy = harness.createActorProxy(DemoCounterActor, "dispose-actor"); + expect(await proxy.sayHello("Dispose")).toBe("Hello, Dispose!"); + } finally { + await network.stop(); + } + }, 120_000); + + it("should configure and run actors with DaprContainer directly", async () => { + const appPort = 8099; + await TestContainers.exposeHostPorts(appPort); + + const network = await new Network().start(); + const dapr = new DaprContainer() + .withNetwork(network) + .withAppName("dapr-actor-direct-app") + .withAppPort(appPort) + .withAppChannelAddress("host.testcontainers.internal") + .withDaprLogLevel("info") + .withActors({ + stateStoreName: "direct-actor-statestore", + }); + + const server = new (await import("@dapr/dapr")).DaprServer({ + serverHost: "0.0.0.0", + serverPort: appPort.toString(), + }); + await server.actor.registerActor(DemoCounterActor); + await server.actor.init(); + await server.daprServer.start("0.0.0.0", appPort.toString()); + + try { + const startedContainer = await dapr.start(); + + const client = new DaprClient({ + daprHost: startedContainer.getHost(), + daprPort: startedContainer.getHttpPort().toString(), + }); + await client.start(); + + (server as any).client = client; + if ((server as any).actor) { + (server as any).actor.client = client; + } + if ((server as any).daprServer) { + (server as any).daprServer.client = (client as any).daprClient; + } + + const actorRuntime = (ActorRuntime as any).instance; + if (actorRuntime) { + actorRuntime.daprClient = client; + if (actorRuntime.actorManagers) { + for (const manager of actorRuntime.actorManagers.values()) { + manager.daprClient = client; + } + } + } + + try { + const builder = new ActorProxyBuilder(DemoCounterActor, client); + const proxy = builder.build(new ActorId("direct-actor-1")); + expect(await proxy.sayHello("Direct")).toBe("Hello, Direct!"); + expect(await proxy.increment(7)).toBe(7); + expect(await proxy.getCount()).toBe(7); + } finally { + await client.stop(); + await startedContainer.stop(); + } + } finally { + await server.stop(); + await network.stop(); + } + }, 120_000); +}); diff --git a/src/ActorHarness.ts b/src/ActorHarness.ts new file mode 100644 index 0000000..1c7403b --- /dev/null +++ b/src/ActorHarness.ts @@ -0,0 +1,290 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + ActorId, + ActorProxyBuilder, + CommunicationProtocolEnum, + DaprClient, + DaprClientOptions, + DaprServer, +} from "@dapr/dapr"; +import ActorRuntime from "@dapr/dapr/actors/runtime/ActorRuntime"; +import { DaprServerOptions } from "@dapr/dapr/types/DaprServerOptions"; +import { Network, StartedNetwork } from "testcontainers"; +import { Configuration } from "./Configuration"; +import { DaprComponentNames } from "./Constants"; +import { ActorOptions, DaprContainer, StartedDaprContainer } from "./DaprContainer"; +import { RedisContainer } from "./RedisContainer"; + +export type ActorClass = new (...args: any[]) => T; + +export type ActorHarnessOptions = { + appId?: string; + appPort?: number; + appChannelAddress?: string; + daprLogLevel?: string; + daprApiLoggingEnabled?: boolean; + daprRuntimeImage?: string; + stateStoreName?: string; + redisContainer?: RedisContainer; + redisHost?: string; + redisPassword?: string; + actorStateStore?: boolean; + actorStateTTL?: boolean; + keyPrefix?: string; + network?: StartedNetwork; + configuration?: Configuration; +}; + +/** + * Provides an implementation harness for Dapr's Actor building block, + * mirroring the ActorHarness in the .NET SDK. + */ +export class ActorHarness { + private network?: StartedNetwork; + private ownsNetwork = false; + private readonly daprContainer: DaprContainer; + private startedDaprContainer?: StartedDaprContainer; + private daprClients: DaprClient[] = []; + private daprServers: DaprServer[] = []; + + constructor(private readonly options: ActorHarnessOptions = {}) { + const actorOpts: ActorOptions = { + stateStoreName: options.stateStoreName ?? DaprComponentNames.StateManagementComponentName, + redisContainer: options.redisContainer, + redisHost: options.redisHost, + redisPassword: options.redisPassword, + enableActorStateStore: options.actorStateStore ?? true, + actorStateTTL: options.actorStateTTL ?? true, + keyPrefix: options.keyPrefix, + }; + + this.daprContainer = new DaprContainer(options.daprRuntimeImage) + .withAppName(options.appId ?? "actor-app") + .withDaprLogLevel(options.daprLogLevel ?? "info") + .withDaprApiLoggingEnabled(options.daprApiLoggingEnabled ?? false) + .withActors(actorOpts); + + if (options.configuration) { + this.daprContainer.withConfiguration(options.configuration); + } + if (options.appPort) { + this.daprContainer.withAppPort(options.appPort); + } + if (options.appChannelAddress) { + this.daprContainer.withAppChannelAddress(options.appChannelAddress); + } + } + + public getDaprContainer(): DaprContainer { + return this.daprContainer; + } + + public getStateStoreName(): string { + return this.options.stateStoreName ?? DaprComponentNames.StateManagementComponentName; + } + + public async start(): Promise { + if (this.options.network) { + this.network = this.options.network; + this.ownsNetwork = false; + } else { + this.network = await new Network().start(); + this.ownsNetwork = true; + } + + this.daprContainer.withNetwork(this.network); + this.startedDaprContainer = await this.daprContainer.start(); + + // Wire up sidecar client to any servers and the ActorRuntime instance + const sidecarClient = this.createDaprClient(); + await sidecarClient.start(); + for (const server of this.daprServers) { + (server as any).client = sidecarClient; + if ((server as any).actor) { + (server as any).actor.client = sidecarClient; + } + if ((server as any).daprServer) { + (server as any).daprServer.client = (sidecarClient as any).daprClient; + } + } + const actorRuntime = (ActorRuntime as any).instance; + if (actorRuntime) { + actorRuntime.daprClient = sidecarClient; + if (actorRuntime.actorManagers) { + for (const manager of actorRuntime.actorManagers.values()) { + manager.daprClient = sidecarClient; + } + } + } + + return this; + } + + public async stop(): Promise { + if (this.startedDaprContainer) { + try { + await this.startedDaprContainer.stop(); + } catch { + // Ignore errors during container shutdown + } + this.startedDaprContainer = undefined; + } + + for (const server of this.daprServers) { + try { + await server.stop(); + } catch { + // Ignore errors during server shutdown + } + } + this.daprServers = []; + + for (const client of this.daprClients) { + try { + await client.stop(); + } catch { + // Ignore errors during client shutdown + } + } + this.daprClients = []; + + try { + ActorRuntime.resetForTesting(); + } catch { + // Ignore if not present + } + + if (this.ownsNetwork && this.network) { + try { + await this.network.stop(); + } catch { + // Ignore errors during network shutdown + } + this.network = undefined; + } + } + + public getStartedDaprContainer(): StartedDaprContainer { + if (!this.startedDaprContainer) { + throw new Error("ActorHarness has not been started. Call start() first."); + } + return this.startedDaprContainer; + } + + public getHost(): string { + return this.getStartedDaprContainer().getHost(); + } + + public getHttpPort(): number { + return this.getStartedDaprContainer().getHttpPort(); + } + + public getGrpcPort(): number { + return this.getStartedDaprContainer().getGrpcPort(); + } + + public getHttpEndpoint(): string { + return this.getStartedDaprContainer().getHttpEndpoint(); + } + + public getGrpcEndpoint(): string { + return this.getStartedDaprContainer().getGrpcEndpoint(); + } + + public createDaprClient(clientOptions?: Partial): DaprClient { + const started = this.getStartedDaprContainer(); + const protocol = clientOptions?.communicationProtocol ?? CommunicationProtocolEnum.HTTP; + const defaultPort = + protocol === CommunicationProtocolEnum.GRPC ? started.getGrpcPort().toString() : started.getHttpPort().toString(); + + const client = new DaprClient({ + daprHost: started.getHost(), + daprPort: defaultPort, + communicationProtocol: protocol, + ...clientOptions, + }); + this.daprClients.push(client); + return client; + } + + public createDaprServer(serverOptions?: Partial): DaprServer { + const requestedServerPort = + serverOptions?.serverPort ?? (this.options.appPort ? this.options.appPort.toString() : "3001"); + const parsedServerPort = Number.parseInt(requestedServerPort, 10); + if (Number.isNaN(parsedServerPort)) { + throw new Error(`Invalid DaprServer port: ${requestedServerPort}`); + } + + if (this.options.appPort !== undefined && this.options.appPort !== parsedServerPort) { + throw new Error( + `ActorHarness appPort (${this.options.appPort}) must match DaprServer serverPort (${parsedServerPort}).` + ); + } + + if (this.options.appPort === undefined) { + this.options.appPort = parsedServerPort; + this.daprContainer.withAppPort(parsedServerPort); + } + + const serverHost = serverOptions?.serverHost ?? "127.0.0.1"; + const protocol = serverOptions?.communicationProtocol ?? CommunicationProtocolEnum.HTTP; + const defaultDaprPort = this.startedDaprContainer + ? protocol === CommunicationProtocolEnum.GRPC + ? this.startedDaprContainer.getGrpcPort().toString() + : this.startedDaprContainer.getHttpPort().toString() + : undefined; + + const server = new DaprServer({ + serverHost, + serverPort: requestedServerPort, + communicationProtocol: protocol, + ...serverOptions, + clientOptions: { + ...(this.startedDaprContainer + ? { + daprHost: this.startedDaprContainer.getHost(), + daprPort: defaultDaprPort, + } + : {}), + communicationProtocol: protocol, + ...serverOptions?.clientOptions, + }, + }); + this.daprServers.push(server); + return server; + } + + public createActorProxyBuilder( + actorTypeClass: ActorClass, + clientOptions?: Partial + ): ActorProxyBuilder { + const client = this.createDaprClient(clientOptions); + return new ActorProxyBuilder(actorTypeClass, client); + } + + public createActorProxy( + actorTypeClass: ActorClass, + actorId: ActorId | string, + clientOptions?: Partial + ): T { + const builder = this.createActorProxyBuilder(actorTypeClass, clientOptions); + const id = typeof actorId === "string" ? new ActorId(actorId) : actorId; + return builder.build(id); + } + + public async [Symbol.asyncDispose](): Promise { + await this.stop(); + } +} diff --git a/src/Configuration.test.ts b/src/Configuration.test.ts index 172540c..5904155 100644 --- a/src/Configuration.test.ts +++ b/src/Configuration.test.ts @@ -56,4 +56,18 @@ describe("Configuration", () => { " type: middleware.http.routeralias\n"; expect(configurationYaml).toEqual(expectedConfigurationYaml); }); + + it("should convert features configuration to YAML", () => { + const config = new Configuration("actorConfig", undefined, undefined, [{ name: "ActorStateTTL", enabled: true }]); + const expectedYaml = + "apiVersion: dapr.io/v1alpha1\n" + + "kind: Configuration\n" + + "metadata:\n" + + " name: actorConfig\n" + + "spec:\n" + + " features:\n" + + " - name: ActorStateTTL\n" + + " enabled: true\n"; + expect(config.toYaml()).toEqual(expectedYaml); + }); }); diff --git a/src/Configuration.ts b/src/Configuration.ts index 80c99c5..9e95046 100644 --- a/src/Configuration.ts +++ b/src/Configuration.ts @@ -18,6 +18,11 @@ export type ListEntry = { type: string; }; +export type FeatureConfigurationSetting = { + name: string; + enabled: boolean; +}; + export class AppHttpPipeline { constructor(public readonly handlers: ListEntry[]) {} } @@ -52,6 +57,7 @@ type ConfigurationResource = { spec: { tracing?: TracingConfigurationSettings; appHttpPipeline?: AppHttpPipeline; + features?: FeatureConfigurationSetting[]; }; }; @@ -60,11 +66,11 @@ type ConfigurationResource = { * * @remarks * This class is used to create a configuration object for Dapr. It includes - * tracing and appHttpPipeline settings. + * tracing, appHttpPipeline, and feature settings. * * @example * ```typescript - * const config = new Configuration("my-config", tracingConfig, appHttpPipeline); + * const config = new Configuration("my-config", tracingConfig, appHttpPipeline, [{ name: "ActorStateTTL", enabled: true }]); * console.log(config.toYaml()); * ``` */ @@ -84,24 +90,33 @@ export class Configuration { * @param tracing TracingConfigParameters tracing configuration * parameters. * @param appHttpPipeline AppHttpPipeline middleware configuration. + * @param features Optional list of feature configuration settings. */ constructor( public readonly name: string, - public readonly tracing: TracingConfigurationSettings, - public readonly appHttpPipeline: AppHttpPipeline + public readonly tracing?: TracingConfigurationSettings, + public readonly appHttpPipeline?: AppHttpPipeline, + public readonly features?: FeatureConfigurationSetting[] ) {} toYaml(): string { + const spec: ConfigurationResource["spec"] = {}; + if (this.tracing !== undefined) { + spec.tracing = this.tracing; + } + if (this.appHttpPipeline !== undefined) { + spec.appHttpPipeline = this.appHttpPipeline; + } + if (this.features !== undefined) { + spec.features = this.features; + } const resource: ConfigurationResource = { apiVersion: "dapr.io/v1alpha1", kind: "Configuration", metadata: { name: this.name, }, - spec: { - ...{ tracing: this.tracing }, - ...{ appHttpPipeline: this.appHttpPipeline }, - }, + spec, }; return YAML.stringify(resource, { indentSeq: false }); } diff --git a/src/DaprContainer.test.ts b/src/DaprContainer.test.ts index c75689a..98abeb4 100644 --- a/src/DaprContainer.test.ts +++ b/src/DaprContainer.test.ts @@ -73,6 +73,38 @@ describe("DaprContainer", () => { expect(startedContainer.getContainers()).toHaveLength(3); }, 60_000); + it("should start a shared Redis container when actors are enabled", async () => { + await using network = await new Network().start(); + const dapr = new DaprContainer(DAPR_RUNTIME_IMAGE).withNetwork(network).withActors(); + + await using startedContainer = await dapr.start(); + + expect(dapr.isActorsEnabled()).toBe(true); + expect(dapr.getRedisContainer()).toBeDefined(); + expect(startedContainer.getContainers()).toHaveLength(3); + }, 60_000); + + it("should merge actor state store settings into an existing state store component", async () => { + await using network = await new Network().start(); + const dapr = new DaprContainer(DAPR_RUNTIME_IMAGE) + .withNetwork(network) + .withStateManagement({ stateStoreName: "statestore", enableActorStateStore: false }) + .withActors({ stateStoreName: "statestore", enableActorStateStore: true, keyPrefix: "actor" }); + + await using startedContainer = await dapr.start(); + + expect(startedContainer).toBeDefined(); + const component = dapr.getComponents().find((item) => item.name === "statestore"); + expect(component).toBeDefined(); + expect(component?.getMetadata()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "redisHost", value: "redis:6379" }), + expect.objectContaining({ name: "actorStateStore", value: "true" }), + expect.objectContaining({ name: "keyPrefix", value: "actor" }), + ]) + ); + }, 60_000); + it("should initialize DaprClient", async () => { await using network = await new Network().start(); const dapr = new DaprContainer(DAPR_RUNTIME_IMAGE) diff --git a/src/DaprContainer.ts b/src/DaprContainer.ts index 4641af0..7ca695c 100644 --- a/src/DaprContainer.ts +++ b/src/DaprContainer.ts @@ -104,6 +104,16 @@ export type StateManagementOptions = { keyPrefix?: string; }; +export type ActorOptions = { + stateStoreName?: string; + redisContainer?: RedisContainer; + redisHost?: string; + redisPassword?: string; + enableActorStateStore?: boolean; + actorStateTTL?: boolean; + keyPrefix?: string; +}; + export type DistributedLockOptions = { lockStoreName?: string; redisContainer?: RedisContainer; @@ -156,6 +166,8 @@ export class DaprContainer extends GenericContainer { private pubSubOptions?: PubSubOptions; private stateManagementEnabled = false; private stateManagementOptions?: StateManagementOptions; + private actorsEnabled = false; + private actorOptions?: ActorOptions; private distributedLockEnabled = false; private distributedLockOptions?: DistributedLockOptions; private startedNetwork?: StartedNetwork; @@ -239,6 +251,7 @@ export class DaprContainer extends GenericContainer { } this.schedulerContainer = container; } + this.schedulerContainer.withBroadcastHost(this.schedulerService); const startedContainers: StartedTestContainer[] = []; const startContainer = async (container: GenericContainer): Promise => { @@ -254,6 +267,7 @@ export class DaprContainer extends GenericContainer { const needsSharedRedis = (this.workflowEnabled && !this.workflowOptions?.redisHost) || (this.stateManagementEnabled && !this.stateManagementOptions?.redisHost) || + (this.actorsEnabled && !this.actorOptions?.redisHost) || (this.distributedLockEnabled && !this.distributedLockOptions?.redisHost); if (this.redisContainer || needsSharedRedis) { @@ -330,6 +344,15 @@ export class DaprContainer extends GenericContainer { protected override async beforeContainerCreated(): Promise { assert(this.placementContainer, "DaprPlacementContainer expected"); assert(this.schedulerContainer, "DaprSchedulerContainer expected"); + + if (this.actorsEnabled) { + if (!this.configuration && (this.actorOptions?.actorStateTTL ?? true)) { + this.configuration = new Configuration("actorConfig", undefined, undefined, [ + { name: "ActorStateTTL", enabled: true }, + ]); + } + } + const cmds = [ "./daprd", "--app-id", @@ -383,18 +406,13 @@ export class DaprContainer extends GenericContainer { if (this.workflowEnabled) { const stateStoreName = this.workflowOptions?.stateStoreName ?? DaprComponentNames.StateManagementComponentName; - const alreadyHasStateStore = this.components.some((c) => c.name === stateStoreName); - if (!alreadyHasStateStore) { - const redisHost = - this.workflowOptions?.redisHost ?? - `${this.redisService}:${this.redisContainer ? this.redisContainer.getPort() : REDIS_DEFAULT_PORT}`; - const redisStateStore = RedisContainer.createStateStoreComponent({ - name: stateStoreName, - redisHost, - actorStateStore: this.workflowOptions?.enableActorStateStore ?? true, - }); - this.components.push(redisStateStore); - } + const redisHost = + this.workflowOptions?.redisHost ?? + `${this.redisService}:${this.redisContainer ? this.redisContainer.getPort() : REDIS_DEFAULT_PORT}`; + this.upsertStateStoreComponent(stateStoreName, { + redisHost, + actorStateStore: this.workflowOptions?.enableActorStateStore ?? true, + }); } if (this.conversationEnabled) { @@ -439,20 +457,28 @@ export class DaprContainer extends GenericContainer { if (this.stateManagementEnabled) { const stateStoreName = this.stateManagementOptions?.stateStoreName ?? DaprComponentNames.StateManagementComponentName; - const alreadyHasStateStore = this.components.some((c) => c.name === stateStoreName); - if (!alreadyHasStateStore) { - const redisHost = - this.stateManagementOptions?.redisHost ?? - `${this.redisService}:${this.redisContainer ? this.redisContainer.getPort() : REDIS_DEFAULT_PORT}`; - const redisStateStore = RedisContainer.createStateStoreComponent({ - name: stateStoreName, - redisHost, - redisPassword: this.stateManagementOptions?.redisPassword, - actorStateStore: this.stateManagementOptions?.enableActorStateStore ?? true, - keyPrefix: this.stateManagementOptions?.keyPrefix, - }); - this.components.push(redisStateStore); - } + const redisHost = + this.stateManagementOptions?.redisHost ?? + `${this.redisService}:${this.redisContainer ? this.redisContainer.getPort() : REDIS_DEFAULT_PORT}`; + this.upsertStateStoreComponent(stateStoreName, { + redisHost, + redisPassword: this.stateManagementOptions?.redisPassword, + actorStateStore: this.stateManagementOptions?.enableActorStateStore ?? true, + keyPrefix: this.stateManagementOptions?.keyPrefix, + }); + } + + if (this.actorsEnabled) { + const stateStoreName = this.actorOptions?.stateStoreName ?? DaprComponentNames.StateManagementComponentName; + const redisHost = + this.actorOptions?.redisHost ?? + `${this.redisService}:${this.redisContainer ? this.redisContainer.getPort() : REDIS_DEFAULT_PORT}`; + this.upsertStateStoreComponent(stateStoreName, { + redisHost, + redisPassword: this.actorOptions?.redisPassword, + actorStateStore: this.actorOptions?.enableActorStateStore ?? true, + keyPrefix: this.actorOptions?.keyPrefix, + }); } if (this.distributedLockEnabled) { @@ -521,6 +547,38 @@ export class DaprContainer extends GenericContainer { } } + private upsertStateStoreComponent( + stateStoreName: string, + options: { + redisHost?: string; + redisPassword?: string; + actorStateStore?: boolean; + keyPrefix?: string; + } + ): void { + const nextStateStore = RedisContainer.createStateStoreComponent({ + name: stateStoreName, + redisHost: options.redisHost, + redisPassword: options.redisPassword, + actorStateStore: options.actorStateStore, + keyPrefix: options.keyPrefix, + }); + + const existingIndex = this.components.findIndex((component) => component.name === stateStoreName); + if (existingIndex === -1) { + this.components.push(nextStateStore); + return; + } + + const existing = this.components[existingIndex]; + const mergedMetadata = existing + .getMetadata() + .filter((entry) => !["redisHost", "redisPassword", "actorStateStore", "keyPrefix"].includes(entry.name)) + .concat(nextStateStore.getMetadata()); + + this.components[existingIndex] = new Component(existing.name, existing.type, existing.version, mergedMetadata); + } + getAppName(): string { return this.appName; } @@ -589,6 +647,14 @@ export class DaprContainer extends GenericContainer { return this.stateManagementOptions; } + isActorsEnabled(): boolean { + return this.actorsEnabled; + } + + getActorOptions(): ActorOptions | undefined { + return this.actorOptions; + } + isDistributedLockEnabled(): boolean { return this.distributedLockEnabled; } @@ -776,6 +842,15 @@ export class DaprContainer extends GenericContainer { return this; } + withActors(options?: ActorOptions): this { + this.actorsEnabled = true; + this.actorOptions = options; + if (options?.redisContainer) { + this.redisContainer = options.redisContainer; + } + return this; + } + withDistributedLock(options?: DistributedLockOptions): this { this.distributedLockEnabled = true; this.distributedLockOptions = options; diff --git a/src/DaprSchedulerContainer.ts b/src/DaprSchedulerContainer.ts index 48b1643..5ecb740 100644 --- a/src/DaprSchedulerContainer.ts +++ b/src/DaprSchedulerContainer.ts @@ -17,6 +17,7 @@ import { getDaprSchedulerImage } from "./Constants"; export class DaprSchedulerContainer extends GenericContainer { private static readonly healthPort = 8080; private schedulerPort = 51005; + private broadcastHost?: string; constructor(image: string = getDaprSchedulerImage()) { super(image); @@ -36,7 +37,11 @@ export class DaprSchedulerContainer extends GenericContainer { { content: "", target: "./dapr-scheduler-existing-cluster/", mode: 0o777 }, ]); this.withExposedPorts(this.schedulerPort, DaprSchedulerContainer.healthPort); - this.withCommand(["./scheduler", "--port", this.schedulerPort.toString(), "--etcd-data-dir", "."]); + const command = ["./scheduler", "--port", this.schedulerPort.toString(), "--etcd-data-dir", "."]; + if (this.broadcastHost) { + command.push("--override-broadcast-host-port", `${this.broadcastHost}:${this.schedulerPort}`); + } + this.withCommand(command); } withPort(port: number): this { @@ -44,6 +49,11 @@ export class DaprSchedulerContainer extends GenericContainer { return this; } + withBroadcastHost(host: string): this { + this.broadcastHost = host; + return this; + } + getPort(): number { return this.schedulerPort; } diff --git a/src/index.ts b/src/index.ts index 32a1d62..a0d58b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +export * from "./ActorHarness"; export * from "./Component"; export * from "./Configuration"; export * from "./ConversationHarness";