diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 252f06e8..1b1b3da6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -178,6 +178,27 @@ jobs: print('All plugin.json files valid against Protocol schema.') " + test-all: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm -r build + + - name: Run all tests + run: pnpm test:all + core-build-test: runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d0e91b8..3dc77ad9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,7 +184,7 @@ Six checks run on every PR via `validate.yml`: | `requires.env` schema | Each env entry has `name`, `description`, and valid `required`/`sensitive` booleans | | Protocol schema | All `plugin.json` files validate against the Protocol's `plugin.schema.json` | -Plus four build jobs: `core-build-test`, `desktop-build-test`, `board-build`, `docs-build`. All must pass before merge. If they fail: fix manifests or source, push, CI re-runs automatically. +Plus five additional jobs: `test-all` (runs `pnpm test:all` across all packages), `core-build-test` (includes `pnpm audit --audit-level=critical`), `desktop-build-test`, `board-build`, `docs-build`. All must pass before merge. If they fail: fix manifests or source, push, CI re-runs automatically. --- diff --git a/apps/cli/__tests__/cli-entry.test.ts b/apps/cli/__tests__/cli-entry.test.ts new file mode 100644 index 00000000..15970a4c --- /dev/null +++ b/apps/cli/__tests__/cli-entry.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { validateCommand } from "../src/commands/validate.js"; +import { compileCommand } from "../src/commands/compile.js"; + +describe("CLI entry point", () => { + it("validates the CLI commands are properly exported", () => { + // This test verifies that the main CLI commands are exported and callable + expect(validateCommand).toBeDefined(); + expect(typeof validateCommand).toBe("function"); + expect(compileCommand).toBeDefined(); + expect(typeof compileCommand).toBe("function"); + }); +}); diff --git a/apps/cli/__tests__/compile.test.ts b/apps/cli/__tests__/compile.test.ts new file mode 100644 index 00000000..d0255dba --- /dev/null +++ b/apps/cli/__tests__/compile.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { compileCommand } from "../src/commands/compile.js"; +import { CliTestEnv } from "./helpers/cli-test-env.js"; + +const FIXTURES = resolve(import.meta.dirname, "fixtures"); + +function loadFixture(name: string): string { + return readFileSync(resolve(FIXTURES, name), "utf-8"); +} + +describe("compile command", () => { + let env: CliTestEnv; + + beforeEach(() => { + env = new CliTestEnv(); + env.setup(); + }); + + afterEach(() => { + env.restore(); + vi.restoreAllMocks(); + }); + + describe("dry-run mode", () => { + it("compiles with --dry-run flag for single target", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("[DRY RUN]"); + expect(output).toContain("test-harness"); + expect(output).toContain("claude-code"); + expect(output).toContain("No files were written"); + expect(env.exitCode).toBeNull(); + }); + + it("compiles with --dry-run for multiple targets", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code,cursor", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("[DRY RUN]"); + expect(output).toContain("claude-code"); + expect(output).toContain("cursor"); + expect(output).toContain("CLAUDE.md"); + expect(output).toContain(".cursor/rules/harness.mdc"); + expect(env.exitCode).toBeNull(); + }); + + it("compiles with --dry-run for all targets", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "all", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("claude-code"); + expect(output).toContain("cursor"); + expect(output).toContain("copilot"); + expect(output).toContain("CLAUDE.md"); + expect(output).toContain(".cursor/rules/harness.mdc"); + expect(output).toContain(".github/copilot-instructions.md"); + expect(env.exitCode).toBeNull(); + }); + + it("shows file previews in dry-run mode", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("Would write:"); + expect(output).toContain("CLAUDE.md"); + // Should show file content preview + expect(output).toContain("────────────────────────────────────────"); + expect(env.exitCode).toBeNull(); + }); + }); + + describe("target selection", () => { + it("compiles for single target", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("Targets: claude-code"); + expect(output).not.toContain(".cursor/rules/harness.mdc"); + expect(output).not.toContain(".github/copilot-instructions.md"); + expect(env.exitCode).toBeNull(); + }); + + it("compiles for multiple comma-separated targets", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code,cursor", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("claude-code"); + expect(output).toContain("cursor"); + expect(output).not.toContain("copilot"); + expect(env.exitCode).toBeNull(); + }); + + it("compiles for all targets with 'all' keyword", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "all", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("claude-code"); + expect(output).toContain("cursor"); + expect(output).toContain("copilot"); + expect(env.exitCode).toBeNull(); + }); + + it("exits with error for invalid target", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await expect( + compileCommand(fixturePath, { + target: "invalid-target", + dryRun: true, + }), + ).rejects.toThrow(); + + expect(env.getError()).toContain("Unknown target"); + expect(env.exitCode).toBe(1); + }); + + it("handles whitespace in target list", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: " claude-code , cursor ", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("claude-code"); + expect(output).toContain("cursor"); + expect(env.exitCode).toBeNull(); + }); + }); + + describe("output generation", () => { + it("generates correct file structure for claude-code", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("CLAUDE.md"); + expect(output).toContain("AGENT.md"); + expect(output).toContain(".mcp.json"); + expect(env.exitCode).toBeNull(); + }); + + it("generates correct file structure for cursor", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "cursor", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain(".cursor/rules/harness.mdc"); + expect(output).toContain(".cursor/mcp.json"); + expect(env.exitCode).toBeNull(); + }); + + it("generates correct file structure for copilot", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "copilot", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain(".github/copilot-instructions.md"); + expect(output).toContain(".vscode/mcp.json"); + expect(env.exitCode).toBeNull(); + }); + + it("shows harness markers in output", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("BEGIN harness:test-harness"); + expect(output).toContain("END harness:test-harness"); + expect(env.exitCode).toBeNull(); + }); + + it("includes compile report summary", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("Compiled harness:"); + expect(output).toContain("test-harness"); + expect(output).toContain("Targets:"); + expect(env.exitCode).toBeNull(); + }); + }); + + describe("error handling", () => { + it("exits with error when harness.yaml not found", async () => { + await expect( + compileCommand("./nonexistent.yaml", { + target: "claude-code", + dryRun: true, + }), + ).rejects.toThrow(); + + expect(env.getError()).toContain("No harness.yaml found"); + expect(env.exitCode).toBe(1); + }); + + it("exits with error for invalid harness.yaml", async () => { + const fixturePath = resolve(FIXTURES, "invalid-harness.yaml"); + + await expect( + compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + }), + ).rejects.toThrow(); + + expect(env.getError()).toContain("name"); + expect(env.exitCode).toBe(1); + }); + + it("uses default path when no file specified", async () => { + // This will fail since harness.yaml doesn't exist in test dir + await expect( + compileCommand(undefined, { + target: "claude-code", + dryRun: true, + }), + ).rejects.toThrow(); + + expect(env.getError()).toContain("harness.yaml"); + expect(env.exitCode).toBe(1); + }); + }); + + describe("verbose mode", () => { + it("passes verbose flag to compile function", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + await compileCommand(fixturePath, { + target: "claude-code", + dryRun: true, + verbose: true, + }); + + // Verbose mode is passed to compile but doesn't change CLI output structure + const output = env.getLog(); + expect(output).toContain("test-harness"); + expect(env.exitCode).toBeNull(); + }); + }); + + describe("warnings", () => { + it("displays warnings in compile report", async () => { + const fixturePath = resolve(FIXTURES, "valid-harness.yaml"); + + // Cursor has non-enforceable deny permissions which generate warnings + await compileCommand(fixturePath, { + target: "cursor", + dryRun: true, + }); + + const output = env.getLog(); + expect(output).toContain("Warnings:"); + expect(output).toContain("not machine-enforceable"); + expect(env.exitCode).toBeNull(); + }); + }); + +}); diff --git a/apps/cli/__tests__/fixtures/invalid-harness.yaml b/apps/cli/__tests__/fixtures/invalid-harness.yaml new file mode 100644 index 00000000..2257aff5 --- /dev/null +++ b/apps/cli/__tests__/fixtures/invalid-harness.yaml @@ -0,0 +1,9 @@ +$schema: https://harnessprotocol.ai/schema/v1/harness.schema.json +version: "1" + +metadata: + # Missing required 'name' field + description: Invalid harness + +instructions: + operational: "Test" diff --git a/apps/cli/__tests__/fixtures/missing-version.yaml b/apps/cli/__tests__/fixtures/missing-version.yaml new file mode 100644 index 00000000..f7f5f8e2 --- /dev/null +++ b/apps/cli/__tests__/fixtures/missing-version.yaml @@ -0,0 +1,7 @@ +metadata: + name: test-harness + description: Missing version field + +plugins: + - name: explain + source: siracusa5/harness-kit diff --git a/apps/cli/__tests__/fixtures/test-harness.yaml b/apps/cli/__tests__/fixtures/test-harness.yaml new file mode 100644 index 00000000..bcd4f7b6 --- /dev/null +++ b/apps/cli/__tests__/fixtures/test-harness.yaml @@ -0,0 +1,19 @@ +$schema: https://harnessprotocol.ai/schema/v1/harness.schema.json +version: "1" + +metadata: + name: test-harness + description: Test harness configuration for CLI tests. + +plugins: + - name: explain + source: siracusa5/harness-kit + description: Layered explanations of files, functions, directories, or concepts + +instructions: + operational: | + ## Commands + - Build: `pnpm build` + - Test: `pnpm test` + behavioral: | + Be concise. Prefer short answers. diff --git a/apps/cli/__tests__/fixtures/valid-harness.yaml b/apps/cli/__tests__/fixtures/valid-harness.yaml new file mode 100644 index 00000000..3b1224b9 --- /dev/null +++ b/apps/cli/__tests__/fixtures/valid-harness.yaml @@ -0,0 +1,49 @@ +$schema: https://harnessprotocol.ai/schema/v1/harness.schema.json +version: "1" + +metadata: + name: test-harness + description: Test harness for CLI tests. + +plugins: + - name: explain + source: siracusa5/harness-kit + description: Layered explanations of files, functions, directories, or concepts + +instructions: + operational: | + ## Commands + - Build: `pnpm build` + - Test: `pnpm test` + behavioral: | + Be concise. Prefer short answers. + import-mode: merge + +mcp-servers: + postgres: + transport: stdio + command: uvx + args: + - mcp-server-postgres + - ${DB_CONNECTION_STRING} + +env: + - name: DB_CONNECTION_STRING + description: PostgreSQL connection string. + required: true + sensitive: true + +permissions: + tools: + allow: + - Read + - Glob + - Grep + - Write + - Edit + deny: + - mcp__postgres__drop_table + paths: + writable: + - sql/ + - migrations/ diff --git a/apps/cli/__tests__/helpers/cli-test-env.ts b/apps/cli/__tests__/helpers/cli-test-env.ts new file mode 100644 index 00000000..f3132667 --- /dev/null +++ b/apps/cli/__tests__/helpers/cli-test-env.ts @@ -0,0 +1,62 @@ +import { vi } from "vitest"; + +/** + * Mock environment for CLI command testing. + * Captures console output and process.exit calls. + */ +export class CliTestEnv { + public consoleLog: string[] = []; + public consoleError: string[] = []; + public exitCode: number | null = null; + + private originalLog: typeof console.log; + private originalError: typeof console.error; + private originalExit: typeof process.exit; + + constructor() { + this.originalLog = console.log; + this.originalError = console.error; + this.originalExit = process.exit; + } + + /** + * Set up mocks. Call this before running the command. + */ + setup(): void { + console.log = vi.fn((...args: unknown[]) => { + this.consoleLog.push(args.map(String).join(" ")); + }); + + console.error = vi.fn((...args: unknown[]) => { + this.consoleError.push(args.map(String).join(" ")); + }); + + process.exit = vi.fn((code?: number) => { + this.exitCode = code ?? 0; + throw new Error(`process.exit(${code ?? 0})`); + }) as never; + } + + /** + * Restore original functions. Call this in afterEach. + */ + restore(): void { + console.log = this.originalLog; + console.error = this.originalError; + process.exit = this.originalExit; + } + + /** + * Get all console.log output as a single string. + */ + getLog(): string { + return this.consoleLog.join("\n"); + } + + /** + * Get all console.error output as a single string. + */ + getError(): string { + return this.consoleError.join("\n"); + } +} diff --git a/apps/cli/__tests__/validate.test.ts b/apps/cli/__tests__/validate.test.ts new file mode 100644 index 00000000..b39ea0f7 --- /dev/null +++ b/apps/cli/__tests__/validate.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolve } from "node:path"; +import { validateCommand } from "../src/commands/validate.js"; +import { CliTestEnv } from "./helpers/cli-test-env.js"; + +const FIXTURES = resolve(import.meta.dirname, "fixtures"); + +describe("validate command", () => { + let env: CliTestEnv; + + beforeEach(() => { + env = new CliTestEnv(); + env.setup(); + }); + + afterEach(() => { + env.restore(); + vi.restoreAllMocks(); + }); + + it("validates a valid harness.yaml file", async () => { + const filePath = resolve(FIXTURES, "test-harness.yaml"); + + await expect(validateCommand(filePath)).rejects.toThrow(); + + expect(env.exitCode).toBe(0); + expect(env.getLog()).toContain("PASS"); + expect(env.getLog()).toContain("is valid"); + }); + + it("fails for an invalid harness.yaml file", async () => { + const filePath = resolve(FIXTURES, "invalid-harness.yaml"); + + await expect(validateCommand(filePath)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getLog()).toContain("FAIL"); + }); + + it("fails when harness.yaml file is missing", async () => { + const filePath = resolve(FIXTURES, "nonexistent.yaml"); + + await expect(validateCommand(filePath)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getError()).toContain("No harness.yaml found"); + expect(env.getError()).toContain(filePath); + }); + + it("fails when version is missing", async () => { + const filePath = resolve(FIXTURES, "missing-version.yaml"); + + await expect(validateCommand(filePath)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getLog()).toContain("FAIL"); + expect(env.getLog()).toContain("version"); + }); + + it("uses default harness.yaml when no path provided", async () => { + // This test will fail because there's no harness.yaml in the test directory + await expect(validateCommand()).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getError()).toContain("No harness.yaml found"); + expect(env.getError()).toContain("harness.yaml"); + }); + + it("handles parse errors gracefully", async () => { + const filePath = resolve(FIXTURES, "malformed.yaml"); + + // Create a malformed YAML file + const { writeFileSync } = await import("node:fs"); + writeFileSync(filePath, "invalid:\n yaml:\n - [unclosed", "utf-8"); + + await expect(validateCommand(filePath)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getError()).toBeTruthy(); + + // Cleanup + const { unlinkSync } = await import("node:fs"); + unlinkSync(filePath); + }); + + it("reports validation errors with details", async () => { + const filePath = resolve(FIXTURES, "invalid-harness.yaml"); + + await expect(validateCommand(filePath)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getLog()).toContain("failed validation"); + }); +}); diff --git a/apps/cli/package.json b/apps/cli/package.json index 86d731cb..301b4b98 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -8,7 +8,9 @@ }, "scripts": { "build": "tsup", - "dev": "tsup --watch" + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@harness-kit/core": "workspace:*", @@ -18,7 +20,9 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "@vitest/coverage-v8": "^3", "tsup": "^8", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3" } } diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts new file mode 100644 index 00000000..9243ae4c --- /dev/null +++ b/apps/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["__tests__/**/*.test.ts"], + }, +}); diff --git a/package.json b/package.json index 10a02a57..ec6c153a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "build": "pnpm -r build", "build:core": "pnpm --filter @harness-kit/core build", "build:cli": "pnpm --filter @harness-kit/cli build", + "test:all": "pnpm -r test", + "test:coverage": "pnpm -r test -- --coverage", "test:core": "pnpm --filter @harness-kit/core test", "test:desktop:unit": "pnpm --filter harness-kit-desktop test", "test:desktop:rust": "cd apps/desktop/src-tauri && cargo test -- --test-threads=1", @@ -27,6 +29,9 @@ "board:logs": "pnpm --filter board-server launchd:logs", "board:restart": "pnpm --filter board-server launchd:restart" }, + "devDependencies": { + "@vitest/coverage-v8": "^3" + }, "pnpm": { "overrides": { "picomatch": ">=4.0.4", diff --git a/packages/board-server/__tests__/routes.test.ts b/packages/board-server/__tests__/routes.test.ts new file mode 100644 index 00000000..69e601f1 --- /dev/null +++ b/packages/board-server/__tests__/routes.test.ts @@ -0,0 +1,642 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import request from "supertest"; +import { createHttpApp } from "../src/http/server.js"; +import * as store from "../src/store/yaml-store.js"; +import { resetBoardDirCache } from "../src/store/yaml-store.js"; +import type { Express } from "express"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +describe("HTTP Routes", () => { + let app: Express; + let tempDir: string; + + beforeAll(() => { + tempDir = path.join(os.tmpdir(), 'board-test-' + Date.now()); + fs.mkdirSync(tempDir, { recursive: true }); + process.env.NODE_ENV = 'test'; + process.env.BOARD_TEST_DIR = tempDir; + }); + + afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + delete process.env.BOARD_TEST_DIR; + }); + + beforeEach(() => { + // Reset the board dir cache so the store picks up the temp dir + resetBoardDirCache(); + // Wipe the temp dir contents between tests for isolation + if (fs.existsSync(tempDir)) { + for (const file of fs.readdirSync(tempDir)) { + fs.rmSync(path.join(tempDir, file), { force: true }); + } + } + + // Create fresh app for each test + app = createHttpApp(); + }); + + // --- Health Check --- + + describe("GET /health", () => { + it("returns health status", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true, service: 'harness-board' }); + }); + }); + + // --- Projects --- + + describe("GET /api/v1/projects", () => { + it("returns array of projects", async () => { + const res = await request(app).get("/api/v1/projects"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it("returns list including created projects", async () => { + store.createProject({ name: "Test Project 1" }); + store.createProject({ name: "Test Project 2" }); + + const res = await request(app).get("/api/v1/projects"); + expect(res.status).toBe(200); + expect(res.body.length).toBeGreaterThanOrEqual(2); + + // Check our test projects are in the list + const projectNames = res.body.map((p: any) => p.name); + expect(projectNames).toContain("Test Project 1"); + expect(projectNames).toContain("Test Project 2"); + }); + }); + + describe("POST /api/v1/projects", () => { + it("creates a new project with required fields", async () => { + const res = await request(app) + .post("/api/v1/projects") + .send({ name: "New Project" }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("New Project"); + expect(res.body.slug).toBe("new-project"); + expect(res.body.epics).toEqual([]); + expect(res.body.next_id).toBe(1); + }); + + it("creates a project with all optional fields", async () => { + const res = await request(app) + .post("/api/v1/projects") + .send({ + name: "Full Project", + description: "A test project", + color: "#FF5733", + repo_url: "https://github.com/user/repo", + }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("Full Project"); + expect(res.body.description).toBe("A test project"); + expect(res.body.color).toBe("#FF5733"); + expect(res.body.repo_url).toBe("https://github.com/user/repo"); + }); + + it("returns 400 when name is missing", async () => { + const res = await request(app) + .post("/api/v1/projects") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("name is required"); + }); + + it("returns 400 when project already exists", async () => { + await request(app) + .post("/api/v1/projects") + .send({ name: "Duplicate Project" }); + + const res = await request(app) + .post("/api/v1/projects") + .send({ name: "Duplicate Project" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("already exists"); + }); + }); + + describe("GET /api/v1/projects/:slug", () => { + it("returns a single project", async () => { + store.createProject({ name: "Test Project" }); + + const res = await request(app).get("/api/v1/projects/test-project"); + expect(res.status).toBe(200); + expect(res.body.name).toBe("Test Project"); + expect(res.body.slug).toBe("test-project"); + }); + + it("returns 404 for non-existent project", async () => { + const res = await request(app).get("/api/v1/projects/non-existent"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("Not found"); + }); + }); + + describe("PATCH /api/v1/projects/:slug", () => { + it("updates project description", async () => { + store.createProject({ name: "Test Project" }); + + const res = await request(app) + .patch("/api/v1/projects/test-project") + .send({ description: "Updated description" }); + + expect(res.status).toBe(200); + expect(res.body.description).toBe("Updated description"); + }); + + it("updates project color", async () => { + store.createProject({ name: "Test Project" }); + + const res = await request(app) + .patch("/api/v1/projects/test-project") + .send({ color: "#00FF00" }); + + expect(res.status).toBe(200); + expect(res.body.color).toBe("#00FF00"); + }); + + it("updates project repo_url", async () => { + store.createProject({ name: "Test Project" }); + + const res = await request(app) + .patch("/api/v1/projects/test-project") + .send({ repo_url: "https://github.com/new/repo" }); + + expect(res.status).toBe(200); + expect(res.body.repo_url).toBe("https://github.com/new/repo"); + }); + + it("updates multiple fields at once", async () => { + store.createProject({ name: "Test Project" }); + + const res = await request(app) + .patch("/api/v1/projects/test-project") + .send({ + description: "New description", + color: "#0000FF", + repo_url: "https://github.com/updated/repo", + }); + + expect(res.status).toBe(200); + expect(res.body.description).toBe("New description"); + expect(res.body.color).toBe("#0000FF"); + expect(res.body.repo_url).toBe("https://github.com/updated/repo"); + }); + + it("returns 400 for non-existent project", async () => { + const res = await request(app) + .patch("/api/v1/projects/non-existent") + .send({ description: "Test" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + }); + + // --- Epics --- + + describe("POST /api/v1/projects/:slug/epics", () => { + beforeEach(() => { + store.createProject({ name: "Test Project" }); + }); + + it("creates a new epic with required fields", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/epics") + .send({ name: "New Epic" }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("New Epic"); + expect(res.body.id).toBe(1); + expect(res.body.status).toBe("active"); + expect(res.body.tasks).toEqual([]); + }); + + it("creates an epic with description", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/epics") + .send({ name: "Epic with Desc", description: "Epic description" }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("Epic with Desc"); + expect(res.body.description).toBe("Epic description"); + }); + + it("returns 400 when name is missing", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/epics") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("name is required"); + }); + + it("returns 400 for non-existent project", async () => { + const res = await request(app) + .post("/api/v1/projects/non-existent/epics") + .send({ name: "Epic" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + + it("increments project next_id for epic", async () => { + await request(app) + .post("/api/v1/projects/test-project/epics") + .send({ name: "Epic 1" }); + + const res = await request(app) + .post("/api/v1/projects/test-project/epics") + .send({ name: "Epic 2" }); + + expect(res.status).toBe(201); + expect(res.body.id).toBe(2); + }); + }); + + describe("PATCH /api/v1/projects/:slug/epics/:epicId", () => { + beforeEach(() => { + store.createProject({ name: "Test Project" }); + store.createEpic("test-project", "Test Epic"); + }); + + it("updates epic status to completed", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/epics/1") + .send({ status: "completed" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("completed"); + }); + + it("updates epic status to archived", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/epics/1") + .send({ status: "archived" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("archived"); + }); + + it("returns 400 for non-existent epic", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/epics/999") + .send({ status: "completed" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + + it("returns 400 for non-existent project", async () => { + const res = await request(app) + .patch("/api/v1/projects/non-existent/epics/1") + .send({ status: "completed" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + }); + + // --- Tasks --- + + describe("POST /api/v1/projects/:slug/epics/:epicId/tasks", () => { + beforeEach(() => { + store.createProject({ name: "Test Project" }); + store.createEpic("test-project", "Test Epic"); + }); + + it("creates a new task with required fields", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/epics/1/tasks") + .send({ title: "New Task" }); + + expect(res.status).toBe(201); + expect(res.body.title).toBe("New Task"); + expect(res.body.id).toBe(2); // Epic used ID 1 + expect(res.body.status).toBe("backlog"); + expect(res.body.linked_commits).toEqual([]); + expect(res.body.comments).toEqual([]); + }); + + it("creates a task with description", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/epics/1/tasks") + .send({ title: "Task with Desc", description: "Task description" }); + + expect(res.status).toBe(201); + expect(res.body.title).toBe("Task with Desc"); + expect(res.body.description).toBe("Task description"); + }); + + it("returns 400 when title is missing", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/epics/1/tasks") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("title is required"); + }); + + it("returns 400 for non-existent epic", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/epics/999/tasks") + .send({ title: "Task" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + + it("increments project next_id for task", async () => { + await request(app) + .post("/api/v1/projects/test-project/epics/1/tasks") + .send({ title: "Task 1" }); + + const res = await request(app) + .post("/api/v1/projects/test-project/epics/1/tasks") + .send({ title: "Task 2" }); + + expect(res.status).toBe(201); + expect(res.body.id).toBe(3); // Epic used 1, first task used 2 + }); + }); + + describe("GET /api/v1/projects/:slug/tasks", () => { + beforeEach(() => { + store.createProject({ name: "Test Project" }); + store.createEpic("test-project", "Epic 1"); + store.createEpic("test-project", "Epic 2"); + store.createTask("test-project", 1, "Task 1"); + store.createTask("test-project", 1, "Task 2"); + store.createTask("test-project", 2, "Task 3"); + }); + + it("returns all tasks for a project", async () => { + const res = await request(app).get("/api/v1/projects/test-project/tasks"); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(3); + expect(res.body[0].title).toBe("Task 1"); + expect(res.body[1].title).toBe("Task 2"); + expect(res.body[2].title).toBe("Task 3"); + }); + + it("filters tasks by epic_id", async () => { + const res = await request(app) + .get("/api/v1/projects/test-project/tasks") + .query({ epic_id: 1 }); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + expect(res.body[0].epic_id).toBe(1); + expect(res.body[1].epic_id).toBe(1); + }); + + it("filters tasks by status", async () => { + store.updateTask("test-project", 3, { status: "in-progress" }); + + const res = await request(app) + .get("/api/v1/projects/test-project/tasks") + .query({ status: "backlog" }); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + expect(res.body.every((t: any) => t.status === "backlog")).toBe(true); + }); + + it("filters tasks by both epic_id and status", async () => { + store.updateTask("test-project", 4, { status: "in-progress" }); + + const res = await request(app) + .get("/api/v1/projects/test-project/tasks") + .query({ epic_id: 1, status: "in-progress" }); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].id).toBe(4); + expect(res.body[0].status).toBe("in-progress"); + }); + + it("returns empty array when no tasks match filters", async () => { + const res = await request(app) + .get("/api/v1/projects/test-project/tasks") + .query({ status: "done" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + }); + + describe("PATCH /api/v1/projects/:slug/tasks/:taskId", () => { + beforeEach(() => { + store.createProject({ name: "Test Project" }); + store.createEpic("test-project", "Test Epic"); + store.createTask("test-project", 1, "Test Task", "Original description"); + }); + + it("updates task title", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/tasks/2") + .send({ title: "Updated Title" }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe("Updated Title"); + }); + + it("updates task description", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/tasks/2") + .send({ description: "Updated description" }); + + expect(res.status).toBe(200); + expect(res.body.description).toBe("Updated description"); + }); + + it("updates task status", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/tasks/2") + .send({ status: "in-progress" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("in-progress"); + }); + + it("updates task no_worktree flag", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/tasks/2") + .send({ no_worktree: true }); + + expect(res.status).toBe(200); + expect(res.body.no_worktree).toBe(true); + }); + + it("updates multiple fields at once", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/tasks/2") + .send({ + title: "Multi Update", + description: "Multi description", + status: "review", + }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe("Multi Update"); + expect(res.body.description).toBe("Multi description"); + expect(res.body.status).toBe("review"); + }); + + it("returns 400 for non-existent task", async () => { + const res = await request(app) + .patch("/api/v1/projects/test-project/tasks/999") + .send({ title: "Updated" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + + it("returns 400 for non-existent project", async () => { + const res = await request(app) + .patch("/api/v1/projects/non-existent/tasks/2") + .send({ title: "Updated" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + }); + + // --- Comments --- + + describe("POST /api/v1/projects/:slug/tasks/:taskId/comments", () => { + beforeEach(() => { + store.createProject({ name: "Test Project" }); + store.createEpic("test-project", "Test Epic"); + store.createTask("test-project", 1, "Test Task"); + }); + + it("adds a user comment", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/tasks/2/comments") + .send({ author: "user", body: "This is a comment" }); + + expect(res.status).toBe(201); + expect(res.body.author).toBe("user"); + expect(res.body.body).toBe("This is a comment"); + expect(res.body.timestamp).toBeDefined(); + }); + + it("adds a claude comment", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/tasks/2/comments") + .send({ author: "claude", body: "Claude's comment" }); + + expect(res.status).toBe(201); + expect(res.body.author).toBe("claude"); + expect(res.body.body).toBe("Claude's comment"); + }); + + it("returns 400 when author is missing", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/tasks/2/comments") + .send({ body: "Comment body" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("author and body are required"); + }); + + it("returns 400 when body is missing", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/tasks/2/comments") + .send({ author: "user" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("author and body are required"); + }); + + it("returns 400 for non-existent task", async () => { + const res = await request(app) + .post("/api/v1/projects/test-project/tasks/999/comments") + .send({ author: "user", body: "Comment" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not found"); + }); + }); + + // --- Integration scenarios --- + + describe("Integration scenarios", () => { + it("creates a full project workflow", async () => { + // Create project + const project = await request(app) + .post("/api/v1/projects") + .send({ name: "Integration Test Project" }); + expect(project.status).toBe(201); + + // Create epic + const epic = await request(app) + .post("/api/v1/projects/integration-test-project/epics") + .send({ name: "Feature Epic" }); + expect(epic.status).toBe(201); + + // Create task + const task = await request(app) + .post(`/api/v1/projects/integration-test-project/epics/${epic.body.id}/tasks`) + .send({ title: "Implement feature" }); + expect(task.status).toBe(201); + + // Move task to in-progress + const movedTask = await request(app) + .patch(`/api/v1/projects/integration-test-project/tasks/${task.body.id}`) + .send({ status: "in-progress" }); + expect(movedTask.status).toBe(200); + expect(movedTask.body.status).toBe("in-progress"); + + // Add comment + const comment = await request(app) + .post(`/api/v1/projects/integration-test-project/tasks/${task.body.id}/comments`) + .send({ author: "claude", body: "Working on this task" }); + expect(comment.status).toBe(201); + + // Complete task + const completedTask = await request(app) + .patch(`/api/v1/projects/integration-test-project/tasks/${task.body.id}`) + .send({ status: "done" }); + expect(completedTask.status).toBe(200); + expect(completedTask.body.status).toBe("done"); + + // Complete epic + const completedEpic = await request(app) + .patch(`/api/v1/projects/integration-test-project/epics/${epic.body.id}`) + .send({ status: "completed" }); + expect(completedEpic.status).toBe(200); + expect(completedEpic.body.status).toBe("completed"); + }); + + it("handles CORS headers correctly", async () => { + const res = await request(app) + .get("/api/v1/projects") + .set("Origin", "http://localhost:3000"); + + expect(res.headers["access-control-allow-origin"]).toBeTruthy(); + expect(res.headers["access-control-allow-methods"]).toBeDefined(); + expect(res.headers["access-control-allow-headers"]).toBeDefined(); + }); + + it("handles OPTIONS preflight requests", async () => { + const res = await request(app) + .options("/api/v1/projects") + .set("Origin", "http://localhost:3000"); + + expect(res.status).toBe(204); + }); + }); +}); diff --git a/packages/board-server/__tests__/ws-hub.test.ts b/packages/board-server/__tests__/ws-hub.test.ts new file mode 100644 index 00000000..b1f9b3a4 --- /dev/null +++ b/packages/board-server/__tests__/ws-hub.test.ts @@ -0,0 +1,479 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Server } from "node:http"; + +// ── Mocks ───────────────────────────────────────────────────── + +// Store references to handlers and state +let mockWssOnHandlers: Map; +let mockFileWatcherHandlers: Map; +let mockClients: Set; +let mockWssInstance: any; +let mockFileWatcherInstance: any; + +// Mock WebSocket and WebSocketServer from ws package +vi.mock("ws", () => { + const OPEN = 1; + + class MockWebSocket { + readyState = OPEN; + send = vi.fn(); + on = vi.fn(); + + static OPEN = OPEN; + } + + class MockWebSocketServer { + clients: Set; + on = vi.fn(); + close = vi.fn(); + + constructor(options: any) { + mockClients = new Set(); + this.clients = mockClients; + mockWssOnHandlers = new Map(); + mockWssInstance = this; + + // Capture event handlers + this.on = vi.fn((event: string, handler: Function) => { + mockWssOnHandlers.set(event, handler); + }); + } + } + + return { + WebSocket: MockWebSocket, + WebSocketServer: MockWebSocketServer, + }; +}); + +// Mock FileWatcher +vi.mock("../src/store/file-watcher.js", () => { + class MockFileWatcher { + on = vi.fn(); + start = vi.fn(); + stop = vi.fn(); + + constructor(dir: string, debounce?: number) { + mockFileWatcherHandlers = new Map(); + mockFileWatcherInstance = this; + + // Capture event handlers + this.on = vi.fn((event: string, handler: Function) => { + mockFileWatcherHandlers.set(event, handler); + }); + } + } + + return { FileWatcher: MockFileWatcher }; +}); + +// Mock yaml-store +vi.mock("../src/store/yaml-store.js", () => ({ + projectsDir: vi.fn(() => "/mock/projects"), + readProject: vi.fn(), +})); + +// Import after mocks are set up +import { WsHub } from "../src/ws/hub.js"; +import type { BoardEvent } from "../src/ws/hub.js"; +import { WebSocket } from "ws"; +import * as store from "../src/store/yaml-store.js"; + +// ── Helpers ─────────────────────────────────────────────────── + +function makeFakeHttpServer(): Server { + return {} as Server; +} + +function makeFakeWebSocket(readyState: number = 1) { + return { + readyState, + send: vi.fn(), + on: vi.fn(), + }; +} + +function makeMockProject(slug: string) { + return { + name: `Project ${slug}`, + slug, + version: 1 as const, + next_id: 1, + epics: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; +} + +// ── Tests ───────────────────────────────────────────────────── + +// Reset module-scope mock state before every test so no state leaks between tests. +beforeEach(() => { + mockWssOnHandlers = new Map(); + mockFileWatcherHandlers = new Map(); + mockClients = new Set(); +}); + +describe("WsHub constructor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates FileWatcher with projects directory", () => { + const httpServer = makeFakeHttpServer(); + new WsHub(httpServer); + + expect(store.projectsDir).toHaveBeenCalled(); + }); + + it("registers connection handler", () => { + const httpServer = makeFakeHttpServer(); + new WsHub(httpServer); + + expect(mockWssInstance.on).toHaveBeenCalledWith('connection', expect.any(Function)); + }); + + it("registers file watcher change handler", () => { + const httpServer = makeFakeHttpServer(); + new WsHub(httpServer); + + expect(mockFileWatcherInstance.on).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it("registers file watcher error handler", () => { + const httpServer = makeFakeHttpServer(); + new WsHub(httpServer); + + expect(mockFileWatcherInstance.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it("starts the file watcher", () => { + const httpServer = makeFakeHttpServer(); + new WsHub(httpServer); + + expect(mockFileWatcherInstance.start).toHaveBeenCalled(); + }); +}); + +describe("WsHub connection handling", () => { + let hub: WsHub; + + beforeEach(() => { + vi.clearAllMocks(); + const httpServer = makeFakeHttpServer(); + hub = new WsHub(httpServer); + }); + + it("sends welcome message on connection", () => { + const ws = makeFakeWebSocket(); + const req = {} as any; + + const connectionHandler = mockWssOnHandlers.get('connection')!; + connectionHandler(ws, req); + + expect(ws.send).toHaveBeenCalledOnce(); + const sentData = JSON.parse(ws.send.mock.calls[0][0]); + expect(sentData).toEqual({ + type: 'connected', + message: 'Harness Board connected' + }); + }); + + it("registers error handler on new connection", () => { + const ws = makeFakeWebSocket(); + const req = {} as any; + + const connectionHandler = mockWssOnHandlers.get('connection')!; + connectionHandler(ws, req); + + expect(ws.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); +}); + +describe("WsHub.broadcast", () => { + let hub: WsHub; + + beforeEach(() => { + vi.clearAllMocks(); + const httpServer = makeFakeHttpServer(); + hub = new WsHub(httpServer); + }); + + it("sends event to all OPEN clients", () => { + const ws1 = makeFakeWebSocket(1); // OPEN + const ws2 = makeFakeWebSocket(1); // OPEN + const ws3 = makeFakeWebSocket(1); // OPEN + + mockClients.add(ws1); + mockClients.add(ws2); + mockClients.add(ws3); + + const event: BoardEvent = { + type: 'project_updated', + slug: 'test-project', + project: makeMockProject('test-project') + }; + + hub.broadcast(event); + + expect(ws1.send).toHaveBeenCalledOnce(); + expect(ws2.send).toHaveBeenCalledOnce(); + expect(ws3.send).toHaveBeenCalledOnce(); + }); + + it("skips clients that are not OPEN", () => { + const openWs = makeFakeWebSocket(1); // OPEN + const closedWs = makeFakeWebSocket(3); // CLOSED + const connectingWs = makeFakeWebSocket(0); // CONNECTING + + mockClients.add(openWs); + mockClients.add(closedWs); + mockClients.add(connectingWs); + + const event: BoardEvent = { + type: 'connected', + message: 'test' + }; + + hub.broadcast(event); + + expect(openWs.send).toHaveBeenCalledOnce(); + expect(closedWs.send).not.toHaveBeenCalled(); + expect(connectingWs.send).not.toHaveBeenCalled(); + }); + + it("sends JSON-serialized payload", () => { + const ws = makeFakeWebSocket(1); + mockClients.add(ws); + + const event: BoardEvent = { + type: 'project_updated', + slug: 'my-project', + project: makeMockProject('my-project') + }; + + hub.broadcast(event); + + expect(ws.send).toHaveBeenCalledWith(JSON.stringify(event)); + }); + + it("handles empty client set gracefully", () => { + const event: BoardEvent = { + type: 'connected', + message: 'test' + }; + + // Should not throw with no clients + expect(() => hub.broadcast(event)).not.toThrow(); + }); + + it("broadcasts connected event correctly", () => { + const ws = makeFakeWebSocket(1); + mockClients.add(ws); + + const event: BoardEvent = { + type: 'connected', + message: 'Hello from server' + }; + + hub.broadcast(event); + + const sentData = JSON.parse(ws.send.mock.calls[0][0]); + expect(sentData.type).toBe('connected'); + expect(sentData.message).toBe('Hello from server'); + }); +}); + +describe("WsHub.notifyProjectChanged", () => { + let hub: WsHub; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(store.readProject).mockReset(); + + const httpServer = makeFakeHttpServer(); + hub = new WsHub(httpServer); + }); + + it("reads project from store", () => { + vi.mocked(store.readProject).mockReturnValue(makeMockProject('test-project')); + + hub.notifyProjectChanged('test-project'); + + expect(store.readProject).toHaveBeenCalledWith('test-project'); + }); + + it("broadcasts project_updated event when project exists", () => { + const ws = makeFakeWebSocket(1); + mockClients.add(ws); + + const project = makeMockProject('test-project'); + vi.mocked(store.readProject).mockReturnValue(project); + + hub.notifyProjectChanged('test-project'); + + expect(ws.send).toHaveBeenCalledOnce(); + const sentData = JSON.parse(ws.send.mock.calls[0][0]); + expect(sentData.type).toBe('project_updated'); + expect(sentData.slug).toBe('test-project'); + expect(sentData.project).toEqual(project); + }); + + it("does nothing when project does not exist", () => { + const ws = makeFakeWebSocket(1); + mockClients.add(ws); + + vi.mocked(store.readProject).mockReturnValue(null); + + hub.notifyProjectChanged('non-existent'); + + expect(ws.send).not.toHaveBeenCalled(); + }); + + it("broadcasts to multiple clients", () => { + const ws1 = makeFakeWebSocket(1); + const ws2 = makeFakeWebSocket(1); + mockClients.add(ws1); + mockClients.add(ws2); + + vi.mocked(store.readProject).mockReturnValue(makeMockProject('test-project')); + + hub.notifyProjectChanged('test-project'); + + expect(ws1.send).toHaveBeenCalledOnce(); + expect(ws2.send).toHaveBeenCalledOnce(); + }); +}); + +describe("WsHub file watcher integration", () => { + let hub: WsHub; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(store.readProject).mockReset(); + + const httpServer = makeFakeHttpServer(); + hub = new WsHub(httpServer); + }); + + it("broadcasts project update on file change", () => { + const ws = makeFakeWebSocket(1); + mockClients.add(ws); + + const project = makeMockProject('my-project'); + vi.mocked(store.readProject).mockReturnValue(project); + + // Simulate file change event + const changeHandler = mockFileWatcherHandlers.get('change')!; + changeHandler({ filename: 'my-project.yaml' }); + + expect(store.readProject).toHaveBeenCalledWith('my-project'); + expect(ws.send).toHaveBeenCalledOnce(); + + const sentData = JSON.parse(ws.send.mock.calls[0][0]); + expect(sentData.type).toBe('project_updated'); + expect(sentData.slug).toBe('my-project'); + }); + + it("strips .yaml extension from filename", () => { + vi.mocked(store.readProject).mockReturnValue(makeMockProject('another-project')); + + const changeHandler = mockFileWatcherHandlers.get('change')!; + changeHandler({ filename: 'another-project.yaml' }); + + expect(store.readProject).toHaveBeenCalledWith('another-project'); + }); + + it("does nothing when project file no longer exists", () => { + const ws = makeFakeWebSocket(1); + mockClients.add(ws); + + vi.mocked(store.readProject).mockReturnValue(null); + + const changeHandler = mockFileWatcherHandlers.get('change')!; + changeHandler({ filename: 'deleted-project.yaml' }); + + expect(ws.send).not.toHaveBeenCalled(); + }); +}); + +describe("WsHub.close", () => { + let hub: WsHub; + + beforeEach(() => { + vi.clearAllMocks(); + const httpServer = makeFakeHttpServer(); + hub = new WsHub(httpServer); + }); + + it("stops the file watcher", () => { + hub.close(); + + expect(mockFileWatcherInstance.stop).toHaveBeenCalledOnce(); + }); + + it("closes the WebSocket server", () => { + hub.close(); + + expect(mockWssInstance.close).toHaveBeenCalledOnce(); + }); + + it("stops watcher before closing server", () => { + hub.close(); + + const stopCallOrder = mockFileWatcherInstance.stop.mock.invocationCallOrder[0]; + const closeCallOrder = mockWssInstance.close.mock.invocationCallOrder[0]; + + expect(stopCallOrder).toBeLessThan(closeCallOrder); + }); +}); + +describe("WsHub error handling", () => { + let hub: WsHub; + let consoleErrorSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + + // Spy on console.error + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const httpServer = makeFakeHttpServer(); + hub = new WsHub(httpServer); + }); + + it("logs file watcher errors", () => { + const error = new Error('File system error'); + const errorHandler = mockFileWatcherHandlers.get('error')!; + errorHandler(error); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[WsHub] file watcher error:', + error + ); + }); + + it("logs client WebSocket errors", () => { + const ws = makeFakeWebSocket(1); + const req = {} as any; + + const connectionHandler = mockWssOnHandlers.get('connection')!; + connectionHandler(ws, req); + + // Extract the error handler registered on the WebSocket + const errorCall = ws.on.mock.calls.find( + (call: any[]) => call[0] === 'error' + ); + const clientErrorHandler = errorCall ? errorCall[1] : null; + + expect(clientErrorHandler).toBeDefined(); + + const error = new Error('Client error'); + clientErrorHandler(error); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[WsHub] client error:', + error + ); + }); +}); diff --git a/packages/board-server/__tests__/yaml-store.test.ts b/packages/board-server/__tests__/yaml-store.test.ts new file mode 100644 index 00000000..6bbc414f --- /dev/null +++ b/packages/board-server/__tests__/yaml-store.test.ts @@ -0,0 +1,747 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import * as store from "../src/store/yaml-store.js"; +import type { Project, Epic, Task } from "../src/types.js"; + +describe("yaml-store", () => { + let testDir: string; + + beforeEach(() => { + // Create a temporary test directory + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "board-test-")); + + // Set environment variable to override BOARD_DIR + process.env.BOARD_TEST_DIR = testDir; + + // Reset the boardDirEnsured cache + store.resetBoardDirCache(); + }); + + afterEach(() => { + // Clean up environment variable + delete process.env.BOARD_TEST_DIR; + + // Reset cache for next test + store.resetBoardDirCache(); + + // Clean up test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + // --- Project CRUD Operations --- + + describe("createProject", () => { + it("creates a new project with minimal fields", () => { + const project = store.createProject({ name: "Test Project" }); + + expect(project.name).toBe("Test Project"); + expect(project.slug).toBe("test-project"); + expect(project.next_id).toBe(1); + expect(project.version).toBe(1); + expect(project.epics).toEqual([]); + expect(project.created_at).toBeDefined(); + expect(project.updated_at).toBeDefined(); + expect(project.created_at).toBe(project.updated_at); + }); + + it("creates a project with all optional fields", () => { + const project = store.createProject({ + name: "Test Full Project", + description: "A test project with all fields", + color: "#FF5733", + repo_url: "https://github.com/test/repo", + }); + + expect(project.name).toBe("Test Full Project"); + expect(project.slug).toBe("test-full-project"); + expect(project.description).toBe("A test project with all fields"); + expect(project.color).toBe("#FF5733"); + expect(project.repo_url).toBe("https://github.com/test/repo"); + }); + + it("slugifies project names correctly", () => { + const project1 = store.createProject({ name: "Test@Slugify#123" }); + expect(project1.slug).toBe("test-slugify-123"); + + const project2 = store.createProject({ name: "Test Multiple Spaces " }); + expect(project2.slug).toBe("test-multiple-spaces"); + + const project3 = store.createProject({ name: "Test UPPERCASE Project" }); + expect(project3.slug).toBe("test-uppercase-project"); + }); + + it("throws error if project already exists", () => { + store.createProject({ name: "Test Duplicate" }); + + expect(() => { + store.createProject({ name: "Test Duplicate" }); + }).toThrow('Project "test-duplicate" already exists'); + }); + + it("persists project to disk", () => { + const project = store.createProject({ name: "Test Persist" }); + const loaded = store.readProject(project.slug); + + expect(loaded).not.toBeNull(); + expect(loaded?.name).toBe("Test Persist"); + expect(loaded?.slug).toBe("test-persist"); + }); + }); + + describe("readProject", () => { + it("returns null for non-existent project", () => { + const project = store.readProject("non-existent-project"); + expect(project).toBeNull(); + }); + + it("reads an existing project", () => { + const created = store.createProject({ name: "Test Read" }); + const loaded = store.readProject("test-read"); + + expect(loaded).not.toBeNull(); + expect(loaded?.name).toBe(created.name); + expect(loaded?.slug).toBe(created.slug); + expect(loaded?.next_id).toBe(created.next_id); + }); + }); + + describe("updateProject", () => { + it("updates project description", () => { + const project = store.createProject({ name: "Test Update" }); + const updated = store.updateProject(project.slug, { + description: "Updated description", + }); + + expect(updated.description).toBe("Updated description"); + // Timestamp should be updated (may be same in fast tests, but at least should be >=) + expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(project.updated_at).getTime()); + }); + + it("updates multiple fields at once", () => { + const project = store.createProject({ name: "Test Multi Update" }); + const updated = store.updateProject(project.slug, { + description: "New description", + color: "#00FF00", + repo_url: "https://github.com/updated/repo", + }); + + expect(updated.description).toBe("New description"); + expect(updated.color).toBe("#00FF00"); + expect(updated.repo_url).toBe("https://github.com/updated/repo"); + }); + + it("strips undefined values", () => { + const project = store.createProject({ + name: "Test Strip", + description: "Original", + color: "#FF0000", + }); + + const updated = store.updateProject(project.slug, { + description: "Updated", + color: undefined, + }); + + expect(updated.description).toBe("Updated"); + expect(updated.color).toBe("#FF0000"); // Should remain unchanged + }); + + it("strips empty string values", () => { + const project = store.createProject({ + name: "Test Empty", + description: "Original", + }); + + const updated = store.updateProject(project.slug, { + description: "", + }); + + expect(updated.description).toBe("Original"); // Should remain unchanged + }); + + it("throws error for non-existent project", () => { + expect(() => { + store.updateProject("non-existent", { description: "Test" }); + }).toThrow('Project "non-existent" not found'); + }); + }); + + describe("listProjects", () => { + it("returns empty array when no projects exist", () => { + const projects = store.listProjects(); + // Filter out any non-test projects that might exist + const testProjects = projects.filter(p => p.slug.startsWith("test-")); + expect(testProjects).toEqual([]); + }); + + it("returns all created projects", () => { + store.createProject({ name: "Test List 1" }); + store.createProject({ name: "Test List 2" }); + store.createProject({ name: "Test List 3" }); + + const projects = store.listProjects(); + const testProjects = projects.filter(p => p.slug.startsWith("test-list-")); + + expect(testProjects).toHaveLength(3); + expect(testProjects.map(p => p.name)).toContain("Test List 1"); + expect(testProjects.map(p => p.name)).toContain("Test List 2"); + expect(testProjects.map(p => p.name)).toContain("Test List 3"); + }); + }); + + // --- Epic CRUD Operations --- + + describe("createEpic", () => { + it("creates an epic in a project", () => { + const project = store.createProject({ name: "Test Epic Project" }); + const epic = store.createEpic(project.slug, "Test Epic", "Epic description"); + + expect(epic.id).toBe(1); + expect(epic.name).toBe("Test Epic"); + expect(epic.description).toBe("Epic description"); + expect(epic.status).toBe("active"); + expect(epic.tasks).toEqual([]); + expect(epic.created_at).toBeDefined(); + expect(epic.updated_at).toBeDefined(); + }); + + it("increments project next_id", () => { + const project = store.createProject({ name: "Test Epic ID" }); + const epic1 = store.createEpic(project.slug, "Epic 1"); + const epic2 = store.createEpic(project.slug, "Epic 2"); + + expect(epic1.id).toBe(1); + expect(epic2.id).toBe(2); + + const loaded = store.readProject(project.slug); + expect(loaded?.next_id).toBe(3); + }); + + it("creates epic without description", () => { + const project = store.createProject({ name: "Test Epic No Desc" }); + const epic = store.createEpic(project.slug, "Test Epic"); + + expect(epic.description).toBeUndefined(); + }); + + it("throws error for non-existent project", () => { + expect(() => { + store.createEpic("non-existent", "Epic"); + }).toThrow('Project "non-existent" not found'); + }); + + it("updates project updated_at timestamp", () => { + const project = store.createProject({ name: "Test Epic Timestamp" }); + const originalTimestamp = project.updated_at; + + const epic = store.createEpic(project.slug, "Epic"); + + const loaded = store.readProject(project.slug); + // Timestamp should be updated (may be same in fast tests, but at least should be >=) + expect(new Date(loaded!.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(originalTimestamp).getTime()); + }); + }); + + describe("findEpic", () => { + it("finds an epic by id", () => { + const project = store.createProject({ name: "Test Find Epic" }); + const epic = store.createEpic(project.slug, "Test Epic"); + + const loaded = store.readProject(project.slug); + const found = store.findEpic(loaded!, epic.id); + + expect(found).toBeDefined(); + expect(found?.id).toBe(epic.id); + expect(found?.name).toBe("Test Epic"); + }); + + it("returns undefined for non-existent epic", () => { + const project = store.createProject({ name: "Test Find None" }); + const loaded = store.readProject(project.slug); + const found = store.findEpic(loaded!, 999); + + expect(found).toBeUndefined(); + }); + }); + + describe("updateEpicStatus", () => { + it("updates epic status to completed", () => { + const project = store.createProject({ name: "Test Epic Status" }); + const epic = store.createEpic(project.slug, "Test Epic"); + + const updated = store.updateEpicStatus(project.slug, epic.id, "completed"); + + expect(updated.status).toBe("completed"); + // Timestamp should be updated (may be same in fast tests, but at least should be >=) + expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(epic.updated_at).getTime()); + }); + + it("updates epic status to archived", () => { + const project = store.createProject({ name: "Test Epic Archive" }); + const epic = store.createEpic(project.slug, "Test Epic"); + + const updated = store.updateEpicStatus(project.slug, epic.id, "archived"); + + expect(updated.status).toBe("archived"); + }); + + it("throws error for non-existent project", () => { + expect(() => { + store.updateEpicStatus("non-existent", 1, "completed"); + }).toThrow('Project "non-existent" not found'); + }); + + it("throws error for non-existent epic", () => { + const project = store.createProject({ name: "Test Epic Missing" }); + + expect(() => { + store.updateEpicStatus(project.slug, 999, "completed"); + }).toThrow(`Epic 999 not found in project "${project.slug}"`); + }); + }); + + // --- Task CRUD Operations --- + + describe("createTask", () => { + it("creates a task in an epic", () => { + const project = store.createProject({ name: "Test Task Project" }); + const epic = store.createEpic(project.slug, "Test Epic"); + const task = store.createTask(project.slug, epic.id, "Test Task", "Task description"); + + expect(task.id).toBe(2); // Epic is 1, task is 2 + expect(task.title).toBe("Test Task"); + expect(task.description).toBe("Task description"); + expect(task.status).toBe("backlog"); + expect(task.linked_commits).toEqual([]); + expect(task.comments).toEqual([]); + expect(task.created_at).toBeDefined(); + expect(task.updated_at).toBeDefined(); + }); + + it("creates task without description", () => { + const project = store.createProject({ name: "Test Task No Desc" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + expect(task.description).toBeUndefined(); + }); + + it("increments project next_id", () => { + const project = store.createProject({ name: "Test Task ID" }); + const epic = store.createEpic(project.slug, "Epic"); + const task1 = store.createTask(project.slug, epic.id, "Task 1"); + const task2 = store.createTask(project.slug, epic.id, "Task 2"); + + expect(task1.id).toBe(2); // Epic is 1 + expect(task2.id).toBe(3); + + const loaded = store.readProject(project.slug); + expect(loaded?.next_id).toBe(4); + }); + + it("throws error for non-existent project", () => { + expect(() => { + store.createTask("non-existent", 1, "Task"); + }).toThrow('Project "non-existent" not found'); + }); + + it("throws error for non-existent epic", () => { + const project = store.createProject({ name: "Test Task No Epic" }); + + expect(() => { + store.createTask(project.slug, 999, "Task"); + }).toThrow("Epic 999 not found"); + }); + + it("updates epic and project timestamps", () => { + const project = store.createProject({ name: "Test Task Timestamp" }); + const epic = store.createEpic(project.slug, "Epic"); + const originalEpicTime = epic.updated_at; + + store.createTask(project.slug, epic.id, "Task"); + + const loaded = store.readProject(project.slug); + const loadedEpic = store.findEpic(loaded!, epic.id); + // Timestamps should be updated (may be same in fast tests, but at least should be >=) + expect(new Date(loadedEpic!.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(originalEpicTime).getTime()); + expect(new Date(loaded!.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(project.updated_at).getTime()); + }); + }); + + describe("findTask", () => { + it("finds a task by id", () => { + const project = store.createProject({ name: "Test Find Task" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const loaded = store.readProject(project.slug); + const found = store.findTask(loaded!, task.id); + + expect(found).toBeDefined(); + expect(found?.task.id).toBe(task.id); + expect(found?.task.title).toBe("Task"); + expect(found?.epic.id).toBe(epic.id); + }); + + it("returns undefined for non-existent task", () => { + const project = store.createProject({ name: "Test Find No Task" }); + const loaded = store.readProject(project.slug); + const found = store.findTask(loaded!, 999); + + expect(found).toBeUndefined(); + }); + + it("searches across all epics", () => { + const project = store.createProject({ name: "Test Find Multi Epic" }); + const epic1 = store.createEpic(project.slug, "Epic 1"); + const epic2 = store.createEpic(project.slug, "Epic 2"); + const task1 = store.createTask(project.slug, epic1.id, "Task 1"); + const task2 = store.createTask(project.slug, epic2.id, "Task 2"); + + const loaded = store.readProject(project.slug); + const found1 = store.findTask(loaded!, task1.id); + const found2 = store.findTask(loaded!, task2.id); + + expect(found1?.epic.id).toBe(epic1.id); + expect(found2?.epic.id).toBe(epic2.id); + }); + }); + + describe("updateTask", () => { + it("updates task title", () => { + const project = store.createProject({ name: "Test Update Task" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Original Title"); + + const updated = store.updateTask(project.slug, task.id, { + title: "Updated Title", + }); + + expect(updated.title).toBe("Updated Title"); + // Timestamp should be updated (may be same in fast tests, but at least should be >=) + expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(task.updated_at).getTime()); + }); + + it("updates multiple fields at once", () => { + const project = store.createProject({ name: "Test Multi Update Task" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const updated = store.updateTask(project.slug, task.id, { + title: "New Title", + description: "New Description", + status: "in-progress", + no_worktree: true, + }); + + expect(updated.title).toBe("New Title"); + expect(updated.description).toBe("New Description"); + expect(updated.status).toBe("in-progress"); + expect(updated.no_worktree).toBe(true); + }); + + it("strips undefined values", () => { + const project = store.createProject({ name: "Test Strip Task" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task", "Original"); + + const updated = store.updateTask(project.slug, task.id, { + title: "Updated", + description: undefined, + }); + + expect(updated.title).toBe("Updated"); + expect(updated.description).toBe("Original"); + }); + + it("throws error for non-existent project", () => { + expect(() => { + store.updateTask("non-existent", 1, { title: "Test" }); + }).toThrow('Project "non-existent" not found'); + }); + + it("throws error for non-existent task", () => { + const project = store.createProject({ name: "Test Update No Task" }); + + expect(() => { + store.updateTask(project.slug, 999, { title: "Test" }); + }).toThrow("Task 999 not found"); + }); + }); + + describe("moveTask", () => { + it("moves task to in-progress", () => { + const project = store.createProject({ name: "Test Move Task" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const moved = store.moveTask(project.slug, task.id, "in-progress"); + + expect(moved.status).toBe("in-progress"); + }); + + it("moves task through all statuses", () => { + const project = store.createProject({ name: "Test Move All" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + let moved = store.moveTask(project.slug, task.id, "in-progress"); + expect(moved.status).toBe("in-progress"); + + moved = store.moveTask(project.slug, task.id, "review"); + expect(moved.status).toBe("review"); + + moved = store.moveTask(project.slug, task.id, "done"); + expect(moved.status).toBe("done"); + }); + }); + + describe("addComment", () => { + it("adds a comment from claude", () => { + const project = store.createProject({ name: "Test Comment" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const comment = store.addComment(project.slug, task.id, "claude", "Test comment"); + + expect(comment.author).toBe("claude"); + expect(comment.body).toBe("Test comment"); + expect(comment.timestamp).toBeDefined(); + + const loaded = store.readProject(project.slug); + const found = store.findTask(loaded!, task.id); + expect(found?.task.comments).toHaveLength(1); + expect(found?.task.comments[0].body).toBe("Test comment"); + }); + + it("adds a comment from user", () => { + const project = store.createProject({ name: "Test User Comment" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const comment = store.addComment(project.slug, task.id, "user", "User comment"); + + expect(comment.author).toBe("user"); + expect(comment.body).toBe("User comment"); + }); + + it("adds multiple comments", () => { + const project = store.createProject({ name: "Test Multi Comment" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + store.addComment(project.slug, task.id, "user", "First"); + store.addComment(project.slug, task.id, "claude", "Second"); + store.addComment(project.slug, task.id, "user", "Third"); + + const loaded = store.readProject(project.slug); + const found = store.findTask(loaded!, task.id); + expect(found?.task.comments).toHaveLength(3); + expect(found?.task.comments[0].body).toBe("First"); + expect(found?.task.comments[1].body).toBe("Second"); + expect(found?.task.comments[2].body).toBe("Third"); + }); + }); + + describe("linkBranch", () => { + it("links a branch to a task", () => { + const project = store.createProject({ name: "Test Link Branch" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const updated = store.linkBranch(project.slug, task.id, "feature/test"); + + expect(updated.branch).toBe("feature/test"); + expect(updated.worktree_path).toBeUndefined(); + }); + + it("links branch with worktree path", () => { + const project = store.createProject({ name: "Test Link Worktree" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const updated = store.linkBranch( + project.slug, + task.id, + "feature/test", + "/path/to/worktree" + ); + + expect(updated.branch).toBe("feature/test"); + expect(updated.worktree_path).toBe("/path/to/worktree"); + }); + }); + + describe("linkCommit", () => { + it("links a commit to a task", () => { + const project = store.createProject({ name: "Test Link Commit" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const updated = store.linkCommit(project.slug, task.id, "abc123"); + + expect(updated.linked_commits).toContain("abc123"); + }); + + it("links multiple commits", () => { + const project = store.createProject({ name: "Test Multi Commit" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + store.linkCommit(project.slug, task.id, "abc123"); + const updated = store.linkCommit(project.slug, task.id, "def456"); + + expect(updated.linked_commits).toHaveLength(2); + expect(updated.linked_commits).toContain("abc123"); + expect(updated.linked_commits).toContain("def456"); + }); + + it("does not duplicate commit links", () => { + const project = store.createProject({ name: "Test Dup Commit" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + store.linkCommit(project.slug, task.id, "abc123"); + const updated = store.linkCommit(project.slug, task.id, "abc123"); + + expect(updated.linked_commits).toHaveLength(1); + expect(updated.linked_commits).toContain("abc123"); + }); + }); + + describe("blockTask", () => { + it("blocks a task with reason", () => { + const project = store.createProject({ name: "Test Block Task" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const blocked = store.blockTask(project.slug, task.id, "Waiting for API"); + + expect(blocked.blocked).toBe(true); + expect(blocked.blocked_reason).toBe("Waiting for API"); + }); + }); + + describe("unblockTask", () => { + it("unblocks a blocked task", () => { + const project = store.createProject({ name: "Test Unblock Task" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + store.blockTask(project.slug, task.id, "Blocked"); + const unblocked = store.unblockTask(project.slug, task.id); + + expect(unblocked.blocked).toBe(false); + expect(unblocked.blocked_reason).toBeUndefined(); + }); + + it("can unblock a non-blocked task", () => { + const project = store.createProject({ name: "Test Unblock Clean" }); + const epic = store.createEpic(project.slug, "Epic"); + const task = store.createTask(project.slug, epic.id, "Task"); + + const unblocked = store.unblockTask(project.slug, task.id); + + expect(unblocked.blocked).toBe(false); + }); + }); + + describe("listTasks", () => { + it("lists all tasks across all projects", () => { + const project1 = store.createProject({ name: "Test List Tasks 1" }); + const epic1 = store.createEpic(project1.slug, "Epic 1"); + store.createTask(project1.slug, epic1.id, "Task 1"); + store.createTask(project1.slug, epic1.id, "Task 2"); + + const project2 = store.createProject({ name: "Test List Tasks 2" }); + const epic2 = store.createEpic(project2.slug, "Epic 2"); + store.createTask(project2.slug, epic2.id, "Task 3"); + + const tasks = store.listTasks(); + const testTasks = tasks.filter(t => t.project_slug.startsWith("test-list-tasks")); + + expect(testTasks).toHaveLength(3); + }); + + it("filters tasks by project", () => { + const project1 = store.createProject({ name: "Test Filter Proj 1" }); + const epic1 = store.createEpic(project1.slug, "Epic 1"); + store.createTask(project1.slug, epic1.id, "Task 1"); + + const project2 = store.createProject({ name: "Test Filter Proj 2" }); + const epic2 = store.createEpic(project2.slug, "Epic 2"); + store.createTask(project2.slug, epic2.id, "Task 2"); + + const tasks = store.listTasks({ project: project1.slug }); + + expect(tasks).toHaveLength(1); + expect(tasks[0].project_slug).toBe(project1.slug); + expect(tasks[0].title).toBe("Task 1"); + }); + + it("filters tasks by epic", () => { + const project = store.createProject({ name: "Test Filter Epic" }); + const epic1 = store.createEpic(project.slug, "Epic 1"); + const epic2 = store.createEpic(project.slug, "Epic 2"); + store.createTask(project.slug, epic1.id, "Task 1"); + store.createTask(project.slug, epic2.id, "Task 2"); + + const tasks = store.listTasks({ project: project.slug, epicId: epic1.id }); + + expect(tasks).toHaveLength(1); + expect(tasks[0].epic_id).toBe(epic1.id); + expect(tasks[0].title).toBe("Task 1"); + }); + + it("filters tasks by status", () => { + const project = store.createProject({ name: "Test Filter Status" }); + const epic = store.createEpic(project.slug, "Epic"); + const task1 = store.createTask(project.slug, epic.id, "Task 1"); + const task2 = store.createTask(project.slug, epic.id, "Task 2"); + store.moveTask(project.slug, task2.id, "in-progress"); + + const backlogTasks = store.listTasks({ project: project.slug, status: "backlog" }); + const inProgressTasks = store.listTasks({ project: project.slug, status: "in-progress" }); + + expect(backlogTasks).toHaveLength(1); + expect(backlogTasks[0].title).toBe("Task 1"); + expect(inProgressTasks).toHaveLength(1); + expect(inProgressTasks[0].title).toBe("Task 2"); + }); + + it("combines multiple filters", () => { + const project = store.createProject({ name: "Test Multi Filter" }); + const epic1 = store.createEpic(project.slug, "Epic 1"); + const epic2 = store.createEpic(project.slug, "Epic 2"); + const task1 = store.createTask(project.slug, epic1.id, "Task 1"); + const task2 = store.createTask(project.slug, epic1.id, "Task 2"); + const task3 = store.createTask(project.slug, epic2.id, "Task 3"); + store.moveTask(project.slug, task2.id, "in-progress"); + + const tasks = store.listTasks({ + project: project.slug, + epicId: epic1.id, + status: "in-progress", + }); + + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toBe("Task 2"); + }); + + it("includes project and epic metadata", () => { + const project = store.createProject({ name: "Test Metadata" }); + const epic = store.createEpic(project.slug, "Test Epic"); + store.createTask(project.slug, epic.id, "Task"); + + const tasks = store.listTasks({ project: project.slug }); + + expect(tasks[0].project_slug).toBe(project.slug); + expect(tasks[0].epic_id).toBe(epic.id); + expect(tasks[0].epic_name).toBe("Test Epic"); + }); + }); +}); diff --git a/packages/board-server/package.json b/packages/board-server/package.json index fee06c14..738b6447 100644 --- a/packages/board-server/package.json +++ b/packages/board-server/package.json @@ -8,6 +8,8 @@ "build": "tsc", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", "mcp": "tsx src/mcp/server.ts", "launchd:install": "bash scripts/launchd-ctl.sh install", "launchd:uninstall": "bash scripts/launchd-ctl.sh uninstall", @@ -26,8 +28,12 @@ "@types/express": "^4.17.21", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", + "@types/supertest": "^6", "@types/ws": "^8.5.10", + "@vitest/coverage-v8": "^3", + "supertest": "^7", "tsx": "^4.7.0", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^3" } } diff --git a/packages/board-server/src/store/yaml-store.ts b/packages/board-server/src/store/yaml-store.ts index 3712447d..37df4f64 100644 --- a/packages/board-server/src/store/yaml-store.ts +++ b/packages/board-server/src/store/yaml-store.ts @@ -4,17 +4,27 @@ import os from 'node:os'; import yaml from 'js-yaml'; import type { Project, Epic, Task, Comment, TaskStatus, EpicStatus } from '../types.js'; -const BOARD_DIR = path.join(os.homedir(), '.harness', 'board', 'projects'); +function getBoardDir(): string { + if (process.env.NODE_ENV === 'test' && process.env.BOARD_TEST_DIR) { + return process.env.BOARD_TEST_DIR; + } + return path.join(os.homedir(), '.harness', 'board', 'projects'); +} let boardDirEnsured = false; function ensureBoardDir(): void { if (boardDirEnsured) return; - fs.mkdirSync(BOARD_DIR, { recursive: true }); + fs.mkdirSync(getBoardDir(), { recursive: true }); boardDirEnsured = true; } +// For testing: reset the boardDirEnsured flag +export function resetBoardDirCache(): void { + boardDirEnsured = false; +} + function projectPath(slug: string): string { - return path.join(BOARD_DIR, `${slug}.yaml`); + return path.join(getBoardDir(), `${slug}.yaml`); } function slugify(name: string): string { @@ -49,16 +59,17 @@ export function writeProject(project: Project): void { export function listProjects(): Project[] { ensureBoardDir(); - const files = fs.readdirSync(BOARD_DIR).filter(f => f.endsWith('.yaml')); + const boardDir = getBoardDir(); + const files = fs.readdirSync(boardDir).filter(f => f.endsWith('.yaml')); return files.map(f => { - const raw = fs.readFileSync(path.join(BOARD_DIR, f), 'utf-8'); + const raw = fs.readFileSync(path.join(boardDir, f), 'utf-8'); return yaml.load(raw) as Project; }); } export function projectsDir(): string { ensureBoardDir(); - return BOARD_DIR; + return getBoardDir(); } // --- Project operations --- diff --git a/packages/board-server/vitest.config.ts b/packages/board-server/vitest.config.ts new file mode 100644 index 00000000..190b8061 --- /dev/null +++ b/packages/board-server/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["__tests__/**/*.test.ts"], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'clover'], + include: ['src/**/*.ts'], + exclude: ['src/index.ts', '**/__tests__/**', '**/*.d.ts'], + }, + }, +}); diff --git a/packages/chat-relay/package.json b/packages/chat-relay/package.json index 4e8ad0d4..a7ecd658 100644 --- a/packages/chat-relay/package.json +++ b/packages/chat-relay/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/ws": "^8.5.10", + "@vitest/coverage-v8": "^3", "tsx": "^4.7.0", "typescript": "^5.4.0", "vitest": "^3.0.0" diff --git a/packages/chat-relay/src/__tests__/protocol.test.ts b/packages/chat-relay/src/__tests__/protocol.test.ts new file mode 100644 index 00000000..07ba5a67 --- /dev/null +++ b/packages/chat-relay/src/__tests__/protocol.test.ts @@ -0,0 +1,647 @@ +import { describe, it, expect } from "vitest"; +import type { + ClientMessage, + ServerMessage, + AnyMessage, + Member, + ChatMessage, + ShareMessage, + SystemMessage, + ShareAction, +} from "../protocol.js"; + +describe("ClientMessage serialization", () => { + it("serializes create_room message without optional fields", () => { + const msg: ClientMessage = { + type: "create_room", + nickname: "alice", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("create_room"); + expect((parsed as Extract).nickname).toBe("alice"); + }); + + it("serializes create_room message with all optional fields", () => { + const msg: ClientMessage = { + type: "create_room", + nickname: "alice", + name: "My Room", + keepAliveMinutes: 10, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("create_room"); + expect((parsed as Extract).nickname).toBe("alice"); + expect((parsed as Extract).name).toBe("My Room"); + expect((parsed as Extract).keepAliveMinutes).toBe(10); + }); + + it("serializes join_room message", () => { + const msg: ClientMessage = { + type: "join_room", + code: "ABC-123", + nickname: "bob", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("join_room"); + expect((parsed as Extract).code).toBe("ABC-123"); + expect((parsed as Extract).nickname).toBe("bob"); + }); + + it("serializes leave_room message", () => { + const msg: ClientMessage = { type: "leave_room" }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("leave_room"); + }); + + it("serializes chat message", () => { + const msg: ClientMessage = { + type: "chat", + body: "Hello, world!", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("chat"); + expect((parsed as Extract).body).toBe("Hello, world!"); + }); + + it("serializes share message without optional fields", () => { + const msg: ClientMessage = { + type: "share", + action: "harness_updated", + target: "harness.yaml", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("share"); + expect((parsed as Extract).action).toBe("harness_updated"); + expect((parsed as Extract).target).toBe("harness.yaml"); + }); + + it("serializes share message with all optional fields", () => { + const msg: ClientMessage = { + type: "share", + action: "plugin_installed", + target: "research", + detail: "Added research plugin", + diff: "+plugin: research", + pullable: true, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("share"); + const shareMsg = parsed as Extract; + expect(shareMsg.action).toBe("plugin_installed"); + expect(shareMsg.target).toBe("research"); + expect(shareMsg.detail).toBe("Added research plugin"); + expect(shareMsg.diff).toBe("+plugin: research"); + expect(shareMsg.pullable).toBe(true); + }); + + it("serializes typing message", () => { + const msg: ClientMessage = { + type: "typing", + typing: true, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("typing"); + expect((parsed as Extract).typing).toBe(true); + }); + + it("serializes heartbeat message", () => { + const msg: ClientMessage = { type: "heartbeat" }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect(parsed.type).toBe("heartbeat"); + }); +}); + +describe("ServerMessage serialization", () => { + it("serializes room_created message without optional fields", () => { + const msg: ServerMessage = { + type: "room_created", + code: "ABC-123", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect(parsed.type).toBe("room_created"); + expect((parsed as Extract).code).toBe("ABC-123"); + }); + + it("serializes room_created message with name", () => { + const msg: ServerMessage = { + type: "room_created", + code: "ABC-123", + name: "My Room", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect(parsed.type).toBe("room_created"); + expect((parsed as Extract).code).toBe("ABC-123"); + expect((parsed as Extract).name).toBe("My Room"); + }); + + it("serializes room_joined message", () => { + const members: Member[] = [ + { nickname: "alice", joinedAt: "2024-01-01T00:00:00Z", typing: false }, + { nickname: "bob", joinedAt: "2024-01-01T00:01:00Z", typing: true }, + ]; + const history: AnyMessage[] = []; + + const msg: ServerMessage = { + type: "room_joined", + code: "ABC-123", + name: "Test Room", + members, + history, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect(parsed.type).toBe("room_joined"); + const joinedMsg = parsed as Extract; + expect(joinedMsg.code).toBe("ABC-123"); + expect(joinedMsg.name).toBe("Test Room"); + expect(joinedMsg.members).toHaveLength(2); + expect(joinedMsg.members[0].nickname).toBe("alice"); + expect(joinedMsg.members[1].typing).toBe(true); + expect(joinedMsg.history).toHaveLength(0); + }); + + it("serializes room_error message", () => { + const msg: ServerMessage = { + type: "room_error", + error: "Nickname already taken", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect(parsed.type).toBe("room_error"); + expect((parsed as Extract).error).toBe("Nickname already taken"); + }); + + it("serializes message wrapper with ChatMessage", () => { + const chatMsg: ChatMessage = { + id: "msg-123", + roomCode: "ABC-123", + type: "chat", + nickname: "alice", + timestamp: "2024-01-01T00:00:00Z", + body: "Hello!", + }; + + const msg: ServerMessage = { + type: "message", + message: chatMsg, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect(parsed.type).toBe("message"); + const messageWrapper = parsed as Extract; + expect(messageWrapper.message.type).toBe("chat"); + expect((messageWrapper.message as ChatMessage).body).toBe("Hello!"); + }); + + it("serializes presence message", () => { + const members: Member[] = [ + { nickname: "alice", joinedAt: "2024-01-01T00:00:00Z", typing: false }, + ]; + + const msg: ServerMessage = { + type: "presence", + members, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect(parsed.type).toBe("presence"); + expect((parsed as Extract).members).toHaveLength(1); + expect((parsed as Extract).members[0].nickname).toBe("alice"); + }); + + it("serializes typing_update message", () => { + const msg: ServerMessage = { + type: "typing_update", + nickname: "bob", + typing: true, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect(parsed.type).toBe("typing_update"); + const typingMsg = parsed as Extract; + expect(typingMsg.nickname).toBe("bob"); + expect(typingMsg.typing).toBe(true); + }); +}); + +describe("AnyMessage serialization", () => { + it("serializes ChatMessage", () => { + const msg: ChatMessage = { + id: "msg-123", + roomCode: "ABC-123", + type: "chat", + nickname: "alice", + timestamp: "2024-01-01T00:00:00Z", + body: "Hello, world!", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ChatMessage; + + expect(parsed.id).toBe("msg-123"); + expect(parsed.roomCode).toBe("ABC-123"); + expect(parsed.type).toBe("chat"); + expect(parsed.nickname).toBe("alice"); + expect(parsed.timestamp).toBe("2024-01-01T00:00:00Z"); + expect(parsed.body).toBe("Hello, world!"); + }); + + it("serializes ShareMessage with all fields", () => { + const msg: ShareMessage = { + id: "msg-456", + roomCode: "ABC-123", + type: "share", + nickname: "bob", + timestamp: "2024-01-01T00:01:00Z", + action: "harness_updated", + target: "harness.yaml", + detail: "Updated plugins section", + diff: "+ - research", + pullable: true, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ShareMessage; + + expect(parsed.id).toBe("msg-456"); + expect(parsed.roomCode).toBe("ABC-123"); + expect(parsed.type).toBe("share"); + expect(parsed.nickname).toBe("bob"); + expect(parsed.timestamp).toBe("2024-01-01T00:01:00Z"); + expect(parsed.action).toBe("harness_updated"); + expect(parsed.target).toBe("harness.yaml"); + expect(parsed.detail).toBe("Updated plugins section"); + expect(parsed.diff).toBe("+ - research"); + expect(parsed.pullable).toBe(true); + }); + + it("serializes ShareMessage with null optional fields", () => { + const msg: ShareMessage = { + id: "msg-789", + roomCode: "ABC-123", + type: "share", + nickname: "carol", + timestamp: "2024-01-01T00:02:00Z", + action: "permissions_changed", + target: "settings", + detail: null, + diff: null, + pullable: false, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ShareMessage; + + expect(parsed.detail).toBeNull(); + expect(parsed.diff).toBeNull(); + expect(parsed.pullable).toBe(false); + }); + + it("serializes SystemMessage with join event", () => { + const msg: SystemMessage = { + id: "sys-123", + roomCode: "ABC-123", + type: "system", + nickname: "alice", + timestamp: "2024-01-01T00:00:00Z", + event: "join", + detail: null, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as SystemMessage; + + expect(parsed.id).toBe("sys-123"); + expect(parsed.roomCode).toBe("ABC-123"); + expect(parsed.type).toBe("system"); + expect(parsed.nickname).toBe("alice"); + expect(parsed.event).toBe("join"); + expect(parsed.detail).toBeNull(); + }); + + it("serializes SystemMessage with all event types", () => { + const events: Array = [ + "join", + "leave", + "nick_change", + "room_created", + "shutdown", + ]; + + for (const event of events) { + const msg: SystemMessage = { + id: `sys-${event}`, + roomCode: "ABC-123", + type: "system", + nickname: "alice", + timestamp: "2024-01-01T00:00:00Z", + event, + detail: null, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as SystemMessage; + + expect(parsed.event).toBe(event); + } + }); +}); + +describe("ShareAction validation", () => { + it("serializes all ShareAction variants", () => { + const actions: ShareAction[] = [ + "harness_updated", + "plugin_installed", + "plugin_uninstalled", + "sync_applied", + "permissions_changed", + "preset_applied", + ]; + + for (const action of actions) { + const msg: ClientMessage = { + type: "share", + action, + target: "test", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect((parsed as Extract).action).toBe(action); + } + }); +}); + +describe("Member serialization", () => { + it("serializes Member object", () => { + const member: Member = { + nickname: "alice", + joinedAt: "2024-01-01T00:00:00Z", + typing: false, + }; + const json = JSON.stringify(member); + const parsed = JSON.parse(json) as Member; + + expect(parsed.nickname).toBe("alice"); + expect(parsed.joinedAt).toBe("2024-01-01T00:00:00Z"); + expect(parsed.typing).toBe(false); + }); + + it("serializes Member array", () => { + const members: Member[] = [ + { nickname: "alice", joinedAt: "2024-01-01T00:00:00Z", typing: false }, + { nickname: "bob", joinedAt: "2024-01-01T00:01:00Z", typing: true }, + { nickname: "carol", joinedAt: "2024-01-01T00:02:00Z", typing: false }, + ]; + const json = JSON.stringify(members); + const parsed = JSON.parse(json) as Member[]; + + expect(parsed).toHaveLength(3); + expect(parsed[0].nickname).toBe("alice"); + expect(parsed[1].typing).toBe(true); + expect(parsed[2].joinedAt).toBe("2024-01-01T00:02:00Z"); + }); +}); + +describe("Protocol edge cases", () => { + it("handles empty strings in message bodies", () => { + const msg: ClientMessage = { + type: "chat", + body: "", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect((parsed as Extract).body).toBe(""); + }); + + it("handles special characters in nicknames", () => { + const msg: ClientMessage = { + type: "create_room", + nickname: "alice_123-🎉", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect((parsed as Extract).nickname).toBe("alice_123-🎉"); + }); + + it("handles multiline text in chat body", () => { + const body = "Line 1\nLine 2\nLine 3"; + const msg: ClientMessage = { + type: "chat", + body, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect((parsed as Extract).body).toBe(body); + }); + + it("handles large diff in share message", () => { + const diff = "- old line\n+ new line\n".repeat(100); + const msg: ClientMessage = { + type: "share", + action: "harness_updated", + target: "harness.yaml", + diff, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + + expect((parsed as Extract).diff).toBe(diff); + }); + + it("handles room code with various formats", () => { + const codes = ["ABC-123", "XYZ-999", "AAA-000"]; + + for (const code of codes) { + const msg: ServerMessage = { + type: "room_created", + code, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect((parsed as Extract).code).toBe(code); + } + }); + + it("preserves ISO 8601 timestamp format", () => { + const timestamp = "2024-01-01T12:34:56.789Z"; + const msg: ChatMessage = { + id: "msg-123", + roomCode: "ABC-123", + type: "chat", + nickname: "alice", + timestamp, + body: "test", + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ChatMessage; + + expect(parsed.timestamp).toBe(timestamp); + }); + + it("handles empty members array", () => { + const msg: ServerMessage = { + type: "presence", + members: [], + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect((parsed as Extract).members).toHaveLength(0); + }); + + it("handles empty history array", () => { + const msg: ServerMessage = { + type: "room_joined", + code: "ABC-123", + members: [], + history: [], + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect((parsed as Extract).history).toHaveLength(0); + }); + + it("handles long error messages", () => { + const error = "Error: ".repeat(50); + const msg: ServerMessage = { + type: "room_error", + error, + }; + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + + expect((parsed as Extract).error).toBe(error); + }); +}); + +describe("Protocol message roundtrip", () => { + it("preserves all ClientMessage types through serialization", () => { + const messages: ClientMessage[] = [ + { type: "create_room", nickname: "alice", name: "Test", keepAliveMinutes: 5 }, + { type: "join_room", code: "ABC-123", nickname: "bob" }, + { type: "leave_room" }, + { type: "chat", body: "Hello!" }, + { type: "share", action: "harness_updated", target: "test", detail: "detail", diff: "diff" }, + { type: "typing", typing: true }, + { type: "heartbeat" }, + ]; + + for (const msg of messages) { + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ClientMessage; + expect(parsed.type).toBe(msg.type); + } + }); + + it("preserves all ServerMessage types through serialization", () => { + const chatMsg: ChatMessage = { + id: "1", + roomCode: "ABC-123", + type: "chat", + nickname: "alice", + timestamp: "2024-01-01T00:00:00Z", + body: "test", + }; + + const messages: ServerMessage[] = [ + { type: "room_created", code: "ABC-123", name: "Test" }, + { type: "room_joined", code: "ABC-123", members: [], history: [] }, + { type: "room_error", error: "Error" }, + { type: "message", message: chatMsg }, + { type: "presence", members: [] }, + { type: "typing_update", nickname: "bob", typing: false }, + ]; + + for (const msg of messages) { + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + expect(parsed.type).toBe(msg.type); + } + }); + + it("preserves complex nested message structures", () => { + const history: AnyMessage[] = [ + { + id: "msg-1", + roomCode: "ABC-123", + type: "chat", + nickname: "alice", + timestamp: "2024-01-01T00:00:00Z", + body: "Hello!", + }, + { + id: "msg-2", + roomCode: "ABC-123", + type: "share", + nickname: "bob", + timestamp: "2024-01-01T00:01:00Z", + action: "plugin_installed", + target: "research", + detail: "Added research plugin", + diff: null, + pullable: true, + }, + { + id: "sys-1", + roomCode: "ABC-123", + type: "system", + nickname: "carol", + timestamp: "2024-01-01T00:02:00Z", + event: "join", + detail: null, + }, + ]; + + const members: Member[] = [ + { nickname: "alice", joinedAt: "2024-01-01T00:00:00Z", typing: false }, + { nickname: "bob", joinedAt: "2024-01-01T00:01:00Z", typing: true }, + { nickname: "carol", joinedAt: "2024-01-01T00:02:00Z", typing: false }, + ]; + + const msg: ServerMessage = { + type: "room_joined", + code: "ABC-123", + name: "Test Room", + members, + history, + }; + + const json = JSON.stringify(msg); + const parsed = JSON.parse(json) as ServerMessage; + const joinedMsg = parsed as Extract; + + expect(joinedMsg.members).toHaveLength(3); + expect(joinedMsg.history).toHaveLength(3); + expect(joinedMsg.history[0].type).toBe("chat"); + expect(joinedMsg.history[1].type).toBe("share"); + expect(joinedMsg.history[2].type).toBe("system"); + }); +}); diff --git a/packages/chat-relay/src/__tests__/relay.test.ts b/packages/chat-relay/src/__tests__/relay.test.ts new file mode 100644 index 00000000..af5ee276 --- /dev/null +++ b/packages/chat-relay/src/__tests__/relay.test.ts @@ -0,0 +1,823 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { WebSocketServer, WebSocket } from "ws"; +import { ChatRelay } from "../relay.js"; +import type { ClientMessage, ServerMessage, Member } from "../protocol.js"; + +// ── WebSocket mock ──────────────────────────────────────────── +// +// Mock the WebSocketServer and WebSocket classes for testing. + +vi.mock("ws", () => { + const OPEN = 1; + const CLOSED = 3; + + class MockWebSocket { + readyState = OPEN; + send = vi.fn(); + on = vi.fn(); + close = vi.fn(); + + // Simulate sending a message to the server + _simulateMessage(data: string) { + const messageHandler = this.on.mock.calls.find(call => call[0] === "message")?.[1]; + if (messageHandler) messageHandler(Buffer.from(data)); + } + + // Simulate disconnect + _simulateClose() { + const closeHandler = this.on.mock.calls.find(call => call[0] === "close")?.[1]; + if (closeHandler) closeHandler(); + } + } + + class MockWebSocketServer { + on = vi.fn(); + close = vi.fn(); + + // Simulate a new connection + _simulateConnection(ws: MockWebSocket) { + const connectionHandler = this.on.mock.calls.find(call => call[0] === "connection")?.[1]; + if (connectionHandler) connectionHandler(ws); + } + } + + (MockWebSocket as unknown as Record).OPEN = OPEN; + (MockWebSocket as unknown as Record).CLOSED = CLOSED; + + return { + WebSocket: MockWebSocket, + WebSocketServer: MockWebSocketServer, + }; +}); + +// ── Helpers ─────────────────────────────────────────────────── + +function createMockWss(): WebSocketServer { + return new WebSocketServer({ noServer: true }); +} + +function createMockWs(): WebSocket { + return new WebSocket("ws://test"); +} + +function parseLastSentMessage(ws: WebSocket): ServerMessage | null { + const mockSend = ws.send as ReturnType; + if (mockSend.mock.calls.length === 0) return null; + const lastCall = mockSend.mock.calls[mockSend.mock.calls.length - 1]; + return JSON.parse(lastCall[0] as string) as ServerMessage; +} + +function getAllSentMessages(ws: WebSocket): ServerMessage[] { + const mockSend = ws.send as ReturnType; + return mockSend.mock.calls.map(call => JSON.parse(call[0] as string) as ServerMessage); +} + +function clearSentMessages(ws: WebSocket): void { + (ws.send as ReturnType).mockClear(); +} + +// ── Tests ───────────────────────────────────────────────────── + +// Clear all vi mock state before every test so nothing bleeds between suites. +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("ChatRelay constructor", () => { + it("sets up connection handler on WebSocketServer", () => { + const wss = createMockWss(); + new ChatRelay(wss); + expect(wss.on).toHaveBeenCalledWith("connection", expect.any(Function)); + }); +}); + +describe("ChatRelay.handleConnection", () => { + it("sets up message, close, and error handlers", () => { + const wss = createMockWss(); + const relay = new ChatRelay(wss); + const ws = createMockWs(); + + relay.handleConnection(ws); + + expect(ws.on).toHaveBeenCalledWith("message", expect.any(Function)); + expect(ws.on).toHaveBeenCalledWith("close", expect.any(Function)); + expect(ws.on).toHaveBeenCalledWith("error", expect.any(Function)); + }); +}); + +describe("ChatRelay room creation", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws = createMockWs(); + relay.handleConnection(ws); + }); + + it("creates a room and sends room_created message", () => { + const msg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws as any)._simulateMessage(JSON.stringify(msg)); + + const messages = getAllSentMessages(ws); + const createdMsg = messages.find(m => m.type === "room_created"); + expect(createdMsg).toBeDefined(); + expect(createdMsg).toHaveProperty("code"); + expect((createdMsg as any).code).toMatch(/^[BCDFGHJKLMNPQRSTVWXYZ]{3}-[A-Z0-9]{3}$/); + }); + + it("creates a room with a custom name", () => { + const msg: ClientMessage = { type: "create_room", nickname: "alice", name: "Test Room" }; + (ws as any)._simulateMessage(JSON.stringify(msg)); + + const messages = getAllSentMessages(ws); + const createdMsg = messages.find(m => m.type === "room_created"); + expect(createdMsg).toBeDefined(); + expect((createdMsg as any).name).toBe("Test Room"); + }); + + it("rejects nickname that is too long", () => { + const longNick = "a".repeat(33); + const msg: ClientMessage = { type: "create_room", nickname: longNick }; + (ws as any)._simulateMessage(JSON.stringify(msg)); + + const lastMsg = parseLastSentMessage(ws); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/nickname/i); + }); + + it("rejects empty nickname", () => { + const msg: ClientMessage = { type: "create_room", nickname: "" }; + (ws as any)._simulateMessage(JSON.stringify(msg)); + + const lastMsg = parseLastSentMessage(ws); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/nickname/i); + }); + + it("rejects room name that is too long", () => { + const longName = "a".repeat(65); + const msg: ClientMessage = { type: "create_room", nickname: "alice", name: longName }; + (ws as any)._simulateMessage(JSON.stringify(msg)); + + const lastMsg = parseLastSentMessage(ws); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/room name/i); + }); + + it("automatically joins the creator to the room", () => { + const msg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws as any)._simulateMessage(JSON.stringify(msg)); + + const messages = getAllSentMessages(ws); + const joinedMsg = messages.find(m => m.type === "room_joined"); + expect(joinedMsg).toBeDefined(); + expect((joinedMsg as any).members).toHaveLength(1); + expect((joinedMsg as any).members[0].nickname).toBe("alice"); + }); +}); + +describe("ChatRelay room joining", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws1: WebSocket; + let ws2: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws1 = createMockWs(); + ws2 = createMockWs(); + relay.handleConnection(ws1); + relay.handleConnection(ws2); + }); + + it("allows a second user to join an existing room", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + clearSentMessages(ws1); + + // Join room + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + const messages = getAllSentMessages(ws2); + const joinedMsg = messages.find(m => m.type === "room_joined"); + expect(joinedMsg).toBeDefined(); + expect((joinedMsg as any).members).toHaveLength(2); + }); + + it("rejects join with invalid nickname", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + // Try to join with empty nickname + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + const lastMsg = parseLastSentMessage(ws2); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/nickname/i); + }); + + it("rejects join to non-existent room", () => { + const joinMsg: ClientMessage = { type: "join_room", code: "XXX-999", nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + const lastMsg = parseLastSentMessage(ws2); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/does not exist/i); + }); + + it("rejects duplicate nickname in same room", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + // Try to join with same nickname + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "alice" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + const lastMsg = parseLastSentMessage(ws2); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/already taken/i); + }); + + it("sends room history to new joiner", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + // Send a chat message + const chatMsg: ClientMessage = { type: "chat", body: "hello" }; + (ws1 as any)._simulateMessage(JSON.stringify(chatMsg)); + + // Join room + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + const messages = getAllSentMessages(ws2); + const joinedMsg = messages.find(m => m.type === "room_joined"); + expect(joinedMsg).toBeDefined(); + expect((joinedMsg as any).history).toBeDefined(); + expect((joinedMsg as any).history.length).toBeGreaterThan(0); + }); + + it("broadcasts join system message to existing members", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + clearSentMessages(ws1); + + // Join room + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + const messages = getAllSentMessages(ws1); + const systemMsg = messages.find(m => m.type === "message" && (m as any).message?.type === "system"); + expect(systemMsg).toBeDefined(); + expect((systemMsg as any).message.event).toBe("join"); + expect((systemMsg as any).message.nickname).toBe("bob"); + }); + + it("broadcasts presence update when user joins", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + clearSentMessages(ws1); + + // Join room + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + const messages = getAllSentMessages(ws1); + const presenceMsg = messages.find(m => m.type === "presence"); + expect(presenceMsg).toBeDefined(); + expect((presenceMsg as any).members).toHaveLength(2); + }); +}); + +describe("ChatRelay room leaving", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws1: WebSocket; + let ws2: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws1 = createMockWs(); + ws2 = createMockWs(); + relay.handleConnection(ws1); + relay.handleConnection(ws2); + }); + + it("broadcasts leave system message", () => { + // Create room and join + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + clearSentMessages(ws1); + + // Leave room + const leaveMsg: ClientMessage = { type: "leave_room" }; + (ws2 as any)._simulateMessage(JSON.stringify(leaveMsg)); + + const messages = getAllSentMessages(ws1); + const systemMsg = messages.find(m => m.type === "message" && (m as any).message?.type === "system"); + expect(systemMsg).toBeDefined(); + expect((systemMsg as any).message.event).toBe("leave"); + expect((systemMsg as any).message.nickname).toBe("bob"); + }); + + it("broadcasts presence update after leave", () => { + // Create room and join + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + clearSentMessages(ws1); + + // Leave room + const leaveMsg: ClientMessage = { type: "leave_room" }; + (ws2 as any)._simulateMessage(JSON.stringify(leaveMsg)); + + const messages = getAllSentMessages(ws1); + const presenceMsg = messages.find(m => m.type === "presence"); + expect(presenceMsg).toBeDefined(); + expect((presenceMsg as any).members).toHaveLength(1); + expect((presenceMsg as any).members[0].nickname).toBe("alice"); + }); + + it("handles disconnect like explicit leave", () => { + // Create room and join + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + clearSentMessages(ws1); + + // Disconnect + (ws2 as any)._simulateClose(); + + const messages = getAllSentMessages(ws1); + const systemMsg = messages.find(m => m.type === "message" && (m as any).message?.type === "system"); + expect(systemMsg).toBeDefined(); + expect((systemMsg as any).message.event).toBe("leave"); + }); +}); + +describe("ChatRelay chat messages", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws1: WebSocket; + let ws2: WebSocket; + let roomCode: string; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws1 = createMockWs(); + ws2 = createMockWs(); + relay.handleConnection(ws1); + relay.handleConnection(ws2); + + // Create and join room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + roomCode = (createdMsg as any).code; + + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + clearSentMessages(ws1); + clearSentMessages(ws2); + }); + + it("broadcasts chat message to all members", () => { + const chatMsg: ClientMessage = { type: "chat", body: "hello everyone" }; + (ws1 as any)._simulateMessage(JSON.stringify(chatMsg)); + + const messages1 = getAllSentMessages(ws1); + const messages2 = getAllSentMessages(ws2); + + const msg1 = messages1.find(m => m.type === "message" && (m as any).message?.type === "chat"); + const msg2 = messages2.find(m => m.type === "message" && (m as any).message?.type === "chat"); + + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + expect((msg1 as any).message.body).toBe("hello everyone"); + expect((msg2 as any).message.body).toBe("hello everyone"); + expect((msg1 as any).message.nickname).toBe("alice"); + }); + + it("rejects message that is too long", () => { + const longBody = "a".repeat(4001); + const chatMsg: ClientMessage = { type: "chat", body: longBody }; + (ws1 as any)._simulateMessage(JSON.stringify(chatMsg)); + + const lastMsg = parseLastSentMessage(ws1); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/too long/i); + }); + + it("ignores chat from client not in a room", () => { + const ws3 = createMockWs(); + relay.handleConnection(ws3); + + const chatMsg: ClientMessage = { type: "chat", body: "hello" }; + (ws3 as any)._simulateMessage(JSON.stringify(chatMsg)); + + // Should not send any error, just ignore + expect((ws3.send as ReturnType).mock.calls.length).toBe(0); + }); +}); + +describe("ChatRelay share messages", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws1: WebSocket; + let ws2: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws1 = createMockWs(); + ws2 = createMockWs(); + relay.handleConnection(ws1); + relay.handleConnection(ws2); + + // Create and join room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + clearSentMessages(ws1); + clearSentMessages(ws2); + }); + + it("broadcasts share message to all members", () => { + const shareMsg: ClientMessage = { + type: "share", + action: "harness_updated", + target: "harness.yaml", + detail: "Updated plugins", + }; + (ws1 as any)._simulateMessage(JSON.stringify(shareMsg)); + + const messages1 = getAllSentMessages(ws1); + const messages2 = getAllSentMessages(ws2); + + const msg1 = messages1.find(m => m.type === "message" && (m as any).message?.type === "share"); + const msg2 = messages2.find(m => m.type === "message" && (m as any).message?.type === "share"); + + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + expect((msg1 as any).message.action).toBe("harness_updated"); + expect((msg1 as any).message.target).toBe("harness.yaml"); + }); + + it("rejects share with target that is too long", () => { + const longTarget = "a".repeat(257); + const shareMsg: ClientMessage = { + type: "share", + action: "harness_updated", + target: longTarget, + }; + (ws1 as any)._simulateMessage(JSON.stringify(shareMsg)); + + const lastMsg = parseLastSentMessage(ws1); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/target too long/i); + }); + + it("rejects share with detail that is too long", () => { + const longDetail = "a".repeat(1025); + const shareMsg: ClientMessage = { + type: "share", + action: "harness_updated", + target: "test", + detail: longDetail, + }; + (ws1 as any)._simulateMessage(JSON.stringify(shareMsg)); + + const lastMsg = parseLastSentMessage(ws1); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/detail too long/i); + }); + + it("rejects share with diff that is too long", () => { + const longDiff = "a".repeat(64001); + const shareMsg: ClientMessage = { + type: "share", + action: "harness_updated", + target: "test", + diff: longDiff, + }; + (ws1 as any)._simulateMessage(JSON.stringify(shareMsg)); + + const lastMsg = parseLastSentMessage(ws1); + expect(lastMsg?.type).toBe("room_error"); + expect((lastMsg as any).error).toMatch(/diff too long/i); + }); + + it("ignores share from client not in a room", () => { + const ws3 = createMockWs(); + relay.handleConnection(ws3); + + const shareMsg: ClientMessage = { + type: "share", + action: "harness_updated", + target: "test", + }; + (ws3 as any)._simulateMessage(JSON.stringify(shareMsg)); + + // Should not send any error, just ignore + expect((ws3.send as ReturnType).mock.calls.length).toBe(0); + }); +}); + +describe("ChatRelay typing indicators", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws1: WebSocket; + let ws2: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws1 = createMockWs(); + ws2 = createMockWs(); + relay.handleConnection(ws1); + relay.handleConnection(ws2); + + // Create and join room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + clearSentMessages(ws1); + clearSentMessages(ws2); + }); + + it("broadcasts typing indicator to other members", () => { + const typingMsg: ClientMessage = { type: "typing", typing: true }; + (ws1 as any)._simulateMessage(JSON.stringify(typingMsg)); + + const messages = getAllSentMessages(ws2); + const typingUpdate = messages.find(m => m.type === "typing_update"); + expect(typingUpdate).toBeDefined(); + expect((typingUpdate as any).nickname).toBe("alice"); + expect((typingUpdate as any).typing).toBe(true); + }); + + it("does not send typing update to the sender", () => { + const typingMsg: ClientMessage = { type: "typing", typing: true }; + (ws1 as any)._simulateMessage(JSON.stringify(typingMsg)); + + const messages = getAllSentMessages(ws1); + const typingUpdate = messages.find(m => m.type === "typing_update"); + expect(typingUpdate).toBeUndefined(); + }); + + it("ignores typing from client not in a room", () => { + const ws3 = createMockWs(); + relay.handleConnection(ws3); + + const typingMsg: ClientMessage = { type: "typing", typing: true }; + (ws3 as any)._simulateMessage(JSON.stringify(typingMsg)); + + // Should not crash or send anything + expect((ws3.send as ReturnType).mock.calls.length).toBe(0); + }); +}); + +describe("ChatRelay heartbeat", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws = createMockWs(); + relay.handleConnection(ws); + + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice" }; + (ws as any)._simulateMessage(JSON.stringify(createMsg)); + }); + + it("handles heartbeat without error", () => { + const heartbeatMsg: ClientMessage = { type: "heartbeat" }; + expect(() => { + (ws as any)._simulateMessage(JSON.stringify(heartbeatMsg)); + }).not.toThrow(); + }); + + it("ignores heartbeat from client not in a room", () => { + const ws2 = createMockWs(); + relay.handleConnection(ws2); + + const heartbeatMsg: ClientMessage = { type: "heartbeat" }; + expect(() => { + (ws2 as any)._simulateMessage(JSON.stringify(heartbeatMsg)); + }).not.toThrow(); + }); +}); + +describe("ChatRelay message routing", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let room1Ws1: WebSocket; + let room1Ws2: WebSocket; + let room2Ws1: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + room1Ws1 = createMockWs(); + room1Ws2 = createMockWs(); + room2Ws1 = createMockWs(); + relay.handleConnection(room1Ws1); + relay.handleConnection(room1Ws2); + relay.handleConnection(room2Ws1); + }); + + it("routes messages only to members of the same room", () => { + // Create room 1 + const createMsg1: ClientMessage = { type: "create_room", nickname: "alice" }; + (room1Ws1 as any)._simulateMessage(JSON.stringify(createMsg1)); + + const createdMsg1 = getAllSentMessages(room1Ws1).find(m => m.type === "room_created"); + const roomCode1 = (createdMsg1 as any).code; + + // Join room 1 + const joinMsg1: ClientMessage = { type: "join_room", code: roomCode1, nickname: "bob" }; + (room1Ws2 as any)._simulateMessage(JSON.stringify(joinMsg1)); + + // Create room 2 + const createMsg2: ClientMessage = { type: "create_room", nickname: "carol" }; + (room2Ws1 as any)._simulateMessage(JSON.stringify(createMsg2)); + + clearSentMessages(room1Ws1); + clearSentMessages(room1Ws2); + clearSentMessages(room2Ws1); + + // Send chat in room 1 + const chatMsg: ClientMessage = { type: "chat", body: "hello room 1" }; + (room1Ws1 as any)._simulateMessage(JSON.stringify(chatMsg)); + + // Room 1 members should receive it + const messages1 = getAllSentMessages(room1Ws1); + const messages2 = getAllSentMessages(room1Ws2); + expect(messages1.find(m => m.type === "message")).toBeDefined(); + expect(messages2.find(m => m.type === "message")).toBeDefined(); + + // Room 2 member should not receive it + const messages3 = getAllSentMessages(room2Ws1); + expect(messages3.find(m => m.type === "message")).toBeUndefined(); + }); +}); + +describe("ChatRelay grace timer", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws1: WebSocket; + let ws2: WebSocket; + + beforeEach(() => { + vi.useFakeTimers(); + wss = createMockWss(); + relay = new ChatRelay(wss); + ws1 = createMockWs(); + ws2 = createMockWs(); + relay.handleConnection(ws1); + relay.handleConnection(ws2); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts grace timer when room becomes empty", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice", keepAliveMinutes: 1 }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + // Leave room + const leaveMsg: ClientMessage = { type: "leave_room" }; + (ws1 as any)._simulateMessage(JSON.stringify(leaveMsg)); + + // Grace timer should be active (verified by not throwing) + expect(() => vi.advanceTimersByTime(30000)).not.toThrow(); + }); + + it("cancels grace timer when user rejoins", () => { + // Create room + const createMsg: ClientMessage = { type: "create_room", nickname: "alice", keepAliveMinutes: 1 }; + (ws1 as any)._simulateMessage(JSON.stringify(createMsg)); + + const createdMsg = getAllSentMessages(ws1).find(m => m.type === "room_created"); + const roomCode = (createdMsg as any).code; + + // Leave room + const leaveMsg: ClientMessage = { type: "leave_room" }; + (ws1 as any)._simulateMessage(JSON.stringify(leaveMsg)); + + // Rejoin before grace period expires + const joinMsg: ClientMessage = { type: "join_room", code: roomCode, nickname: "bob" }; + (ws2 as any)._simulateMessage(JSON.stringify(joinMsg)); + + // Should successfully join (room wasn't deleted) + const messages = getAllSentMessages(ws2); + const joinedMsg = messages.find(m => m.type === "room_joined"); + expect(joinedMsg).toBeDefined(); + }); +}); + +describe("ChatRelay.close", () => { + it("closes the WebSocketServer", () => { + const wss = createMockWss(); + const relay = new ChatRelay(wss); + relay.close(); + expect(wss.close).toHaveBeenCalled(); + }); +}); + +describe("ChatRelay malformed messages", () => { + let wss: WebSocketServer; + let relay: ChatRelay; + let ws: WebSocket; + + beforeEach(() => { + wss = createMockWss(); + relay = new ChatRelay(wss); + ws = createMockWs(); + relay.handleConnection(ws); + }); + + it("ignores malformed JSON without crashing", () => { + expect(() => { + (ws as any)._simulateMessage("not valid json"); + }).not.toThrow(); + }); + + it("does not send error for malformed messages", () => { + (ws as any)._simulateMessage("not valid json"); + expect((ws.send as ReturnType).mock.calls.length).toBe(0); + }); +}); diff --git a/packages/chat-relay/vitest.config.ts b/packages/chat-relay/vitest.config.ts new file mode 100644 index 00000000..85c1800b --- /dev/null +++ b/packages/chat-relay/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/__tests__/**/*.test.ts"], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'clover'], + include: ['src/**/*.ts'], + exclude: ['src/index.ts', 'src/__tests__/**', '**/*.d.ts'], + }, + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index 1107eb5a..82bdfe1d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@types/node": "^22.19.15", + "@vitest/coverage-v8": "^3", "tsup": "^8", "typescript": "^5", "vitest": "^3" diff --git a/packages/shared/__tests__/chat-types.test.ts b/packages/shared/__tests__/chat-types.test.ts new file mode 100644 index 00000000..d1abdec1 --- /dev/null +++ b/packages/shared/__tests__/chat-types.test.ts @@ -0,0 +1,359 @@ +import { describe, it, expect } from "vitest"; +import type { + ChatMessageType, + ShareAction, + ChatMessage, + ShareMessage, + SystemMessage, + AnyMessage, + Member, + ClientMessage, + ServerMessage, +} from "../src/chat-types.js"; + +describe("Chat message type unions", () => { + it("validates ChatMessageType union", () => { + const validTypes: ChatMessageType[] = ["chat", "share", "system"]; + expect(validTypes).toHaveLength(3); + }); + + it("validates ShareAction union", () => { + const validActions: ShareAction[] = [ + "harness_updated", + "plugin_installed", + "plugin_uninstalled", + "sync_applied", + "permissions_changed", + "preset_applied", + ]; + expect(validActions).toHaveLength(6); + }); +}); + +describe("Message interfaces", () => { + it("validates ChatMessage interface", () => { + const message: ChatMessage = { + id: "msg-1", + roomCode: "ABC123", + type: "chat", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + body: "Hello, world!", + }; + expect(message.type).toBe("chat"); + expect(message.body).toBe("Hello, world!"); + }); + + it("validates ShareMessage interface with all fields", () => { + const message: ShareMessage = { + id: "msg-2", + roomCode: "ABC123", + type: "share", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + action: "plugin_installed", + target: "research@harness-kit", + detail: "v1.0.0", + diff: "plugin.json content...", + pullable: true, + }; + expect(message.type).toBe("share"); + expect(message.action).toBe("plugin_installed"); + expect(message.pullable).toBe(true); + }); + + it("validates ShareMessage interface with null fields", () => { + const message: ShareMessage = { + id: "msg-3", + roomCode: "ABC123", + type: "share", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + action: "harness_updated", + target: "harness.yaml", + detail: null, + diff: null, + pullable: false, + }; + expect(message.detail).toBeNull(); + expect(message.diff).toBeNull(); + }); + + it("validates SystemMessage interface with join event", () => { + const message: SystemMessage = { + id: "msg-4", + roomCode: "ABC123", + type: "system", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + event: "join", + detail: "joined the room", + }; + expect(message.type).toBe("system"); + expect(message.event).toBe("join"); + }); + + it("validates SystemMessage interface with leave event", () => { + const message: SystemMessage = { + id: "msg-5", + roomCode: "ABC123", + type: "system", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + event: "leave", + detail: null, + }; + expect(message.event).toBe("leave"); + expect(message.detail).toBeNull(); + }); + + it("validates SystemMessage interface with all event types", () => { + const events: Array<"join" | "leave" | "nick_change" | "room_created" | "shutdown"> = [ + "join", + "leave", + "nick_change", + "room_created", + "shutdown", + ]; + expect(events).toHaveLength(5); + }); + + it("validates AnyMessage union with ChatMessage", () => { + const message: AnyMessage = { + id: "msg-6", + roomCode: "ABC123", + type: "chat", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + body: "Test", + }; + expect(message.type).toBe("chat"); + }); + + it("validates AnyMessage union with ShareMessage", () => { + const message: AnyMessage = { + id: "msg-7", + roomCode: "ABC123", + type: "share", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + action: "sync_applied", + target: "config", + detail: null, + diff: null, + pullable: false, + }; + expect(message.type).toBe("share"); + }); + + it("validates AnyMessage union with SystemMessage", () => { + const message: AnyMessage = { + id: "msg-8", + roomCode: "ABC123", + type: "system", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + event: "room_created", + detail: "Room created", + }; + expect(message.type).toBe("system"); + }); +}); + +describe("Relay protocol types", () => { + it("validates Member interface", () => { + const member: Member = { + nickname: "testuser", + joinedAt: "2024-01-01T00:00:00Z", + typing: false, + }; + expect(member.nickname).toBe("testuser"); + expect(member.typing).toBe(false); + }); + + it("validates Member interface with typing indicator", () => { + const member: Member = { + nickname: "testuser", + joinedAt: "2024-01-01T00:00:00Z", + typing: true, + }; + expect(member.typing).toBe(true); + }); +}); + +describe("ClientMessage discriminated union", () => { + it("validates create_room message with all fields", () => { + const message: ClientMessage = { + type: "create_room", + name: "Dev Team", + nickname: "testuser", + keepAliveMinutes: 60, + }; + expect(message.type).toBe("create_room"); + expect(message.keepAliveMinutes).toBe(60); + }); + + it("validates create_room message with minimal fields", () => { + const message: ClientMessage = { + type: "create_room", + nickname: "testuser", + }; + expect(message.type).toBe("create_room"); + }); + + it("validates join_room message", () => { + const message: ClientMessage = { + type: "join_room", + code: "ABC123", + nickname: "testuser", + }; + expect(message.type).toBe("join_room"); + expect(message.code).toBe("ABC123"); + }); + + it("validates leave_room message", () => { + const message: ClientMessage = { + type: "leave_room", + }; + expect(message.type).toBe("leave_room"); + }); + + it("validates chat message", () => { + const message: ClientMessage = { + type: "chat", + body: "Hello!", + }; + expect(message.type).toBe("chat"); + expect(message.body).toBe("Hello!"); + }); + + it("validates share message with all fields", () => { + const message: ClientMessage = { + type: "share", + action: "plugin_installed", + target: "research@harness-kit", + detail: "v1.0.0", + diff: "plugin content...", + pullable: true, + }; + expect(message.type).toBe("share"); + expect(message.pullable).toBe(true); + }); + + it("validates share message with minimal fields", () => { + const message: ClientMessage = { + type: "share", + action: "harness_updated", + target: "harness.yaml", + }; + expect(message.type).toBe("share"); + }); + + it("validates typing message", () => { + const message: ClientMessage = { + type: "typing", + typing: true, + }; + expect(message.type).toBe("typing"); + expect(message.typing).toBe(true); + }); + + it("validates heartbeat message", () => { + const message: ClientMessage = { + type: "heartbeat", + }; + expect(message.type).toBe("heartbeat"); + }); +}); + +describe("ServerMessage discriminated union", () => { + it("validates room_created message with name", () => { + const message: ServerMessage = { + type: "room_created", + code: "ABC123", + name: "Dev Team", + }; + expect(message.type).toBe("room_created"); + expect(message.name).toBe("Dev Team"); + }); + + it("validates room_created message without name", () => { + const message: ServerMessage = { + type: "room_created", + code: "ABC123", + }; + expect(message.type).toBe("room_created"); + }); + + it("validates room_joined message", () => { + const message: ServerMessage = { + type: "room_joined", + code: "ABC123", + name: "Dev Team", + members: [ + { nickname: "user1", joinedAt: "2024-01-01T00:00:00Z", typing: false }, + { nickname: "user2", joinedAt: "2024-01-01T00:01:00Z", typing: true }, + ], + history: [ + { + id: "msg-1", + roomCode: "ABC123", + type: "chat", + nickname: "user1", + timestamp: "2024-01-01T00:00:00Z", + body: "Hello", + }, + ], + }; + expect(message.type).toBe("room_joined"); + expect(message.members).toHaveLength(2); + expect(message.history).toHaveLength(1); + }); + + it("validates room_error message", () => { + const message: ServerMessage = { + type: "room_error", + error: "Room not found", + }; + expect(message.type).toBe("room_error"); + expect(message.error).toBe("Room not found"); + }); + + it("validates message wrapper", () => { + const message: ServerMessage = { + type: "message", + message: { + id: "msg-1", + roomCode: "ABC123", + type: "chat", + nickname: "testuser", + timestamp: "2024-01-01T00:00:00Z", + body: "Hello", + }, + }; + expect(message.type).toBe("message"); + expect(message.message.type).toBe("chat"); + }); + + it("validates presence update message", () => { + const message: ServerMessage = { + type: "presence", + members: [ + { nickname: "user1", joinedAt: "2024-01-01T00:00:00Z", typing: false }, + { nickname: "user2", joinedAt: "2024-01-01T00:01:00Z", typing: false }, + ], + }; + expect(message.type).toBe("presence"); + expect(message.members).toHaveLength(2); + }); + + it("validates typing_update message", () => { + const message: ServerMessage = { + type: "typing_update", + nickname: "testuser", + typing: true, + }; + expect(message.type).toBe("typing_update"); + expect(message.typing).toBe(true); + }); +}); diff --git a/packages/shared/__tests__/types.test.ts b/packages/shared/__tests__/types.test.ts new file mode 100644 index 00000000..72270b62 --- /dev/null +++ b/packages/shared/__tests__/types.test.ts @@ -0,0 +1,870 @@ +import { describe, it, expect } from "vitest"; +import type { + ComponentType, + TrustTier, + Author, + Component, + Profile, + Category, + Tag, + ComponentCategory, + ComponentTag, + ProfileComponent, + ProfileCategory, + ProfileTag, + ProfileYaml, + ComponentCounts, + InstalledPlugin, + FileTreeNode, + PluginUpdateInfo, + KnownMarketplace, + HookCommand, + HooksConfig, + PluginManifest, + MarketplaceCategory, + MarketplacePlugin, + MarketplaceManifest, + DailyActivity, + DailyModelTokens, + ModelUsageEntry, + StatsCache, + SessionSummary, + SessionFacet, + ActiveSession, + LiveDailyActivity, + LiveStats, + SessionTranscript, + TranscriptEntry, + HarnessInfo, + PanelConfig, + ComparisonRequest, + PanelOutput, + PanelComplete, + GitRepoInfo, + WorktreeResult, + FileDiffEntry, + ComparisonSummary, + PanelSummary, + ComparisonDetail, + PanelDetail, + FileDiff, + EvaluationScores, + PanelDiffs, + ReplaySetup, + ReplayPanel, + SaveEvaluationRequest, + AnalyticsData, + HarnessWinRate, + ModelWinRate, + DimensionAvg, + EvaluationSession, + PairwiseVote, + EloEntry, + DimensionWinRate, + PairwiseAnalytics, + PermissionsState, + SecurityPreset, + KeychainSecretInfo, + EnvConfigEntry, + AuditEntry, +} from "../src/types.js"; + +describe("Core enums", () => { + it("validates ComponentType union", () => { + const validTypes: ComponentType[] = [ + "skill", + "plugin", + "agent", + "hook", + "script", + "knowledge", + "rules", + ]; + expect(validTypes).toHaveLength(7); + }); + + it("validates TrustTier union", () => { + const validTiers: TrustTier[] = ["official", "verified", "community"]; + expect(validTiers).toHaveLength(3); + }); +}); + +describe("Core entities", () => { + it("validates Author interface", () => { + const author: Author = { + name: "test-author", + url: "https://example.com", + }; + expect(author.name).toBe("test-author"); + expect(author.url).toBe("https://example.com"); + }); + + it("validates Author with optional url", () => { + const author: Author = { + name: "test-author", + }; + expect(author.name).toBe("test-author"); + expect(author.url).toBeUndefined(); + }); + + it("validates Component interface", () => { + const component: Component = { + id: "comp-1", + slug: "test-component", + name: "Test Component", + type: "skill", + description: "A test component", + trust_tier: "community", + version: "1.0.0", + author: { name: "test" }, + license: "MIT", + skill_md: "# Skill", + readme_md: "# README", + repo_url: "https://github.com/test/repo", + install_count: 42, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + }; + expect(component.id).toBe("comp-1"); + expect(component.type).toBe("skill"); + expect(component.trust_tier).toBe("community"); + }); + + it("validates Component with null fields", () => { + const component: Component = { + id: "comp-2", + slug: "test", + name: "Test", + type: "plugin", + description: "Test", + trust_tier: "official", + version: "1.0.0", + author: { name: "test" }, + license: "Apache-2.0", + skill_md: null, + readme_md: null, + repo_url: null, + install_count: 0, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + expect(component.skill_md).toBeNull(); + expect(component.readme_md).toBeNull(); + expect(component.repo_url).toBeNull(); + }); + + it("validates Profile interface", () => { + const profile: Profile = { + id: "prof-1", + slug: "test-profile", + name: "Test Profile", + description: "A test profile", + author: { name: "author" }, + trust_tier: "verified", + harness_yaml_template: "version: 1\nplugins: []", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + }; + expect(profile.id).toBe("prof-1"); + expect(profile.trust_tier).toBe("verified"); + }); + + it("validates Category interface", () => { + const category: Category = { + id: "cat-1", + slug: "productivity", + name: "Productivity", + display_order: 1, + }; + expect(category.display_order).toBe(1); + }); + + it("validates Tag interface", () => { + const tag: Tag = { + id: "tag-1", + slug: "testing", + }; + expect(tag.slug).toBe("testing"); + }); +}); + +describe("Join tables", () => { + it("validates ComponentCategory interface", () => { + const cc: ComponentCategory = { + component_id: "comp-1", + category_id: "cat-1", + }; + expect(cc.component_id).toBe("comp-1"); + }); + + it("validates ComponentTag interface", () => { + const ct: ComponentTag = { + component_id: "comp-1", + tag_id: "tag-1", + }; + expect(ct.tag_id).toBe("tag-1"); + }); + + it("validates ProfileComponent interface", () => { + const pc: ProfileComponent = { + profile_id: "prof-1", + component_id: "comp-1", + pinned_version: "1.0.0", + }; + expect(pc.pinned_version).toBe("1.0.0"); + }); + + it("validates ProfileCategory interface", () => { + const pc: ProfileCategory = { + profile_id: "prof-1", + category_id: "cat-1", + }; + expect(pc.profile_id).toBe("prof-1"); + }); + + it("validates ProfileTag interface", () => { + const pt: ProfileTag = { + profile_id: "prof-1", + tag_id: "tag-1", + }; + expect(pt.tag_id).toBe("tag-1"); + }); +}); + +describe("Profile YAML", () => { + it("validates ProfileYaml interface with all fields", () => { + const yaml: ProfileYaml = { + name: "Test Profile", + description: "A test profile", + author: { name: "test" }, + components: [ + { name: "skill-1", version: "1.0.0" }, + { name: "plugin-1", version: "2.0.0" }, + ], + knowledge: { + backend: "vector-db", + seed_docs: [ + { topic: "API", description: "API docs" }, + ], + }, + rules: ["rule1", "rule2"], + }; + expect(yaml.components).toHaveLength(2); + expect(yaml.knowledge?.backend).toBe("vector-db"); + expect(yaml.rules).toHaveLength(2); + }); + + it("validates ProfileYaml with optional fields omitted", () => { + const yaml: ProfileYaml = { + name: "Minimal Profile", + description: "Minimal", + author: { name: "test" }, + components: [], + }; + expect(yaml.knowledge).toBeUndefined(); + expect(yaml.rules).toBeUndefined(); + }); +}); + +describe("Desktop app types", () => { + it("validates ComponentCounts interface", () => { + const counts: ComponentCounts = { + skills: 5, + agents: 2, + scripts: 3, + }; + expect(counts.skills).toBe(5); + }); + + it("validates InstalledPlugin interface", () => { + const plugin: InstalledPlugin = { + name: "test-plugin", + version: "1.0.0", + description: "Test plugin", + marketplace: "harness-kit", + source: "./plugins/test", + installed_at: "2024-01-01T00:00:00Z", + category: "productivity", + tags: ["testing", "dev"], + component_counts: { skills: 1, agents: 0, scripts: 0 }, + }; + expect(plugin.name).toBe("test-plugin"); + expect(plugin.tags).toHaveLength(2); + }); + + it("validates InstalledPlugin with minimal fields", () => { + const plugin: InstalledPlugin = { + name: "minimal", + version: "1.0.0", + }; + expect(plugin.description).toBeUndefined(); + }); + + it("validates FileTreeNode interface", () => { + const tree: FileTreeNode = { + name: "src", + path: "./src", + kind: "directory", + children: [ + { name: "index.ts", path: "./src/index.ts", kind: "file" }, + ], + }; + expect(tree.kind).toBe("directory"); + expect(tree.children).toHaveLength(1); + }); + + it("validates PluginUpdateInfo interface", () => { + const update: PluginUpdateInfo = { + name: "test-plugin", + installed_version: "1.0.0", + latest_version: "2.0.0", + marketplace: "harness-kit", + }; + expect(update.latest_version).toBe("2.0.0"); + }); + + it("validates KnownMarketplace interface", () => { + const marketplace: KnownMarketplace = { + name: "harness-kit", + url: "https://github.com/harnessprotocol/harness-kit", + description: "Official marketplace", + }; + expect(marketplace.name).toBe("harness-kit"); + }); + + it("validates HookCommand interface", () => { + const hook: HookCommand = { + type: "skill", + command: "/test", + }; + expect(hook.type).toBe("skill"); + }); + + it("validates HooksConfig type", () => { + const config: HooksConfig = { + "pre-commit": [ + { type: "skill", command: "/lint" }, + ], + "post-build": [ + { type: "skill", command: "/test" }, + { type: "agent", command: "verify" }, + ], + }; + expect(config["pre-commit"]).toHaveLength(1); + expect(config["post-build"]).toHaveLength(2); + }); +}); + +describe("Plugin manifest", () => { + it("validates PluginManifest interface with all fields", () => { + const manifest: PluginManifest = { + name: "test-plugin", + description: "Test plugin", + version: "1.0.0", + developed_with: "claude-code", + tags: ["test"], + category: "productivity", + requires: { + env: [ + { + name: "API_KEY", + description: "API key", + required: true, + sensitive: true, + when: "always", + }, + ], + }, + }; + expect(manifest.requires?.env).toHaveLength(1); + }); + + it("validates PluginManifest with minimal fields", () => { + const manifest: PluginManifest = { + name: "minimal", + description: "Minimal", + version: "1.0.0", + }; + expect(manifest.developed_with).toBeUndefined(); + }); +}); + +describe("Marketplace manifest", () => { + it("validates MarketplaceCategory interface", () => { + const category: MarketplaceCategory = { + slug: "productivity", + name: "Productivity", + display_order: 1, + }; + expect(category.display_order).toBe(1); + }); + + it("validates MarketplacePlugin interface", () => { + const plugin: MarketplacePlugin = { + name: "test", + source: "./plugins/test", + description: "Test", + version: "1.0.0", + author: { name: "test" }, + license: "MIT", + category: "productivity", + tags: ["test"], + }; + expect(plugin.source).toBe("./plugins/test"); + }); + + it("validates MarketplaceManifest interface", () => { + const manifest: MarketplaceManifest = { + name: "test-marketplace", + owner: { name: "test-org" }, + metadata: { + description: "Test marketplace", + pluginRoot: "./plugins", + }, + categories: [ + { slug: "productivity", name: "Productivity", display_order: 1 }, + ], + plugins: [ + { + name: "test", + source: "./plugins/test", + description: "Test", + version: "1.0.0", + author: { name: "test" }, + license: "MIT", + }, + ], + }; + expect(manifest.categories).toHaveLength(1); + expect(manifest.plugins).toHaveLength(1); + }); +}); + +describe("Observatory types", () => { + it("validates DailyActivity interface", () => { + const activity: DailyActivity = { + date: "2024-01-01", + messageCount: 10, + sessionCount: 2, + toolCallCount: 15, + }; + expect(activity.messageCount).toBe(10); + }); + + it("validates DailyModelTokens interface", () => { + const tokens: DailyModelTokens = { + date: "2024-01-01", + tokensByModel: { + "claude-3-5-sonnet": 1000, + "claude-3-opus": 500, + }, + }; + expect(tokens.tokensByModel).toBeDefined(); + }); + + it("validates ModelUsageEntry interface", () => { + const entry: ModelUsageEntry = { + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + }; + expect(entry.inputTokens).toBe(1000); + }); + + it("validates StatsCache interface", () => { + const cache: StatsCache = { + lastComputedDate: "2024-01-01", + dailyActivity: [{ date: "2024-01-01", messageCount: 10 }], + dailyModelTokens: [{ date: "2024-01-01" }], + modelUsage: { "claude-3-5-sonnet": { inputTokens: 1000 } }, + totalSessions: 5, + totalMessages: 50, + hourCounts: { "12": 10, "13": 15 }, + }; + expect(cache.totalSessions).toBe(5); + }); + + it("validates SessionSummary interface", () => { + const summary: SessionSummary = { + sessionId: "session-1", + project: "/path/to/project", + projectShort: "project", + firstTimestamp: 1234567890, + lastTimestamp: 1234567900, + messageCount: 10, + }; + expect(summary.sessionId).toBe("session-1"); + }); + + it("validates SessionFacet interface", () => { + const facet: SessionFacet = { + session_id: "session-1", + underlying_goal: "Build a feature", + outcome: "success", + claude_helpfulness: "very helpful", + session_type: "coding", + brief_summary: "Built feature X", + friction_counts: { "tool-error": 2 }, + }; + expect(facet.underlying_goal).toBe("Build a feature"); + }); + + it("validates ActiveSession interface", () => { + const session: ActiveSession = { + pid: 12345, + sessionId: "session-1", + cwd: "/path/to/project", + startedAt: 1234567890, + }; + expect(session.pid).toBe(12345); + }); + + it("validates LiveStats interface", () => { + const stats: LiveStats = { + dailyActivity: [], + dailyModelTokens: [], + modelUsage: {}, + hourCounts: {}, + totalToolCalls: 100, + totalOutputTokens: 5000, + scannedFiles: 50, + scanDurationMs: 1000, + }; + expect(stats.totalToolCalls).toBe(100); + }); + + it("validates SessionTranscript interface", () => { + const transcript: SessionTranscript = { + sessionId: "session-1", + entries: [], + totalInputTokens: 1000, + totalOutputTokens: 500, + totalToolCalls: 10, + modelsUsed: ["claude-3-5-sonnet"], + subagentCount: 2, + truncated: false, + }; + expect(transcript.modelsUsed).toHaveLength(1); + }); + + it("validates TranscriptEntry interface", () => { + const entry: TranscriptEntry = { + timestamp: "2024-01-01T00:00:00Z", + role: "user", + model: "claude-3-5-sonnet", + toolNames: ["Read", "Write"], + inputTokens: 100, + outputTokens: 50, + contentPreview: "Hello", + isSubagent: false, + }; + expect(entry.toolNames).toHaveLength(2); + }); +}); + +describe("Comparator types", () => { + it("validates HarnessInfo interface", () => { + const info: HarnessInfo = { + id: "harness-1", + name: "Test Harness", + command: "test", + available: true, + version: "1.0.0", + mode: "supported", + authenticated: true, + models: ["claude-3-5-sonnet"], + defaultModel: "claude-3-5-sonnet", + }; + expect(info.available).toBe(true); + }); + + it("validates PanelConfig interface", () => { + const config: PanelConfig = { + panelId: "panel-1", + harnessId: "harness-1", + model: "claude-3-5-sonnet", + workingDir: "/path/to/dir", + }; + expect(config.panelId).toBe("panel-1"); + }); + + it("validates ComparisonRequest interface", () => { + const request: ComparisonRequest = { + comparisonId: "comp-1", + prompt: "Test prompt", + workingDir: "/path/to/dir", + pinnedCommit: "abc123", + panels: [ + { panelId: "panel-1", harnessId: "harness-1" }, + ], + }; + expect(request.panels).toHaveLength(1); + }); + + it("validates PanelOutput interface", () => { + const output: PanelOutput = { + comparisonId: "comp-1", + panelId: "panel-1", + stream: "stdout", + data: "output text", + }; + expect(output.stream).toBe("stdout"); + }); + + it("validates PanelComplete interface", () => { + const complete: PanelComplete = { + comparisonId: "comp-1", + panelId: "panel-1", + exitCode: 0, + durationMs: 1000, + }; + expect(complete.exitCode).toBe(0); + }); +}); + +describe("Git types", () => { + it("validates GitRepoInfo interface", () => { + const info: GitRepoInfo = { + isGitRepo: true, + currentCommit: "abc123", + branch: "main", + }; + expect(info.isGitRepo).toBe(true); + }); + + it("validates WorktreeResult interface", () => { + const result: WorktreeResult = { + panelId: "panel-1", + worktreePath: "/path/to/worktree", + }; + expect(result.worktreePath).toBe("/path/to/worktree"); + }); + + it("validates FileDiffEntry interface", () => { + const diff: FileDiffEntry = { + filePath: "src/index.ts", + diffText: "diff content", + changeType: "modified", + }; + expect(diff.changeType).toBe("modified"); + }); +}); + +describe("Persistence types", () => { + it("validates ComparisonSummary interface", () => { + const summary: ComparisonSummary = { + id: "comp-1", + prompt: "Test", + workingDir: "/path", + pinnedCommit: "abc123", + createdAt: "2024-01-01T00:00:00Z", + status: "complete", + panels: [], + }; + expect(summary.status).toBe("complete"); + }); + + it("validates PanelSummary interface", () => { + const summary: PanelSummary = { + id: "panel-1", + harnessId: "harness-1", + harnessName: "Test", + model: "claude-3-5-sonnet", + exitCode: 0, + durationMs: 1000, + status: "complete", + }; + expect(summary.status).toBe("complete"); + }); + + it("validates EvaluationScores interface", () => { + const scores: EvaluationScores = { + id: "eval-1", + panelId: "panel-1", + correctness: 8, + completeness: 7, + codeQuality: 9, + efficiency: 8, + reasoning: 8, + speed: 7, + safety: 9, + contextAwareness: 8, + autonomy: 7, + adherence: 9, + overallScore: 8, + notes: "Good work", + }; + expect(scores.overallScore).toBe(8); + }); + + it("validates EvaluationScores with null values", () => { + const scores: EvaluationScores = { + id: "eval-2", + panelId: "panel-2", + correctness: null, + completeness: null, + codeQuality: null, + efficiency: null, + reasoning: null, + speed: null, + safety: null, + contextAwareness: null, + autonomy: null, + adherence: null, + overallScore: null, + notes: null, + }; + expect(scores.correctness).toBeNull(); + }); + + it("validates ReplaySetup interface", () => { + const setup: ReplaySetup = { + prompt: "Test", + workingDir: "/path", + pinnedCommit: "abc123", + panels: [ + { harnessId: "harness-1", harnessName: "Test", model: "claude-3-5-sonnet" }, + ], + }; + expect(setup.panels).toHaveLength(1); + }); + + it("validates AnalyticsData interface", () => { + const data: AnalyticsData = { + totalComparisons: 10, + winRates: [], + modelWinRates: [], + dimensionAverages: [], + }; + expect(data.totalComparisons).toBe(10); + }); + + it("validates HarnessWinRate interface", () => { + const rate: HarnessWinRate = { + harnessId: "harness-1", + harnessName: "Test", + wins: 5, + total: 10, + rate: 0.5, + }; + expect(rate.rate).toBe(0.5); + }); +}); + +describe("Pairwise voting types", () => { + it("validates EvaluationSession interface", () => { + const session: EvaluationSession = { + id: "session-1", + comparisonId: "comp-1", + evalMethod: "pairwise", + blindOrder: "AB", + revealedAt: "2024-01-01T00:00:00Z", + createdAt: "2024-01-01T00:00:00Z", + }; + expect(session.evalMethod).toBe("pairwise"); + }); + + it("validates PairwiseVote interface", () => { + const vote: PairwiseVote = { + id: "vote-1", + comparisonId: "comp-1", + sessionId: "session-1", + leftPanelId: "panel-1", + rightPanelId: "panel-2", + dimension: "correctness", + result: "left", + createdAt: "2024-01-01T00:00:00Z", + }; + expect(vote.result).toBe("left"); + }); + + it("validates EloEntry interface", () => { + const entry: EloEntry = { + panelId: "panel-1", + harnessName: "Test", + elo: 1500, + wins: 5, + losses: 3, + ties: 2, + }; + expect(entry.elo).toBe(1500); + }); + + it("validates PairwiseAnalytics interface", () => { + const analytics: PairwiseAnalytics = { + totalVotes: 10, + eloRankings: [], + dimensionWinRates: [], + }; + expect(analytics.totalVotes).toBe(10); + }); +}); + +describe("Security types", () => { + it("validates PermissionsState interface", () => { + const perms: PermissionsState = { + tools: { + allow: ["Read", "Write"], + deny: ["Bash"], + ask: ["Agent"], + }, + paths: { + writable: ["./src"], + readonly: ["./config"], + }, + network: { + allowedHosts: ["api.example.com"], + }, + }; + expect(perms.tools.allow).toHaveLength(2); + }); + + it("validates SecurityPreset interface", () => { + const preset: SecurityPreset = { + id: "preset-1", + name: "Strict", + description: "Strict security", + permissions: { + tools: { allow: [], deny: [], ask: [] }, + paths: { writable: [], readonly: [] }, + network: { allowedHosts: [] }, + }, + }; + expect(preset.name).toBe("Strict"); + }); + + it("validates KeychainSecretInfo interface", () => { + const secret: KeychainSecretInfo = { + name: "API_KEY", + description: "API key", + required: true, + isSet: false, + pluginName: "test-plugin", + }; + expect(secret.required).toBe(true); + }); + + it("validates EnvConfigEntry interface", () => { + const entry: EnvConfigEntry = { + name: "NODE_ENV", + description: "Node environment", + value: "development", + pluginName: "test-plugin", + }; + expect(entry.value).toBe("development"); + }); + + it("validates AuditEntry interface", () => { + const entry: AuditEntry = { + id: "audit-1", + timestamp: "2024-01-01T00:00:00Z", + eventType: "file-write", + category: "security", + summary: "File written", + details: "Wrote file X", + source: "claude-code", + }; + expect(entry.category).toBe("security"); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index daf8723a..281e824e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -13,9 +13,13 @@ }, "scripts": { "build": "tsc", - "dev": "tsc --watch" + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { - "typescript": "^5.0.0" + "@vitest/coverage-v8": "^3", + "typescript": "^5.0.0", + "vitest": "^3" } } diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 00000000..3b755e7e --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["__tests__/**/*.test.ts"], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'clover'], + include: ['src/**/*.ts'], + exclude: ['**/__tests__/**', '**/*.d.ts'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9f92039..341d858e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,11 @@ overrides: importers: - .: {} + .: + devDependencies: + '@vitest/coverage-v8': + specifier: ^3 + version: 3.2.4(vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))) apps/board: dependencies: @@ -73,12 +77,18 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@vitest/coverage-v8': + specifier: ^3 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) tsup: specifier: ^8 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) apps/desktop: dependencies: @@ -187,13 +197,13 @@ importers: version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: ^4.1.0 - version: 4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))) autoprefixer: specifier: ^10.4.20 version: 10.4.27(postcss@8.5.8) jsdom: specifier: ^28.1.0 - version: 28.1.0 + version: 28.1.0(@noble/hashes@1.8.0) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -205,7 +215,7 @@ importers: version: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(jsdom@28.1.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) apps/marketplace: dependencies: @@ -280,15 +290,27 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.15 + '@types/supertest': + specifier: ^6 + version: 6.0.3 '@types/ws': specifier: ^8.5.10 version: 8.18.1 + '@vitest/coverage-v8': + specifier: ^3 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + supertest: + specifier: ^7 + version: 7.2.2 tsx: specifier: ^4.7.0 version: 4.21.0 typescript: specifier: ^5.4.0 version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) packages/chat-relay: dependencies: @@ -305,6 +327,9 @@ importers: '@types/ws': specifier: ^8.5.10 version: 8.18.1 + '@vitest/coverage-v8': + specifier: ^3 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) tsx: specifier: ^4.7.0 version: 4.21.0 @@ -313,7 +338,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) packages/core: dependencies: @@ -330,6 +355,9 @@ importers: '@types/node': specifier: ^22.19.15 version: 22.19.15 + '@vitest/coverage-v8': + specifier: ^3 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) tsup: specifier: ^8 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -338,13 +366,19 @@ importers: version: 5.9.3 vitest: specifier: ^3 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) packages/shared: devDependencies: + '@vitest/coverage-v8': + specifier: ^3 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: ^5.0.0 version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) packages/ui: dependencies: @@ -426,6 +460,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1240,6 +1278,14 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1334,10 +1380,21 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@orama/orama@3.1.18': resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -2170,6 +2227,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -2234,6 +2294,9 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -2275,6 +2338,12 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2299,6 +2368,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/coverage-v8@4.1.0': resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} peerDependencies: @@ -2409,6 +2487,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2417,6 +2499,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2437,10 +2523,16 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} @@ -2448,6 +2540,9 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.4.27: resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} @@ -2458,6 +2553,13 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.7: resolution: {integrity: sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==} engines: {node: '>=6.0.0'} @@ -2474,6 +2576,13 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2570,6 +2679,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2581,6 +2694,9 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -2621,6 +2737,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2730,6 +2849,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2752,6 +2875,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -2778,6 +2904,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2787,6 +2916,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2825,6 +2957,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.45.1: resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} @@ -2922,6 +3058,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -2945,6 +3084,18 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3147,6 +3298,11 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3162,6 +3318,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -3317,10 +3477,17 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3461,6 +3628,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -3480,6 +3650,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} @@ -3699,10 +3872,27 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.1: resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} @@ -3822,6 +4012,9 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3842,6 +4035,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} @@ -4262,6 +4459,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -4269,6 +4470,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -4300,6 +4505,14 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4317,6 +4530,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -4665,6 +4882,14 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4721,6 +4946,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -5076,7 +5306,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@exodus/bytes@1.15.0': {} + '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 '@floating-ui/core@1.7.5': dependencies: @@ -5333,6 +5565,17 @@ snapshots: optionalDependencies: '@types/node': 25.5.0 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5441,8 +5684,17 @@ snapshots: '@next/swc-win32-x64-msvc@16.1.7': optional: true + '@noble/hashes@1.8.0': {} + '@orama/orama@3.1.18': {} + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -6200,6 +6452,8 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -6268,6 +6522,8 @@ snapshots: '@types/mdx@2.0.13': {} + '@types/methods@1.1.4': {} + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} @@ -6313,6 +6569,18 @@ snapshots: '@types/node': 22.19.15 '@types/send': 0.17.6 + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.19.15 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@types/trusted-types@2.0.7': optional: true @@ -6340,7 +6608,64 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@3.2.4(vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.0 @@ -6352,7 +6677,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.0(@types/node@25.5.0)(jsdom@28.1.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -6371,13 +6696,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/mocker@4.1.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: @@ -6472,12 +6797,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} argparse@2.0.1: {} @@ -6494,8 +6823,16 @@ snapshots: array-flatten@1.1.1: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + ast-v8-to-istanbul@1.0.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -6504,6 +6841,8 @@ snapshots: astring@1.9.0: {} + asynckit@0.4.0: {} + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 @@ -6515,6 +6854,10 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.7: {} bidi-js@1.0.3: @@ -6552,6 +6895,14 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.7 @@ -6633,12 +6984,18 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@13.1.0: {} commander@4.1.1: {} + component-emitter@1.3.1: {} + compute-scroll-into-view@3.1.1: {} confbox@0.1.8: {} @@ -6663,6 +7020,8 @@ snapshots: cookie@1.1.1: {} + cookiejar@2.1.4: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -6730,10 +7089,10 @@ snapshots: d3-timer@3.0.1: {} - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@1.8.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - '@noble/hashes' @@ -6757,6 +7116,8 @@ snapshots: deepmerge@4.3.1: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -6771,6 +7132,11 @@ snapshots: dependencies: dequal: 2.0.3 + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -6803,12 +7169,16 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.313: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} enhanced-resolve@5.20.0: @@ -6834,6 +7204,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + es-toolkit@1.45.1: {} esast-util-from-estree@2.0.0: @@ -7043,6 +7420,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} fdir@6.5.0(picomatch@4.0.4): @@ -7078,6 +7457,25 @@ snapshots: mlly: 1.8.1 rollup: 4.59.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -7254,6 +7652,15 @@ snapshots: github-slugger@2.0.0: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -7262,6 +7669,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -7380,9 +7791,9 @@ snapshots: hono@4.12.8: {} - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) transitivePeerDependencies: - '@noble/hashes' @@ -7487,11 +7898,25 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.6.1: {} jose@6.2.1: {} @@ -7508,16 +7933,16 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@28.1.0: + jsdom@28.1.0(@noble/hashes@1.8.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 '@bramus/specificity': 2.4.2 - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) cssstyle: 6.2.0 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@1.8.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -7529,7 +7954,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -7602,6 +8027,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.2.6: {} lru-cache@5.1.1: @@ -7618,6 +8045,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + magicast@0.5.2: dependencies: '@babel/parser': 7.29.0 @@ -8089,8 +8522,20 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + min-indent@1.0.1: {} + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.3 + + minipass@7.1.3: {} + mlly@1.8.1: dependencies: acorn: 8.16.0 @@ -8201,6 +8646,8 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + package-json-from-dist@1.0.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -8225,6 +8672,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@0.1.12: {} path-to-regexp@8.3.0: {} @@ -8797,6 +9249,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -8806,6 +9264,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -8837,6 +9299,28 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -8849,6 +9333,12 @@ snapshots: tapable@2.3.0: {} + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.4 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -9088,6 +9578,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -9120,11 +9631,11 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -9148,7 +9659,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.19.15 - jsdom: 28.1.0 + jsdom: 28.1.0(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti - less @@ -9163,7 +9674,50 @@ snapshots: - tsx - yaml - vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.5.0 + jsdom: 28.1.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@4.1.0(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -9187,7 +9741,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 - jsdom: 28.1.0 + jsdom: 28.1.0(@noble/hashes@1.8.0) transitivePeerDependencies: - msw @@ -9201,9 +9755,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@1.8.0): dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -9224,6 +9778,18 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.19.0: {}