Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,83 @@ Optional `prefix`, `maxSize`, and `maxTotalSize` settings are passed to the in-m
The store needs no external storage service; data is local to the sidecar and is lost when it stops.
See `src/BinaryStoreHarness.test.ts` for configuration and integration examples.

## Dapr Search Testing

`SearchHarness` or `.withSearch()` on `DaprContainer` provisions a `search.meilisearch` component and an
authenticated Meilisearch container. Search is an upcoming alpha building block: the default `1.18.4`
runtime does not support it. Select an RC or development image that includes **both** the Search API and
the Meilisearch component using `daprRuntimeImage` or `DAPR_RUNTIME_VERSION`. No unreleased RC tag is assumed.

```typescript
import { SearchHarness } from "@dapr/testcontainer-node";

// Set DAPR_RUNTIME_VERSION to a supporting RC/development tag before running.
await using harness = await new SearchHarness().start();
await harness.createIndex("books", {
searchableAttributes: "title",
filterableAttributes: "category",
sortableAttributes: "price",
});

const result = await harness.indexDocuments(
"books",
[{ id: "one", content: Buffer.from(JSON.stringify({ title: "Dapr guide", category: "guide", price: 10 })) }],
{
options: {
mode: "INDEXING_MODE_WAIT_FOR_COMPLETION",
waitTimeout: "10s",
onWaitTimeout: "INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST",
},
}
);
if (result.failedItems.length) {
throw new Error(JSON.stringify(result.failedItems));
}

const page = await harness.search("books", {
text: "Dapr",
filter: { category: { $eq: "guide" } },
includeContent: true,
topK: 10,
});
console.log(page.hits.map((hit) => JSON.parse(hit.document.content.toString("utf8"))));
```

