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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,18 @@ jobs:

- name: Type check
run: bun check:types

test-unit:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 10
Comment thread
coderabbitai[bot] marked this conversation as resolved.
needs: check-types

steps:
- uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false
- uses: ./tooling/github/setup

- name: Unit tests
run: bun test:unit
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"scripts": {
"dev": "wrangler dev",
"check:types": "tsc --noEmit",
"test:unit": "bun test",
"db:push": "dotenvx run -- drizzle-kit push",
"db:generate": "dotenvx run -- drizzle-kit generate",
"wrangler:types": "wrangler types --cwd src",
Expand Down
75 changes: 75 additions & 0 deletions apps/server/src/handlers/signaling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import type { ServerEvent } from "@cyrus/schemas/signaling";
import { encodeHibernationRPCEvent } from "@orpc/server/hibernation";
import { broadcastSignalingEvent, type SignalingWS } from "./signaling";

function connection(
id: string,
eventId: string | null,
sent: unknown[]
): SignalingWS {
return {
deserializeAttachment: <T = unknown>() =>
(eventId ? { eventId, name: id, role: "worker" } : null) as T | null,
id,
send: (data) => sent.push(data),
serializeAttachment: () => undefined,
};
}

describe("broadcastSignalingEvent", () => {
test("sends encoded hibernation events to peers with event iterators", () => {
const sent: unknown[] = [];
const event: ServerEvent = {
id: "worker-left",
type: "peer-left",
};

broadcastSignalingEvent(
[
connection("controller-1", "event-controller", sent),
connection("worker-1", "event-worker", sent),
],
event
);

expect(sent).toHaveLength(2);
expect(sent).toEqual([
encodeHibernationRPCEvent("event-controller", event),
encodeHibernationRPCEvent("event-worker", event),
]);
});

test("skips excluded peers and peers without attachments", () => {
const sent: unknown[] = [];

broadcastSignalingEvent(
[
connection("controller-1", "event-controller", sent),
connection("worker-1", "event-worker", sent),
connection("joining-peer", null, sent),
],
{
peer: {
id: "worker-2",
name: "Worker 2",
role: "worker",
},
type: "peer-joined",
},
["controller-1"]
);

expect(sent).toHaveLength(1);
expect(sent[0]).toBe(
encodeHibernationRPCEvent("event-worker", {
peer: {
id: "worker-2",
name: "Worker 2",
role: "worker",
},
type: "peer-joined",
})
);
});
});
3 changes: 3 additions & 0 deletions bun.lock

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

42 changes: 42 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Testing Strategy

Cyrus uses a layered test setup so each part of the system is tested with the
runtime closest to production.

## Runners

| Scope | Runner | Location |
| --- | --- | --- |
| Pure TypeScript, schemas, CLI, database, process tests | Bun test | Colocated `*.test.ts` or package `__tests__/integration/` |
| React hooks, providers, Cloudflare Workers runtime | Vitest | Package-local `vitest.config.ts` |
| Browser user flows | Playwright | Root `tests/e2e/web/` |

Bun test is the default. Use Vitest when the package needs a browser-like React
test environment or the Cloudflare Workers test pool.

## Layout

```text
<package>/src/**/*.test.ts
<package>/__tests__/integration/
tests/e2e/harness/
tests/e2e/scenarios/
tests/e2e/web/
tooling/test/
```

Unit tests stay close to the code they cover. Integration tests live under the
package boundary they exercise. Cross-app tests live at the repo root.

## CI Levels

| Level | Trigger | Tests |
| --- | --- | --- |
| 0 | pre-commit | Ultracite only |
| 1 | pre-push | Typecheck now; unit tests once stable |
| 2 | pull request | Lint, typecheck, unit tests |
| 3 | main or nightly | Integration and E2E |
| 4 | deploy | Health and WebSocket smoke |

Phase 1 only adds the unit test foundation. Integration and E2E are introduced
in later phases.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
"dev": "turbo dev",
"build": "turbo build",
"check:types": "turbo check:types",
"test": "turbo test:unit test:integration",
"test:unit": "turbo test:unit",
"test:integration": "turbo test:integration",
"test:e2e": "turbo test:e2e",
"dev:mobile": "turbo -F @cyrus/mobile dev",
"dev:web": "turbo -F @cyrus/web dev",
"dev:desktop": "turbo -F @cyrus/desktop dev",
Expand Down
4 changes: 3 additions & 1 deletion shared/constants/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
"./*": "./src/*.ts"
},
"scripts": {
"check:types": "tsc --noEmit"
"check:types": "tsc --noEmit",
"test:unit": "bun test"
},
"devDependencies": {
"@cyrus/typescript": "workspace:*",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
45 changes: 45 additions & 0 deletions shared/constants/src/operation-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import {
AUTH_OPERATION_KEYS,
RTC_OPERATION_KEYS,
SIGNALING_OPERATION_KEYS,
} from "./operation-keys";

describe("operation keys", () => {
test("builds stable signaling keys", () => {
expect(SIGNALING_OPERATION_KEYS.connection("user-1")).toEqual([
"signaling",
"user-1",
]);
expect(SIGNALING_OPERATION_KEYS.listPeers).toEqual([
"signaling",
"list-peers",
]);
});

test("builds stable RTC keys", () => {
expect(RTC_OPERATION_KEYS.connection("worker-1")).toEqual([
"controller",
"worker-1",
]);
expect(RTC_OPERATION_KEYS.listThreads("project-1")).toEqual([
"controller",
"list-threads",
"project-1",
]);
expect(RTC_OPERATION_KEYS.listDir("/tmp/cyrus", 2)).toEqual([
"controller",
"list-dir",
"/tmp/cyrus",
2,
]);
});

test("keeps auth keys stable", () => {
expect(AUTH_OPERATION_KEYS.deviceDecide).toEqual([
"auth",
"device",
"decide",
]);
});
});
2 changes: 1 addition & 1 deletion shared/constants/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "@cyrus/typescript/tsconfig.base.json",
"compilerOptions": {
"types": [],
"types": ["bun"],
"strictNullChecks": true
},
"include": ["src/**/*.ts"],
Expand Down
6 changes: 5 additions & 1 deletion shared/schemas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
"./rtc/*": "./src/rtc/*.ts",
"./signaling": "./src/signaling.ts"
},
"scripts": {
"test:unit": "bun test"
},
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@cyrus/typescript": "workspace:*"
"@cyrus/typescript": "workspace:*",
"@types/bun": "catalog:"
}
}
100 changes: 100 additions & 0 deletions shared/schemas/src/rtc/chat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test";
import {
AgentEventSchema,
ChatChunkSchema,
ChatInputSchema,
ToolCallContentSchema,
} from "./chat";

