diff --git a/apps/cli/__tests__/helpers/acp-runtime.ts b/apps/cli/__tests__/helpers/acp-runtime.ts new file mode 100644 index 00000000..d479d570 --- /dev/null +++ b/apps/cli/__tests__/helpers/acp-runtime.ts @@ -0,0 +1,26 @@ +import type { AgentEvent } from "@cyrus/schemas/rtc/chat"; + +export type MockPromptOptions = { + message?: string; + messageId?: string; + failAfterToken?: boolean; +}; + +export function* createMockPromptStream( + options: MockPromptOptions = {} +): Generator { + const messageId = options.messageId ?? "mock-message-1"; + yield { + type: "token", + text: options.message ?? "mock response", + messageId, + }; + if (options.failAfterToken) { + throw new Error("mock runtime failed"); + } + yield { + type: "message_completed", + text: options.message ?? "mock response", + messageId, + }; +} diff --git a/apps/cli/__tests__/integration/wiring.test.ts b/apps/cli/__tests__/integration/wiring.test.ts new file mode 100644 index 00000000..79b5a801 --- /dev/null +++ b/apps/cli/__tests__/integration/wiring.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentEvent } from "@cyrus/schemas/rtc/chat"; +import { runTurn } from "../../src/utils/run-turn"; +import { createMockPromptStream } from "../helpers/acp-runtime"; + +describe("acp mock runtime", () => { + test("streams token and completion events", () => { + const events: AgentEvent[] = []; + for (const event of createMockPromptStream({ message: "hello" })) { + events.push(event); + } + + expect(events).toEqual([ + { type: "token", text: "hello", messageId: "mock-message-1" }, + { type: "message_completed", text: "hello", messageId: "mock-message-1" }, + ]); + }); + + test("drives runTurn through a mock coordinator", async () => { + const emitted: Array<{ type: string }> = []; + + const result = await runTurn({ + agentName: "mock-agent", + threadId: "thread-1", + projectId: "project-1", + message: "ping", + emit: (event) => { + emitted.push({ type: event.type }); + return Promise.resolve(); + }, + emitTerminal: () => Promise.resolve(), + runtime: { + threadCoordinator: { + prompt: () => createMockPromptStream({ message: "pong" }), + }, + } as never, + }); + + expect(result.isOk()).toBe(true); + expect(emitted.map((event) => event.type)).toEqual([ + "user_message", + "thread_started", + "token", + "message_completed", + ]); + }); + + test("emits turn_interrupted when the mock stream fails", async () => { + const terminal: Array<{ type: string }> = []; + + const result = await runTurn({ + agentName: "mock-agent", + threadId: "thread-1", + projectId: "project-1", + message: "ping", + emit: () => Promise.resolve(), + emitTerminal: (event) => { + terminal.push({ type: event.type }); + return Promise.resolve(); + }, + runtime: { + threadCoordinator: { + prompt: () => + createMockPromptStream({ + message: "pong", + failAfterToken: true, + }), + }, + } as never, + }); + + expect(result.isErr()).toBe(true); + expect(terminal).toEqual([{ type: "turn_interrupted" }]); + }); +}); + +describe("cli process integration", () => { + test("exits when start is invoked without a login token", async () => { + const home = await mkdtemp(join(tmpdir(), "cyrus-cli-test-")); + + try { + const proc = Bun.spawn(["bun", "src/cli.ts", "start"], { + cwd: join(import.meta.dir, "../.."), + env: { + ...process.env, + CYRUS_HOME: home, + CYRUS_DAEMON: "1", + }, + stdout: "ignore", + stderr: "ignore", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(1); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/package.json b/apps/cli/package.json index 5fd2d3da..d27b02d2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,7 +9,8 @@ "scripts": { "dev": "bun src/cli.ts", "check:types": "tsc --noEmit", - "test:unit": "bun test", + "test:unit": "bun test src", + "test:integration": "bun test __tests__/integration", "build": "bun build src/cli.ts --compile --env 'CLI_PUBLIC_*' --outfile dist/cyrusd" }, "dependencies": { diff --git a/bun.lock b/bun.lock index 281ea729..ee4fe074 100644 --- a/bun.lock +++ b/bun.lock @@ -247,6 +247,7 @@ }, "devDependencies": { "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", "@types/react": "^19.2.15", "react": "catalog:", "typescript": "catalog:", diff --git a/docs/testing.md b/docs/testing.md index df36ee0e..2b2bf191 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -41,7 +41,12 @@ package boundary they exercise. Cross-app tests live at the repo root. Phase 1 only adds the unit test foundation. Integration and E2E are introduced in later phases. -## Phase 2 notes +## Phase 3 notes + +- ACP prompt mocking lives in `apps/cli/__tests__/helpers/acp-runtime.ts`. +- CLI integration tests use isolated `CYRUS_HOME` directories and subprocess checks. +- Hooks tests currently cover the optimistic conversations cache contract used by + `use-controller-threads`. - `@cyrus/database` integration tests use isolated in-memory Turso databases via `shared/database/__tests__/helpers/turso.ts`. diff --git a/shared/hooks/package.json b/shared/hooks/package.json index 7eec27c5..475146f2 100644 --- a/shared/hooks/package.json +++ b/shared/hooks/package.json @@ -7,7 +7,8 @@ "./*": "./src/*" }, "scripts": { - "check:types": "tsc --noEmit" + "check:types": "tsc --noEmit", + "test:unit": "bun test" }, "dependencies": { "@cyrus/connections": "workspace:*", @@ -23,6 +24,7 @@ }, "devDependencies": { "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", "@types/react": "^19.2.15", "react": "catalog:", "typescript": "catalog:" diff --git a/shared/hooks/src/connection/use-controller-threads.test.ts b/shared/hooks/src/connection/use-controller-threads.test.ts new file mode 100644 index 00000000..611e4a75 --- /dev/null +++ b/shared/hooks/src/connection/use-controller-threads.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { RTC_OPERATION_KEYS } from "@cyrus/constants/operation-keys"; +import { appendOptimisticUserMessage } from "@cyrus/utils/conversations/cache"; +import { QueryClient } from "@tanstack/react-query"; + +describe("controller thread cache integration", () => { + test("appends an optimistic user message to the conversations cache", () => { + const queryClient = new QueryClient(); + const threadId = "thread-1"; + const turnId = "turn-1"; + + appendOptimisticUserMessage(queryClient, threadId, turnId, "hello"); + + const cached = queryClient.getQueryData<{ + conversations: Array<{ + chunk: { event: { type: string; content: string } }; + }>; + }>(RTC_OPERATION_KEYS.getConversations(threadId)); + + expect(cached?.conversations).toHaveLength(1); + expect(cached?.conversations[0]?.chunk.event).toEqual({ + type: "user_message", + content: "hello", + }); + }); +}); diff --git a/shared/hooks/tsconfig.json b/shared/hooks/tsconfig.json index 4fdc92aa..a5b66e8f 100644 --- a/shared/hooks/tsconfig.json +++ b/shared/hooks/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "react-jsx", "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": [], + "types": ["bun"], "strictNullChecks": true, "paths": { "@cyrus/providers/*": ["../providers/src/*"] diff --git a/tooling/test/mocks/README.md b/tooling/test/mocks/README.md index bfb0352d..4184b3f7 100644 --- a/tooling/test/mocks/README.md +++ b/tooling/test/mocks/README.md @@ -2,7 +2,4 @@ Shared mocks for cross-package tests live here. -Planned Phase 3 additions: - -- ACP mock runtime based on `acp-kit/examples/mock-runtime` -- Mock signaling and data-channel fixtures for PR CI +- Mock data-channel fixtures for PR CI WebRTC paths (`data-channel.ts`) diff --git a/tooling/test/mocks/data-channel.ts b/tooling/test/mocks/data-channel.ts new file mode 100644 index 00000000..93f3a224 --- /dev/null +++ b/tooling/test/mocks/data-channel.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +type MockChannelState = "connecting" | "open" | "closing" | "closed"; + +type MockChannel = { + readyState: MockChannelState; + listeners: Map void>>; +}; + +export function createMockDataChannel( + initialState: MockChannelState = "connecting" +): MockChannel { + return { + readyState: initialState, + listeners: new Map(), + }; +} + +export function openMockDataChannel(channel: MockChannel): void { + channel.readyState = "open"; + for (const listener of channel.listeners.get("open") ?? []) { + listener(); + } +} + +export function attachMockChannelListeners( + channel: MockChannel, + handlers: { + onOpen?: () => void; + onError?: () => void; + onClose?: () => void; + } +): void { + const register = (type: string, handler?: () => void) => { + if (!handler) return; + const listeners = channel.listeners.get(type) ?? new Set(); + listeners.add(handler); + channel.listeners.set(type, listeners); + }; + + register("open", handlers.onOpen); + register("error", handlers.onError); + register("close", handlers.onClose); +} + +describe("mock data channel", () => { + test("opens and notifies listeners", () => { + const channel = createMockDataChannel(); + let opened = false; + + attachMockChannelListeners(channel, { + onOpen: () => { + opened = true; + }, + }); + openMockDataChannel(channel); + + expect(channel.readyState).toBe("open"); + expect(opened).toBe(true); + }); +});