Testing
Ko-Lab uses Vitest with jsdom and Testing Library. As of writing: 31 test files, 261 tests, all passing in ~70 seconds.
cd apps/web
npm test # one run
npm run test:watch # watch mode
npm run test:coverage # with coverage
:::warning Use Vitest, not Jest
The runner is Vitest. npx jest fails on all 31 files with ESM/CommonJS
errors — it is not the configured runner, and those failures mean nothing.
:::
Configuration
apps/web/vitest.config.ts:
export default defineConfig({
plugins: [react()],
test: {
setupFiles: ["./__tests__/setup.ts"],
environment: "jsdom",
globals: true,
include: ["__tests__/**/*.test.{ts,tsx}"],
alias: { "@": path.resolve(__dirname, "./app") },
},
});
globals: true means describe/it/expect need no import, though existing
tests import them explicitly anyway.
Layout
apps/web/__tests__/
├── setup.ts # global setup
├── api/ # route handler tests
│ ├── calls/ # 11 files — the call system
│ ├── notifications.test.ts
│ └── trash.test.ts
├── components/ # React component tests
│ └── CallUI, CallProvider, TrashPanel, …
└── workspace/ # pure-logic tests
├── dependencyCycle.test.ts
├── timelineScale.test.ts
└── monthGrid.test.ts
Three tiers, roughly: pure logic (workspace/), route handlers (api/), and
rendered components (components/).
Running a subset
npx vitest run __tests__/workspace # a directory
npx vitest run dependencyCycle # by name fragment
npx vitest run -t "rejects a longer loop" # a single test
Writing tests
Pure logic — the cheapest and most valuable
Extract the rule, then test it directly. wouldCreateCycle guards the
planning graph against loops, and
__tests__/workspace/dependencyCycle.test.ts covers it without touching React
or the database:
import { describe, expect, it } from "vitest";
import { wouldCreateCycle } from "../../app/api/workspace/[groupId]/planning/_shared";
describe("wouldCreateCycle", () => {
it("allows a chain to extend", () => {
const edges = [{ blockerId: "a", dependentId: "b" }];
expect(wouldCreateCycle(edges, "b", "c")).toBe(false);
});
it("rejects a longer loop closing back to the start", () => {
const edges = [
{ blockerId: "a", dependentId: "b" },
{ blockerId: "b", dependentId: "c" },
];
expect(wouldCreateCycle(edges, "c", "a")).toBe(true);
});
});
Note the comment style in the real file: it explains why the invariant matters (a cycle makes graph traversal non-terminating), not just what the assertion does.
Route handlers
Import the handler and call it with a Request. Mock Prisma so nothing
touches a live database:
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/prisma", () => ({
prisma: {
notifications: { findMany: vi.fn() },
},
}));
import { prisma } from "@/lib/prisma";
import { GET } from "@/api/notifications/route";
describe("GET /api/notifications", () => {
beforeEach(() => vi.clearAllMocks());
it("returns notifications for a user", async () => {
vi.mocked(prisma.notifications.findMany).mockResolvedValue([
{ id: "n1", userId: "u1" },
] as never);
const res = await GET(
new Request("http://localhost/api/notifications?userId=u1"),
);
expect(res.status).toBe(200);
expect((await res.json()).success).toBe(true);
});
it("rejects a missing userId", async () => {
const res = await GET(new Request("http://localhost/api/notifications"));
expect(res.status).toBe(400);
});
});
Always cover the failure paths — missing params, unauthorised callers, and not-found. Those are where route bugs actually live.
Components
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import BoardToolbar from "@/workspace/[groupId]/ui-design/BoardToolbar";
describe("BoardToolbar", () => {
it("disables Group until two nodes are selected", () => {
render(<BoardToolbar canGroup={false} onGroup={vi.fn()} /* … */ />);
expect(screen.getByTitle(/Group selection/)).toBeDisabled();
});
it("calls onGroup when clicked", () => {
const onGroup = vi.fn();
render(<BoardToolbar canGroup onGroup={onGroup} /* … */ />);
fireEvent.click(screen.getByTitle(/Group selection/));
expect(onGroup).toHaveBeenCalledOnce();
});
});
Query by what the user perceives — role, label, title — rather than by CSS class, so refactoring styles does not break tests.
Mocking external services
// LiveKit — never hit a real SFU in tests
vi.mock("livekit-server-sdk", () => ({
AccessToken: vi.fn(() => ({
addGrant: vi.fn(),
toJwt: vi.fn(() => "fake.jwt.token"),
})),
}));
// S3
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(() => ({ send: vi.fn().mockResolvedValue({}) })),
PutObjectCommand: vi.fn(),
}));
// WebSocket
class MockWebSocket {
readyState = 1;
send = vi.fn();
close = vi.fn();
addEventListener = vi.fn();
}
vi.stubGlobal("WebSocket", MockWebSocket);
Manual testing
Automated tests do not cover realtime collaboration, drag interactions, or media. These need two browsers and a human.
Realtime — the two-window rule
Anything collaborative must be verified with two browser windows in different profiles (or one normal, one incognito), signed in as different users in the same group.
Group chat
- Both windows open the same group
- Send from A → appears in B within ~1s, no refresh
- Kill the WS server → the offline indicator appears
- Restart it → reconnects and backfills
- Confirm delivery receipts update
Workspace boards
- Both open
/workspace/<groupId>/ui-design - A drops a component → appears for B
- Move the mouse in A → B sees A's live cursor with their name
- Both edit different nodes at once → no lost updates
- Both drag the same node → last write wins, no crash or desync
- A goes offline, edits, comes back → state converges
- Reload both → the board persists identically
Voice & video
Needs real devices; cannot be automated meaningfully.
- A calls B → B sees the incoming UI and hears a ring
- Accept → two-way audio and video
- Mute, camera off, screen share — each reflected on the far side
- Third participant joins → all three see each other
- Network drop → reconnect behaviour
- Decline, and unanswered-timeout, both land in notifications
Code editor & VCS
- Open a repo-linked group's editor
- Edit a file → draft persists across reload
- Create a branch, commit, raise a change request
- As the owner, review the diff and approve → verify the commit on GitHub
- Reject with a reason → the author sees it in notifications
- Confirm a member without code access cannot commit
Accessibility and responsive
- Keyboard-only: tab through, confirm focus is always visible
- Dark and light mode on every page — the app defaults to dark, and light-mode contrast bugs are the common regression
- 320 px, 768 px, 1440 px widths
- Zoom to 200%
CI
There is no CI workflow in the repo yet. A minimal one:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run db:web
- run: cd apps/web && npm test
- run: npx tsc --noEmit
working-directory: apps/web
- run: npm run lint
Gaps worth closing
| Area | State |
|---|---|
| Call system | Well covered — 11 files |
| Workspace pure logic | Good — cycles, timeline, grid |
| File handling | Covered — chunking, download, trash |
| Auth flows | No tests |
| VCS / change requests | No tests |
| Group membership & access | No tests |
| WebSocket server | No tests — apps/web-socket has no suite |
| End-to-end | None — no Playwright or Cypress |
The highest-value additions would be the codeAccess state machine and the
change-request approval path: both gate repository writes, and both are
currently unguarded by any test.