From d9ca890de1aaec0301588e28d379f1db7399123b Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Fri, 29 May 2026 13:17:28 -0700 Subject: [PATCH 1/2] test(resumable-streams): add unit tests for core factory functions and chat session --- .changeset/resumable-streams-unit-tests.md | 5 + packages/resumable-streams/package.json | 5 +- .../src/chat-session.spec.ts | 377 ++++++++++++++++++ .../src/resumable-streams.spec.ts | 342 ++++++++++++++++ packages/resumable-streams/vitest.config.ts | 28 ++ pnpm-lock.yaml | 115 +++--- 6 files changed, 817 insertions(+), 55 deletions(-) create mode 100644 .changeset/resumable-streams-unit-tests.md create mode 100644 packages/resumable-streams/src/chat-session.spec.ts create mode 100644 packages/resumable-streams/src/resumable-streams.spec.ts create mode 100644 packages/resumable-streams/vitest.config.ts diff --git a/.changeset/resumable-streams-unit-tests.md b/.changeset/resumable-streams-unit-tests.md new file mode 100644 index 000000000..890f15c21 --- /dev/null +++ b/.changeset/resumable-streams-unit-tests.md @@ -0,0 +1,5 @@ +--- +"@voltagent/resumable-streams": patch +--- + +Add unit tests for resumable-streams package diff --git a/packages/resumable-streams/package.json b/packages/resumable-streams/package.json index 8c2c03a27..11d784b8a 100644 --- a/packages/resumable-streams/package.json +++ b/packages/resumable-streams/package.json @@ -9,7 +9,10 @@ "redis": "^4.7.0", "resumable-stream": "^2.2.10" }, - "devDependencies": {}, + "devDependencies": { + "@vitest/coverage-v8": "^3.2.4", + "vitest": "^3.2.4" + }, "exports": { ".": { "import": { diff --git a/packages/resumable-streams/src/chat-session.spec.ts b/packages/resumable-streams/src/chat-session.spec.ts new file mode 100644 index 000000000..dee8e0f4d --- /dev/null +++ b/packages/resumable-streams/src/chat-session.spec.ts @@ -0,0 +1,377 @@ +import type { ResumableStreamAdapter } from "@voltagent/core"; +import { describe, expect, it, vi } from "vitest"; +import { createResumableChatSession } from "./chat-session"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeAdapter = (overrides: Partial = {}): ResumableStreamAdapter => ({ + createStream: vi.fn(async () => "generated-stream-id"), + resumeStream: vi.fn(async () => null), + getActiveStreamId: vi.fn(async () => null), + clearActiveStream: vi.fn(async () => {}), + ...overrides, +}); + +const makeReadableStream = (text = "hello"): ReadableStream => { + return new ReadableStream({ + start(controller) { + controller.enqueue(text); + controller.close(); + }, + }); +}; + +// --------------------------------------------------------------------------- +// createResumableChatSession — validation +// --------------------------------------------------------------------------- + +describe("createResumableChatSession", () => { + describe("validation", () => { + it("throws when conversationId is missing", () => { + expect(() => + createResumableChatSession({ + adapter: makeAdapter(), + conversationId: "", + userId: "u1", + }), + ).toThrow("conversationId is required"); + }); + + it("throws when userId is missing", () => { + expect(() => + createResumableChatSession({ + adapter: makeAdapter(), + conversationId: "conv1", + userId: "", + }), + ).toThrow("userId is required"); + }); + + it("creates a session without throwing for valid inputs", () => { + expect(() => + createResumableChatSession({ + adapter: makeAdapter(), + conversationId: "conv1", + userId: "u1", + }), + ).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // createStream + // --------------------------------------------------------------------------- + + describe("createStream", () => { + it("delegates to adapter.createStream and returns the stream id", async () => { + const adapter = makeAdapter({ + createStream: vi.fn(async () => "sid-123"), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const streamId = await session.createStream(makeReadableStream()); + expect(streamId).toBe("sid-123"); + expect(adapter.createStream).toHaveBeenCalledOnce(); + }); + + it("passes conversationId and userId to adapter.createStream", async () => { + const adapter = makeAdapter({ + createStream: vi.fn(async () => "sid-xyz"), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv-abc", + userId: "user-42", + }); + + await session.createStream(makeReadableStream()); + expect(adapter.createStream).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-abc", userId: "user-42" }), + ); + }); + }); + + // --------------------------------------------------------------------------- + // resumeStream + // --------------------------------------------------------------------------- + + describe("resumeStream", () => { + it("returns null when no stream exists for the given id", async () => { + const adapter = makeAdapter({ + resumeStream: vi.fn(async () => null), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const stream = await session.resumeStream("unknown-id"); + expect(stream).toBeNull(); + }); + + it("returns the stream when it exists", async () => { + const readable = makeReadableStream("data"); + const adapter = makeAdapter({ + resumeStream: vi.fn(async () => readable), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const stream = await session.resumeStream("known-id"); + expect(stream).toBe(readable); + }); + }); + + // --------------------------------------------------------------------------- + // getActiveStreamId + // --------------------------------------------------------------------------- + + describe("getActiveStreamId", () => { + it("returns null when no active stream", async () => { + const adapter = makeAdapter({ + getActiveStreamId: vi.fn(async () => null), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const sid = await session.getActiveStreamId(); + expect(sid).toBeNull(); + }); + + it("returns the active stream id when one is set", async () => { + const adapter = makeAdapter({ + getActiveStreamId: vi.fn(async () => "active-sid"), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const sid = await session.getActiveStreamId(); + expect(sid).toBe("active-sid"); + }); + }); + + // --------------------------------------------------------------------------- + // clearActiveStream + // --------------------------------------------------------------------------- + + describe("clearActiveStream", () => { + it("calls adapter.clearActiveStream with context", async () => { + const adapter = makeAdapter(); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + await session.clearActiveStream(); + expect(adapter.clearActiveStream).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv1", userId: "u1" }), + ); + }); + + it("passes streamId when provided", async () => { + const adapter = makeAdapter(); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + await session.clearActiveStream("sid-to-clear"); + expect(adapter.clearActiveStream).toHaveBeenCalledWith( + expect.objectContaining({ streamId: "sid-to-clear" }), + ); + }); + }); + + // --------------------------------------------------------------------------- + // consumeSseStream + // --------------------------------------------------------------------------- + + describe("consumeSseStream", () => { + it("creates a stream from the provided SSE stream", async () => { + const adapter = makeAdapter({ + createStream: vi.fn(async () => "sse-sid"), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + await session.consumeSseStream({ stream: makeReadableStream() }); + expect(adapter.createStream).toHaveBeenCalledOnce(); + }); + + it("does not throw when adapter.createStream rejects", async () => { + const adapter = makeAdapter({ + createStream: vi.fn(async () => { + throw new Error("persist failed"); + }), + }); + const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + logger: logger as never, + }); + + // Should not throw even when adapter fails + await expect( + session.consumeSseStream({ stream: makeReadableStream() }), + ).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // onFinish + // --------------------------------------------------------------------------- + + describe("onFinish", () => { + it("clears the active stream when one has been created", async () => { + const adapter = makeAdapter({ + createStream: vi.fn(async () => "fin-sid"), + clearActiveStream: vi.fn(async () => {}), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + await session.createStream(makeReadableStream()); + await session.onFinish(); + expect(adapter.clearActiveStream).toHaveBeenCalledWith( + expect.objectContaining({ streamId: "fin-sid" }), + ); + }); + + it("does nothing when there is no active stream", async () => { + const adapter = makeAdapter({ + getActiveStreamId: vi.fn(async () => null), + clearActiveStream: vi.fn(async () => {}), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + await session.onFinish(); + expect(adapter.clearActiveStream).not.toHaveBeenCalled(); + }); + + it("does not throw when clearActiveStream rejects", async () => { + const adapter = makeAdapter({ + createStream: vi.fn(async () => "fin-sid"), + clearActiveStream: vi.fn(async () => { + throw new Error("clear failed"); + }), + }); + const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + logger: logger as never, + }); + + await session.createStream(makeReadableStream()); + await expect(session.onFinish()).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // resumeResponse + // --------------------------------------------------------------------------- + + describe("resumeResponse", () => { + it("returns 204 when no active stream exists", async () => { + const adapter = makeAdapter({ + getActiveStreamId: vi.fn(async () => null), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const response = await session.resumeResponse(); + expect(response.status).toBe(204); + }); + + it("returns 204 and clears stream when the stream cannot be resumed", async () => { + const adapter = makeAdapter({ + getActiveStreamId: vi.fn(async () => "stale-sid"), + resumeStream: vi.fn(async () => null), + clearActiveStream: vi.fn(async () => {}), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const response = await session.resumeResponse(); + expect(response.status).toBe(204); + expect(adapter.clearActiveStream).toHaveBeenCalledWith( + expect.objectContaining({ streamId: "stale-sid" }), + ); + }); + + it("returns 200 with body when the stream can be resumed", async () => { + const readable = makeReadableStream("chunk"); + const adapter = makeAdapter({ + getActiveStreamId: vi.fn(async () => "live-sid"), + resumeStream: vi.fn(async () => readable), + }); + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + }); + + const response = await session.resumeResponse(); + expect(response.status).toBe(200); + expect(response.body).not.toBeNull(); + }); + + it("returns 204 when getActiveStreamId throws", async () => { + const adapter = makeAdapter({ + getActiveStreamId: vi.fn(async () => { + throw new Error("network error"); + }), + }); + const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const session = createResumableChatSession({ + adapter, + conversationId: "conv1", + userId: "u1", + logger: logger as never, + }); + + const response = await session.resumeResponse(); + expect(response.status).toBe(204); + expect(logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/resumable-streams/src/resumable-streams.spec.ts b/packages/resumable-streams/src/resumable-streams.spec.ts new file mode 100644 index 000000000..a64a40240 --- /dev/null +++ b/packages/resumable-streams/src/resumable-streams.spec.ts @@ -0,0 +1,342 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMemoryResumableStreamActiveStore, + createResumableStreamAdapter, + createResumableStreamGenericStore, + createResumableStreamMemoryStore, + createResumableStreamVoltOpsStore, + resolveResumableStreamAdapter, +} from "./resumable-streams"; + +// Marker keys used internally +const DISABLED = "__voltagentResumableDisabled"; +const DISABLED_REASON = "__voltagentResumableDisabledReason"; +const STORE_TYPE = "__voltagentResumableStoreType"; + +const isDisabled = (value: unknown): boolean => + !!(value && typeof value === "object" && (value as Record)[DISABLED] === true); + +const getStoreType = (value: unknown): string | null => { + if (!value || typeof value !== "object") return null; + const t = (value as Record)[STORE_TYPE]; + return typeof t === "string" ? t : null; +}; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("resumable-stream/generic", () => ({ + createResumableStreamContext: vi.fn(() => ({ + createNewResumableStream: vi.fn(async (_id: string, makeStream: () => ReadableStream) => + makeStream(), + ), + resumeExistingStream: vi.fn(async () => null), + })), +})); + +vi.mock("resumable-stream/redis", () => ({ + createResumableStreamContext: vi.fn(() => ({ + createNewResumableStream: vi.fn(async (_id: string, makeStream: () => ReadableStream) => + makeStream(), + ), + resumeExistingStream: vi.fn(async () => null), + })), +})); + +vi.mock("redis", () => ({ + createClient: vi.fn(() => ({ + connect: vi.fn(async () => {}), + publish: vi.fn(async () => 0), + subscribe: vi.fn(async () => {}), + unsubscribe: vi.fn(async () => {}), + set: vi.fn(async () => "OK"), + get: vi.fn(async () => null), + del: vi.fn(async () => 1), + incr: vi.fn(async () => 1), + })), +})); + +vi.mock("@voltagent/core", () => ({ + getGlobalVoltOpsClient: vi.fn(() => null), + VoltOpsClient: vi.fn().mockImplementation(() => ({ + sendRequest: vi.fn( + async () => new Response(JSON.stringify({ streamId: "test-id" }), { status: 200 }), + ), + })), +})); + +// --------------------------------------------------------------------------- +// createMemoryResumableStreamActiveStore +// --------------------------------------------------------------------------- + +describe("createMemoryResumableStreamActiveStore", () => { + it("returns null for an unknown context", async () => { + const store = createMemoryResumableStreamActiveStore(); + const result = await store.getActiveStreamId({ conversationId: "c1", userId: "u1" }); + expect(result).toBeNull(); + }); + + it("stores and retrieves an active stream id", async () => { + const store = createMemoryResumableStreamActiveStore(); + await store.setActiveStreamId({ conversationId: "c1", userId: "u1" }, "stream-abc"); + const result = await store.getActiveStreamId({ conversationId: "c1", userId: "u1" }); + expect(result).toBe("stream-abc"); + }); + + it("clears the active stream id when no streamId specified", async () => { + const store = createMemoryResumableStreamActiveStore(); + await store.setActiveStreamId({ conversationId: "c1", userId: "u1" }, "stream-abc"); + await store.clearActiveStream({ conversationId: "c1", userId: "u1" }); + const result = await store.getActiveStreamId({ conversationId: "c1", userId: "u1" }); + expect(result).toBeNull(); + }); + + it("does not clear when streamId does not match", async () => { + const store = createMemoryResumableStreamActiveStore(); + await store.setActiveStreamId({ conversationId: "c1", userId: "u1" }, "stream-abc"); + await store.clearActiveStream({ conversationId: "c1", userId: "u1", streamId: "other-id" }); + const result = await store.getActiveStreamId({ conversationId: "c1", userId: "u1" }); + expect(result).toBe("stream-abc"); + }); + + it("clears when streamId matches the stored one", async () => { + const store = createMemoryResumableStreamActiveStore(); + await store.setActiveStreamId({ conversationId: "c1", userId: "u1" }, "stream-abc"); + await store.clearActiveStream({ conversationId: "c1", userId: "u1", streamId: "stream-abc" }); + const result = await store.getActiveStreamId({ conversationId: "c1", userId: "u1" }); + expect(result).toBeNull(); + }); + + it("isolates contexts with different conversationId", async () => { + const store = createMemoryResumableStreamActiveStore(); + await store.setActiveStreamId({ conversationId: "c1", userId: "u1" }, "stream-1"); + await store.setActiveStreamId({ conversationId: "c2", userId: "u1" }, "stream-2"); + expect(await store.getActiveStreamId({ conversationId: "c1", userId: "u1" })).toBe("stream-1"); + expect(await store.getActiveStreamId({ conversationId: "c2", userId: "u1" })).toBe("stream-2"); + }); +}); + +// --------------------------------------------------------------------------- +// createResumableStreamMemoryStore +// --------------------------------------------------------------------------- + +describe("createResumableStreamMemoryStore", () => { + it("returns a store marked with type 'memory'", async () => { + const store = await createResumableStreamMemoryStore(); + expect(getStoreType(store)).toBe("memory"); + }); + + it("is not marked as disabled", async () => { + const store = await createResumableStreamMemoryStore(); + expect(isDisabled(store)).toBe(false); + }); + + it("exposes all required methods", async () => { + const store = await createResumableStreamMemoryStore(); + expect(typeof store.createNewResumableStream).toBe("function"); + expect(typeof store.resumeExistingStream).toBe("function"); + expect(typeof store.getActiveStreamId).toBe("function"); + expect(typeof store.setActiveStreamId).toBe("function"); + expect(typeof store.clearActiveStream).toBe("function"); + }); + + it("sets and gets active stream ids", async () => { + const store = await createResumableStreamMemoryStore(); + await store.setActiveStreamId({ conversationId: "conv1", userId: "user1" }, "sid-1"); + const sid = await store.getActiveStreamId({ conversationId: "conv1", userId: "user1" }); + expect(sid).toBe("sid-1"); + }); + + it("accepts a custom keyPrefix without throwing", async () => { + const store = await createResumableStreamMemoryStore({ keyPrefix: "my-app" }); + expect(getStoreType(store)).toBe("memory"); + }); +}); + +// --------------------------------------------------------------------------- +// createResumableStreamGenericStore +// --------------------------------------------------------------------------- + +describe("createResumableStreamGenericStore", () => { + const makePublisher = () => ({ + connect: vi.fn(async () => {}), + publish: vi.fn(async () => 0), + set: vi.fn(async () => "OK" as const), + get: vi.fn(async () => null as string | null), + incr: vi.fn(async () => 1), + del: vi.fn(async () => 1), + }); + + const makeSubscriber = () => ({ + connect: vi.fn(async () => {}), + subscribe: vi.fn(async () => 1), + unsubscribe: vi.fn(async () => {}), + }); + + it("returns a store marked with type 'custom'", async () => { + const store = await createResumableStreamGenericStore({ + publisher: makePublisher(), + subscriber: makeSubscriber(), + }); + expect(getStoreType(store)).toBe("custom"); + }); + + it("is not disabled", async () => { + const store = await createResumableStreamGenericStore({ + publisher: makePublisher(), + subscriber: makeSubscriber(), + }); + expect(isDisabled(store)).toBe(false); + }); + + it("throws when publisher is missing", async () => { + await expect( + createResumableStreamGenericStore({ + publisher: undefined as never, + subscriber: makeSubscriber(), + }), + ).rejects.toThrow("Generic resumable streams require both publisher and subscriber"); + }); + + it("throws when subscriber is missing", async () => { + await expect( + createResumableStreamGenericStore({ + publisher: makePublisher(), + subscriber: undefined as never, + }), + ).rejects.toThrow("Generic resumable streams require both publisher and subscriber"); + }); +}); + +// --------------------------------------------------------------------------- +// createResumableStreamVoltOpsStore +// --------------------------------------------------------------------------- + +describe("createResumableStreamVoltOpsStore", () => { + beforeEach(() => { + vi.stubEnv("VOLTAGENT_PUBLIC_KEY", ""); + vi.stubEnv("VOLTAGENT_SECRET_KEY", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns a disabled store when no keys are provided", async () => { + const store = await createResumableStreamVoltOpsStore(); + expect(isDisabled(store)).toBe(true); + const reason = (store as Record)[DISABLED_REASON] as string; + expect(reason).toContain("VOLTAGENT_PUBLIC_KEY"); + }); + + it("returns a non-disabled store when keys are supplied via options", async () => { + const store = await createResumableStreamVoltOpsStore({ + publicKey: "pk_test", + secretKey: "sk_test", + }); + expect(isDisabled(store)).toBe(false); + expect(getStoreType(store)).toBe("voltops"); + }); + + it("uses env vars for keys", async () => { + vi.stubEnv("VOLTAGENT_PUBLIC_KEY", "pk_env"); + vi.stubEnv("VOLTAGENT_SECRET_KEY", "sk_env"); + const store = await createResumableStreamVoltOpsStore(); + expect(isDisabled(store)).toBe(false); + expect(getStoreType(store)).toBe("voltops"); + }); +}); + +// --------------------------------------------------------------------------- +// createResumableStreamAdapter +// --------------------------------------------------------------------------- + +describe("createResumableStreamAdapter", () => { + it("throws when no streamStore is provided", async () => { + await expect(createResumableStreamAdapter({ streamStore: undefined as never })).rejects.toThrow( + "Resumable stream store is required", + ); + }); + + it("returns a disabled adapter when the store is disabled", async () => { + const disabledStore = await createResumableStreamVoltOpsStore(); // no keys → disabled + const adapter = await createResumableStreamAdapter({ streamStore: disabledStore }); + expect(isDisabled(adapter)).toBe(true); + }); + + it("builds a valid adapter from a memory store", async () => { + const store = await createResumableStreamMemoryStore(); + const adapter = await createResumableStreamAdapter({ streamStore: store }); + expect(isDisabled(adapter)).toBe(false); + expect(typeof adapter.createStream).toBe("function"); + expect(typeof adapter.resumeStream).toBe("function"); + expect(typeof adapter.getActiveStreamId).toBe("function"); + expect(typeof adapter.clearActiveStream).toBe("function"); + }); + + it("propagates store type marker to the adapter", async () => { + const store = await createResumableStreamMemoryStore(); + const adapter = await createResumableStreamAdapter({ streamStore: store }); + expect(getStoreType(adapter)).toBe("memory"); + }); + + it("throws when store has no active stream capability and none is provided", async () => { + const minimalStore = { + createNewResumableStream: vi.fn(async () => null), + resumeExistingStream: vi.fn(async () => null), + }; + await expect(createResumableStreamAdapter({ streamStore: minimalStore })).rejects.toThrow( + "Resumable stream activeStreamStore is required", + ); + }); + + it("accepts an explicit activeStreamStore", async () => { + const minimalStore = { + createNewResumableStream: vi.fn(async () => null), + resumeExistingStream: vi.fn(async () => null), + }; + const activeStore = createMemoryResumableStreamActiveStore(); + const adapter = await createResumableStreamAdapter({ + streamStore: minimalStore, + activeStreamStore: activeStore, + }); + expect(isDisabled(adapter)).toBe(false); + expect(typeof adapter.createStream).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// resolveResumableStreamAdapter +// --------------------------------------------------------------------------- + +describe("resolveResumableStreamAdapter", () => { + it("returns undefined when no adapter is provided", () => { + const result = resolveResumableStreamAdapter(undefined); + expect(result).toBeUndefined(); + }); + + it("returns the adapter when it is valid", async () => { + const store = await createResumableStreamMemoryStore(); + const adapter = await createResumableStreamAdapter({ streamStore: store }); + const result = resolveResumableStreamAdapter(adapter); + expect(result).toBe(adapter); + }); + + it("returns undefined and warns when the adapter is disabled", async () => { + const disabledStore = await createResumableStreamVoltOpsStore(); // no keys + const adapter = await createResumableStreamAdapter({ streamStore: disabledStore }); + const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const result = resolveResumableStreamAdapter(adapter, logger as never); + expect(result).toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined and errors on an invalid adapter shape", () => { + const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const badAdapter = { notAnAdapter: true } as never; + const result = resolveResumableStreamAdapter(badAdapter, logger as never); + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/packages/resumable-streams/vitest.config.ts b/packages/resumable-streams/vitest.config.ts new file mode 100644 index 000000000..41c455fd6 --- /dev/null +++ b/packages/resumable-streams/vitest.config.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@voltagent/core": path.resolve(__dirname, "../../packages/core/src/index.ts"), + "@voltagent/internal": path.resolve(__dirname, "../../packages/internal/src/index.ts"), + }, + }, + test: { + include: ["**/*.spec.ts"], + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.d.ts", "src/**/index.ts"], + }, + typecheck: { + include: ["**/**/*.spec-d.ts"], + exclude: ["**/**/*.spec.ts"], + }, + globals: true, + testTimeout: 10000, + hookTimeout: 10000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdc45c581..93ffd3cc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4269,6 +4269,13 @@ importers: resumable-stream: specifier: ^2.2.10 version: 2.2.10 + devDependencies: + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.2.1)(@vitest/ui@1.6.1)(jsdom@22.1.0)(msw@2.11.6) packages/sandbox-blaxel: dependencies: @@ -15401,8 +15408,8 @@ packages: dev: false optional: true - /@oxc-project/types@0.132.0: - resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + /@oxc-project/types@0.133.0: + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} dev: true /@oxc-project/types@0.94.0: @@ -17614,8 +17621,8 @@ packages: /@repeaterjs/repeater@3.0.6: resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} - /@rolldown/binding-android-arm64@1.0.2: - resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + /@rolldown/binding-android-arm64@1.0.3: + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -17623,8 +17630,8 @@ packages: dev: true optional: true - /@rolldown/binding-darwin-arm64@1.0.2: - resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + /@rolldown/binding-darwin-arm64@1.0.3: + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -17632,8 +17639,8 @@ packages: dev: true optional: true - /@rolldown/binding-darwin-x64@1.0.2: - resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + /@rolldown/binding-darwin-x64@1.0.3: + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -17641,8 +17648,8 @@ packages: dev: true optional: true - /@rolldown/binding-freebsd-x64@1.0.2: - resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + /@rolldown/binding-freebsd-x64@1.0.3: + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -17650,8 +17657,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-arm-gnueabihf@1.0.2: - resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + /@rolldown/binding-linux-arm-gnueabihf@1.0.3: + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -17659,8 +17666,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-arm64-gnu@1.0.2: - resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + /@rolldown/binding-linux-arm64-gnu@1.0.3: + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -17668,8 +17675,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-arm64-musl@1.0.2: - resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + /@rolldown/binding-linux-arm64-musl@1.0.3: + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -17677,8 +17684,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-ppc64-gnu@1.0.2: - resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + /@rolldown/binding-linux-ppc64-gnu@1.0.3: + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -17686,8 +17693,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-s390x-gnu@1.0.2: - resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + /@rolldown/binding-linux-s390x-gnu@1.0.3: + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -17695,8 +17702,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-x64-gnu@1.0.2: - resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + /@rolldown/binding-linux-x64-gnu@1.0.3: + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -17704,8 +17711,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-x64-musl@1.0.2: - resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + /@rolldown/binding-linux-x64-musl@1.0.3: + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -17713,8 +17720,8 @@ packages: dev: true optional: true - /@rolldown/binding-openharmony-arm64@1.0.2: - resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + /@rolldown/binding-openharmony-arm64@1.0.3: + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -17722,8 +17729,8 @@ packages: dev: true optional: true - /@rolldown/binding-wasm32-wasi@1.0.2: - resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + /@rolldown/binding-wasm32-wasi@1.0.3: + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] requiresBuild: true @@ -17734,8 +17741,8 @@ packages: dev: true optional: true - /@rolldown/binding-win32-arm64-msvc@1.0.2: - resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + /@rolldown/binding-win32-arm64-msvc@1.0.3: + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -17743,8 +17750,8 @@ packages: dev: true optional: true - /@rolldown/binding-win32-x64-msvc@1.0.2: - resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + /@rolldown/binding-win32-x64-msvc@1.0.3: + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -37557,7 +37564,7 @@ packages: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} dev: false - /rolldown-plugin-dts@0.16.11(rolldown@1.0.2)(typescript@5.9.2): + /rolldown-plugin-dts@0.16.11(rolldown@1.0.3)(typescript@5.9.2): resolution: {integrity: sha512-9IQDaPvPqTx3RjG2eQCK5GYZITo203BxKunGI80AGYicu1ySFTUyugicAaTZWRzFWh9DSnzkgNeMNbDWBbSs0w==} engines: {node: '>=20.18.0'} peerDependencies: @@ -37585,36 +37592,36 @@ packages: dts-resolver: 2.1.2 get-tsconfig: 4.10.1 magic-string: 0.30.19 - rolldown: 1.0.2 + rolldown: 1.0.3 typescript: 5.9.2 transitivePeerDependencies: - oxc-resolver - supports-color dev: true - /rolldown@1.0.2: - resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + /rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true dependencies: - '@oxc-project/types': 0.132.0 + '@oxc-project/types': 0.133.0 '@rolldown/pluginutils': 1.0.0 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.2 - '@rolldown/binding-darwin-arm64': 1.0.2 - '@rolldown/binding-darwin-x64': 1.0.2 - '@rolldown/binding-freebsd-x64': 1.0.2 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 - '@rolldown/binding-linux-arm64-gnu': 1.0.2 - '@rolldown/binding-linux-arm64-musl': 1.0.2 - '@rolldown/binding-linux-ppc64-gnu': 1.0.2 - '@rolldown/binding-linux-s390x-gnu': 1.0.2 - '@rolldown/binding-linux-x64-gnu': 1.0.2 - '@rolldown/binding-linux-x64-musl': 1.0.2 - '@rolldown/binding-openharmony-arm64': 1.0.2 - '@rolldown/binding-wasm32-wasi': 1.0.2 - '@rolldown/binding-win32-arm64-msvc': 1.0.2 - '@rolldown/binding-win32-x64-msvc': 1.0.2 + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 dev: true /rollup-plugin-inject@3.0.2: @@ -39801,8 +39808,8 @@ packages: empathic: 2.0.0 hookable: 5.5.3 publint: 0.3.12 - rolldown: 1.0.2 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.2)(typescript@5.9.2) + rolldown: 1.0.3 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.3)(typescript@5.9.2) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 From 5646fd7ff309d617db1301ef8b83fd44974492da Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Fri, 29 May 2026 22:08:40 -0700 Subject: [PATCH 2/2] test(resumable-streams): fix __dirname in ESM config, use safeStringify, guard env vars in tests Co-Authored-By: Claude Sonnet 4.6 --- .../src/resumable-streams.spec.ts | 21 ++++++++++++++++++- packages/resumable-streams/vitest.config.ts | 3 +++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/resumable-streams/src/resumable-streams.spec.ts b/packages/resumable-streams/src/resumable-streams.spec.ts index a64a40240..a0abefb73 100644 --- a/packages/resumable-streams/src/resumable-streams.spec.ts +++ b/packages/resumable-streams/src/resumable-streams.spec.ts @@ -1,3 +1,4 @@ +import { safeStringify } from "@voltagent/internal"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMemoryResumableStreamActiveStore, @@ -61,7 +62,7 @@ vi.mock("@voltagent/core", () => ({ getGlobalVoltOpsClient: vi.fn(() => null), VoltOpsClient: vi.fn().mockImplementation(() => ({ sendRequest: vi.fn( - async () => new Response(JSON.stringify({ streamId: "test-id" }), { status: 200 }), + async () => new Response(safeStringify({ streamId: "test-id" }), { status: 200 }), ), })), })); @@ -253,6 +254,15 @@ describe("createResumableStreamVoltOpsStore", () => { // --------------------------------------------------------------------------- describe("createResumableStreamAdapter", () => { + beforeEach(() => { + vi.stubEnv("VOLTAGENT_PUBLIC_KEY", ""); + vi.stubEnv("VOLTAGENT_SECRET_KEY", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("throws when no streamStore is provided", async () => { await expect(createResumableStreamAdapter({ streamStore: undefined as never })).rejects.toThrow( "Resumable stream store is required", @@ -311,6 +321,15 @@ describe("createResumableStreamAdapter", () => { // --------------------------------------------------------------------------- describe("resolveResumableStreamAdapter", () => { + beforeEach(() => { + vi.stubEnv("VOLTAGENT_PUBLIC_KEY", ""); + vi.stubEnv("VOLTAGENT_SECRET_KEY", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("returns undefined when no adapter is provided", () => { const result = resolveResumableStreamAdapter(undefined); expect(result).toBeUndefined(); diff --git a/packages/resumable-streams/vitest.config.ts b/packages/resumable-streams/vitest.config.ts index 41c455fd6..c6a3b7cee 100644 --- a/packages/resumable-streams/vitest.config.ts +++ b/packages/resumable-streams/vitest.config.ts @@ -1,6 +1,9 @@ import path from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + export default defineConfig({ resolve: { alias: {