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