The helpers follow the [Search proto](https://github.com/dapr/dapr/blob/master/dapr/proto/runtime/v1/search.proto)
through the `v1.0-alpha1` HTTP API; the Node SDK does not yet expose these operations.
They cover index creation/listing/retrieval/deletion, document indexing/retrieval/deletion, and search
(portable filters, native queries, sorting, projection, highlighting, and continuation tokens).
Document content is a `Buffer` containing UTF-8 JSON object bytes, automatically base64-encoded on the wire.
Content is omitted by default for reads; pass `includeContent: true` to retrieve it. Found documents are
returned in request order, with missing IDs omitted. Document metadata is opaque and not searchable.
Counts (`documentCount`, `totalHits`) remain protobuf JSON strings to preserve uint64 precision.

Writes, including document deletion, default to return-on-acceptance. `INDEX_ACK_QUEUED` does not mean
the data is visible yet. Use explicit wait-for-completion options for deterministic assertions, and check
`failedItems` even when the request succeeds. Wait timeouts must be positive and shorter than any
applicable request/resiliency deadline. HTTP failures throw with the status and response body.

Use `searchStoreName`, `meilisearchContainer: new MeilisearchContainer(customImage)`, `apiKey`, or the
component's `timeout` (a Go duration) to customize provisioning. The default image is
`getmeili/meilisearch:v1.37.0`, with a randomly generated master key and analytics disabled.
Alternatively, supply `meilisearchHost` (a URL reachable **from the sidecar**) and `apiKey` to use an
external provider without starting one. Do not combine `meilisearchHost` and `meilisearchContainer`.
The harness stops its managed containers and any network it creates, but leaves caller-supplied networks
and external providers running. `getStartedDaprContainer().getMeilisearchContainer()` exposes the managed
provider's host endpoint and API key for backend-specific assertions.

The Search integration suite is explicitly opt-in until a supporting RC is available. In PowerShell:

```powershell
$env:DAPR_SEARCH_RUNTIME_IMAGE = "daprio/daprd:edge" # Or a supporting RC image when available
npm test -- --runInBand --runTestsByPath src\SearchHarness.test.ts src\MeilisearchContainer.test.ts
```

`edge` is a moving development tag, not a pinned release. `DAPR_SEARCH_RUNTIME_IMAGE` selects the sidecar
image for these tests only. Without it, Search integration
tests are skipped; unit tests and the standalone Meilisearch container test still run. An explicitly
selected unsupported image fails rather than silently skipping the integration checks.

## Dapr Workflow Testing

You can use `WorkflowHarness` or `.withWorkflow()` on `DaprContainer` to test Dapr Workflows with an automated Redis actor state store, placement service, and scheduler service:
Expand Down
1 change: 1 addition & 0 deletions src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function getDaprSchedulerImage(version: string = getDaprVersion()): strin
export const DaprComponentNames = {
StateManagementComponentName: "statestore",
BinaryStoreComponentName: "binarystore",
SearchComponentName: "search",
PubSubComponentName: "pubsub",
ConversationComponentName: "conversation",
CryptographyComponentName: "cryptography",
Expand Down
66 changes: 59 additions & 7 deletions src/DaprContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { DaprSchedulerContainer } from "./DaprSchedulerContainer";
import { DaprTelemetryCollector } from "./DaprTelemetryCollector";
import { HttpEndpoint } from "./HttpEndpoint";
import { MetadataEntry } from "./MetadataEntry";
import { MEILISEARCH_DEFAULT_PORT, MeilisearchContainer, StartedMeilisearchContainer } from "./MeilisearchContainer";
import { OLLAMA_DEFAULT_MODEL, OLLAMA_DEFAULT_PORT, OllamaContainer, StartedOllamaContainer } from "./OllamaContainer";
import {
RABBITMQ_DEFAULT_PASSWORD,
Expand Down Expand Up @@ -114,6 +115,14 @@ export type BinaryStoreOptions = {
maxTotalSize?: string;
};

export type SearchOptions = {
searchStoreName?: string;
meilisearchContainer?: MeilisearchContainer;
meilisearchHost?: string;
apiKey?: string;
timeout?: string;
};

export type ActorOptions = {
stateStoreName?: string;
redisContainer?: RedisContainer;
Expand Down Expand Up @@ -162,6 +171,8 @@ export class DaprContainer extends GenericContainer {
private schedulerContainer?: DaprSchedulerContainer;
private redisContainer?: RedisContainer;
private ollamaContainer?: OllamaContainer;
private meilisearchContainer?: MeilisearchContainer;
private searchEnabled = false;
private shouldReusePlacement = false;
private shouldReuseScheduler = false;
private shouldReuseRedis = false;
Expand Down Expand Up @@ -246,24 +257,21 @@ export class DaprContainer extends GenericContainer {
throw new Error("ollamaPort requires ollamaHost or ollamaEndpoint.");
}
if (!this.placementContainer) {
const container = new DaprPlacementContainer(this.placementImage)
.withNetwork(this.startedNetwork)
.withNetworkAliases(this.placementService);
const container = new DaprPlacementContainer(this.placementImage).withNetworkAliases(this.placementService);
if (this.shouldReusePlacement) {
container.withReuse().withAutoRemove(false);
}
this.placementContainer = container;
}
if (!this.schedulerContainer) {
const container = new DaprSchedulerContainer(this.schedulerImage)
.withNetwork(this.startedNetwork)
.withNetworkAliases(this.schedulerService);
const container = new DaprSchedulerContainer(this.schedulerImage).withNetworkAliases(this.schedulerService);
if (this.shouldReuseScheduler) {
container.withReuse().withAutoRemove(false);
}
this.schedulerContainer = container;
}
this.schedulerContainer.withBroadcastHost(this.schedulerService);
this.placementContainer.withNetwork(this.startedNetwork);
this.schedulerContainer.withNetwork(this.startedNetwork).withBroadcastHost(this.schedulerService);

const startedContainers: StartedTestContainer[] = [];
const startContainer = async (container: GenericContainer): Promise<StartedTestContainer> => {
Expand Down Expand Up @@ -343,6 +351,11 @@ export class DaprContainer extends GenericContainer {
}
}

if (this.meilisearchContainer) {
this.meilisearchContainer.withNetwork(this.startedNetwork).withNetworkAliases("meilisearch");
startTasks.push(startContainer(this.meilisearchContainer));
}

this.telemetryConfigurationLocked = true;
try {
const containers = await Promise.all(startTasks);
Expand Down Expand Up @@ -925,6 +938,39 @@ export class DaprContainer extends GenericContainer {
return this;
}

/** Requires a runtime image containing the alpha Search API and search.meilisearch component. */
withSearch(options: SearchOptions = {}): this {
if (this.searchEnabled) {
throw new Error("Search has already been configured on this DaprContainer.");
}
if (options.meilisearchContainer && options.meilisearchHost) {
throw new Error("Specify either meilisearchContainer or meilisearchHost, not both.");
}
const name = options.searchStoreName ?? DaprComponentNames.SearchComponentName;
if (this.components.some((component) => component.name === name)) {
throw new Error(`A component named "${name}" has already been registered`);
}
const container = options.meilisearchHost
? undefined
: (options.meilisearchContainer ?? new MeilisearchContainer());
if (container && options.apiKey !== undefined) {
container.withApiKey(options.apiKey);
}
const component = MeilisearchContainer.createSearchComponent({
name,
host: options.meilisearchHost ?? `http://meilisearch:${container?.getPort() ?? MEILISEARCH_DEFAULT_PORT}`,
apiKey: options.apiKey ?? container?.getApiKey(),
timeout: options.timeout,
});
this.meilisearchContainer = container;
this.searchEnabled = true;
return this.withComponent(component);
}

getMeilisearchContainer(): MeilisearchContainer | undefined {
return this.meilisearchContainer;
}

/**
* Adds an in-memory binary store. Requires a Dapr runtime supporting the Binary Store API (1.19+).
*/
Expand Down Expand Up @@ -1034,6 +1080,12 @@ export class StartedDaprContainer extends AbstractStartedContainer {
return this.containers.slice();
}

getMeilisearchContainer(): StartedMeilisearchContainer | undefined {
return this.containers.find((container): container is StartedMeilisearchContainer => {
return container instanceof StartedMeilisearchContainer;
});
}

getOllamaContainer(): StartedOllamaContainer | undefined {
return this.containers.find((container): container is StartedOllamaContainer => {
return container instanceof StartedOllamaContainer;
Expand Down
63 changes: 63 additions & 0 deletions src/MeilisearchContainer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
Copyright 2026 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { Component } from "./Component";
import { MEILISEARCH_DEFAULT_PORT, MeilisearchContainer } from "./MeilisearchContainer";

describe("MeilisearchContainer", () => {
it("generates an isolated master key and configures the search component", () => {
const container = new MeilisearchContainer();
expect(container.getPort()).toBe(MEILISEARCH_DEFAULT_PORT);
expect(container.getApiKey().length).toBeGreaterThanOrEqual(16);
expect(container.getApiKey()).not.toBe(new MeilisearchContainer().getApiKey());
const component = container.createSearchComponent();
expect(component).toBeInstanceOf(Component);
expect(component.name).toBe("search");
expect(component.type).toBe("search.meilisearch");
expect(component.version).toBe("v1");
expect(component.getMetadata()).toEqual([
{ name: "host", value: "http://meilisearch:7700" },
{ name: "apiKey", value: container.getApiKey() },
]);
});

it("supports a custom image, key, and component metadata", () => {
const container = new MeilisearchContainer("getmeili/meilisearch:custom").withApiKey("test-master-key-1234");
expect(
container.createSearchComponent({ name: "books", host: "http://custom:7700", timeout: "10s" }).getMetadata()
).toEqual([
{ name: "host", value: "http://custom:7700" },
{ name: "apiKey", value: "test-master-key-1234" },
{ name: "timeout", value: "10s" },
]);
expect(() => container.withApiKey("short")).toThrow("at least 16");
});

it("supports externally managed stores without a key", () => {
expect(MeilisearchContainer.createSearchComponent().getMetadata()).toEqual([
{ name: "host", value: "http://localhost:7700" },
{ name: "apiKey", value: "" },
]);
});

it("starts an authenticated Meilisearch server", async () => {
await using started = await new MeilisearchContainer().start();
expect((await fetch(`${started.getHttpEndpoint()}/health`)).status).toBe(200);
const response = await fetch(`${started.getHttpEndpoint()}/indexes`, {
headers: { Authorization: `Bearer ${started.getApiKey()}` },
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ results: [] });
expect((await fetch(`${started.getHttpEndpoint()}/indexes`)).status).toBe(401);
}, 180_000);
});
108 changes: 108 additions & 0 deletions src/MeilisearchContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
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 { randomBytes } from "node:crypto";
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
import { Component, MetadataEntry } from "./Component";
import { DaprComponentNames } from "./Constants";

export const MEILISEARCH_DEFAULT_IMAGE = "getmeili/meilisearch:v1.37.0";
export const MEILISEARCH_DEFAULT_PORT = 7700;

export type MeilisearchStoreOptions = {
name?: string;
host?: string;
apiKey?: string;
timeout?: string;
};

export class MeilisearchContainer extends GenericContainer {
private apiKey = randomBytes(24).toString("hex");

constructor(image: string = MEILISEARCH_DEFAULT_IMAGE) {
super(image);
this.withEnvironment({
MEILI_ENV: "production",
MEILI_MASTER_KEY: this.apiKey,
MEILI_NO_ANALYTICS: "true",
})
.withExposedPorts(MEILISEARCH_DEFAULT_PORT)
.withWaitStrategy(Wait.forHttp("/health", MEILISEARCH_DEFAULT_PORT).forStatusCode(200))
.withStartupTimeout(120_000);
}

public withApiKey(apiKey: string): this {
if (apiKey.length < 16) {
throw new Error("Meilisearch master key must contain at least 16 characters.");
}
this.apiKey = apiKey;
return this.withEnvironment({ MEILI_MASTER_KEY: apiKey });
}

public getApiKey(): string {
return this.apiKey;
}

public getPort(): number {
return MEILISEARCH_DEFAULT_PORT;
}

public createSearchComponent(options: MeilisearchStoreOptions = {}): Component {
return MeilisearchContainer.createSearchComponent({
...options,
host: options.host ?? `http://meilisearch:${this.getPort()}`,
apiKey: options.apiKey ?? this.apiKey,
});
}

public static createSearchComponent(options: MeilisearchStoreOptions = {}): Component {
const name = options.name ?? DaprComponentNames.SearchComponentName;
if (!name.trim() || /[/\\]/.test(name) || name === "." || name === "..") {
throw new Error("Search store name must be non-empty and must not contain path separators or dot segments.");
}
const host = options.host ?? `http://localhost:${MEILISEARCH_DEFAULT_PORT}`;
const url = new URL(host);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Meilisearch host must be an HTTP or HTTPS URL.");
}
const metadata: MetadataEntry[] = [
{ name: "host", value: host },
{ name: "apiKey", value: options.apiKey ?? "" },
];
if (options.timeout !== undefined) {
metadata.push({ name: "timeout", value: options.timeout });
}
return new Component(name, "search.meilisearch", "v1", metadata);
}

public override async start(): Promise<StartedMeilisearchContainer> {
return new StartedMeilisearchContainer(await super.start(), this.apiKey);
}
}

export class StartedMeilisearchContainer extends AbstractStartedContainer {
constructor(
startedTestContainer: StartedTestContainer,
private readonly apiKey: string
) {
super(startedTestContainer);
}

public getHttpEndpoint(): string {
return `http://${this.getHost()}:${this.getMappedPort(MEILISEARCH_DEFAULT_PORT)}`;
}

public getApiKey(): string {
return this.apiKey;
}
}
Loading
Loading