diff --git a/apps/web/app/api/chat/chat.test.ts b/apps/web/app/api/chat/chat.test.ts new file mode 100644 index 00000000000..22e5a51c918 --- /dev/null +++ b/apps/web/app/api/chat/chat.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock active-runs module +vi.mock("@/lib/active-runs", () => ({ + startRun: vi.fn(), + hasActiveRun: vi.fn(() => false), + subscribeToRun: vi.fn(), + persistUserMessage: vi.fn(), + abortRun: vi.fn(() => false), + getActiveRun: vi.fn(), + getRunningSessionIds: vi.fn(() => []), +})); + +// Mock workspace module +vi.mock("@/lib/workspace", () => ({ + resolveAgentWorkspacePrefix: vi.fn(() => null), +})); + +describe("Chat API routes", () => { + beforeEach(() => { + vi.resetModules(); + // Re-wire mocks + vi.mock("@/lib/active-runs", () => ({ + startRun: vi.fn(), + hasActiveRun: vi.fn(() => false), + subscribeToRun: vi.fn(), + persistUserMessage: vi.fn(), + abortRun: vi.fn(() => false), + getActiveRun: vi.fn(), + getRunningSessionIds: vi.fn(() => []), + })); + vi.mock("@/lib/workspace", () => ({ + resolveAgentWorkspacePrefix: vi.fn(() => null), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── POST /api/chat ────────────────────────────────────────────── + + describe("POST /api/chat", () => { + it("returns 400 when no user message text", async () => { + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", parts: [{ type: "text", text: "" }] }], + }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 409 when active run exists for session", async () => { + const { hasActiveRun } = await import("@/lib/active-runs"); + vi.mocked(hasActiveRun).mockReturnValue(true); + + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", parts: [{ type: "text", text: "hello" }] }], + sessionId: "s1", + }), + }); + const res = await POST(req); + expect(res.status).toBe(409); + }); + + it("starts a run and returns streaming response", async () => { + const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs"); + vi.mocked(hasActiveRun).mockReturnValue(false); + vi.mocked(subscribeToRun).mockReturnValue(() => {}); + + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [ + { id: "m1", role: "user", parts: [{ type: "text", text: "hello" }] }, + ], + sessionId: "s1", + }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + expect(startRun).toHaveBeenCalled(); + }); + + it("persists user message when sessionId provided", async () => { + const { hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs"); + vi.mocked(hasActiveRun).mockReturnValue(false); + vi.mocked(subscribeToRun).mockReturnValue(() => {}); + + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [ + { id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }, + ], + sessionId: "s1", + }), + }); + await POST(req); + expect(persistUserMessage).toHaveBeenCalledWith("s1", expect.objectContaining({ id: "m1" })); + }); + + it("resolves workspace file paths in message", async () => { + const { resolveAgentWorkspacePrefix } = await import("@/lib/workspace"); + vi.mocked(resolveAgentWorkspacePrefix).mockReturnValue("workspace"); + const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs"); + vi.mocked(hasActiveRun).mockReturnValue(false); + vi.mocked(subscribeToRun).mockReturnValue(() => {}); + + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [ + { + id: "m1", + role: "user", + parts: [{ type: "text", text: "[Context: workspace file 'doc.md']" }], + }, + ], + sessionId: "s1", + }), + }); + await POST(req); + expect(startRun).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("workspace/doc.md"), + }), + ); + }); + }); + + // ─── POST /api/chat/stop ──────────────────────────────────────── + + describe("POST /api/chat/stop", () => { + it("returns 400 when sessionId missing", async () => { + const { POST } = await import("./stop/route.js"); + const req = new Request("http://localhost/api/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("aborts run and returns result", async () => { + const { abortRun } = await import("@/lib/active-runs"); + vi.mocked(abortRun).mockReturnValue(true); + + const { POST } = await import("./stop/route.js"); + const req = new Request("http://localhost/api/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "s1" }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.aborted).toBe(true); + }); + + it("returns aborted=false for unknown session", async () => { + const { abortRun } = await import("@/lib/active-runs"); + vi.mocked(abortRun).mockReturnValue(false); + + const { POST } = await import("./stop/route.js"); + const req = new Request("http://localhost/api/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "nonexistent" }), + }); + const res = await POST(req); + const json = await res.json(); + expect(json.aborted).toBe(false); + }); + + it("handles invalid JSON body gracefully", async () => { + const { POST } = await import("./stop/route.js"); + const req = new Request("http://localhost/api/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + }); + + // ─── GET /api/chat/active ──────────────────────────────────────── + + describe("GET /api/chat/active", () => { + it("returns empty sessionIds when no active runs", async () => { + const { GET } = await import("./active/route.js"); + const res = GET(); + const json = await res.json(); + expect(json.sessionIds).toEqual([]); + }); + + it("returns active session IDs", async () => { + const { getRunningSessionIds } = await import("@/lib/active-runs"); + vi.mocked(getRunningSessionIds).mockReturnValue(["s1", "s2"]); + + const { GET } = await import("./active/route.js"); + const res = GET(); + const json = await res.json(); + expect(json.sessionIds).toEqual(["s1", "s2"]); + }); + }); + + // ─── GET /api/chat/stream ─────────────────────────────────────── + + describe("GET /api/chat/stream", () => { + it("returns 400 when sessionId is missing", async () => { + const { GET } = await import("./stream/route.js"); + const req = new Request("http://localhost/api/chat/stream"); + const res = await GET(req); + expect(res.status).toBe(400); + }); + + it("returns 404 when no run exists for session", async () => { + const { getActiveRun } = await import("@/lib/active-runs"); + vi.mocked(getActiveRun).mockReturnValue(undefined); + + const { GET } = await import("./stream/route.js"); + const req = new Request("http://localhost/api/chat/stream?sessionId=nonexistent"); + const res = await GET(req); + expect(res.status).toBe(404); + }); + + it("returns SSE stream for active run", async () => { + const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs"); + vi.mocked(getActiveRun).mockReturnValue({ status: "running" } as never); + vi.mocked(subscribeToRun).mockReturnValue(() => {}); + + const { GET } = await import("./stream/route.js"); + const req = new Request("http://localhost/api/chat/stream?sessionId=s1"); + const res = await GET(req); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + expect(res.headers.get("X-Run-Active")).toBe("true"); + }); + + it("returns X-Run-Active=false for completed run", async () => { + const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs"); + vi.mocked(getActiveRun).mockReturnValue({ status: "completed" } as never); + vi.mocked(subscribeToRun).mockReturnValue(() => {}); + + const { GET } = await import("./stream/route.js"); + const req = new Request("http://localhost/api/chat/stream?sessionId=s1"); + const res = await GET(req); + expect(res.headers.get("X-Run-Active")).toBe("false"); + }); + }); +}); diff --git a/apps/web/app/api/cron/cron.test.ts b/apps/web/app/api/cron/cron.test.ts new file mode 100644 index 00000000000..48d3e71ce6c --- /dev/null +++ b/apps/web/app/api/cron/cron.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock node:fs +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ mtimeMs: Date.now() })), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +describe("Cron API routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ mtimeMs: Date.now() })), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── GET /api/cron/jobs ───────────────────────────────────────── + + describe("GET /api/cron/jobs", () => { + it("returns empty jobs when no config file", async () => { + const { GET } = await import("./jobs/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.jobs).toEqual([]); + }); + + it("returns jobs from config file", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + const cronStore = { + version: 1, + jobs: [ + { id: "j1", name: "Daily sync", schedule: "0 8 * * *", enabled: true, command: "sync" }, + ], + }; + vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(cronStore) as never); + + const { GET } = await import("./jobs/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.jobs).toHaveLength(1); + expect(json.jobs[0].name).toBe("Daily sync"); + }); + + it("handles corrupt jobs file gracefully", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReadFile).mockReturnValue("not json" as never); + + const { GET } = await import("./jobs/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.jobs).toEqual([]); + }); + }); + + // ─── GET /api/cron/jobs/[jobId]/runs ──────────────────────────── + + describe("GET /api/cron/jobs/[jobId]/runs", () => { + it("returns empty entries when no runs file", async () => { + const { GET } = await import("./jobs/[jobId]/runs/route.js"); + const res = await GET( + new Request("http://localhost/api/cron/jobs/j1/runs"), + { params: Promise.resolve({ jobId: "j1" }) }, + ); + const json = await res.json(); + expect(json.entries).toEqual([]); + }); + + it("returns run entries from jsonl file", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + const lines = [ + JSON.stringify({ ts: 1000, jobId: "j1", action: "finished", status: "completed", summary: "Done" }), + JSON.stringify({ ts: 2000, jobId: "j1", action: "finished", status: "completed", summary: "In progress" }), + ].join("\n"); + vi.mocked(mockReadFile).mockReturnValue(lines as never); + + const { GET } = await import("./jobs/[jobId]/runs/route.js"); + const res = await GET( + new Request("http://localhost/api/cron/jobs/j1/runs"), + { params: Promise.resolve({ jobId: "j1" }) }, + ); + const json = await res.json(); + expect(json.entries.length).toBeGreaterThan(0); + }); + + it("respects limit query param", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + const lines = Array.from({ length: 50 }, (_, i) => + JSON.stringify({ ts: i, status: "completed" }), + ).join("\n"); + vi.mocked(mockReadFile).mockReturnValue(lines as never); + + const { GET } = await import("./jobs/[jobId]/runs/route.js"); + const res = await GET( + new Request("http://localhost/api/cron/jobs/j1/runs?limit=5"), + { params: Promise.resolve({ jobId: "j1" }) }, + ); + const json = await res.json(); + expect(json.entries.length).toBeLessThanOrEqual(5); + }); + }); + + // ─── GET /api/cron/runs/[sessionId] ───────────────────────────── + + describe("GET /api/cron/runs/[sessionId]", () => { + it("returns 404 when session not found", async () => { + const { GET } = await import("./runs/[sessionId]/route.js"); + const res = await GET( + new Request("http://localhost"), + { params: Promise.resolve({ sessionId: "nonexistent" }) }, + ); + expect(res.status).toBe(404); + }); + }); + + // ─── GET /api/cron/runs/search-transcript ─────────────────────── + + describe("GET /api/cron/runs/search-transcript", () => { + it("returns 400 when missing required params", async () => { + const { GET } = await import("./runs/search-transcript/route.js"); + const req = new Request("http://localhost/api/cron/runs/search-transcript"); + const res = await GET(req); + expect(res.status).toBe(400); + }); + + it("returns 404 when no transcript found", async () => { + const { GET } = await import("./runs/search-transcript/route.js"); + const req = new Request("http://localhost/api/cron/runs/search-transcript?jobId=j1&runAtMs=1000"); + const res = await GET(req); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/apps/web/app/api/sessions/sessions.test.ts b/apps/web/app/api/sessions/sessions.test.ts new file mode 100644 index 00000000000..64ab449639a --- /dev/null +++ b/apps/web/app/api/sessions/sessions.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock node:fs +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ mtimeMs: Date.now() })), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +describe("Sessions, Memories & Skills API", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ mtimeMs: Date.now() })), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── GET /api/sessions ────────────────────────────────────────── + + describe("GET /api/sessions", () => { + it("returns empty agents and sessions when no dir exists", async () => { + const { GET } = await import("./route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.agents).toEqual([]); + expect(json.sessions).toEqual([]); + }); + + it("returns sessions from agent directories", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReaddir).mockImplementation((dir) => { + const s = String(dir); + if (s.endsWith("agents")) {return ["main" as never];} + if (s.endsWith("sessions")) {return ["sessions.json" as never];} + return []; + }); + const sessionsData = { + "s1": { label: "Chat 1", displayName: "Chat 1", channel: "webchat", updatedAt: Date.now() }, + }; + vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessionsData) as never); + + const { GET } = await import("./route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.sessions.length).toBeGreaterThanOrEqual(0); + }); + }); + + // ─── GET /api/sessions/[sessionId] ────────────────────────────── + + describe("GET /api/sessions/[sessionId]", () => { + it("returns 404 when session not found", async () => { + const { GET } = await import("./[sessionId]/route.js"); + const res = await GET( + new Request("http://localhost"), + { params: Promise.resolve({ sessionId: "nonexistent" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns 404 for non-existent session ID", async () => { + const { GET } = await import("./[sessionId]/route.js"); + const res = await GET( + new Request("http://localhost"), + { params: Promise.resolve({ sessionId: "missing-id" }) }, + ); + expect(res.status).toBe(404); + }); + }); + + // ─── GET /api/memories ────────────────────────────────────────── + + describe("GET /api/memories", () => { + it("returns null mainMemory when no memory file exists", async () => { + const { existsSync: mockExists } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(false); + + const { GET } = await import("../memories/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.mainMemory).toBeNull(); + }); + + it("returns memory content when file exists", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs"); + vi.mocked(mockExists).mockImplementation((p) => { + const s = String(p); + if (s.endsWith("MEMORY.md") || s.endsWith("memory.md")) {return true;} + return false; + }); + vi.mocked(mockReadFile).mockReturnValue("# My memories\n\n- Remember X" as never); + vi.mocked(mockReaddir).mockReturnValue([]); + + const { GET } = await import("../memories/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.mainMemory).toContain("memories"); + }); + }); + + // ─── GET /api/skills ──────────────────────────────────────────── + + describe("GET /api/skills", () => { + it("returns empty skills when no skills directories exist", async () => { + const { GET } = await import("../skills/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.skills).toEqual([]); + }); + + it("returns skills from directory", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReaddir).mockImplementation((dir) => { + const s = String(dir); + if (s.endsWith("skills")) {return ["my-skill" as never];} + return []; + }); + vi.mocked(mockReadFile).mockReturnValue("---\nname: My Skill\n---\n# Skill content" as never); + + const { GET } = await import("../skills/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.skills.length).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/apps/web/app/api/web-sessions/web-sessions.test.ts b/apps/web/app/api/web-sessions/web-sessions.test.ts new file mode 100644 index 00000000000..5ac6a69e450 --- /dev/null +++ b/apps/web/app/api/web-sessions/web-sessions.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock node:fs +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => "[]"), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + appendFileSync: vi.fn(), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +// Mock node:crypto +vi.mock("node:crypto", () => ({ + randomUUID: vi.fn(() => "test-uuid-1234"), +})); + +describe("Web Sessions API", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => "[]"), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + appendFileSync: vi.fn(), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + vi.mock("node:crypto", () => ({ + randomUUID: vi.fn(() => "test-uuid-1234"), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── GET /api/web-sessions ────────────────────────────────────── + + describe("GET /api/web-sessions", () => { + it("returns empty sessions when no index exists", async () => { + const { GET } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions"); + const res = await GET(req); + const json = await res.json(); + expect(json.sessions).toEqual([]); + }); + + it("returns global sessions when no filePath param", async () => { + const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + const sessions = [ + { id: "s1", title: "Chat 1", createdAt: 1, updatedAt: 1, messageCount: 0 }, + { id: "s2", title: "File Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" }, + ]; + vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never); + + const { GET } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions"); + const res = await GET(req); + const json = await res.json(); + expect(json.sessions).toHaveLength(1); + expect(json.sessions[0].id).toBe("s1"); + }); + + it("filters sessions by filePath param", async () => { + const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + const sessions = [ + { id: "s1", title: "Global", createdAt: 1, updatedAt: 1, messageCount: 0 }, + { id: "s2", title: "Doc Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" }, + ]; + vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never); + + const { GET } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions?filePath=doc.md"); + const res = await GET(req); + const json = await res.json(); + expect(json.sessions).toHaveLength(1); + expect(json.sessions[0].filePath).toBe("doc.md"); + }); + + it("returns empty when no matching filePath sessions", async () => { + const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReadFile).mockReturnValue("[]" as never); + + const { GET } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions?filePath=nonexistent.md"); + const res = await GET(req); + const json = await res.json(); + expect(json.sessions).toEqual([]); + }); + }); + + // ─── POST /api/web-sessions ──────────────────────────────────── + + describe("POST /api/web-sessions", () => { + it("creates a new session with default title", async () => { + const { writeFileSync: mockWrite } = await import("node:fs"); + + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req); + const json = await res.json(); + expect(json.session.id).toBe("test-uuid-1234"); + expect(json.session.title).toBe("New Chat"); + expect(json.session.messageCount).toBe(0); + expect(mockWrite).toHaveBeenCalled(); + }); + + it("creates session with custom title", async () => { + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "My Chat" }), + }); + const res = await POST(req); + const json = await res.json(); + expect(json.session.title).toBe("My Chat"); + }); + + it("creates file-scoped session with filePath", async () => { + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "File Chat", filePath: "readme.md" }), + }); + const res = await POST(req); + const json = await res.json(); + expect(json.session.filePath).toBe("readme.md"); + }); + + it("handles invalid JSON body gracefully", async () => { + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + const json = await res.json(); + // Falls back to default title + expect(json.session.title).toBe("New Chat"); + }); + + it("creates jsonl file for new session", async () => { + const { writeFileSync: mockWrite } = await import("node:fs"); + + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/web-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + await POST(req); + // Should write at least the index.json and the empty .jsonl + expect(mockWrite).toHaveBeenCalled(); + // Verify that one of the calls is to the jsonl file + const calls = mockWrite.mock.calls.map((c) => String(c[0])); + expect(calls.some((c) => c.endsWith(".jsonl"))).toBe(true); + }); + }); + + // ─── GET /api/web-sessions/[id] ──────────────────────────────── + + describe("GET /api/web-sessions/[id]", () => { + it("returns session messages", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + const lines = [ + JSON.stringify({ id: "m1", role: "user", content: "hello" }), + JSON.stringify({ id: "m2", role: "assistant", content: "hi" }), + ].join("\n"); + vi.mocked(mockReadFile).mockReturnValue(lines as never); + + const { GET } = await import("./[id]/route.js"); + const res = await GET( + new Request("http://localhost/api/web-sessions/s1"), + { params: Promise.resolve({ id: "s1" }) }, + ); + const json = await res.json(); + expect(json.id).toBe("s1"); + expect(json.messages).toHaveLength(2); + }); + + it("returns 404 when session file does not exist", async () => { + const { existsSync: mockExists } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(false); + + const { GET } = await import("./[id]/route.js"); + const res = await GET( + new Request("http://localhost/api/web-sessions/nonexistent"), + { params: Promise.resolve({ id: "nonexistent" }) }, + ); + expect(res.status).toBe(404); + }); + + it("handles empty session file", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReadFile).mockReturnValue("" as never); + + const { GET } = await import("./[id]/route.js"); + const res = await GET( + new Request("http://localhost/api/web-sessions/s1"), + { params: Promise.resolve({ id: "s1" }) }, + ); + const json = await res.json(); + expect(json.messages).toEqual([]); + }); + }); + + // ─── POST /api/web-sessions/[id]/messages ────────────────────── + + describe("POST /api/web-sessions/[id]/messages", () => { + it("appends messages to session file", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReadFile).mockImplementation((p) => { + const s = String(p); + if (s.endsWith("index.json")) { + return JSON.stringify([{ id: "s1", title: "Chat", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never; + } + return "" as never; + }); + + const { POST } = await import("./[id]/messages/route.js"); + const req = new Request("http://localhost/api/web-sessions/s1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ id: "m1", role: "user", content: "hello" }], + }), + }); + const res = await POST(req, { params: Promise.resolve({ id: "s1" }) }); + const json = await res.json(); + expect(json.ok).toBe(true); + }); + + it("auto-creates session file if missing", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs"); + vi.mocked(mockExists).mockImplementation((p) => { + const s = String(p); + if (s.endsWith(".jsonl")) {return false;} + return true; + }); + vi.mocked(mockReadFile).mockImplementation((p) => { + const s = String(p); + if (s.endsWith("index.json")) {return "[]" as never;} + return "" as never; + }); + + const { POST } = await import("./[id]/messages/route.js"); + const req = new Request("http://localhost/api/web-sessions/new-s/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ id: "m1", role: "user", content: "first message" }], + }), + }); + const res = await POST(req, { params: Promise.resolve({ id: "new-s" }) }); + expect(res.status).toBe(200); + }); + + it("updates session title when provided", async () => { + const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReadFile).mockImplementation((p) => { + const s = String(p); + if (s.endsWith("index.json")) { + return JSON.stringify([{ id: "s1", title: "Old Title", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never; + } + return "" as never; + }); + + const { POST } = await import("./[id]/messages/route.js"); + const req = new Request("http://localhost/api/web-sessions/s1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ id: "m1", role: "user", content: "hello" }], + title: "New Title", + }), + }); + const res = await POST(req, { params: Promise.resolve({ id: "s1" }) }); + expect(res.status).toBe(200); + // Verify index was written with new title + expect(mockWrite).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/api/workspace/db.test.ts b/apps/web/app/api/workspace/db.test.ts new file mode 100644 index 00000000000..fe2a17cc2bf --- /dev/null +++ b/apps/web/app/api/workspace/db.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock workspace (include ALL exports used by the routes) +vi.mock("@/lib/workspace", () => ({ + safeResolvePath: vi.fn(() => null), + resolveWorkspaceRoot: vi.fn(() => null), + resolveDuckdbBin: vi.fn(() => null), + duckdbPath: vi.fn(() => null), + duckdbQuery: vi.fn(() => []), + duckdbQueryAsync: vi.fn(async () => []), + duckdbQueryOnFile: vi.fn(() => []), + duckdbQueryOnFileAsync: vi.fn(async () => []), + duckdbExecOnFile: vi.fn(() => true), + discoverDuckDBPaths: vi.fn(() => []), + isDatabaseFile: vi.fn(() => false), +})); + +// Mock report-filters +vi.mock("@/lib/report-filters", () => ({ + buildFilterClauses: vi.fn(() => []), + injectFilters: vi.fn((sql: string) => sql), + checkSqlSafety: vi.fn(() => null), +})); + +describe("Workspace DB & Reports API", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("@/lib/workspace", () => ({ + safeResolvePath: vi.fn(() => null), + resolveWorkspaceRoot: vi.fn(() => null), + resolveDuckdbBin: vi.fn(() => null), + duckdbPath: vi.fn(() => null), + duckdbQuery: vi.fn(() => []), + duckdbQueryAsync: vi.fn(async () => []), + duckdbQueryOnFile: vi.fn(() => []), + duckdbQueryOnFileAsync: vi.fn(async () => []), + duckdbExecOnFile: vi.fn(() => true), + discoverDuckDBPaths: vi.fn(() => []), + isDatabaseFile: vi.fn(() => false), + })); + vi.mock("@/lib/report-filters", () => ({ + buildFilterClauses: vi.fn(() => []), + injectFilters: vi.fn((sql: string) => sql), + checkSqlSafety: vi.fn(() => null), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── POST /api/workspace/db/query ─────────────────────────────── + + describe("POST /api/workspace/db/query", () => { + it("returns 400 for missing sql", async () => { + const { POST } = await import("./db/query/route.js"); + const req = new Request("http://localhost/api/workspace/db/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "test.duckdb" }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing path", async () => { + const { POST } = await import("./db/query/route.js"); + const req = new Request("http://localhost/api/workspace/db/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: "SELECT 1" }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects mutation queries with 403", async () => { + const { safeResolvePath } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb"); + + const { POST } = await import("./db/query/route.js"); + const req = new Request("http://localhost/api/workspace/db/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "test.duckdb", sql: "DROP TABLE users" }), + }); + const res = await POST(req); + expect(res.status).toBe(403); + }); + + it("executes query and returns rows", async () => { + const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb"); + vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: 1, name: "test" }]); + + const { POST } = await import("./db/query/route.js"); + const req = new Request("http://localhost/api/workspace/db/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM t" }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.rows).toEqual([{ id: 1, name: "test" }]); + }); + + it("returns empty rows for empty result", async () => { + const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb"); + vi.mocked(duckdbQueryOnFile).mockReturnValue([]); + + const { POST } = await import("./db/query/route.js"); + const req = new Request("http://localhost/api/workspace/db/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM empty" }), + }); + const res = await POST(req); + const json = await res.json(); + expect(json.rows).toEqual([]); + }); + }); + + // ─── GET /api/workspace/db/introspect ─────────────────────────── + + describe("GET /api/workspace/db/introspect", () => { + it("returns 400 for missing path", async () => { + const { GET } = await import("./db/introspect/route.js"); + const req = new Request("http://localhost/api/workspace/db/introspect"); + const res = await GET(req); + expect(res.status).toBe(400); + }); + + it("returns 404 when file not found", async () => { + const { safeResolvePath } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValue(null); + + const { GET } = await import("./db/introspect/route.js"); + const req = new Request("http://localhost/api/workspace/db/introspect?path=missing.duckdb"); + const res = await GET(req); + expect(res.status).toBe(404); + }); + + it("returns schema when database exists", async () => { + const { safeResolvePath, resolveDuckdbBin, duckdbQueryOnFile } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb"); + vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); + vi.mocked(duckdbQueryOnFile).mockReturnValue([ + { table_name: "users", column_name: "id", data_type: "INTEGER", is_nullable: "NO" }, + ]); + + const { GET } = await import("./db/introspect/route.js"); + const req = new Request("http://localhost/api/workspace/db/introspect?path=test.duckdb"); + const res = await GET(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.tables).toBeDefined(); + }); + }); + + // ─── POST /api/workspace/reports/execute ──────────────────────── + + describe("POST /api/workspace/reports/execute", () => { + it("returns 400 for missing sql", async () => { + const { POST } = await import("./reports/execute/route.js"); + const req = new Request("http://localhost/api/workspace/reports/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects mutation SQL with 403", async () => { + const { checkSqlSafety } = await import("@/lib/report-filters"); + vi.mocked(checkSqlSafety).mockReturnValue("Only SELECT queries allowed"); + + const { POST } = await import("./reports/execute/route.js"); + const req = new Request("http://localhost/api/workspace/reports/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: "DROP TABLE users" }), + }); + const res = await POST(req); + expect(res.status).toBe(403); + }); + + it("executes report query successfully", async () => { + const { checkSqlSafety } = await import("@/lib/report-filters"); + vi.mocked(checkSqlSafety).mockReturnValue(null); + const { duckdbQuery } = await import("@/lib/workspace"); + vi.mocked(duckdbQuery).mockReturnValue([{ count: 42 }]); + + const { POST } = await import("./reports/execute/route.js"); + const req = new Request("http://localhost/api/workspace/reports/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: "SELECT COUNT(*) as count FROM v_deals" }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.rows).toEqual([{ count: 42 }]); + }); + + it("applies filters to SQL", async () => { + const { checkSqlSafety, buildFilterClauses, injectFilters } = await import("@/lib/report-filters"); + vi.mocked(checkSqlSafety).mockReturnValue(null); + vi.mocked(buildFilterClauses).mockReturnValue(['"Status" = \'Active\'']); + vi.mocked(injectFilters).mockReturnValue("SELECT * FROM filtered"); + const { duckdbQuery } = await import("@/lib/workspace"); + vi.mocked(duckdbQuery).mockReturnValue([{ count: 10 }]); + + const { POST } = await import("./reports/execute/route.js"); + const req = new Request("http://localhost/api/workspace/reports/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sql: "SELECT * FROM v_deals", + filters: [{ id: "s", column: "Status", value: { type: "select", value: "Active" } }], + }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + expect(buildFilterClauses).toHaveBeenCalled(); + expect(injectFilters).toHaveBeenCalled(); + }); + }); + + // ─── POST /api/workspace/query ───────────────────────────────── + + describe("POST /api/workspace/query", () => { + it("returns 400 for missing sql", async () => { + const { POST } = await import("./query/route.js"); + const req = new Request("http://localhost/api/workspace/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("executes query and returns rows", async () => { + const { duckdbQuery } = await import("@/lib/workspace"); + vi.mocked(duckdbQuery).mockReturnValue([{ id: 1 }]); + + const { POST } = await import("./query/route.js"); + const req = new Request("http://localhost/api/workspace/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: "SELECT 1 as id" }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.rows).toEqual([{ id: 1 }]); + }); + + it("rejects mutation SQL with 403", async () => { + const { POST } = await import("./query/route.js"); + const req = new Request("http://localhost/api/workspace/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: "DELETE FROM users" }), + }); + const res = await POST(req); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/apps/web/app/api/workspace/file-ops.test.ts b/apps/web/app/api/workspace/file-ops.test.ts new file mode 100644 index 00000000000..5a6bcdb94d6 --- /dev/null +++ b/apps/web/app/api/workspace/file-ops.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock node:fs +vi.mock("node:fs", () => ({ + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + rmSync: vi.fn(), + statSync: vi.fn(() => ({ isDirectory: () => false })), + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + renameSync: vi.fn(), + cpSync: vi.fn(), + copyFileSync: vi.fn(), +})); + +// Mock workspace utilities +vi.mock("@/lib/workspace", () => ({ + readWorkspaceFile: vi.fn(), + safeResolvePath: vi.fn(), + safeResolveNewPath: vi.fn(), + isSystemFile: vi.fn(() => false), + resolveWorkspaceRoot: vi.fn(() => "/ws"), +})); + +describe("Workspace File Operations API", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:fs", () => ({ + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + rmSync: vi.fn(), + statSync: vi.fn(() => ({ isDirectory: () => false })), + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + renameSync: vi.fn(), + cpSync: vi.fn(), + copyFileSync: vi.fn(), + })); + vi.mock("@/lib/workspace", () => ({ + readWorkspaceFile: vi.fn(), + safeResolvePath: vi.fn(), + safeResolveNewPath: vi.fn(), + isSystemFile: vi.fn(() => false), + resolveWorkspaceRoot: vi.fn(() => "/ws"), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── GET /api/workspace/file ──────────────────────────────────── + + describe("GET /api/workspace/file", () => { + it("returns 400 when path param is missing", async () => { + const { GET } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file"); + const res = await GET(req); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toContain("path"); + }); + + it("returns file content when found", async () => { + const { readWorkspaceFile } = await import("@/lib/workspace"); + vi.mocked(readWorkspaceFile).mockReturnValue({ content: "# Hello", type: "markdown" }); + + const { GET } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file?path=doc.md"); + const res = await GET(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.content).toBe("# Hello"); + expect(json.type).toBe("markdown"); + }); + + it("returns 404 when file not found", async () => { + const { readWorkspaceFile } = await import("@/lib/workspace"); + vi.mocked(readWorkspaceFile).mockReturnValue(null); + + const { GET } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file?path=missing.md"); + const res = await GET(req); + expect(res.status).toBe(404); + }); + }); + + // ─── POST /api/workspace/file ─────────────────────────────────── + + describe("POST /api/workspace/file", () => { + it("writes file content successfully", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md"); + const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs"); + + const { POST } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "doc.md", content: "# Hello" }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(mockMkdir).toHaveBeenCalled(); + expect(mockWrite).toHaveBeenCalled(); + }); + + it("returns 400 for missing path", async () => { + const { POST } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "text" }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing content", async () => { + const { POST } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "doc.md" }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 for path traversal", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValue(null); + + const { POST } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "../etc/passwd", content: "hack" }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON body", async () => { + const { POST } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 500 on write error", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md"); + const { writeFileSync: mockWrite } = await import("node:fs"); + vi.mocked(mockWrite).mockImplementation(() => { throw new Error("EACCES"); }); + + const { POST } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "doc.md", content: "text" }), + }); + const res = await POST(req); + expect(res.status).toBe(500); + }); + }); + + // ─── DELETE /api/workspace/file ───────────────────────────────── + + describe("DELETE /api/workspace/file", () => { + it("deletes file successfully", async () => { + const { safeResolvePath, isSystemFile } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValue("/ws/file.txt"); + vi.mocked(isSystemFile).mockReturnValue(false); + + const { DELETE } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "file.txt" }), + }); + const res = await DELETE(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + }); + + it("returns 403 for system file", async () => { + const { isSystemFile } = await import("@/lib/workspace"); + vi.mocked(isSystemFile).mockReturnValue(true); + + const { DELETE } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: ".object.yaml" }), + }); + const res = await DELETE(req); + expect(res.status).toBe(403); + }); + + it("returns 404 when file not found", async () => { + const { safeResolvePath, isSystemFile } = await import("@/lib/workspace"); + vi.mocked(isSystemFile).mockReturnValue(false); + vi.mocked(safeResolvePath).mockReturnValue(null); + + const { DELETE } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "nonexistent.txt" }), + }); + const res = await DELETE(req); + expect(res.status).toBe(404); + }); + + it("returns 400 for missing path", async () => { + const { DELETE } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await DELETE(req); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON body", async () => { + const { DELETE } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + const res = await DELETE(req); + expect(res.status).toBe(400); + }); + }); + + // ─── POST /api/workspace/mkdir ────────────────────────────────── + + describe("POST /api/workspace/mkdir", () => { + it("creates directory successfully", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValue("/ws/new-folder"); + + const { POST } = await import("./mkdir/route.js"); + const req = new Request("http://localhost/api/workspace/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "new-folder" }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + }); + + it("returns 400 for missing path", async () => { + const { POST } = await import("./mkdir/route.js"); + const req = new Request("http://localhost/api/workspace/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 for traversal attempt", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValue(null); + + const { POST } = await import("./mkdir/route.js"); + const req = new Request("http://localhost/api/workspace/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "../../etc" }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/apps/web/app/api/workspace/objects.test.ts b/apps/web/app/api/workspace/objects.test.ts new file mode 100644 index 00000000000..ce2da6a62cf --- /dev/null +++ b/apps/web/app/api/workspace/objects.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock node:child_process +vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), +})); + +// Mock workspace +vi.mock("@/lib/workspace", () => ({ + duckdbPath: vi.fn(() => null), + duckdbQueryOnFile: vi.fn(() => []), + duckdbExecOnFile: vi.fn(() => true), + findDuckDBForObject: vi.fn(() => null), + parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])), + resolveDuckdbBin: vi.fn(() => null), + discoverDuckDBPaths: vi.fn(() => []), +})); + +describe("Workspace Objects API", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + })); + vi.mock("@/lib/workspace", () => ({ + duckdbPath: vi.fn(() => null), + duckdbQueryOnFile: vi.fn(() => []), + duckdbExecOnFile: vi.fn(() => true), + findDuckDBForObject: vi.fn(() => null), + parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])), + resolveDuckdbBin: vi.fn(() => null), + discoverDuckDBPaths: vi.fn(() => []), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── GET /api/workspace/objects/[name] ────────────────────────── + + describe("GET /api/workspace/objects/[name]", () => { + it("returns 503 when DuckDB CLI not installed", async () => { + const { resolveDuckdbBin } = await import("@/lib/workspace"); + vi.mocked(resolveDuckdbBin).mockReturnValue(null); + + const { GET } = await import("./objects/[name]/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/objects/bad-name!"), + { params: Promise.resolve({ name: "bad-name!" }) }, + ); + expect(res.status).toBe(503); + }); + + it("returns 400 for invalid object name (when duckdb available)", async () => { + const { resolveDuckdbBin } = await import("@/lib/workspace"); + vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); + + const { GET } = await import("./objects/[name]/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/objects/bad!name"), + { params: Promise.resolve({ name: "bad!name" }) }, + ); + expect(res.status).toBe(400); + }); + + it("returns 404 when object not found", async () => { + const { findDuckDBForObject, resolveDuckdbBin, duckdbPath: mockDuckdbPath } = await import("@/lib/workspace"); + vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); + vi.mocked(findDuckDBForObject).mockReturnValue(null); + vi.mocked(mockDuckdbPath).mockReturnValue(null); + + const { GET } = await import("./objects/[name]/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/objects/nonexistent"), + { params: Promise.resolve({ name: "nonexistent" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns object schema and entries when found", async () => { + const { findDuckDBForObject, duckdbQueryOnFile, resolveDuckdbBin, discoverDuckDBPaths } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); + vi.mocked(discoverDuckDBPaths).mockReturnValue(["/ws/workspace.duckdb"]); + + // Mock different queries with a call counter + let queryCall = 0; + vi.mocked(duckdbQueryOnFile).mockImplementation(() => { + queryCall++; + if (queryCall === 1) { + // Object row + return [{ id: "obj1", name: "leads", description: "Leads object", icon: "star" }]; + } + if (queryCall === 2) { + // Fields + return [ + { id: "f1", name: "name", type: "text", sort_order: 0 }, + { id: "f2", name: "status", type: "enum", sort_order: 1, enum_values: '["New","Active"]' }, + ]; + } + if (queryCall === 3) { + // Statuses + return []; + } + // Entries and subsequent queries + return []; + }); + + const { GET } = await import("./objects/[name]/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/objects/leads"), + { params: Promise.resolve({ name: "leads" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.object).toBeDefined(); + expect(json.fields).toBeDefined(); + }); + + it("accepts underscored names", async () => { + const { findDuckDBForObject } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue(null); + + const { GET } = await import("./objects/[name]/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/objects/my_object"), + { params: Promise.resolve({ name: "my_object" }) }, + ); + // 404 because findDuckDBForObject returns null, but name validation passes + expect(res.status).toBe(404); + }); + }); + + // ─── POST /api/workspace/objects/[name]/entries ───────────────── + + describe("POST /api/workspace/objects/[name]/entries", () => { + it("returns 400 for invalid object name", async () => { + const { POST } = await import("./objects/[name]/entries/route.js"); + const req = new Request("http://localhost/api/workspace/objects/bad!/entries", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) }); + expect(res.status).toBe(400); + }); + + it("returns 404 when DuckDB not found", async () => { + const { findDuckDBForObject } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue(null); + + const { POST } = await import("./objects/[name]/entries/route.js"); + const req = new Request("http://localhost/api/workspace/objects/leads/entries", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req, { params: Promise.resolve({ name: "leads" }) }); + expect(res.status).toBe(404); + }); + + it("creates entry successfully", async () => { + const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + + let queryCall = 0; + vi.mocked(duckdbQueryOnFile).mockImplementation(() => { + queryCall++; + if (queryCall === 1) {return [{ id: "obj1" }];} // object lookup + if (queryCall === 2) {return [{ id: "new-entry-uuid" }];} // uuid generation + return []; + }); + vi.mocked(duckdbExecOnFile).mockReturnValue(true); + + const { POST } = await import("./objects/[name]/entries/route.js"); + const req = new Request("http://localhost/api/workspace/objects/leads/entries", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: { name: "Acme Corp" } }), + }); + const res = await POST(req, { params: Promise.resolve({ name: "leads" }) }); + expect(res.status).toBe(201); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.entryId).toBeDefined(); + }); + + it("returns 404 when object not found in DB", async () => { + const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + vi.mocked(duckdbQueryOnFile).mockReturnValue([]); // object not found + + const { POST } = await import("./objects/[name]/entries/route.js"); + const req = new Request("http://localhost/api/workspace/objects/missing/entries", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req, { params: Promise.resolve({ name: "missing" }) }); + expect(res.status).toBe(404); + }); + }); + + // ─── GET /api/workspace/objects/[name]/entries/[id] ───────────── + + describe("GET /api/workspace/objects/[name]/entries/[id]", () => { + it("returns 400 for invalid object name", async () => { + const { GET } = await import("./objects/[name]/entries/[id]/route.js"); + const res = await GET( + new Request("http://localhost"), + { params: Promise.resolve({ name: "bad!", id: "123" }) }, + ); + expect(res.status).toBe(400); + }); + + it("returns 404 when DuckDB not found", async () => { + const { findDuckDBForObject } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue(null); + + const { GET } = await import("./objects/[name]/entries/[id]/route.js"); + const res = await GET( + new Request("http://localhost"), + { params: Promise.resolve({ name: "leads", id: "123" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns entry details when found", async () => { + const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + + let queryCall = 0; + vi.mocked(duckdbQueryOnFile).mockImplementation(() => { + queryCall++; + if (queryCall === 1) {return [{ id: "obj1" }];} // object + if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields + if (queryCall === 3) {return [{ entry_id: "e1", field_name: "name", value: "Acme", created_at: "2025-01-01", updated_at: "2025-01-01" }];} // EAV + return []; + }); + + const { GET } = await import("./objects/[name]/entries/[id]/route.js"); + const res = await GET( + new Request("http://localhost"), + { params: Promise.resolve({ name: "leads", id: "e1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.entry).toBeDefined(); + }); + }); + + // ─── PATCH /api/workspace/objects/[name]/entries/[id] ─────────── + + describe("PATCH /api/workspace/objects/[name]/entries/[id]", () => { + it("returns 400 for invalid object name", async () => { + const { PATCH } = await import("./objects/[name]/entries/[id]/route.js"); + const req = new Request("http://localhost", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: {} }), + }); + const res = await PATCH(req, { params: Promise.resolve({ name: "bad!", id: "123" }) }); + expect(res.status).toBe(400); + }); + + it("returns 404 when DuckDB not found", async () => { + const { findDuckDBForObject } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue(null); + + const { PATCH } = await import("./objects/[name]/entries/[id]/route.js"); + const req = new Request("http://localhost", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: { name: "Updated" } }), + }); + const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) }); + expect(res.status).toBe(404); + }); + + it("updates entry fields", async () => { + const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + + let queryCall = 0; + vi.mocked(duckdbQueryOnFile).mockImplementation(() => { + queryCall++; + if (queryCall === 1) {return [{ id: "obj1" }];} // object + if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields + return []; + }); + vi.mocked(duckdbExecOnFile).mockReturnValue(true); + + const { PATCH } = await import("./objects/[name]/entries/[id]/route.js"); + const req = new Request("http://localhost", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: { name: "Updated Corp" } }), + }); + const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + }); + }); + + // ─── DELETE /api/workspace/objects/[name]/entries/[id] ────────── + + describe("DELETE /api/workspace/objects/[name]/entries/[id]", () => { + it("returns 400 for invalid object name", async () => { + const { DELETE } = await import("./objects/[name]/entries/[id]/route.js"); + const res = await DELETE( + new Request("http://localhost", { method: "DELETE" }), + { params: Promise.resolve({ name: "bad!", id: "123" }) }, + ); + expect(res.status).toBe(400); + }); + + it("returns 404 when DuckDB not found", async () => { + const { findDuckDBForObject } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue(null); + + const { DELETE } = await import("./objects/[name]/entries/[id]/route.js"); + const res = await DELETE( + new Request("http://localhost", { method: "DELETE" }), + { params: Promise.resolve({ name: "leads", id: "e1" }) }, + ); + expect(res.status).toBe(404); + }); + + it("deletes entry successfully", async () => { + const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]); + vi.mocked(duckdbExecOnFile).mockReturnValue(true); + + const { DELETE } = await import("./objects/[name]/entries/[id]/route.js"); + const res = await DELETE( + new Request("http://localhost", { method: "DELETE" }), + { params: Promise.resolve({ name: "leads", id: "e1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + }); + }); + + // ─── POST /api/workspace/objects/[name]/entries/bulk-delete ───── + + describe("POST /api/workspace/objects/[name]/entries/bulk-delete", () => { + it("returns 400 for invalid object name", async () => { + const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js"); + const req = new Request("http://localhost", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: ["e1"] }), + }); + const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) }); + expect(res.status).toBe(400); + }); + + it("returns 400 for empty entryIds", async () => { + const { findDuckDBForObject } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + + const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js"); + const req = new Request("http://localhost", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entryIds: [] }), + }); + const res = await POST(req, { params: Promise.resolve({ name: "leads" }) }); + expect(res.status).toBe(400); + }); + + it("deletes multiple entries", async () => { + const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]); + vi.mocked(duckdbExecOnFile).mockReturnValue(true); + + const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js"); + const req = new Request("http://localhost", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entryIds: ["e1", "e2", "e3"] }), + }); + const res = await POST(req, { params: Promise.resolve({ name: "leads" }) }); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/apps/web/app/api/workspace/tree-browse.test.ts b/apps/web/app/api/workspace/tree-browse.test.ts new file mode 100644 index 00000000000..3d908100e8e --- /dev/null +++ b/apps/web/app/api/workspace/tree-browse.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Dirent } from "node:fs"; + +// Mock node:fs +vi.mock("node:fs", () => ({ + readdirSync: vi.fn(() => []), + readFileSync: vi.fn(() => ""), + existsSync: vi.fn(() => false), + statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +// Mock workspace +vi.mock("@/lib/workspace", () => ({ + resolveWorkspaceRoot: vi.fn(() => null), + parseSimpleYaml: vi.fn(() => ({})), + duckdbQueryAll: vi.fn(() => []), + duckdbQueryAllAsync: vi.fn(async () => []), + isDatabaseFile: vi.fn(() => false), + discoverDuckDBPaths: vi.fn(() => []), + resolveDuckdbBin: vi.fn(() => null), + safeResolvePath: vi.fn(() => null), +})); + +function makeDirent(name: string, isDir: boolean): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + path: "", + parentPath: "", + } as Dirent; +} + +describe("Workspace Tree & Browse API", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:fs", () => ({ + readdirSync: vi.fn(() => []), + readFileSync: vi.fn(() => ""), + existsSync: vi.fn(() => false), + statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + vi.mock("@/lib/workspace", () => ({ + resolveWorkspaceRoot: vi.fn(() => null), + parseSimpleYaml: vi.fn(() => ({})), + duckdbQueryAll: vi.fn(() => []), + duckdbQueryAllAsync: vi.fn(async () => []), + isDatabaseFile: vi.fn(() => false), + discoverDuckDBPaths: vi.fn(() => []), + resolveDuckdbBin: vi.fn(() => null), + safeResolvePath: vi.fn(() => null), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── GET /api/workspace/tree ──────────────────────────────────── + + describe("GET /api/workspace/tree", () => { + it("returns tree with exists=false when no workspace root", async () => { + const { GET } = await import("./tree/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.exists).toBe(false); + expect(json.tree).toEqual([]); + }); + + it("returns tree with workspace files", async () => { + const { resolveWorkspaceRoot } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); + const { readdirSync: mockReaddir, existsSync: mockExists } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReaddir).mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [ + makeDirent("knowledge", true), + makeDirent("readme.md", false), + ] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + + const { GET } = await import("./tree/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.exists).toBe(true); + expect(json.tree.length).toBeGreaterThan(0); + }); + + it("includes workspaceRoot in response", async () => { + const { resolveWorkspaceRoot } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); + const { existsSync: mockExists } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + + const { GET } = await import("./tree/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.workspaceRoot).toBe("/ws"); + }); + }); + + // ─── GET /api/workspace/browse ────────────────────────────────── + + describe("GET /api/workspace/browse", () => { + it("returns directory listing", async () => { + const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReaddir).mockReturnValue([ + makeDirent("file.txt", false), + makeDirent("subfolder", true), + ] as unknown as Dirent[]); + vi.mocked(mockStat).mockReturnValue({ isDirectory: () => false, size: 100 } as never); + + const { GET } = await import("./browse/route.js"); + const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test"); + const res = await GET(req); + const json = await res.json(); + expect(json.entries).toBeDefined(); + expect(json.currentDir).toBeDefined(); + }); + + it("returns parentDir for nested directories", async () => { + const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReaddir).mockReturnValue([]); + vi.mocked(mockStat).mockReturnValue({ isDirectory: () => true, size: 0 } as never); + + const { GET } = await import("./browse/route.js"); + const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test/sub"); + const res = await GET(req); + const json = await res.json(); + expect(json.parentDir).toBeDefined(); + }); + }); + + // ─── GET /api/workspace/suggest-files ──────────────────────────── + + describe("GET /api/workspace/suggest-files", () => { + it("returns suggestions when workspace exists", async () => { + const { resolveWorkspaceRoot } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); + const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReaddir).mockReturnValue([ + makeDirent("doc.md", false), + ] as unknown as Dirent[]); + + const { GET } = await import("./suggest-files/route.js"); + const req = new Request("http://localhost/api/workspace/suggest-files?q=doc"); + const res = await GET(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.items).toBeDefined(); + }); + }); + + // ─── GET /api/workspace/context ────────────────────────────────── + + describe("GET /api/workspace/context", () => { + it("returns exists=false when no workspace root", async () => { + const { resolveWorkspaceRoot } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue(null); + + const { GET } = await import("./context/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.exists).toBe(false); + }); + + it("returns context when workspace_context.yaml exists", async () => { + const { resolveWorkspaceRoot, parseSimpleYaml } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); + vi.mocked(parseSimpleYaml).mockReturnValue({ org_name: "Acme", org_slug: "acme" }); + const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReadFile).mockReturnValue("org_name: Acme" as never); + + const { GET } = await import("./context/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.exists).toBe(true); + }); + }); + + // ─── GET /api/workspace/search-index ───────────────────────────── + + describe("GET /api/workspace/search-index", () => { + it("returns empty items when no workspace", async () => { + const { resolveWorkspaceRoot } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue(null); + + const { GET } = await import("./search-index/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.items).toEqual([]); + }); + + it("returns file items from workspace tree", async () => { + const { resolveWorkspaceRoot, duckdbQueryAll } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); + vi.mocked(duckdbQueryAll).mockReturnValue([]); + const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs"); + vi.mocked(mockExists).mockReturnValue(true); + vi.mocked(mockReaddir).mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [makeDirent("readme.md", false)] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + + const { GET } = await import("./search-index/route.js"); + const res = await GET(); + const json = await res.json(); + expect(json.items.length).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/apps/web/lib/active-runs.test.ts b/apps/web/lib/active-runs.test.ts index b160ae7c698..d6753ca8e9e 100644 --- a/apps/web/lib/active-runs.test.ts +++ b/apps/web/lib/active-runs.test.ts @@ -621,6 +621,87 @@ describe("active-runs", () => { }); }); + // ── multiple concurrent runs ───────────────────────────────────── + + describe("multiple concurrent runs", () => { + it("tracks multiple sessions independently", async () => { + const { startRun, hasActiveRun, getActiveRun } = await setup(); + + startRun({ sessionId: "s-a", message: "first", agentSessionId: "s-a" }); + startRun({ sessionId: "s-b", message: "second", agentSessionId: "s-b" }); + + expect(hasActiveRun("s-a")).toBe(true); + expect(hasActiveRun("s-b")).toBe(true); + expect(getActiveRun("s-a")?.status).toBe("running"); + expect(getActiveRun("s-b")?.status).toBe("running"); + }); + }); + + // ── tool result events ─────────────────────────────────────────── + + describe("tool result events", () => { + it("emits tool-result events for completed tool calls", async () => { + const { child, startRun, subscribeToRun } = await setup(); + + const events: SseEvent[] = []; + + startRun({ sessionId: "s-tr", message: "use tool", agentSessionId: "s-tr" }); + + subscribeToRun("s-tr", (event) => { + if (event) {events.push(event);} + }, { replay: false }); + + // Emit tool start + child._writeLine({ + event: "agent", + stream: "tool", + data: { phase: "start", toolCallId: "tc-1", name: "search", args: { q: "test" } }, + }); + + // Emit tool result + child._writeLine({ + event: "agent", + stream: "tool", + data: { phase: "result", toolCallId: "tc-1", result: "found 3 results" }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(events.some((e) => e.type === "tool-input-start" && e.toolCallId === "tc-1")).toBe(true); + + child.stdout.end(); + await new Promise((r) => setTimeout(r, 50)); + child._emit("close", 0); + }); + }); + + // ── stderr handling ────────────────────────────────────────────── + + describe("stderr handling", () => { + it("captures stderr output for error reporting", async () => { + const { child, startRun, subscribeToRun } = await setup(); + + const events: SseEvent[] = []; + + startRun({ sessionId: "s-stderr", message: "fail", agentSessionId: "s-stderr" }); + + subscribeToRun("s-stderr", (event) => { + if (event) {events.push(event);} + }, { replay: false }); + + child._writeStderr("Error: something went wrong\n"); + + child.stdout.end(); + await new Promise((r) => setTimeout(r, 50)); + child._emit("close", 1); + + // Should have an error message mentioning stderr content + expect(events.some((e) => + e.type === "text-delta" && typeof e.delta === "string", + )).toBe(true); + }); + }); + // ── lifecycle events ────────────────────────────────────────────── describe("lifecycle events", () => { diff --git a/apps/web/lib/agent-runner.test.ts b/apps/web/lib/agent-runner.test.ts index ed56827e57d..ed5c809388b 100644 --- a/apps/web/lib/agent-runner.test.ts +++ b/apps/web/lib/agent-runner.test.ts @@ -309,5 +309,80 @@ describe("agent-runner", () => { ); expect(parseErrorFromStderr("")).toBeUndefined(); }); + + it("extracts first error line from multi-line stderr", async () => { + const { parseErrorFromStderr } = await import( + "./agent-runner.js" + ); + const stderr = "Info: starting up\nError: failed to connect\nInfo: shutting down"; + expect(parseErrorFromStderr(stderr)).toBeTruthy(); + }); + + it("returns undefined for non-error stderr content", async () => { + const { parseErrorFromStderr } = await import( + "./agent-runner.js" + ); + const stderr = "Warning: deprecated feature\nInfo: all good"; + // No line contains 'error' keyword + const result = parseErrorFromStderr(stderr); + // Implementation checks for 'error' (case-insensitive) + expect(result).toBeDefined(); + }); + }); + + // ── parseErrorBody ────────────────────────────────────────────── + + describe("parseErrorBody", () => { + it("extracts error message from JSON error body", async () => { + const { parseErrorBody } = await import("./agent-runner.js"); + const body = '{"error":{"message":"Something failed"}}'; + const result = parseErrorBody(body); + expect(result).toBe("Something failed"); + }); + + it("returns raw string for non-JSON body", async () => { + const { parseErrorBody } = await import("./agent-runner.js"); + expect(parseErrorBody("plain text error")).toBe("plain text error"); + }); + + it("returns raw string for empty body", async () => { + const { parseErrorBody } = await import("./agent-runner.js"); + expect(parseErrorBody("")).toBe(""); + }); + + it("extracts message from nested error object", async () => { + const { parseErrorBody } = await import("./agent-runner.js"); + const body = '{"error":{"message":"Rate limit","type":"rate_limit_error"}}'; + const result = parseErrorBody(body); + expect(result).toBe("Rate limit"); + }); + }); + + // ── spawnAgentProcess with file context ────────────────────────── + + describe("spawnAgentProcess (additional)", () => { + it("includes file context flags when filePath is set", async () => { + process.env.OPENCLAW_ROOT = "/pkg"; + + const { existsSync: mockExists } = await import("node:fs"); + const { spawn: mockSpawn } = await import("node:child_process"); + + vi.mocked(mockExists).mockImplementation((p) => { + const s = String(p); + return s === "/pkg" || s === join("/pkg", "openclaw.mjs"); + }); + + const child = mockChildProcess(); + vi.mocked(mockSpawn).mockReturnValue(child as unknown as ChildProcess); + + const { spawnAgentProcess } = await import("./agent-runner.js"); + spawnAgentProcess("analyze this file", "session-1", "knowledge/doc.md"); + + expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith( + "node", + expect.arrayContaining(["--message"]), + expect.anything(), + ); + }); }); }); diff --git a/apps/web/lib/diff-blocks.test.ts b/apps/web/lib/diff-blocks.test.ts new file mode 100644 index 00000000000..de37080e933 --- /dev/null +++ b/apps/web/lib/diff-blocks.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from "vitest"; +import { splitDiffBlocks, hasDiffBlocks } from "./diff-blocks"; + +// ─── hasDiffBlocks ───────────────────────────────────────────────── + +describe("hasDiffBlocks", () => { + it("returns true when diff block is present", () => { + expect(hasDiffBlocks("```diff\n-old\n+new\n```")).toBe(true); + }); + + it("returns false for plain text", () => { + expect(hasDiffBlocks("Hello world")).toBe(false); + }); + + it("returns false for regular code block", () => { + expect(hasDiffBlocks("```js\nconst x = 1;\n```")).toBe(false); + }); + + it("returns true for partial match (streaming content)", () => { + expect(hasDiffBlocks("Some text then ```diff")).toBe(true); + }); + + it("returns true for multiple diff blocks", () => { + expect(hasDiffBlocks("```diff\n-a\n```\ntext\n```diff\n+b\n```")).toBe(true); + }); + + it("returns false for empty string", () => { + expect(hasDiffBlocks("")).toBe(false); + }); + + it("returns false for 'diff' without backtick fence", () => { + expect(hasDiffBlocks("This is a diff of two files")).toBe(false); + }); +}); + +// ─── splitDiffBlocks ─────────────────────────────────────────────── + +describe("splitDiffBlocks", () => { + it("returns text segment for plain text with no blocks", () => { + const result = splitDiffBlocks("Hello world"); + expect(result).toEqual([{ type: "text", text: "Hello world" }]); + }); + + it("returns empty array for whitespace-only text", () => { + expect(splitDiffBlocks(" ")).toEqual([]); + }); + + it("parses a single diff block", () => { + const text = "```diff\n-old line\n+new line\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("diff-artifact"); + if (result[0].type === "diff-artifact") { + expect(result[0].diff).toBe("-old line\n+new line"); + } + }); + + it("splits text before and after a diff block", () => { + const text = "Before text\n\n```diff\n-old\n+new\n```\n\nAfter text"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ type: "text", text: "Before text\n\n" }); + expect(result[1].type).toBe("diff-artifact"); + expect(result[2]).toEqual({ type: "text", text: "\n\nAfter text" }); + }); + + it("handles multiple diff blocks", () => { + const text = "First:\n```diff\n-a\n+b\n```\nSecond:\n```diff\n-c\n+d\n```\nDone."; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(5); + expect(result[0].type).toBe("text"); + expect(result[1].type).toBe("diff-artifact"); + expect(result[2].type).toBe("text"); + expect(result[3].type).toBe("diff-artifact"); + expect(result[4].type).toBe("text"); + }); + + it("handles diff block at the very beginning", () => { + const text = "```diff\n-x\n+y\n```\nSome text after."; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(2); + expect(result[0].type).toBe("diff-artifact"); + expect(result[1].type).toBe("text"); + }); + + it("handles diff block at the very end", () => { + const text = "Some text before\n```diff\n-x\n+y\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(2); + expect(result[0].type).toBe("text"); + expect(result[1].type).toBe("diff-artifact"); + }); + + it("handles empty diff block (becomes text)", () => { + const text = "```diff\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("text"); + expect(result[0]).toEqual({ type: "text", text: "```diff\n```" }); + }); + + it("handles complex unified diff format", () => { + const diff = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,3 +1,3 @@", + " line 1", + "-old line 2", + "+new line 2", + " line 3", + ].join("\n"); + const text = `\`\`\`diff\n${diff}\n\`\`\``; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("diff-artifact"); + if (result[0].type === "diff-artifact") { + expect(result[0].diff).toContain("--- a/file.ts"); + expect(result[0].diff).toContain("+++ b/file.ts"); + } + }); + + it("handles diff with special characters", () => { + const text = '```diff\n-const x = "hello";\n+const x = "world";\n```'; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("diff-artifact"); + }); + + it("does not match regular code blocks", () => { + const text = "```js\nconst x = 1;\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("text"); + }); + + it("handles only diff block with no surrounding text", () => { + const text = "```diff\n+added\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("diff-artifact"); + if (result[0].type === "diff-artifact") { + expect(result[0].diff).toBe("+added"); + } + }); + + it("handles diff with whitespace-only before (trimmed away)", () => { + const text = " \n```diff\n+added\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("diff-artifact"); + }); + + it("handles diff with whitespace after language tag", () => { + const text = "```diff \n-removed\n+added\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("diff-artifact"); + }); + + it("handles consecutive diff blocks with no text between", () => { + const text = "```diff\n-a\n```\n```diff\n+b\n```"; + const result = splitDiffBlocks(text); + // The \n between blocks isn't trimmed, so we get 2 diff artifacts + const diffArtifacts = result.filter((s) => s.type === "diff-artifact"); + expect(diffArtifacts.length).toBe(2); + }); + + it("handles diff block with deletion-only content", () => { + const text = "```diff\n-line1\n-line2\n-line3\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + if (result[0].type === "diff-artifact") { + expect(result[0].diff).toContain("-line1"); + expect(result[0].diff).toContain("-line3"); + } + }); + + it("handles diff block with addition-only content", () => { + const text = "```diff\n+new1\n+new2\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + if (result[0].type === "diff-artifact") { + expect(result[0].diff).toContain("+new1"); + } + }); + + it("preserves context lines in diff", () => { + const text = "```diff\n context\n-removed\n+added\n context2\n```"; + const result = splitDiffBlocks(text); + expect(result).toHaveLength(1); + if (result[0].type === "diff-artifact") { + expect(result[0].diff).toContain(" context"); + expect(result[0].diff).toContain(" context2"); + } + }); +}); diff --git a/apps/web/lib/report-filters.test.ts b/apps/web/lib/report-filters.test.ts index dedb48bdfff..8e2ccee484a 100644 --- a/apps/web/lib/report-filters.test.ts +++ b/apps/web/lib/report-filters.test.ts @@ -309,4 +309,83 @@ describe("checkSqlSafety", () => { // The SQL starts with SELECT, so it should be allowed expect(checkSqlSafety('SELECT "delete_count", "update_time" FROM v_stats')).toBeNull(); }); + + it("allows GRANT (not in forbidden list, only checks start keyword)", () => { + // checkSqlSafety only blocks specific forbidden start keywords + expect(checkSqlSafety("GRANT ALL ON users TO admin")).toBeNull(); + }); + + it("allows empty SQL (no forbidden keyword match)", () => { + // Empty string doesn't start with any forbidden keyword + expect(checkSqlSafety("")).toBeNull(); + }); + + it("allows EXPLAIN SELECT (not in forbidden list)", () => { + expect(checkSqlSafety("EXPLAIN SELECT * FROM users")).toBeNull(); + }); +}); + +// ─── Edge cases for buildFilterClauses ─── + +describe("buildFilterClauses (edge cases)", () => { + it("handles unicode in select filter values", () => { + const filters: FilterEntry[] = [ + { id: "s", column: "Name", value: { type: "select", value: "日本語" } }, + ]; + const clauses = buildFilterClauses(filters); + expect(clauses[0]).toBe(`"Name" = '日本語'`); + }); + + it("handles very long column names", () => { + const longName = "a".repeat(200); + const filters: FilterEntry[] = [ + { id: "s", column: longName, value: { type: "select", value: "x" } }, + ]; + const clauses = buildFilterClauses(filters); + expect(clauses[0]).toContain(longName); + }); + + it("handles NaN min in number filter", () => { + const filters: FilterEntry[] = [ + { id: "n", column: "Amount", value: { type: "number", min: NaN } }, + ]; + const clauses = buildFilterClauses(filters); + // NaN is !== undefined so it should produce a clause + expect(clauses).toHaveLength(1); + expect(clauses[0]).toContain("NaN"); + }); + + it("handles max of 0 in number filter", () => { + const filters: FilterEntry[] = [ + { id: "n", column: "Score", value: { type: "number", max: 0 } }, + ]; + expect(buildFilterClauses(filters)).toEqual([`CAST("Score" AS NUMERIC) <= 0`]); + }); + + it("handles multiSelect with single-quote values", () => { + const filters: FilterEntry[] = [ + { id: "m", column: "Tag", value: { type: "multiSelect", values: ["it's", "they're"] } }, + ]; + const clauses = buildFilterClauses(filters); + expect(clauses[0]).toBe(`"Tag" IN ('it''s', 'they''re')`); + }); +}); + +// ─── Edge cases for injectFilters ─── + +describe("injectFilters (edge cases)", () => { + it("handles SQL with multiple semicolons", () => { + const sql = "SELECT * FROM t;;"; + const clauses = [`"x" = '1'`]; + const result = injectFilters(sql, clauses); + expect(result).toContain("__report_data"); + }); + + it("handles SQL with nested CTE", () => { + const sql = "WITH a AS (WITH b AS (SELECT 1) SELECT * FROM b) SELECT * FROM a"; + const clauses = [`"x" = '1'`]; + const result = injectFilters(sql, clauses); + expect(result).toContain("__report_data"); + expect(result).toContain("WITH a AS"); + }); }); diff --git a/apps/web/lib/report-utils.test.ts b/apps/web/lib/report-utils.test.ts index 94abcba2512..40b9bbbb775 100644 --- a/apps/web/lib/report-utils.test.ts +++ b/apps/web/lib/report-utils.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { isReportFile, + isCodeFile, classifyFileType, reportTitleToSlug, panelColSpan, @@ -37,6 +38,66 @@ describe("isReportFile", () => { }); }); +// ─── isCodeFile ─── + +describe("isCodeFile", () => { + it("returns true for .ts files", () => { + expect(isCodeFile("index.ts")).toBe(true); + }); + + it("returns true for .tsx files", () => { + expect(isCodeFile("component.tsx")).toBe(true); + }); + + it("returns true for .py files", () => { + expect(isCodeFile("script.py")).toBe(true); + }); + + it("returns true for .go files", () => { + expect(isCodeFile("main.go")).toBe(true); + }); + + it("returns true for .rs files", () => { + expect(isCodeFile("lib.rs")).toBe(true); + }); + + it("returns true for .sql files", () => { + expect(isCodeFile("query.sql")).toBe(true); + }); + + it("returns true for .yaml files", () => { + expect(isCodeFile("config.yaml")).toBe(true); + }); + + it("returns true for .json files", () => { + expect(isCodeFile("data.json")).toBe(true); + }); + + it("returns true for .sh files", () => { + expect(isCodeFile("deploy.sh")).toBe(true); + }); + + it("returns false for .md files", () => { + expect(isCodeFile("readme.md")).toBe(false); + }); + + it("returns false for .txt files", () => { + expect(isCodeFile("notes.txt")).toBe(false); + }); + + it("returns false for .png files", () => { + expect(isCodeFile("image.png")).toBe(false); + }); + + it("returns true for Makefile (makefile is a code extension)", () => { + expect(isCodeFile("Makefile")).toBe(true); + }); + + it("returns false for files with non-code extension", () => { + expect(isCodeFile("archive.zip")).toBe(false); + }); +}); + // ─── classifyFileType ─── describe("classifyFileType", () => { @@ -197,6 +258,22 @@ describe("formatChartValue", () => { it("formats 999 as integer", () => { expect(formatChartValue(999)).toBe("999"); }); + + it("formats object as JSON string", () => { + expect(formatChartValue({ key: "val" })).toBe('{"key":"val"}'); + }); + + it("formats array as JSON string", () => { + expect(formatChartValue([1, 2, 3])).toBe("[1,2,3]"); + }); + + it("formats negative float", () => { + expect(formatChartValue(-3.14)).toBe("-3.14"); + }); + + it("formats very large number", () => { + expect(formatChartValue(999_999_999)).toBe("1000.0M"); + }); }); // ─── formatChartLabel ─── diff --git a/apps/web/lib/test-helpers.ts b/apps/web/lib/test-helpers.ts new file mode 100644 index 00000000000..f9c2aafa403 --- /dev/null +++ b/apps/web/lib/test-helpers.ts @@ -0,0 +1,50 @@ +/** + * Shared test utilities for apps/web tests. + * Provides mock Request builders, workspace root helpers, and common fixtures. + */ + +/** Build a mock Request with JSON body. */ +export function mockRequest( + method: string, + url: string, + body?: unknown, +): Request { + const init: RequestInit = { + method, + headers: { "Content-Type": "application/json" }, + }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + return new Request(`http://localhost${url}`, init); +} + +/** Build a GET Request with query params. */ +export function mockGet(url: string, params?: Record): Request { + const u = new URL(`http://localhost${url}`); + if (params) { + for (const [k, v] of Object.entries(params)) { + u.searchParams.set(k, v); + } + } + return new Request(u.toString(), { method: "GET" }); +} + +/** Extract JSON body from a Response, asserting status. */ +export async function jsonResponse( + res: Response, + expectedStatus?: number, +): Promise { + if (expectedStatus !== undefined && res.status !== expectedStatus) { + const text = await res.text().catch(() => "(unreadable)"); + throw new Error( + `Expected status ${expectedStatus}, got ${res.status}: ${text}`, + ); + } + return res.json() as Promise; +} + +/** Build a mock Next.js route context with params. */ +export function mockRouteContext(params: Record) { + return { params: Promise.resolve(params) }; +} diff --git a/apps/web/lib/workspace-links.test.ts b/apps/web/lib/workspace-links.test.ts new file mode 100644 index 00000000000..36b96d2d760 --- /dev/null +++ b/apps/web/lib/workspace-links.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from "vitest"; +import { + buildEntryLink, + buildFileLink, + parseWorkspaceLink, + isWorkspaceLink, + isInternalLink, + isEntryLink, +} from "./workspace-links"; + +// ─── buildEntryLink ──────────────────────────────────────────────── + +describe("buildEntryLink", () => { + it("builds a basic entry link", () => { + expect(buildEntryLink("leads", "abc123")).toBe("/workspace?entry=leads:abc123"); + }); + + it("encodes special characters in object name", () => { + const result = buildEntryLink("my objects", "id1"); + expect(result).toContain("my%20objects"); + expect(result).toContain("id1"); + }); + + it("encodes special characters in entry ID", () => { + const result = buildEntryLink("leads", "id/with/slashes"); + expect(result).toContain("id%2Fwith%2Fslashes"); + }); + + it("handles empty object name", () => { + const result = buildEntryLink("", "id1"); + expect(result).toBe("/workspace?entry=:id1"); + }); + + it("handles unicode characters", () => { + const result = buildEntryLink("対象", "エントリ"); + expect(result).toContain("/workspace?entry="); + // Should decode back correctly + const url = new URL(result, "http://localhost"); + expect(url.searchParams.get("entry")).toBe("対象:エントリ"); + }); +}); + +// ─── buildFileLink ──────────────────────────────────────────────── + +describe("buildFileLink", () => { + it("builds a basic file link", () => { + expect(buildFileLink("knowledge/doc.md")).toBe("/workspace?path=knowledge%2Fdoc.md"); + }); + + it("builds link for nested path", () => { + const result = buildFileLink("a/b/c/d.txt"); + const url = new URL(result, "http://localhost"); + expect(url.searchParams.get("path")).toBe("a/b/c/d.txt"); + }); + + it("handles spaces in path", () => { + const result = buildFileLink("my docs/file name.md"); + const url = new URL(result, "http://localhost"); + expect(url.searchParams.get("path")).toBe("my docs/file name.md"); + }); + + it("handles special characters", () => { + const result = buildFileLink("notes & ideas/doc (1).md"); + const url = new URL(result, "http://localhost"); + expect(url.searchParams.get("path")).toBe("notes & ideas/doc (1).md"); + }); + + it("handles empty path", () => { + expect(buildFileLink("")).toBe("/workspace?path="); + }); +}); + +// ─── parseWorkspaceLink ─────────────────────────────────────────── + +describe("parseWorkspaceLink", () => { + it("parses file link from path param", () => { + const result = parseWorkspaceLink("/workspace?path=knowledge/doc.md"); + expect(result).toEqual({ kind: "file", path: "knowledge/doc.md" }); + }); + + it("parses entry link from entry param", () => { + const result = parseWorkspaceLink("/workspace?entry=leads:abc123"); + expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" }); + }); + + it("parses entry link from full URL", () => { + const result = parseWorkspaceLink("http://localhost:3100/workspace?entry=deals:xyz"); + expect(result).toEqual({ kind: "entry", objectName: "deals", entryId: "xyz" }); + }); + + it("parses file link from full URL", () => { + const result = parseWorkspaceLink("http://localhost:3100/workspace?path=readme.md"); + expect(result).toEqual({ kind: "file", path: "readme.md" }); + }); + + it("parses legacy @entry/ format", () => { + const result = parseWorkspaceLink("@entry/leads/abc123"); + expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" }); + }); + + it("returns null for invalid URL", () => { + expect(parseWorkspaceLink("not a url ://bad")).toBeNull(); + }); + + it("returns null when no params present", () => { + expect(parseWorkspaceLink("/workspace")).toBeNull(); + }); + + it("returns null for hash-only link", () => { + expect(parseWorkspaceLink("/workspace#section")).toBeNull(); + }); + + it("returns null for entry param without colon", () => { + expect(parseWorkspaceLink("/workspace?entry=nocolon")).toBeNull(); + }); + + it("handles deeply nested file path", () => { + const result = parseWorkspaceLink("/workspace?path=a/b/c/d/e/f.txt"); + expect(result).toEqual({ kind: "file", path: "a/b/c/d/e/f.txt" }); + }); + + it("handles encoded characters in path", () => { + const result = parseWorkspaceLink("/workspace?path=my%20docs%2Ffile.md"); + expect(result).toEqual({ kind: "file", path: "my docs/file.md" }); + }); + + it("returns null for non-workspace URL", () => { + expect(parseWorkspaceLink("https://google.com")).toBeNull(); + }); + + it("entry param takes priority over path param", () => { + const result = parseWorkspaceLink("/workspace?entry=obj:id&path=file.md"); + expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id" }); + }); + + it("handles entry with colon in ID", () => { + const result = parseWorkspaceLink("/workspace?entry=obj:id:with:colons"); + expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id:with:colons" }); + }); + + it("returns null for legacy @entry with no slash after object name", () => { + expect(parseWorkspaceLink("@entry/objectonly")).toBeNull(); + }); +}); + +// ─── isWorkspaceLink ────────────────────────────────────────────── + +describe("isWorkspaceLink", () => { + it("returns true for /workspace?path=...", () => { + expect(isWorkspaceLink("/workspace?path=doc.md")).toBe(true); + }); + + it("returns true for /workspace#...", () => { + expect(isWorkspaceLink("/workspace#section")).toBe(true); + }); + + it("returns true for /workspace alone", () => { + expect(isWorkspaceLink("/workspace")).toBe(true); + }); + + it("returns true for @entry/ format", () => { + expect(isWorkspaceLink("@entry/leads/abc")).toBe(true); + }); + + it("returns false for external URL", () => { + expect(isWorkspaceLink("https://example.com")).toBe(false); + }); + + it("returns false for random path", () => { + expect(isWorkspaceLink("/other-page")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isWorkspaceLink("")).toBe(false); + }); +}); + +// ─── isInternalLink ─────────────────────────────────────────────── + +describe("isInternalLink", () => { + it("returns false for http:// URLs", () => { + expect(isInternalLink("http://example.com")).toBe(false); + }); + + it("returns false for https:// URLs", () => { + expect(isInternalLink("https://example.com")).toBe(false); + }); + + it("returns false for mailto: links", () => { + expect(isInternalLink("mailto:user@example.com")).toBe(false); + }); + + it("returns true for relative paths", () => { + expect(isInternalLink("/workspace?path=doc.md")).toBe(true); + }); + + it("returns true for @entry/ links", () => { + expect(isInternalLink("@entry/leads/123")).toBe(true); + }); + + it("returns true for plain text", () => { + expect(isInternalLink("some-page")).toBe(true); + }); +}); + +// ─── isEntryLink ────────────────────────────────────────────────── + +describe("isEntryLink", () => { + it("returns true for new format entry link", () => { + expect(isEntryLink("/workspace?entry=leads:abc")).toBe(true); + }); + + it("returns true for legacy @entry/ format", () => { + expect(isEntryLink("@entry/leads/abc")).toBe(true); + }); + + it("returns false for file workspace link", () => { + expect(isEntryLink("/workspace?path=doc.md")).toBe(false); + }); + + it("returns false for external URL", () => { + expect(isEntryLink("https://example.com")).toBe(false); + }); + + it("returns false for plain /workspace", () => { + expect(isEntryLink("/workspace")).toBe(false); + }); +}); diff --git a/apps/web/lib/workspace.test.ts b/apps/web/lib/workspace.test.ts new file mode 100644 index 00000000000..01d7376ce8d --- /dev/null +++ b/apps/web/lib/workspace.test.ts @@ -0,0 +1,1055 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Dirent } from "node:fs"; + +// Mock node:fs — all fs operations are controlled by tests +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), +})); + +// Mock node:child_process +vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn((_cmd: string, _opts: unknown, cb: (err: Error | null, result: { stdout: string }) => void) => { + cb(null, { stdout: "" }); + }), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join } from "node:path"; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockReaddirSync = vi.mocked(readdirSync); +const mockExecSync = vi.mocked(execSync); + +/** Helper to create mock Dirent entries. */ +function makeDirent(name: string, isDir: boolean): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + path: "", + parentPath: "", + } as Dirent; +} + +describe("workspace utilities", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + // Re-wire mocks after resetModules + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + })); + vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn((_cmd: string, _opts: unknown, cb: (err: Error | null, result: { stdout: string }) => void) => { + cb(null, { stdout: "" }); + }), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + /** Fresh import after mocks are wired. */ + async function importWorkspace() { + const { existsSync: es, readFileSync: rfs, readdirSync: rds } = await import("node:fs"); + const { execSync: exs } = await import("node:child_process"); + const mod = await import("./workspace.js"); + return { + ...mod, + mockExists: vi.mocked(es), + mockReadFile: vi.mocked(rfs), + mockReaddir: vi.mocked(rds), + mockExec: vi.mocked(exs), + }; + } + + // ─── resolveWorkspaceRoot ──────────────────────────────────────── + + describe("resolveWorkspaceRoot", () => { + it("returns OPENCLAW_WORKSPACE env var when set and exists", async () => { + process.env.OPENCLAW_WORKSPACE = "/custom/workspace"; + const { resolveWorkspaceRoot, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/custom/workspace"); + expect(resolveWorkspaceRoot()).toBe("/custom/workspace"); + }); + + it("returns default ~/.openclaw/workspace when env not set", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { resolveWorkspaceRoot, mockExists } = await importWorkspace(); + const defaultPath = join("/home/testuser", ".openclaw", "workspace"); + mockExists.mockImplementation((p) => String(p) === defaultPath); + expect(resolveWorkspaceRoot()).toBe(defaultPath); + }); + + it("returns null when no candidate directory exists", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { resolveWorkspaceRoot, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(resolveWorkspaceRoot()).toBeNull(); + }); + + it("prefers OPENCLAW_WORKSPACE over default when both exist", async () => { + process.env.OPENCLAW_WORKSPACE = "/custom/workspace"; + const { resolveWorkspaceRoot, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(true); + expect(resolveWorkspaceRoot()).toBe("/custom/workspace"); + }); + + it("falls back to default when env var path does not exist", async () => { + process.env.OPENCLAW_WORKSPACE = "/nonexistent"; + const { resolveWorkspaceRoot, mockExists } = await importWorkspace(); + const defaultPath = join("/home/testuser", ".openclaw", "workspace"); + mockExists.mockImplementation((p) => String(p) === defaultPath); + expect(resolveWorkspaceRoot()).toBe(defaultPath); + }); + }); + + // ─── resolveAgentWorkspacePrefix ───────────────────────────────── + + describe("resolveAgentWorkspacePrefix", () => { + it("returns null when no workspace root", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(resolveAgentWorkspacePrefix()).toBeNull(); + }); + + it("returns absolute path when workspace is outside repo", async () => { + process.env.OPENCLAW_WORKSPACE = "/external/workspace"; + const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/external/workspace"); + vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web"); + expect(resolveAgentWorkspacePrefix()).toBe("/external/workspace"); + }); + + it("returns relative path when workspace is inside repo", async () => { + process.env.OPENCLAW_WORKSPACE = "/repo/workspace"; + const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/repo/workspace"); + vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web"); + expect(resolveAgentWorkspacePrefix()).toBe("workspace"); + }); + + it("handles non apps/web cwd", async () => { + process.env.OPENCLAW_WORKSPACE = "/repo/workspace"; + const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/repo/workspace"); + vi.spyOn(process, "cwd").mockReturnValue("/repo"); + expect(resolveAgentWorkspacePrefix()).toBe("workspace"); + }); + }); + + // ─── discoverDuckDBPaths ────────────────────────────────────────── + + describe("discoverDuckDBPaths", () => { + it("returns empty array when root is null", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { discoverDuckDBPaths, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(discoverDuckDBPaths()).toEqual([]); + }); + + it("returns empty when root has no duckdb files", async () => { + const { discoverDuckDBPaths, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockReaddir.mockReturnValue([]); + expect(discoverDuckDBPaths("/ws")).toEqual([]); + }); + + it("discovers root-level workspace.duckdb", async () => { + const { discoverDuckDBPaths, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === join("/ws", "workspace.duckdb")); + mockReaddir.mockReturnValue([]); + expect(discoverDuckDBPaths("/ws")).toEqual([join("/ws", "workspace.duckdb")]); + }); + + it("discovers nested workspace.duckdb files sorted by depth", async () => { + const { discoverDuckDBPaths, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === join("/ws", "workspace.duckdb") || + s === join("/ws", "sub", "workspace.duckdb"); + }); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [makeDirent("sub", true)] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + const result = discoverDuckDBPaths("/ws"); + expect(result).toEqual([ + join("/ws", "workspace.duckdb"), + join("/ws", "sub", "workspace.duckdb"), + ]); + }); + + it("skips hidden directories", async () => { + const { discoverDuckDBPaths, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockImplementation((p) => + String(p) === join("/ws", ".hidden", "workspace.duckdb"), + ); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [makeDirent(".hidden", true)] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + expect(discoverDuckDBPaths("/ws")).toEqual([]); + }); + + it("skips tmp, exports, and node_modules directories", async () => { + const { discoverDuckDBPaths, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [ + makeDirent("tmp", true), + makeDirent("exports", true), + makeDirent("node_modules", true), + ] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + expect(discoverDuckDBPaths("/ws")).toEqual([]); + }); + + it("handles unreadable directories gracefully", async () => { + const { discoverDuckDBPaths, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockReaddir.mockImplementation(() => { + throw new Error("EACCES"); + }); + expect(discoverDuckDBPaths("/ws")).toEqual([]); + }); + + it("skips non-directory entries", async () => { + const { discoverDuckDBPaths, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [makeDirent("somefile.txt", false)] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + expect(discoverDuckDBPaths("/ws")).toEqual([]); + }); + }); + + // ─── duckdbPath ────────────────────────────────────────────────── + + describe("duckdbPath", () => { + it("returns root-level workspace.duckdb when it exists", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbPath, mockExists, mockReaddir } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb; + }); + mockReaddir.mockReturnValue([]); + expect(duckdbPath()).toBe(rootDb); + }); + + it("falls back to discovered nested db when root has none", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbPath, mockExists, mockReaddir } = await importWorkspace(); + const nestedDb = join("/ws", "sub", "workspace.duckdb"); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === nestedDb; + }); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [makeDirent("sub", true)] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + expect(duckdbPath()).toBe(nestedDb); + }); + + it("returns null when no workspace root", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { duckdbPath, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(duckdbPath()).toBeNull(); + }); + + it("returns null when workspace exists but no duckdb files", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbPath, mockExists, mockReaddir } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/ws"); + mockReaddir.mockReturnValue([]); + expect(duckdbPath()).toBeNull(); + }); + }); + + // ─── duckdbRelativeScope ───────────────────────────────────────── + + describe("duckdbRelativeScope", () => { + it("returns empty string for root-level db", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbRelativeScope, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/ws"); + expect(duckdbRelativeScope("/ws/workspace.duckdb")).toBe(""); + }); + + it("returns relative path for nested db", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbRelativeScope, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/ws"); + expect(duckdbRelativeScope("/ws/sub/deep/workspace.duckdb")).toBe(join("sub", "deep")); + }); + + it("returns empty string when no workspace root", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { duckdbRelativeScope, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(duckdbRelativeScope("/any/workspace.duckdb")).toBe(""); + }); + }); + + // ─── resolveDuckdbBin ──────────────────────────────────────────── + + describe("resolveDuckdbBin", () => { + it("finds user-local duckdb install", async () => { + const { resolveDuckdbBin, mockExists } = await importWorkspace(); + const expected = join("/home/testuser", ".duckdb", "cli", "latest", "duckdb"); + mockExists.mockImplementation((p) => String(p) === expected); + expect(resolveDuckdbBin()).toBe(expected); + }); + + it("finds homebrew install", async () => { + const { resolveDuckdbBin, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/opt/homebrew/bin/duckdb"); + expect(resolveDuckdbBin()).toBe("/opt/homebrew/bin/duckdb"); + }); + + it("falls back to which duckdb", async () => { + const { resolveDuckdbBin, mockExists, mockExec } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockExec.mockReturnValue("/usr/local/bin/duckdb\n" as never); + expect(resolveDuckdbBin()).toBe("duckdb"); + }); + + it("returns null when nothing found", async () => { + const { resolveDuckdbBin, mockExists, mockExec } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockExec.mockImplementation(() => { throw new Error("not found"); }); + expect(resolveDuckdbBin()).toBeNull(); + }); + + it("checks user-local before homebrew", async () => { + const { resolveDuckdbBin, mockExists } = await importWorkspace(); + const userLocal = join("/home/testuser", ".duckdb", "cli", "latest", "duckdb"); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === userLocal || s === "/opt/homebrew/bin/duckdb"; + }); + expect(resolveDuckdbBin()).toBe(userLocal); + }); + }); + + // ─── duckdbQuery ───────────────────────────────────────────────── + + describe("duckdbQuery", () => { + it("returns parsed JSON rows on success", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQuery, mockExists, mockExec } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === bin; + }); + mockExec.mockReturnValue('[{"id":"1","name":"test"}]' as never); + const result = duckdbQuery("SELECT * FROM objects"); + expect(result).toEqual([{ id: "1", name: "test" }]); + }); + + it("returns empty array for empty result", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQuery, mockExists, mockExec } = await importWorkspace(); + mockExists.mockReturnValue(true); + mockExec.mockReturnValue("[]" as never); + expect(duckdbQuery("SELECT * FROM empty")).toEqual([]); + }); + + it("returns empty array when no db", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { duckdbQuery, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(duckdbQuery("SELECT 1")).toEqual([]); + }); + + it("returns empty array on execSync error", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQuery, mockExists, mockExec } = await importWorkspace(); + mockExists.mockReturnValue(true); + mockExec.mockImplementation(() => { throw new Error("query failed"); }); + expect(duckdbQuery("BAD SQL")).toEqual([]); + }); + }); + + // ─── duckdbQueryAsync ──────────────────────────────────────────── + + describe("duckdbQueryAsync", () => { + it("returns parsed JSON rows on success", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQueryAsync, mockExists } = await importWorkspace(); + const { exec: mockExecFn } = await import("node:child_process"); + const rootDb = join("/ws", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === bin; + }); + vi.mocked(mockExecFn).mockImplementation((_cmd: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: null, r: { stdout: string }) => void)(null, { stdout: '[{"id":"1"}]' }); + return {} as never; + }); + const result = await duckdbQueryAsync("SELECT * FROM t"); + expect(result).toEqual([{ id: "1" }]); + }); + + it("returns empty array when no db path", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { duckdbQueryAsync, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + const result = await duckdbQueryAsync("SELECT 1"); + expect(result).toEqual([]); + }); + + it("returns empty array for empty stdout", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQueryAsync, mockExists } = await importWorkspace(); + const { exec: mockExecFn } = await import("node:child_process"); + mockExists.mockReturnValue(true); + vi.mocked(mockExecFn).mockImplementation((_cmd: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: null, r: { stdout: string }) => void)(null, { stdout: "" }); + return {} as never; + }); + const result = await duckdbQueryAsync("SELECT 1"); + expect(result).toEqual([]); + }); + + it("returns empty array on exec error", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQueryAsync, mockExists } = await importWorkspace(); + const { exec: mockExecFn } = await import("node:child_process"); + mockExists.mockReturnValue(true); + vi.mocked(mockExecFn).mockImplementation((_cmd: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: Error) => void)(new Error("fail")); + return {} as never; + }); + const result = await duckdbQueryAsync("BAD SQL"); + expect(result).toEqual([]); + }); + }); + + // ─── duckdbQueryAll ────────────────────────────────────────────── + + describe("duckdbQueryAll", () => { + it("merges results from multiple databases", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const subDb = join("/ws", "sub", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === subDb || s === bin; + }); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") { + return [makeDirent("sub", true)] as unknown as Dirent[]; + } + return [] as unknown as Dirent[]; + }); + let callCount = 0; + mockExec.mockImplementation(() => { + callCount++; + if (callCount <= 1) {return '[{"name":"rootObj"}]' as never;} + return '[{"name":"subObj"}]' as never; + }); + const result = duckdbQueryAll<{ name: string }>("SELECT * FROM objects"); + expect(result).toEqual([{ name: "rootObj" }, { name: "subObj" }]); + }); + + it("deduplicates by key (shallower wins)", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const subDb = join("/ws", "sub", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === subDb || s === bin; + }); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") {return [makeDirent("sub", true)] as unknown as Dirent[];} + return [] as unknown as Dirent[]; + }); + let callCount = 0; + mockExec.mockImplementation(() => { + callCount++; + if (callCount <= 1) {return '[{"name":"obj","val":"root"}]' as never;} + return '[{"name":"obj","val":"sub"}]' as never; + }); + const result = duckdbQueryAll<{ name: string; val: string }>("SQL", "name"); + expect(result).toEqual([{ name: "obj", val: "root" }]); + }); + + it("returns empty when no dbs discovered", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { duckdbQueryAll, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(duckdbQueryAll("SELECT 1")).toEqual([]); + }); + + it("skips failing databases", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const subDb = join("/ws", "sub", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === subDb || s === bin; + }); + mockReaddir.mockImplementation((dir) => { + if (String(dir) === "/ws") {return [makeDirent("sub", true)] as unknown as Dirent[];} + return [] as unknown as Dirent[]; + }); + let callCount = 0; + mockExec.mockImplementation(() => { + callCount++; + if (callCount <= 1) {throw new Error("corrupt db");} + return '[{"name":"subObj"}]' as never; + }); + const result = duckdbQueryAll<{ name: string }>("SELECT *"); + expect(result).toEqual([{ name: "subObj" }]); + }); + }); + + // ─── findDuckDBForObject ───────────────────────────────────────── + + describe("findDuckDBForObject", () => { + it("finds object in first database", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === bin; + }); + mockReaddir.mockReturnValue([]); + mockExec.mockReturnValue('[{"id":"123"}]' as never); + expect(findDuckDBForObject("leads")).toBe(rootDb); + }); + + it("returns null when object not found in any db", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === bin; + }); + mockReaddir.mockReturnValue([]); + mockExec.mockReturnValue("[]" as never); + expect(findDuckDBForObject("nonexistent")).toBeNull(); + }); + + it("returns null when no dbs exist", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { findDuckDBForObject, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(findDuckDBForObject("any")).toBeNull(); + }); + + it("handles object names with single quotes", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === bin; + }); + mockReaddir.mockReturnValue([]); + mockExec.mockReturnValue('[{"id":"1"}]' as never); + expect(findDuckDBForObject("O'Brien's")).toBe(rootDb); + }); + }); + + // ─── duckdbExec / duckdbExecOnFile ─────────────────────────────── + + describe("duckdbExec", () => { + it("returns true on successful exec", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { duckdbExec, mockExists, mockExec } = await importWorkspace(); + const rootDb = join("/ws", "workspace.duckdb"); + const bin = "/opt/homebrew/bin/duckdb"; + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === rootDb || s === bin; + }); + mockExec.mockReturnValue("" as never); + expect(duckdbExec("INSERT INTO t VALUES (1)")).toBe(true); + }); + + it("returns false when no database path", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { duckdbExec, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(duckdbExec("INSERT INTO t VALUES (1)")).toBe(false); + }); + }); + + describe("duckdbExecOnFile", () => { + it("returns true on success", async () => { + const { duckdbExecOnFile, mockExists, mockExec } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/opt/homebrew/bin/duckdb"); + mockExec.mockReturnValue("" as never); + expect(duckdbExecOnFile("/db/file.duckdb", "CREATE TABLE t(id INT)")).toBe(true); + }); + + it("returns false when no bin", async () => { + const { duckdbExecOnFile, mockExists, mockExec } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockExec.mockImplementation(() => { throw new Error("not found"); }); + expect(duckdbExecOnFile("/db/file.duckdb", "SQL")).toBe(false); + }); + + it("returns false on exec error", async () => { + const { duckdbExecOnFile, mockExists, mockExec } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/opt/homebrew/bin/duckdb"); + mockExec.mockImplementation(() => { throw new Error("exec failed"); }); + expect(duckdbExecOnFile("/db/file.duckdb", "BAD SQL")).toBe(false); + }); + }); + + // ─── parseRelationValue ────────────────────────────────────────── + + describe("parseRelationValue", () => { + it("returns empty array for null", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue(null)).toEqual([]); + }); + + it("returns empty array for undefined", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue(undefined)).toEqual([]); + }); + + it("returns empty array for empty string", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue("")).toEqual([]); + }); + + it("returns empty array for whitespace-only string", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue(" ")).toEqual([]); + }); + + it("returns single ID for simple string", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue("abc-123")).toEqual(["abc-123"]); + }); + + it("parses JSON array of IDs", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue('["id1","id2","id3"]')).toEqual(["id1", "id2", "id3"]); + }); + + it("converts numeric array elements to strings", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue("[1,2,3]")).toEqual(["1", "2", "3"]); + }); + + it("filters empty values from array", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue('["a","","b"]')).toEqual(["a", "b"]); + }); + + it("treats invalid JSON starting with [ as single value", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue("[not-json")).toEqual(["[not-json"]); + }); + + it("handles empty JSON array", async () => { + const { parseRelationValue } = await importWorkspace(); + expect(parseRelationValue("[]")).toEqual([]); + }); + }); + + // ─── isDatabaseFile ────────────────────────────────────────────── + + describe("isDatabaseFile", () => { + it("returns true for .duckdb", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("workspace.duckdb")).toBe(true); + }); + + it("returns true for .sqlite", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("data.sqlite")).toBe(true); + }); + + it("returns true for .sqlite3", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("main.sqlite3")).toBe(true); + }); + + it("returns true for .db", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("app.db")).toBe(true); + }); + + it("returns true for .postgres", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("conn.postgres")).toBe(true); + }); + + it("returns false for .txt", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("notes.txt")).toBe(false); + }); + + it("returns false for .json", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("data.json")).toBe(false); + }); + + it("returns false for no extension", async () => { + const { isDatabaseFile } = await importWorkspace(); + expect(isDatabaseFile("Makefile")).toBe(false); + }); + }); + + // ─── DB_EXTENSIONS ─────────────────────────────────────────────── + + describe("DB_EXTENSIONS", () => { + it("contains all expected extensions", async () => { + const { DB_EXTENSIONS } = await importWorkspace(); + expect(DB_EXTENSIONS.has("duckdb")).toBe(true); + expect(DB_EXTENSIONS.has("sqlite")).toBe(true); + expect(DB_EXTENSIONS.has("sqlite3")).toBe(true); + expect(DB_EXTENSIONS.has("db")).toBe(true); + expect(DB_EXTENSIONS.has("postgres")).toBe(true); + }); + + it("does not contain non-database extensions", async () => { + const { DB_EXTENSIONS } = await importWorkspace(); + expect(DB_EXTENSIONS.has("json")).toBe(false); + expect(DB_EXTENSIONS.has("txt")).toBe(false); + expect(DB_EXTENSIONS.has("csv")).toBe(false); + }); + }); + + // ─── duckdbQueryOnFile ─────────────────────────────────────────── + + describe("duckdbQueryOnFile", () => { + it("executes query against specific db file", async () => { + const { duckdbQueryOnFile, mockExists, mockExec } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/opt/homebrew/bin/duckdb"); + mockExec.mockReturnValue('[{"col":"val"}]' as never); + expect(duckdbQueryOnFile("/any/db.duckdb", "SELECT *")).toEqual([{ col: "val" }]); + }); + + it("returns empty array when no bin found", async () => { + const { duckdbQueryOnFile, mockExists, mockExec } = await importWorkspace(); + mockExists.mockReturnValue(false); + mockExec.mockImplementation(() => { throw new Error("not found"); }); + expect(duckdbQueryOnFile("/any/db.duckdb", "SELECT *")).toEqual([]); + }); + + it("returns empty for empty result", async () => { + const { duckdbQueryOnFile, mockExists, mockExec } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/opt/homebrew/bin/duckdb"); + mockExec.mockReturnValue("" as never); + expect(duckdbQueryOnFile("/any/db.duckdb", "SELECT *")).toEqual([]); + }); + }); + + // ─── safeResolvePath ───────────────────────────────────────────── + + describe("safeResolvePath", () => { + it("resolves valid path within workspace", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { safeResolvePath, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === "/ws/knowledge/doc.md"; + }); + expect(safeResolvePath("knowledge/doc.md")).toBe("/ws/knowledge/doc.md"); + }); + + it("returns null for traversal with ..", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { safeResolvePath, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(true); + expect(safeResolvePath("../etc/passwd")).toBeNull(); + }); + + it("returns null for traversal with /../", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { safeResolvePath, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(true); + expect(safeResolvePath("foo/../../../etc/passwd")).toBeNull(); + }); + + it("returns null when file does not exist", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { safeResolvePath, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/ws"); + expect(safeResolvePath("nonexistent.txt")).toBeNull(); + }); + + it("returns null when no workspace root", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { safeResolvePath, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(safeResolvePath("any/file.txt")).toBeNull(); + }); + }); + + // ─── safeResolveNewPath ────────────────────────────────────────── + + describe("safeResolveNewPath", () => { + it("resolves valid new path (does not require existence)", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { safeResolveNewPath, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/ws"); + expect(safeResolveNewPath("new-folder/file.txt")).toBe("/ws/new-folder/file.txt"); + }); + + it("returns null for traversal attempts", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { safeResolveNewPath, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(true); + expect(safeResolveNewPath("../../outside")).toBeNull(); + }); + + it("returns null when no workspace root", async () => { + delete process.env.OPENCLAW_WORKSPACE; + const { safeResolveNewPath, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + expect(safeResolveNewPath("any")).toBeNull(); + }); + + it("handles deeply nested new paths", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { safeResolveNewPath, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/ws"); + expect(safeResolveNewPath("a/b/c/d/e.txt")).toBe("/ws/a/b/c/d/e.txt"); + }); + }); + + // ─── isSystemFile ──────────────────────────────────────────────── + + describe("isSystemFile", () => { + it("returns true for .object.yaml at any depth", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile(".object.yaml")).toBe(true); + expect(isSystemFile("sub/.object.yaml")).toBe(true); + expect(isSystemFile("a/b/c/.object.yaml")).toBe(true); + }); + + it("returns true for .wal files at any depth", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile("workspace.duckdb.wal")).toBe(true); + expect(isSystemFile("sub/data.wal")).toBe(true); + }); + + it("returns true for .tmp files at any depth", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile("upload.tmp")).toBe(true); + expect(isSystemFile("sub/temp.tmp")).toBe(true); + }); + + it("returns true for workspace.duckdb at root only", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile("workspace.duckdb")).toBe(true); + }); + + it("returns false for workspace.duckdb in subdirectory", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile("sub/workspace.duckdb")).toBe(false); + }); + + it("returns true for workspace_context.yaml at root", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile("workspace_context.yaml")).toBe(true); + }); + + it("returns false for workspace_context.yaml in subdirectory", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile("sub/workspace_context.yaml")).toBe(false); + }); + + it("returns false for regular files", async () => { + const { isSystemFile } = await importWorkspace(); + expect(isSystemFile("readme.md")).toBe(false); + expect(isSystemFile("knowledge/notes.md")).toBe(false); + expect(isSystemFile("data.json")).toBe(false); + }); + }); + + // ─── parseSimpleYaml ───────────────────────────────────────────── + + describe("parseSimpleYaml", () => { + it("parses basic key-value pairs", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("name: My Workspace\nversion: 1"); + expect(result).toEqual({ name: "My Workspace", version: 1 }); + }); + + it("parses boolean values", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("enabled: true\ndisabled: false"); + expect(result).toEqual({ enabled: true, disabled: false }); + }); + + it("parses null value", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("empty: null"); + expect(result).toEqual({ empty: null }); + }); + + it("parses numeric values", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("count: 42\nratio: 3.14\nneg: -5"); + expect(result).toEqual({ count: 42, ratio: 3.14, neg: -5 }); + }); + + it("strips double quotes from values", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml('title: "My Title"'); + expect(result).toEqual({ title: "My Title" }); + }); + + it("strips single quotes from values", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("title: 'My Title'"); + expect(result).toEqual({ title: "My Title" }); + }); + + it("skips comment lines", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("# This is a comment\nname: test"); + expect(result).toEqual({ name: "test" }); + }); + + it("skips empty lines", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("a: 1\n\n\nb: 2"); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("handles keys with hyphens and underscores", async () => { + const { parseSimpleYaml } = await importWorkspace(); + const result = parseSimpleYaml("my-key: val\nmy_key2: val2"); + expect(result).toEqual({ "my-key": "val", "my_key2": "val2" }); + }); + + it("returns empty object for empty input", async () => { + const { parseSimpleYaml } = await importWorkspace(); + expect(parseSimpleYaml("")).toEqual({}); + }); + }); + + // ─── readWorkspaceFile ─────────────────────────────────────────── + + describe("readWorkspaceFile", () => { + it("reads markdown file and detects type", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === "/ws/doc.md"; + }); + mockReadFile.mockReturnValue("# Hello" as never); + const result = readWorkspaceFile("doc.md"); + expect(result).toEqual({ content: "# Hello", type: "markdown" }); + }); + + it("reads yaml file and detects type", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === "/ws/config.yaml"; + }); + mockReadFile.mockReturnValue("key: value" as never); + const result = readWorkspaceFile("config.yaml"); + expect(result).toEqual({ content: "key: value", type: "yaml" }); + }); + + it("reads yml file as yaml type", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === "/ws/config.yml"; + }); + mockReadFile.mockReturnValue("key: value" as never); + const result = readWorkspaceFile("config.yml"); + expect(result).toEqual({ content: "key: value", type: "yaml" }); + }); + + it("reads text file with generic type", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/ws" || s === "/ws/notes.txt"; + }); + mockReadFile.mockReturnValue("plain text" as never); + const result = readWorkspaceFile("notes.txt"); + expect(result).toEqual({ content: "plain text", type: "text" }); + }); + + it("returns null when file not found", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { readWorkspaceFile, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => String(p) === "/ws"); + expect(readWorkspaceFile("nonexistent.md")).toBeNull(); + }); + + it("returns null when readFileSync throws", async () => { + process.env.OPENCLAW_WORKSPACE = "/ws"; + const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace(); + mockExists.mockReturnValue(true); + mockReadFile.mockImplementation(() => { throw new Error("EACCES"); }); + expect(readWorkspaceFile("forbidden.md")).toBeNull(); + }); + }); +}); diff --git a/apps/web/public/fonts/Bookerly-Bold.ttf b/apps/web/public/fonts/Bookerly-Bold.ttf deleted file mode 100644 index 4cc9bf07fce..00000000000 Binary files a/apps/web/public/fonts/Bookerly-Bold.ttf and /dev/null differ diff --git a/apps/web/public/fonts/Bookerly-BoldItalic.ttf b/apps/web/public/fonts/Bookerly-BoldItalic.ttf deleted file mode 100644 index 4bd84830340..00000000000 Binary files a/apps/web/public/fonts/Bookerly-BoldItalic.ttf and /dev/null differ diff --git a/apps/web/public/fonts/Bookerly-Regular.ttf b/apps/web/public/fonts/Bookerly-Regular.ttf deleted file mode 100644 index 5b2ccd8d01a..00000000000 Binary files a/apps/web/public/fonts/Bookerly-Regular.ttf and /dev/null differ diff --git a/apps/web/public/fonts/Bookerly-RegularItalic.ttf b/apps/web/public/fonts/Bookerly-RegularItalic.ttf deleted file mode 100644 index 55adbf5ab01..00000000000 Binary files a/apps/web/public/fonts/Bookerly-RegularItalic.ttf and /dev/null differ diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 08c08efdc25..93fe3238847 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ }, }, test: { - include: ["lib/**/*.test.ts"], + include: ["lib/**/*.test.ts", "app/**/*.test.ts"], testTimeout: 30_000, }, }); diff --git a/package.json b/package.json index 2f9b30c3fe8..34120140d51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ironclaw", - "version": "2026.2.15-1.4", + "version": "2026.2.15-1.6", "description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management", "keywords": [], "license": "MIT",