describe("chat schemas", () => {
test("parses a valid chat request", () => {
expect(
ChatInputSchema.parse({
agentName: "claude",
message: "hello",
projectId: "project-1",
threadId: "00000000-0000-4000-8000-000000000001",
turnId: "00000000-0000-4000-8000-000000000002",
})
).toMatchObject({
agentName: "claude",
message: "hello",
projectId: "project-1",
});
});

test("rejects non-uuid optional chat identifiers", () => {
expect(() =>
ChatInputSchema.parse({
agentName: "claude",
message: "hello",
projectId: "project-1",
threadId: "not-a-uuid",
})
).toThrow();
});

test("parses representative agent events", () => {
expect(
AgentEventSchema.parse({
type: "tool_call",
toolCallId: "tool-1",
title: "Read file",
kind: "read",
status: "in_progress",
content: [
{
type: "content",
content: { type: "text", text: "reading package.json" },
},
],
})
).toMatchObject({
type: "tool_call",
toolCallId: "tool-1",
kind: "read",
});

expect(
AgentEventSchema.parse({
type: "plan_update",
plan: {
type: "items",
id: "plan-1",
entries: [
{
content: "Write schema tests",
priority: "high",
status: "completed",
},
],
},
})
).toMatchObject({ type: "plan_update" });
});

test("parses chat chunks with nested events", () => {
expect(
ChatChunkSchema.parse({
threadId: "thread-1",
turnId: "turn-1",
seq: 1,
event: { type: "token", text: "hello", messageId: "message-1" },
})
).toEqual({
threadId: "thread-1",
turnId: "turn-1",
seq: 1,
event: { type: "token", text: "hello", messageId: "message-1" },
});
});

test("rejects unknown tool content types", () => {
expect(() =>
ToolCallContentSchema.parse({
type: "unknown",
content: { type: "text", text: "nope" },
})
).toThrow();
});
});
Loading
Loading