TEST: full frontend web nextjs test suite

This commit is contained in:
kumarabhirup 2026-02-16 01:01:12 -08:00
parent 371035978c
commit 4965e3ca67
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
22 changed files with 3903 additions and 2 deletions

View File

@ -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");
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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();
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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", () => {

View File

@ -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(),
);
});
});
});

View File

@ -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");
}
});
});

View File

@ -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");
});
});

View File

@ -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 ───

View File

@ -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<string, string>): 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<T = unknown>(
res: Response,
expectedStatus?: number,
): Promise<T> {
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<T>;
}
/** Build a mock Next.js route context with params. */
export function mockRouteContext(params: Record<string, string>) {
return { params: Promise.resolve(params) };
}

View File

@ -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);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ export default defineConfig({
},
},
test: {
include: ["lib/**/*.test.ts"],
include: ["lib/**/*.test.ts", "app/**/*.test.ts"],
testTimeout: 30_000,
},
});

View File

@ -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",