From 307fb29df9572a515e110f787f5b7f8e42f1a6e8 Mon Sep 17 00:00:00 2001 From: Soorya U Date: Sat, 11 Jul 2026 11:17:48 +0530 Subject: [PATCH 1/2] Add Phase 1 testing foundation with Bun unit tests. Establish turbo test tasks, CI unit-test job, shared test tooling, and initial coverage for schemas, utils, constants, and server signaling helpers. Co-authored-by: Cursor --- .github/workflows/ci.yml | 12 ++ apps/server/package.json | 1 + apps/server/src/handlers/signaling.test.ts | 61 +++++++ bun.lock | 3 + docs/testing.md | 42 +++++ package.json | 4 + shared/constants/package.json | 4 +- shared/constants/src/operation-keys.test.ts | 45 +++++ shared/constants/tsconfig.json | 2 +- shared/schemas/package.json | 6 +- shared/schemas/src/rtc/chat.test.ts | 100 +++++++++++ shared/schemas/src/rtc/threads.test.ts | 62 +++++++ shared/schemas/src/signaling.test.ts | 67 ++++++++ shared/schemas/tsconfig.json | 2 +- shared/utils/package.json | 4 + .../src/conversations/turn-waiters.test.ts | 66 ++++++++ shared/utils/src/fold.test.ts | 156 ++++++++++++++++++ shared/utils/src/path.test.ts | 58 +++++++ shared/utils/tsconfig.json | 2 +- tooling/test/mocks/README.md | 8 + tooling/test/setup/bun.setup.ts | 2 + tooling/test/setup/vitest.shared.ts | 2 + turbo.json | 13 ++ 23 files changed, 717 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/handlers/signaling.test.ts create mode 100644 docs/testing.md create mode 100644 shared/constants/src/operation-keys.test.ts create mode 100644 shared/schemas/src/rtc/chat.test.ts create mode 100644 shared/schemas/src/rtc/threads.test.ts create mode 100644 shared/schemas/src/signaling.test.ts create mode 100644 shared/utils/src/conversations/turn-waiters.test.ts create mode 100644 shared/utils/src/fold.test.ts create mode 100644 shared/utils/src/path.test.ts create mode 100644 tooling/test/mocks/README.md create mode 100644 tooling/test/setup/bun.setup.ts create mode 100644 tooling/test/setup/vitest.shared.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c6713f..a142532 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,15 @@ jobs: - name: Type check run: bun check:types + + test-unit: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + - uses: ./tooling/github/setup + + - name: Unit tests + run: bun test:unit diff --git a/apps/server/package.json b/apps/server/package.json index 43b178c..581f1b1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/handlers/signaling.test.ts b/apps/server/src/handlers/signaling.test.ts new file mode 100644 index 0000000..5e92469 --- /dev/null +++ b/apps/server/src/handlers/signaling.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import type { ServerEvent } from "@cyrus/schemas/signaling"; +import { broadcastSignalingEvent, type SignalingWS } from "./signaling"; + +function connection( + id: string, + eventId: string | null, + sent: unknown[] +): SignalingWS { + return { + deserializeAttachment: () => + (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.every((payload) => typeof payload === "string")).toBe(true); + }); + + 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); + }); +}); diff --git a/bun.lock b/bun.lock index 5f5ba5b..7bcf518 100644 --- a/bun.lock +++ b/bun.lock @@ -207,6 +207,7 @@ "version": "0.0.0", "devDependencies": { "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", "typescript": "catalog:", }, }, @@ -274,6 +275,7 @@ }, "devDependencies": { "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", }, }, "shared/styles": { @@ -294,6 +296,7 @@ }, "devDependencies": { "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", "react": "catalog:", "typescript": "catalog:", }, diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b229a9d --- /dev/null +++ b/docs/testing.md @@ -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 +/src/**/*.test.ts +/__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. diff --git a/package.json b/package.json index f4e960b..079950f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/constants/package.json b/shared/constants/package.json index 754f436..7a04118 100644 --- a/shared/constants/package.json +++ b/shared/constants/package.json @@ -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:" } } diff --git a/shared/constants/src/operation-keys.test.ts b/shared/constants/src/operation-keys.test.ts new file mode 100644 index 0000000..a66d32d --- /dev/null +++ b/shared/constants/src/operation-keys.test.ts @@ -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", + ]); + }); +}); diff --git a/shared/constants/tsconfig.json b/shared/constants/tsconfig.json index 91caab0..449b871 100644 --- a/shared/constants/tsconfig.json +++ b/shared/constants/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@cyrus/typescript/tsconfig.base.json", "compilerOptions": { - "types": [], + "types": ["bun"], "strictNullChecks": true }, "include": ["src/**/*.ts"], diff --git a/shared/schemas/package.json b/shared/schemas/package.json index 18afcde..5651cad 100644 --- a/shared/schemas/package.json +++ b/shared/schemas/package.json @@ -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:" } } diff --git a/shared/schemas/src/rtc/chat.test.ts b/shared/schemas/src/rtc/chat.test.ts new file mode 100644 index 0000000..0a560f2 --- /dev/null +++ b/shared/schemas/src/rtc/chat.test.ts @@ -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(); + }); +}); diff --git a/shared/schemas/src/rtc/threads.test.ts b/shared/schemas/src/rtc/threads.test.ts new file mode 100644 index 0000000..a6e8a4d --- /dev/null +++ b/shared/schemas/src/rtc/threads.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { + ConversationEntrySchema, + RenameThreadInputSchema, + ThreadSchema, + WatchThreadOutputSchema, +} from "./threads"; + +describe("thread schemas", () => { + test("normalizes nullable agent names to undefined", () => { + expect( + ThreadSchema.parse({ + id: "thread-1", + projectId: "project-1", + name: "Main", + agentName: null, + createdAt: "2026-07-11T00:00:00.000Z", + updatedAt: "2026-07-11T00:00:00.000Z", + }) + ).toEqual({ + id: "thread-1", + projectId: "project-1", + name: "Main", + agentName: undefined, + createdAt: "2026-07-11T00:00:00.000Z", + updatedAt: "2026-07-11T00:00:00.000Z", + }); + }); + + test("rejects empty thread names for rename input", () => { + expect(() => + RenameThreadInputSchema.parse({ threadId: "thread-1", name: "" }) + ).toThrow(); + }); + + test("parses conversation entries with chat chunks", () => { + expect( + ConversationEntrySchema.parse({ + id: "entry-1", + threadId: "thread-1", + seq: 1, + createdAt: "2026-07-11T00:00:01.000Z", + chunk: { + threadId: "thread-1", + turnId: "turn-1", + seq: 1, + event: { type: "user_message", content: "hello" }, + }, + }) + ).toMatchObject({ + id: "entry-1", + threadId: "thread-1", + chunk: { event: { type: "user_message" } }, + }); + }); + + test("parses watch output high water marks", () => { + expect( + WatchThreadOutputSchema.parse({ snapshotHighWaterMark: 42 }) + ).toEqual({ snapshotHighWaterMark: 42 }); + }); +}); diff --git a/shared/schemas/src/signaling.test.ts b/shared/schemas/src/signaling.test.ts new file mode 100644 index 0000000..9b2dee1 --- /dev/null +++ b/shared/schemas/src/signaling.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { + DeviceInfoSchema, + DeviceRoleSchema, + OfferInputSchema, + ServerEventSchema, +} from "./signaling"; + +describe("signaling schemas", () => { + test("accepts declared device metadata", () => { + expect(DeviceRoleSchema.parse("controller")).toBe("controller"); + expect( + DeviceInfoSchema.parse({ + id: "device-1", + name: "Laptop", + role: "worker", + }) + ).toEqual({ + id: "device-1", + name: "Laptop", + role: "worker", + }); + }); + + test("rejects unknown device roles", () => { + expect(() => + DeviceInfoSchema.parse({ + id: "device-1", + name: "Laptop", + role: "observer", + }) + ).toThrow(); + }); + + test("parses offer input and relayed offer events", () => { + const offer = { + sdp: "v=0", + type: "offer", + } as const; + + expect(OfferInputSchema.parse({ to: "worker-1", offer })).toEqual({ + to: "worker-1", + offer, + }); + expect( + ServerEventSchema.parse({ + type: "offer", + from: "controller-1", + offer, + }) + ).toEqual({ + type: "offer", + from: "controller-1", + offer, + }); + }); + + test("rejects malformed ICE candidates", () => { + expect(() => + ServerEventSchema.parse({ + type: "ice-candidate", + from: "controller-1", + candidate: { sdpMid: "0" }, + }) + ).toThrow(); + }); +}); diff --git a/shared/schemas/tsconfig.json b/shared/schemas/tsconfig.json index 542570d..4716ed9 100644 --- a/shared/schemas/tsconfig.json +++ b/shared/schemas/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "react-jsx", "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": [], + "types": ["bun"], "strictNullChecks": true }, "include": ["src/**/*.ts", "src/**/*.tsx"], diff --git a/shared/utils/package.json b/shared/utils/package.json index 94ffcad..699a940 100644 --- a/shared/utils/package.json +++ b/shared/utils/package.json @@ -6,6 +6,9 @@ "exports": { "./*": "./src/*.ts" }, + "scripts": { + "test:unit": "bun test" + }, "dependencies": { "@cyrus/constants": "workspace:*", "@cyrus/schemas": "workspace:*", @@ -17,6 +20,7 @@ }, "devDependencies": { "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", "react": "catalog:", "typescript": "catalog:" } diff --git a/shared/utils/src/conversations/turn-waiters.test.ts b/shared/utils/src/conversations/turn-waiters.test.ts new file mode 100644 index 0000000..f403b02 --- /dev/null +++ b/shared/utils/src/conversations/turn-waiters.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { + isTurnInterruptedError, + rejectTurnWaiter, + settleTurnWaiter, + waitForTurnEnd, +} from "./turn-waiters"; + +describe("turn waiters", () => { + test("resolves when a turn completes", () => { + const ended = waitForTurnEnd("thread-complete", "turn-1"); + + settleTurnWaiter("thread-complete", "turn-1", { + type: "turn_completed", + }); + + expect(ended).resolves.toBeUndefined(); + }); + + test("rejects with an interrupted error when a turn is interrupted", async () => { + const ended = waitForTurnEnd("thread-interrupt", "turn-1"); + + settleTurnWaiter("thread-interrupt", "turn-1", { + type: "turn_interrupted", + }); + + expect(ended).rejects.toThrow("turn interrupted"); + await ended.catch((error) => + expect(isTurnInterruptedError(error)).toBe(true) + ); + }); + + test("ignores non-terminal events", () => { + const ended = waitForTurnEnd("thread-token", "turn-1"); + + settleTurnWaiter("thread-token", "turn-1", { + type: "token", + text: "hello", + }); + settleTurnWaiter("thread-token", "turn-1", { + type: "turn_completed", + }); + + expect(ended).resolves.toBeUndefined(); + }); + + test("rejects explicitly and removes the waiter", () => { + const ended = waitForTurnEnd("thread-error", "turn-1"); + + rejectTurnWaiter("thread-error", "turn-1", new Error("boom")); + settleTurnWaiter("thread-error", "turn-1", { + type: "turn_completed", + }); + + expect(ended).rejects.toThrow("boom"); + }); + + test("rejects immediately when the abort signal is already aborted", () => { + const controller = new AbortController(); + controller.abort(); + + expect( + waitForTurnEnd("thread-abort", "turn-1", controller.signal) + ).rejects.toThrow("turn aborted"); + }); +}); diff --git a/shared/utils/src/fold.test.ts b/shared/utils/src/fold.test.ts new file mode 100644 index 0000000..ca3678d --- /dev/null +++ b/shared/utils/src/fold.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentEvent } from "@cyrus/schemas/rtc/chat"; +import type { ConversationEntry } from "@cyrus/schemas/rtc/threads"; +import { fold } from "./fold"; + +function entry( + seq: number, + turnId: string, + event: AgentEvent, + createdAt = `2026-07-11T00:00:${String(seq).padStart(2, "0")}.000Z` +): ConversationEntry { + return { + chunk: { + event, + seq, + threadId: "thread-1", + turnId, + }, + createdAt, + id: `entry-${seq}`, + seq, + threadId: "thread-1", + }; +} + +function folded(entries: ConversationEntry[]) { + const result = fold(entries); + if (result.isErr()) throw result.error; + + return result.value; +} + +describe("fold", () => { + test("folds user and assistant events into ordered messages", () => { + const conversation = folded([ + entry(1, "turn-1", { type: "user_message", content: "Hello" }), + entry(2, "turn-1", { type: "token", text: "Hi " }), + entry(3, "turn-1", { type: "token", text: "there" }), + entry(4, "turn-1", { type: "turn_completed" }), + ]); + + expect(conversation.messages).toEqual([ + { + content: "Hello", + createdAt: "2026-07-11T00:00:01.000Z", + id: "user-turn-1", + role: "user", + streaming: false, + turnId: "turn-1", + }, + { + content: "Hi there", + createdAt: "2026-07-11T00:00:02.000Z", + id: "turn-1", + role: "assistant", + streaming: false, + turnId: "turn-1", + }, + ]); + expect(conversation.turns).toEqual([ + { + completedAt: "2026-07-11T00:00:04.000Z", + id: "turn-1", + index: 0, + state: "complete", + threadId: "thread-1", + }, + ]); + }); + + test("marks the latest unfinished turn as running", () => { + const conversation = folded([ + entry(1, "turn-1", { type: "user_message", content: "First" }), + entry(2, "turn-1", { type: "turn_completed" }), + entry(3, "turn-2", { type: "user_message", content: "Second" }), + entry(4, "turn-2", { type: "token", text: "Working" }), + ]); + + expect(conversation.turns.map((turn) => turn.state)).toEqual([ + "complete", + "running", + ]); + expect(conversation.messages.at(-1)).toMatchObject({ + content: "Working", + role: "assistant", + streaming: true, + turnId: "turn-2", + }); + }); + + test("folds thoughts, tool calls, and diffs", () => { + const conversation = folded([ + entry(1, "turn-1", { type: "user_message", content: "Change it" }), + entry(2, "turn-1", { + messageId: "thought-1", + text: "Inspecting", + type: "thought", + }), + entry(3, "turn-1", { + content: [ + { + additions: 1, + deletions: 1, + newText: "new", + oldText: "old", + patch: "@@ -1 +1 @@", + path: "README.md", + type: "diff", + }, + ], + status: "completed", + title: "Edit README", + toolCallId: "tool-1", + type: "tool_call", + }), + entry(4, "turn-1", { type: "turn_completed" }), + ]); + + expect(conversation.thoughts).toEqual([ + { + content: "Inspecting", + createdAt: "2026-07-11T00:00:02.000Z", + id: "turn-1:thought:thought-1", + streaming: false, + turnId: "turn-1", + }, + ]); + expect(conversation.toolCalls).toEqual([ + expect.objectContaining({ + status: "completed", + title: "Edit README", + toolCallId: "tool-1", + turnId: "turn-1", + }), + ]); + expect(conversation.diffs).toEqual([ + { + additions: 1, + deletions: 1, + id: "turn-1:README.md", + patch: "@@ -1 +1 @@", + path: "README.md", + turnId: "turn-1", + }, + ]); + }); + + test("marks interrupted turns", () => { + const conversation = folded([ + entry(1, "turn-1", { type: "user_message", content: "Stop" }), + entry(2, "turn-1", { type: "turn_interrupted" }), + ]); + + expect(conversation.turns[0]?.state).toBe("interrupted"); + }); +}); diff --git a/shared/utils/src/path.test.ts b/shared/utils/src/path.test.ts new file mode 100644 index 0000000..0314412 --- /dev/null +++ b/shared/utils/src/path.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, + getBrowseParentPath, + inferProjectTitleFromPath, + isFilesystemBrowseQuery, + isUnsupportedWindowsProjectPath, + joinBrowseEntryPath, + normalizeProjectPathForDispatch, + resolveProjectPathForDispatch, +} from "./path"; + +describe("project path helpers", () => { + test("normalizes whitespace and trailing separators", () => { + expect(normalizeProjectPathForDispatch(" /tmp/cyrus/// ")).toBe( + "/tmp/cyrus" + ); + expect(normalizeProjectPathForDispatch("C:\\Users\\soorya\\\\")).toBe( + "C:\\Users\\soorya" + ); + }); + + test("resolves explicit relative paths against an absolute cwd", () => { + expect(resolveProjectPathForDispatch("../other", "/home/me/cyrus")).toBe( + "/home/me/other" + ); + expect(resolveProjectPathForDispatch("./apps/web", "/home/me/cyrus")).toBe( + "/home/me/cyrus/apps/web" + ); + }); + + test("detects filesystem browse queries by platform", () => { + expect(isFilesystemBrowseQuery("~/code")).toBe(true); + expect(isFilesystemBrowseQuery("C:\\Users\\me", "linux")).toBe(false); + expect(isFilesystemBrowseQuery("C:\\Users\\me", "win32")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Users\\me", "linux")).toBe( + true + ); + }); + + test("infers project titles from unix and windows paths", () => { + expect(inferProjectTitleFromPath("/home/me/cyrus/")).toBe("cyrus"); + expect(inferProjectTitleFromPath("C:\\Users\\me\\cyrus\\")).toBe("cyrus"); + }); + + test("builds browse paths and parents", () => { + expect(ensureBrowseDirectoryPath("/home/me")).toBe("/home/me/"); + expect(appendBrowsePathSegment("/home/me/", "cyrus")).toBe( + "/home/me/cyrus/" + ); + expect(joinBrowseEntryPath("/home/me", "cyrus")).toBe("/home/me/cyrus"); + expect(getBrowseParentPath("/home/me/cyrus/")).toBe("/home/me/"); + expect(canNavigateUp("/home/me/cyrus/")).toBe(true); + expect(canNavigateUp("/")).toBe(false); + }); +}); diff --git a/shared/utils/tsconfig.json b/shared/utils/tsconfig.json index 542570d..4716ed9 100644 --- a/shared/utils/tsconfig.json +++ b/shared/utils/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "react-jsx", "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": [], + "types": ["bun"], "strictNullChecks": true }, "include": ["src/**/*.ts", "src/**/*.tsx"], diff --git a/tooling/test/mocks/README.md b/tooling/test/mocks/README.md new file mode 100644 index 0000000..bfb0352 --- /dev/null +++ b/tooling/test/mocks/README.md @@ -0,0 +1,8 @@ +# Test Mocks + +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 diff --git a/tooling/test/setup/bun.setup.ts b/tooling/test/setup/bun.setup.ts new file mode 100644 index 0000000..0a3af9b --- /dev/null +++ b/tooling/test/setup/bun.setup.ts @@ -0,0 +1,2 @@ +// Shared Bun test setup belongs here when suites need process-wide hooks. +export {}; diff --git a/tooling/test/setup/vitest.shared.ts b/tooling/test/setup/vitest.shared.ts new file mode 100644 index 0000000..43a4f27 --- /dev/null +++ b/tooling/test/setup/vitest.shared.ts @@ -0,0 +1,2 @@ +// Shared Vitest setup belongs here when React or Workers suites are introduced. +export {}; diff --git a/turbo.json b/turbo.json index 15c6297..783f277 100644 --- a/turbo.json +++ b/turbo.json @@ -19,6 +19,19 @@ "check:types": { "dependsOn": ["^check:types"] }, + "test:unit": { + "dependsOn": ["^test:unit"], + "inputs": ["$TURBO_DEFAULT$", "src/**/*.test.ts", "src/**/*.test.tsx"], + "outputs": [] + }, + "test:integration": { + "dependsOn": ["^build"], + "cache": false + }, + "test:e2e": { + "dependsOn": ["build"], + "cache": false + }, "dev": { "dependsOn": ["dev:db"], "cache": false, From fbebe25298a065f21f12727252a2c8487391e717 Mon Sep 17 00:00:00 2001 From: Soorya U Date: Sat, 11 Jul 2026 11:55:08 +0530 Subject: [PATCH 2/2] Address Phase 1 PR review feedback. Run unit tests after typecheck with non-persisted checkout credentials, await async test assertions, add identity helper coverage, and assert encoded signaling payloads. Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 +++ apps/server/src/handlers/signaling.test.ts | 16 ++++++++++++++- .../src/conversations/turn-waiters.test.ts | 18 ++++++++--------- shared/utils/src/identity.test.ts | 20 +++++++++++++++++++ 4 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 shared/utils/src/identity.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a142532..87360ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,12 @@ jobs: name: Unit Tests runs-on: ubuntu-latest timeout-minutes: 10 + needs: check-types steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: ./tooling/github/setup - name: Unit tests diff --git a/apps/server/src/handlers/signaling.test.ts b/apps/server/src/handlers/signaling.test.ts index 5e92469..62c805a 100644 --- a/apps/server/src/handlers/signaling.test.ts +++ b/apps/server/src/handlers/signaling.test.ts @@ -1,5 +1,6 @@ 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( @@ -33,7 +34,10 @@ describe("broadcastSignalingEvent", () => { ); expect(sent).toHaveLength(2); - expect(sent.every((payload) => typeof payload === "string")).toBe(true); + expect(sent).toEqual([ + encodeHibernationRPCEvent("event-controller", event), + encodeHibernationRPCEvent("event-worker", event), + ]); }); test("skips excluded peers and peers without attachments", () => { @@ -57,5 +61,15 @@ describe("broadcastSignalingEvent", () => { ); expect(sent).toHaveLength(1); + expect(sent[0]).toBe( + encodeHibernationRPCEvent("event-worker", { + peer: { + id: "worker-2", + name: "Worker 2", + role: "worker", + }, + type: "peer-joined", + }) + ); }); }); diff --git a/shared/utils/src/conversations/turn-waiters.test.ts b/shared/utils/src/conversations/turn-waiters.test.ts index f403b02..9697eb4 100644 --- a/shared/utils/src/conversations/turn-waiters.test.ts +++ b/shared/utils/src/conversations/turn-waiters.test.ts @@ -7,14 +7,14 @@ import { } from "./turn-waiters"; describe("turn waiters", () => { - test("resolves when a turn completes", () => { + test("resolves when a turn completes", async () => { const ended = waitForTurnEnd("thread-complete", "turn-1"); settleTurnWaiter("thread-complete", "turn-1", { type: "turn_completed", }); - expect(ended).resolves.toBeUndefined(); + await expect(ended).resolves.toBeUndefined(); }); test("rejects with an interrupted error when a turn is interrupted", async () => { @@ -24,13 +24,13 @@ describe("turn waiters", () => { type: "turn_interrupted", }); - expect(ended).rejects.toThrow("turn interrupted"); + await expect(ended).rejects.toThrow("turn interrupted"); await ended.catch((error) => expect(isTurnInterruptedError(error)).toBe(true) ); }); - test("ignores non-terminal events", () => { + test("ignores non-terminal events", async () => { const ended = waitForTurnEnd("thread-token", "turn-1"); settleTurnWaiter("thread-token", "turn-1", { @@ -41,10 +41,10 @@ describe("turn waiters", () => { type: "turn_completed", }); - expect(ended).resolves.toBeUndefined(); + await expect(ended).resolves.toBeUndefined(); }); - test("rejects explicitly and removes the waiter", () => { + test("rejects explicitly and removes the waiter", async () => { const ended = waitForTurnEnd("thread-error", "turn-1"); rejectTurnWaiter("thread-error", "turn-1", new Error("boom")); @@ -52,14 +52,14 @@ describe("turn waiters", () => { type: "turn_completed", }); - expect(ended).rejects.toThrow("boom"); + await expect(ended).rejects.toThrow("boom"); }); - test("rejects immediately when the abort signal is already aborted", () => { + test("rejects immediately when the abort signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); - expect( + await expect( waitForTurnEnd("thread-abort", "turn-1", controller.signal) ).rejects.toThrow("turn aborted"); }); diff --git a/shared/utils/src/identity.test.ts b/shared/utils/src/identity.test.ts new file mode 100644 index 0000000..9749507 --- /dev/null +++ b/shared/utils/src/identity.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { generateName, randomId } from "./identity"; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SLUG_PATTERN = /^[a-z]+-[a-z]+$/; + +describe("identity helpers", () => { + test("randomId returns a uuid", () => { + const id = randomId(); + + expect(id).toMatch(UUID_PATTERN); + }); + + test("generateName returns a two-word slug", () => { + const name = generateName(); + + expect(name).toMatch(SLUG_PATTERN); + }); +});