openclaw/apps/web/app/api/workspace/tree-browse.test.ts
kumarabhirup 039cbe6a43
feat: async I/O, tags field type, rich chat messages, deploy verification
- Convert sync filesystem and DuckDB operations to async across API routes,
  workspace lib, and active-runs to prevent event loop blocking during tree
  discovery, object lookups, and database queries
- Add "tags" field type for free-form string arrays with parse-tags utility,
  TagsBadges/TagsInput UI components, filter operators, and CRM skill docs
- Preserve rich text formatting (bold, italic, code, @mentions) in user chat
  messages by sending HTML alongside plain text through the transport layer
- Detect empty-stream errors, improve agent error emission, and add file
  mutation queues for concurrent write safety in active-runs
- Add pre-publish standalone node_modules verification in deploy script
  checking serverExternalPackages are present
- Extract syncManagedSkills and discoverWorkspaceDirs for multi-workspace
  skill syncing, add ensureSeedAssets for runtime app dir
- Bump version 2.1.1 → 2.1.4
2026-03-08 19:53:18 -07:00

382 lines
15 KiB
TypeScript

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 })),
}));
vi.mock("node:fs/promises", () => ({
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => ""),
access: vi.fn(async () => {
throw new Error("ENOENT");
}),
stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => false })),
}));
// Mock node:os
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
// Mock workspace
vi.mock("@/lib/workspace", () => ({
resolveWorkspaceRoot: vi.fn(() => null),
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"),
getActiveWorkspaceName: 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:fs/promises", () => ({
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => ""),
access: vi.fn(async () => {
throw new Error("ENOENT");
}),
stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => false })),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
vi.mock("@/lib/workspace", () => ({
resolveWorkspaceRoot: vi.fn(() => null),
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"),
getActiveWorkspaceName: 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 req = new Request("http://localhost/api/workspace/tree");
const res = await GET(req);
const json = await res.json();
expect(json.exists).toBe(false);
expect(json.tree).toEqual([]);
expect(json.workspace).toBeNull();
});
it("returns tree with workspace files", async () => {
const { resolveWorkspaceRoot, getActiveWorkspaceName } = await import("@/lib/workspace");
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
vi.mocked(getActiveWorkspaceName).mockReturnValue("default");
const { readdir: mockReaddir } = await import("node:fs/promises");
vi.mocked(mockReaddir).mockImplementation((dir) => {
if (String(dir) === "/ws") {
return Promise.resolve([
makeDirent("knowledge", true),
makeDirent("readme.md", false),
] as unknown as Dirent[]);
}
return Promise.resolve([] as unknown as Dirent[]);
});
const { GET } = await import("./tree/route.js");
const req = new Request("http://localhost/api/workspace/tree");
const res = await GET(req);
const json = await res.json();
expect(json.exists).toBe(true);
expect(json.tree.length).toBeGreaterThan(0);
expect(json.workspace).toBe("default");
});
it("includes workspaceRoot in response", async () => {
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
const { GET } = await import("./tree/route.js");
const req = new Request("http://localhost/api/workspace/tree");
const res = await GET(req);
const json = await res.json();
expect(json.workspaceRoot).toBe("/ws");
});
it("includes root IDENTITY.md in the workspace tree", async () => {
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
const { readdir: mockReaddir } = await import("node:fs/promises");
vi.mocked(mockReaddir).mockImplementation((dir) => {
if (String(dir) === "/ws") {
return Promise.resolve([
makeDirent("IDENTITY.md", false),
makeDirent("notes.md", false),
] as unknown as Dirent[]);
}
return Promise.resolve([] as unknown as Dirent[]);
});
const { GET } = await import("./tree/route.js");
const req = new Request("http://localhost/api/workspace/tree");
const res = await GET(req);
const json = await res.json();
const paths = (json.tree as Array<{ path: string }>).map((n) => n.path);
expect(paths).toContain("IDENTITY.md");
expect(paths).toContain("notes.md");
});
it("omits managed crm skill from the virtual skills folder", async () => {
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
const { readdir: mockReaddir, access: mockAccess } = await import("node:fs/promises");
vi.mocked(mockAccess).mockImplementation(async (p) => {
const value = String(p);
if (
value === "/ws" ||
value === "/ws/skills" ||
value === "/ws/skills/alpha/SKILL.md" ||
value === "/ws/skills/crm/SKILL.md"
) {
return;
}
throw new Error("ENOENT");
});
vi.mocked(mockReaddir).mockImplementation((dir) => {
if (String(dir) === "/ws") {
return Promise.resolve([] as unknown as Dirent[]);
}
if (String(dir) === "/ws/skills") {
return Promise.resolve([
makeDirent("alpha", true),
makeDirent("crm", true),
] as unknown as Dirent[]);
}
return Promise.resolve([] as unknown as Dirent[]);
});
const { GET } = await import("./tree/route.js");
const req = new Request("http://localhost/api/workspace/tree");
const res = await GET(req);
const json = await res.json();
const skillsFolder = (json.tree as Array<{ path: string; children?: Array<{ path: string }> }>).find(
(node) => node.path === "~skills",
);
const skillPaths = (skillsFolder?.children ?? []).map((child) => child.path);
expect(skillPaths).toContain("~skills/alpha/SKILL.md");
expect(skillPaths).not.toContain("~skills/crm/SKILL.md");
});
it("yields before tree discovery completes (prevents UI freeze during active agent runs)", async () => {
const { resolveWorkspaceRoot, duckdbQueryAll, duckdbQueryAllAsync } = await import("@/lib/workspace");
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
vi.mocked(duckdbQueryAll).mockImplementation(() => {
const start = Date.now();
while (Date.now() - start < 75) {
// busy wait: if the route ever regresses to the sync helper,
// this test should fail on the elapsed-time assertion below.
}
return [];
});
let releaseDuckdb: (rows: Array<{ name: string }>) => void;
const duckdbGate = new Promise<Array<{ name: string }>>((resolve) => {
releaseDuckdb = resolve;
});
vi.mocked(duckdbQueryAllAsync).mockReturnValue(duckdbGate);
const { readdir: mockReaddir } = await import("node:fs/promises");
vi.mocked(mockReaddir).mockResolvedValue([] as unknown as Dirent[]);
const { GET } = await import("./tree/route.js");
const req = new Request("http://localhost/api/workspace/tree");
const startedAt = Date.now();
const responsePromise = GET(req);
const elapsedMs = Date.now() - startedAt;
expect(elapsedMs).toBeLessThan(40);
releaseDuckdb!([]);
const res = await responsePromise;
expect(res.status).toBe(200);
});
});
// ─── 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();
});
it("includes root IDENTITY.md in sidebar file suggestions", 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).mockImplementation((dir) => {
if (String(dir) === "/ws") {
return [
makeDirent("IDENTITY.md", false),
makeDirent("doc.md", false),
] as unknown as Dirent[];
}
return [] as unknown as Dirent[];
});
const { GET } = await import("./suggest-files/route.js");
const req = new Request("http://localhost/api/workspace/suggest-files");
const res = await GET(req);
expect(res.status).toBe(200);
const json = await res.json();
const names = (json.items as Array<{ name: string }>).map((item) => item.name);
expect(names).toContain("doc.md");
expect(names).toContain("IDENTITY.md");
});
});
// ─── 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);
});
});
});