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
26 changes: 26 additions & 0 deletions apps/cli/__tests__/helpers/acp-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<AgentEvent> {
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,
};
}
Comment thread
soorya-u marked this conversation as resolved.
102 changes: 102 additions & 0 deletions apps/cli/__tests__/integration/wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
3 changes: 2 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 3 additions & 1 deletion shared/hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"./*": "./src/*"
},
"scripts": {
"check:types": "tsc --noEmit"
"check:types": "tsc --noEmit",
"test:unit": "bun test"
},
"dependencies": {
"@cyrus/connections": "workspace:*",
Expand All @@ -23,6 +24,7 @@
},
"devDependencies": {
"@cyrus/typescript": "workspace:*",
"@types/bun": "catalog:",
"@types/react": "^19.2.15",
"react": "catalog:",
"typescript": "catalog:"
Expand Down
26 changes: 26 additions & 0 deletions shared/hooks/src/connection/use-controller-threads.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
2 changes: 1 addition & 1 deletion shared/hooks/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": [],
"types": ["bun"],
"strictNullChecks": true,
"paths": {
"@cyrus/providers/*": ["../providers/src/*"]
Expand Down
5 changes: 1 addition & 4 deletions tooling/test/mocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
61 changes: 61 additions & 0 deletions tooling/test/mocks/data-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";

type MockChannelState = "connecting" | "open" | "closing" | "closed";

type MockChannel = {
readyState: MockChannelState;
listeners: Map<string, Set<() => 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);
});
});
Comment thread
soorya-u marked this conversation as resolved.
Loading