openclaw/apps/web/lib/workspace-profiles.test.ts
kumarabhirup f6eee0b398
refactor(cli): update workspace-seed with dynamic identity and dench skill
Build identity template with workspace path; add seedDenchSkill for skills/dench.
2026-03-03 13:47:38 -08:00

627 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Dirent } from "node:fs";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const existsSync = vi.fn(() => false);
const readFileSync = vi.fn(() => "");
const readdirSync = vi.fn(() => []);
const writeFileSync = vi.fn();
const mkdirSync = vi.fn();
const renameSync = vi.fn();
return {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
default: {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
},
};
});
vi.mock("node:child_process", () => ({
execSync: vi.fn(() => ""),
exec: vi.fn(
(
_cmd: string,
_opts: unknown,
cb: (err: Error | null, result: { stdout: string }) => void,
) => {
cb(null, { stdout: "" });
},
),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
import { join } from "node:path";
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 (flat workspace model)", () => {
const originalEnv = { ...process.env };
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
const UI_STATE_PATH = join(STATE_DIR, ".ironclaw-ui-state.json");
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
process.env = { ...originalEnv };
delete process.env.OPENCLAW_PROFILE;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_WORKSPACE;
delete process.env.OPENCLAW_STATE_DIR;
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const existsSync = vi.fn(() => false);
const readFileSync = vi.fn(() => "");
const readdirSync = vi.fn(() => []);
const writeFileSync = vi.fn();
const mkdirSync = vi.fn();
const renameSync = vi.fn();
return {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
default: {
...actual,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
},
};
});
vi.mock("node:child_process", () => ({
execSync: vi.fn(() => ""),
exec: vi.fn(
(
_cmd: string,
_opts: unknown,
cb: (err: Error | null, result: { stdout: string }) => void,
) => {
cb(null, { stdout: "" });
},
),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
});
afterEach(() => {
process.env = originalEnv;
});
async function importWorkspace() {
const {
existsSync: es,
readFileSync: rfs,
readdirSync: rds,
writeFileSync: wfs,
renameSync: rs,
} = await import("node:fs");
const mod = await import("./workspace.js");
return {
...mod,
mockExists: vi.mocked(es),
mockReadFile: vi.mocked(rfs),
mockReaddir: vi.mocked(rds),
mockWriteFile: vi.mocked(wfs),
mockRename: vi.mocked(rs),
};
}
// ─── getEffectiveProfile ──────────────────────────────────────────
describe("getEffectiveProfile", () => {
it("always returns 'ironclaw' regardless of env/state (single profile enforcement)", async () => {
process.env.OPENCLAW_PROFILE = "work";
const { getEffectiveProfile, setUIActiveProfile, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "something" }) as never,
);
setUIActiveProfile("custom");
expect(getEffectiveProfile()).toBe("ironclaw");
});
});
// ─── getActiveWorkspaceName ───────────────────────────────────────
describe("getActiveWorkspaceName", () => {
it("returns null when nothing is set and no workspace dirs exist", async () => {
const { getActiveWorkspaceName, mockReadFile } = await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(getActiveWorkspaceName()).toBeNull();
});
it("returns persisted workspace from state file", async () => {
const { getActiveWorkspaceName, mockReadFile, mockReaddir } =
await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-dev", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "dev" }) as never,
);
expect(getActiveWorkspaceName()).toBe("dev");
});
it("in-memory override takes precedence over persisted file", async () => {
const {
getActiveWorkspaceName,
setUIActiveWorkspace,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-memory-ws", true),
makeDirent("workspace-file-ws", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "file-ws" }) as never,
);
setUIActiveWorkspace("memory-ws");
expect(getActiveWorkspaceName()).toBe("memory-ws");
});
it("falls back to first discovered workspace when nothing is set", async () => {
const { getActiveWorkspaceName, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-beta", true),
makeDirent("workspace-alpha", true),
makeDirent("unrelated-dir", true),
] as unknown as Dirent[]);
// scanWorkspaceNames sorts alphabetically, so "alpha" comes first
expect(getActiveWorkspaceName()).toBe("alpha");
});
it("OPENCLAW_WORKSPACE env pointing to workspace-<name> dir under state dir takes priority", async () => {
process.env.OPENCLAW_WORKSPACE = join(STATE_DIR, "workspace-envws");
const {
getActiveWorkspaceName,
setUIActiveWorkspace,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-envws", true),
makeDirent("workspace-memory", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "persisted" }) as never,
);
setUIActiveWorkspace("memory");
expect(getActiveWorkspaceName()).toBe("envws");
});
it("OPENCLAW_WORKSPACE env pointing to root workspace dir resolves to default", async () => {
process.env.OPENCLAW_WORKSPACE = join(STATE_DIR, "workspace");
const {
getActiveWorkspaceName,
setUIActiveWorkspace,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-memory", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "persisted" }) as never,
);
setUIActiveWorkspace("memory");
expect(getActiveWorkspaceName()).toBe("default");
});
});
// ─── setUIActiveWorkspace ─────────────────────────────────────────
describe("setUIActiveWorkspace", () => {
it("persists workspace name to state file with activeWorkspace key", async () => {
const { setUIActiveWorkspace, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockExists.mockReturnValue(true);
setUIActiveWorkspace("dev");
expect(mockWriteFile).toHaveBeenCalledWith(
UI_STATE_PATH,
expect.stringContaining('"activeWorkspace": "dev"'),
);
});
it("null clears the override", async () => {
const { setUIActiveWorkspace, mockReadFile, mockWriteFile, mockExists } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockExists.mockReturnValue(true);
setUIActiveWorkspace(null);
expect(mockWriteFile).toHaveBeenCalledWith(
UI_STATE_PATH,
expect.stringContaining('"activeWorkspace": null'),
);
});
});
// ─── clearUIActiveWorkspaceCache ──────────────────────────────────
describe("clearUIActiveWorkspaceCache", () => {
it("re-reads from file after clearing in-memory override", async () => {
const {
getActiveWorkspaceName,
setUIActiveWorkspace,
clearUIActiveWorkspaceCache,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-in-memory", true),
makeDirent("workspace-from-file", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(
JSON.stringify({ activeWorkspace: "from-file" }) as never,
);
setUIActiveWorkspace("in-memory");
expect(getActiveWorkspaceName()).toBe("in-memory");
clearUIActiveWorkspaceCache();
expect(getActiveWorkspaceName()).toBe("from-file");
});
});
// ─── discoverWorkspaces ───────────────────────────────────────────
describe("discoverWorkspaces", () => {
it("returns empty array when state dir has no workspace-* dirs", async () => {
const { discoverWorkspaces, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(0);
});
it("discovers workspace-<name> directories under state dir", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-alpha", true),
makeDirent("workspace-beta", true),
makeDirent("some-other-dir", true),
makeDirent("config.json", false),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace-alpha") ||
s === join(STATE_DIR, "workspace-beta")
);
});
const workspaces = discoverWorkspaces();
const names = workspaces.map((w) => w.name);
expect(names).toContain("alpha");
expect(names).toContain("beta");
expect(names).not.toContain("some-other-dir");
expect(names).not.toContain("config.json");
});
it("discovers root workspace dir as default", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => String(p) === join(STATE_DIR, "workspace"));
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(1);
expect(workspaces[0]?.name).toBe("default");
expect(workspaces[0]?.workspaceDir).toBe(join(STATE_DIR, "workspace"));
expect(workspaces[0]?.isActive).toBe(true);
});
it("keeps root default and workspace-ironclaw as distinct workspaces", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-ironclaw", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return s === join(STATE_DIR, "workspace") || s === join(STATE_DIR, "workspace-ironclaw");
});
const workspaces = discoverWorkspaces();
expect(workspaces).toHaveLength(2);
const names = workspaces.map((workspace) => workspace.name);
expect(names).toContain("default");
expect(names).toContain("ironclaw");
const rootDefault = workspaces.find((workspace) => workspace.name === "default");
const profileIronclaw = workspaces.find((workspace) => workspace.name === "ironclaw");
expect(rootDefault?.workspaceDir).toBe(join(STATE_DIR, "workspace"));
expect(profileIronclaw?.workspaceDir).toBe(join(STATE_DIR, "workspace-ironclaw"));
});
it("lists default, ironclaw, and custom workspace side by side", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-ironclaw", true),
makeDirent("workspace-kumareth", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace") ||
s === join(STATE_DIR, "workspace-ironclaw") ||
s === join(STATE_DIR, "workspace-kumareth")
);
});
const workspaces = discoverWorkspaces();
expect(workspaces.map((workspace) => workspace.name)).toEqual([
"default",
"ironclaw",
"kumareth",
]);
});
it("first discovered workspace is marked active when no explicit selection", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace-beta", true),
makeDirent("workspace-alpha", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace-alpha") ||
s === join(STATE_DIR, "workspace-beta")
);
});
const workspaces = discoverWorkspaces();
// sorted alphabetically: alpha first
expect(workspaces[0].name).toBe("alpha");
expect(workspaces[0].isActive).toBe(true);
expect(workspaces[1].isActive).toBe(false);
});
it("marks the explicitly active workspace correctly", async () => {
const {
discoverWorkspaces,
setUIActiveWorkspace,
mockReaddir,
mockExists,
mockReadFile,
} = await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
mockReaddir.mockReturnValue([
makeDirent("workspace-alpha", true),
makeDirent("workspace-beta", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace-alpha") ||
s === join(STATE_DIR, "workspace-beta") ||
s === STATE_DIR
);
});
setUIActiveWorkspace("beta");
const workspaces = discoverWorkspaces();
const alpha = workspaces.find((w) => w.name === "alpha");
const beta = workspaces.find((w) => w.name === "beta");
expect(alpha?.isActive).toBe(false);
expect(beta?.isActive).toBe(true);
});
});
// ─── resolveWebChatDir ────────────────────────────────────────────
describe("resolveWebChatDir", () => {
it("returns <workspace>/.openclaw/web-chat for active workspace (per-workspace chat isolation)", async () => {
const {
resolveWebChatDir,
setUIActiveWorkspace,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-dev", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveWorkspace("dev");
const wsDir = join(STATE_DIR, "workspace-dev");
mockExists.mockImplementation((p) => String(p) === wsDir);
expect(resolveWebChatDir()).toBe(join(wsDir, ".openclaw", "web-chat"));
});
it("different workspaces produce different chat directories", async () => {
const {
resolveWebChatDir,
setUIActiveWorkspace,
clearUIActiveWorkspaceCache,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-work", true),
makeDirent("workspace-personal", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveWorkspace("work");
const workDir = join(STATE_DIR, "workspace-work");
mockExists.mockImplementation((p) => String(p) === workDir);
const chatWork = resolveWebChatDir();
clearUIActiveWorkspaceCache();
setUIActiveWorkspace("personal");
const personalDir = join(STATE_DIR, "workspace-personal");
mockExists.mockImplementation((p) => String(p) === personalDir);
const chatPersonal = resolveWebChatDir();
expect(chatWork).not.toBe(chatPersonal);
expect(chatWork).toBe(join(workDir, ".openclaw", "web-chat"));
expect(chatPersonal).toBe(join(personalDir, ".openclaw", "web-chat"));
});
it("falls back to root workspace path when no workspace is active", async () => {
const { resolveWebChatDir, mockReadFile, mockReaddir } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
expect(resolveWebChatDir()).toBe(
join(STATE_DIR, "workspace", ".openclaw", "web-chat"),
);
});
});
// ─── resolveWorkspaceRoot ─────────────────────────────────────────
describe("resolveWorkspaceRoot", () => {
it("returns active workspace dir when it exists on disk", async () => {
const {
resolveWorkspaceRoot,
setUIActiveWorkspace,
mockExists,
mockReadFile,
mockReaddir,
} = await importWorkspace();
mockReaddir.mockReturnValue([
makeDirent("workspace-dev", true),
] as unknown as Dirent[]);
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveWorkspace("dev");
const wsDir = join(STATE_DIR, "workspace-dev");
mockExists.mockImplementation((p) => String(p) === wsDir);
expect(resolveWorkspaceRoot()).toBe(wsDir);
});
it("returns null when no workspace dirs exist", async () => {
const { resolveWorkspaceRoot, mockReadFile, mockReaddir } = await importWorkspace();
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(resolveWorkspaceRoot()).toBeNull();
});
it("OPENCLAW_WORKSPACE env takes priority if it points to a valid workspace-<name> path", async () => {
const envWsDir = join(STATE_DIR, "workspace-envws");
process.env.OPENCLAW_WORKSPACE = envWsDir;
const { resolveWorkspaceRoot, setUIActiveWorkspace, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveWorkspace("other");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
});
expect(resolveWorkspaceRoot()).toBe(envWsDir);
});
it("OPENCLAW_WORKSPACE env takes priority for root workspace path", async () => {
const envWsDir = join(STATE_DIR, "workspace");
process.env.OPENCLAW_WORKSPACE = envWsDir;
const { resolveWorkspaceRoot, setUIActiveWorkspace, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
setUIActiveWorkspace("other");
mockExists.mockImplementation((p) => {
const s = String(p);
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
});
expect(resolveWorkspaceRoot()).toBe(envWsDir);
});
});
// ─── registerWorkspacePath / getRegisteredWorkspacePath ────────────
describe("registerWorkspacePath / getRegisteredWorkspacePath", () => {
it("registerWorkspacePath is now a no-op (custom paths disabled)", async () => {
const { registerWorkspacePath, mockWriteFile } = await importWorkspace();
mockWriteFile.mockClear();
registerWorkspacePath("myprofile", "/my/workspace");
const stateWrites = mockWriteFile.mock.calls.filter((c) =>
(c[0] as string).includes(".ironclaw-ui-state.json"),
);
expect(stateWrites).toHaveLength(0);
});
it("getRegisteredWorkspacePath always returns null", async () => {
const { getRegisteredWorkspacePath } = await importWorkspace();
expect(getRegisteredWorkspacePath("anything")).toBeNull();
expect(getRegisteredWorkspacePath(null)).toBeNull();
expect(getRegisteredWorkspacePath("test")).toBeNull();
});
});
});