refactor(cli): update workspace-seed with dynamic identity and dench skill
Build identity template with workspace path; add seedDenchSkill for skills/dench.
This commit is contained in:
parent
c704ddd15f
commit
f6eee0b398
@ -93,6 +93,32 @@ describe("Chat API routes", () => {
|
||||
expect(startRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reuse an old run when sessionId is absent", async () => {
|
||||
const { startRun, hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(true);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
vi.mocked(hasActiveRun).mockClear();
|
||||
vi.mocked(startRun).mockClear();
|
||||
vi.mocked(persistUserMessage).mockClear();
|
||||
|
||||
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: "new workspace question" }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(hasActiveRun).not.toHaveBeenCalled();
|
||||
expect(startRun).not.toHaveBeenCalled();
|
||||
expect(persistUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists user message when sessionId provided", async () => {
|
||||
const { hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(false);
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import type { Dirent } from "node:fs";
|
||||
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
discoverWorkspaces: vi.fn(() => []),
|
||||
getActiveWorkspaceName: vi.fn(() => null),
|
||||
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-ironclaw"),
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
setUIActiveWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
@ -8,87 +14,55 @@ vi.mock("node:fs", () => ({
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout: "" });
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
describe("profiles API", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const DEFAULT_STATE_DIR = join("/home/testuser", ".openclaw");
|
||||
const WORK_STATE_DIR = join("/home/testuser", ".openclaw-work");
|
||||
const WORK_WORKSPACE_DIR = join(WORK_STATE_DIR, "workspace");
|
||||
|
||||
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;
|
||||
}
|
||||
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("lists discovered profiles and includes gateway metadata", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "work";
|
||||
const { existsSync, readFileSync, readdirSync } = await import("node:fs");
|
||||
it("lists discovered workspaces with gateway metadata", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
const { existsSync, readFileSync } = await import("node:fs");
|
||||
const mockExists = vi.mocked(existsSync);
|
||||
const mockReadFile = vi.mocked(readFileSync);
|
||||
const mockReaddir = vi.mocked(readdirSync);
|
||||
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent(".openclaw-work", true),
|
||||
makeDirent("Documents", true),
|
||||
] as unknown as Dirent[]);
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
|
||||
{
|
||||
name: "main",
|
||||
stateDir: STATE_DIR,
|
||||
workspaceDir: `${STATE_DIR}/workspace-main`,
|
||||
isActive: false,
|
||||
hasConfig: true,
|
||||
},
|
||||
{
|
||||
name: "work",
|
||||
stateDir: STATE_DIR,
|
||||
workspaceDir: `${STATE_DIR}/workspace-work`,
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(workspace.getActiveWorkspaceName).mockReturnValue("work");
|
||||
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return (
|
||||
s === DEFAULT_STATE_DIR ||
|
||||
s === join(DEFAULT_STATE_DIR, "openclaw.json") ||
|
||||
s === WORK_WORKSPACE_DIR ||
|
||||
s === join(WORK_STATE_DIR, "openclaw.json")
|
||||
);
|
||||
return s.endsWith("openclaw.json");
|
||||
});
|
||||
mockReadFile.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s === join(WORK_STATE_DIR, "openclaw.json")) {
|
||||
return JSON.stringify({ gateway: { mode: "local", port: 19001 } }) as never;
|
||||
}
|
||||
if (s === join(DEFAULT_STATE_DIR, "openclaw.json")) {
|
||||
if (s.includes("openclaw.json")) {
|
||||
return JSON.stringify({ gateway: { mode: "local", port: 18789 } }) as never;
|
||||
}
|
||||
return "" as never;
|
||||
@ -99,107 +73,128 @@ describe("profiles API", () => {
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
|
||||
expect(json.activeWorkspace).toBe("work");
|
||||
expect(json.activeProfile).toBe("work");
|
||||
const work = json.profiles.find((p: { name: string }) => p.name === "work");
|
||||
const def = json.profiles.find((p: { name: string }) => p.name === "default");
|
||||
expect(json.workspaces).toHaveLength(2);
|
||||
expect(json.profiles).toHaveLength(2);
|
||||
|
||||
const work = json.workspaces.find((w: { name: string }) => w.name === "work");
|
||||
expect(work).toMatchObject({
|
||||
name: "work",
|
||||
stateDir: WORK_STATE_DIR,
|
||||
workspaceDir: WORK_WORKSPACE_DIR,
|
||||
stateDir: STATE_DIR,
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
gateway: {
|
||||
mode: "local",
|
||||
port: 19001,
|
||||
url: "ws://127.0.0.1:19001",
|
||||
},
|
||||
});
|
||||
expect(def).toMatchObject({
|
||||
name: "default",
|
||||
stateDir: DEFAULT_STATE_DIR,
|
||||
isActive: false,
|
||||
hasConfig: true,
|
||||
gateway: {
|
||||
mode: "local",
|
||||
port: 18789,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
gateway: { mode: "local", port: 18789, url: "ws://127.0.0.1:18789" },
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to an existing profile", async () => {
|
||||
const { existsSync, readdirSync, readFileSync } = await import("node:fs");
|
||||
it("includes bootstrap-root workspace as default", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
const { existsSync, readFileSync } = await import("node:fs");
|
||||
const mockExists = vi.mocked(existsSync);
|
||||
const mockReaddir = vi.mocked(readdirSync);
|
||||
const mockReadFile = vi.mocked(readFileSync);
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([makeDirent(".openclaw-work", true)] as unknown as Dirent[]);
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === WORK_WORKSPACE_DIR;
|
||||
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
|
||||
{
|
||||
name: "default",
|
||||
stateDir: STATE_DIR,
|
||||
workspaceDir: `${STATE_DIR}/workspace`,
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(workspace.getActiveWorkspaceName).mockReturnValue("default");
|
||||
|
||||
mockExists.mockImplementation((p) => String(p).endsWith("openclaw.json"));
|
||||
mockReadFile.mockImplementation((p) => {
|
||||
if (String(p).includes("openclaw.json")) {
|
||||
return JSON.stringify({ gateway: { mode: "local", port: 19001 } }) as never;
|
||||
}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
|
||||
expect(json.activeWorkspace).toBe("default");
|
||||
expect(json.activeProfile).toBe("default");
|
||||
expect(json.workspaces).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "default",
|
||||
workspaceDir: `${STATE_DIR}/workspace`,
|
||||
gateway: { mode: "local", port: 19001, url: "ws://127.0.0.1:19001" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("switches to an existing workspace", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverWorkspaces)
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
name: "work",
|
||||
stateDir: STATE_DIR,
|
||||
workspaceDir: `${STATE_DIR}/workspace-work`,
|
||||
isActive: false,
|
||||
hasConfig: true,
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
name: "work",
|
||||
stateDir: STATE_DIR,
|
||||
workspaceDir: `${STATE_DIR}/workspace-work`,
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(workspace.getActiveWorkspaceName).mockReturnValue("work");
|
||||
vi.mocked(workspace.resolveOpenClawStateDir).mockReturnValue(STATE_DIR);
|
||||
vi.mocked(workspace.resolveWorkspaceRoot).mockReturnValue(`${STATE_DIR}/workspace-work`);
|
||||
|
||||
const { POST } = await import("./switch/route.js");
|
||||
const req = new Request("http://localhost/api/profiles/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: "work" }),
|
||||
body: JSON.stringify({ workspace: "work" }),
|
||||
});
|
||||
|
||||
const response = await POST(req);
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.activeWorkspace).toBe("work");
|
||||
expect(json.activeProfile).toBe("work");
|
||||
expect(json.stateDir).toBe(WORK_STATE_DIR);
|
||||
expect(json.workspaceRoot).toBe(WORK_WORKSPACE_DIR);
|
||||
expect(json.profile.name).toBe("work");
|
||||
expect(json.stateDir).toBe(STATE_DIR);
|
||||
expect(json.workspaceRoot).toBe(`${STATE_DIR}/workspace-work`);
|
||||
expect(json.workspace.name).toBe("work");
|
||||
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work");
|
||||
});
|
||||
|
||||
it("rejects invalid switch profile names", async () => {
|
||||
it("rejects invalid names", async () => {
|
||||
const { POST } = await import("./switch/route.js");
|
||||
const req = new Request("http://localhost/api/profiles/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: "../bad" }),
|
||||
body: JSON.stringify({ workspace: "../bad" }),
|
||||
});
|
||||
const response = await POST(req);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when switching to an unknown profile", async () => {
|
||||
const { readdirSync, readFileSync } = await import("node:fs");
|
||||
const mockReaddir = vi.mocked(readdirSync);
|
||||
const mockReadFile = vi.mocked(readFileSync);
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
|
||||
it("returns 404 for unknown workspace", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
|
||||
|
||||
const { POST } = await import("./switch/route.js");
|
||||
const req = new Request("http://localhost/api/profiles/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: "work" }),
|
||||
body: JSON.stringify({ workspace: "nonexistent" }),
|
||||
});
|
||||
const response = await POST(req);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when OPENCLAW_PROFILE forces a different profile", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "ironclaw";
|
||||
const { readdirSync } = await import("node:fs");
|
||||
const mockReaddir = vi.mocked(readdirSync);
|
||||
mockReaddir.mockReturnValue([makeDirent(".openclaw-work", true)] as unknown as Dirent[]);
|
||||
|
||||
const { POST } = await import("./switch/route.js");
|
||||
const req = new Request("http://localhost/api/profiles/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: "work" }),
|
||||
});
|
||||
const response = await POST(req);
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,72 +1,16 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
vi.mock("node:fs", () => ({
|
||||
rmSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
discoverProfiles: vi.fn(() => []),
|
||||
getEffectiveProfile: vi.fn(() => "default"),
|
||||
discoverWorkspaces: vi.fn(() => []),
|
||||
getActiveWorkspaceName: vi.fn(() => null),
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
setUIActiveWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
type MockSpawnChild = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
stdin: {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function mockSpawnResult(
|
||||
spawnMock: {
|
||||
mockImplementation: (
|
||||
implementation: (...args: unknown[]) => unknown,
|
||||
) => unknown;
|
||||
},
|
||||
params: {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
emitError?: Error | null;
|
||||
},
|
||||
): { getChild: () => MockSpawnChild | null } {
|
||||
let spawnedChild: MockSpawnChild | null = null;
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new EventEmitter() as MockSpawnChild;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
child.kill = vi.fn();
|
||||
spawnedChild = child;
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (params.stdout) {
|
||||
child.stdout.emit("data", Buffer.from(params.stdout));
|
||||
}
|
||||
if (params.stderr) {
|
||||
child.stderr.emit("data", Buffer.from(params.stderr));
|
||||
}
|
||||
if (params.emitError) {
|
||||
child.emit("error", params.emitError);
|
||||
return;
|
||||
}
|
||||
child.emit("close", params.code ?? 0);
|
||||
});
|
||||
|
||||
return child as unknown as ReturnType<typeof import("node:child_process").spawn>;
|
||||
});
|
||||
return {
|
||||
getChild: () => spawnedChild,
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/workspace/delete", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@ -83,86 +27,95 @@ describe("POST /api/workspace/delete", () => {
|
||||
return POST(req);
|
||||
}
|
||||
|
||||
it("returns 400 for invalid profile names", async () => {
|
||||
const response = await callDelete({ profile: "../bad" });
|
||||
expect(response.status).toBe(400);
|
||||
it("returns 400 for invalid workspace names (prevents traversal)", async () => {
|
||||
const res1 = await callDelete({ workspace: "../bad" });
|
||||
expect(res1.status).toBe(400);
|
||||
|
||||
const res2 = await callDelete({ profile: "../../etc" });
|
||||
expect(res2.status).toBe(400);
|
||||
|
||||
const res3 = await callDelete({});
|
||||
expect(res3.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when profile does not exist", async () => {
|
||||
it("returns 404 when workspace does not exist", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverProfiles).mockReturnValue([]);
|
||||
const response = await callDelete({ profile: "work" });
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
|
||||
|
||||
const response = await callDelete({ workspace: "work" });
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when profile has no workspace directory", async () => {
|
||||
it("returns 409 when workspace has no directory to delete", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverProfiles).mockReturnValue([
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
|
||||
{
|
||||
name: "work",
|
||||
stateDir: "/home/testuser/.openclaw-work",
|
||||
stateDir: "/home/testuser/.openclaw-ironclaw",
|
||||
workspaceDir: null,
|
||||
isActive: false,
|
||||
hasConfig: true,
|
||||
},
|
||||
]);
|
||||
const response = await callDelete({ profile: "work" });
|
||||
|
||||
const response = await callDelete({ workspace: "work" });
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
|
||||
it("runs openclaw workspace delete for the selected profile", async () => {
|
||||
it("deletes workspace directory directly via rmSync", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
const { spawn } = await import("node:child_process");
|
||||
vi.mocked(workspace.discoverProfiles).mockReturnValue([
|
||||
{
|
||||
name: "work",
|
||||
stateDir: "/home/testuser/.openclaw-work",
|
||||
workspaceDir: "/home/testuser/.openclaw-work/workspace",
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(workspace.getEffectiveProfile).mockReturnValue("work");
|
||||
vi.mocked(workspace.resolveWorkspaceRoot).mockReturnValue("/home/testuser/.openclaw-work/workspace");
|
||||
const spawnResult = mockSpawnResult(vi.mocked(spawn), { code: 0 });
|
||||
const { rmSync } = await import("node:fs");
|
||||
const workspaceDir = "/home/testuser/.openclaw-ironclaw/workspace-work";
|
||||
|
||||
const response = await callDelete({ profile: "work" });
|
||||
vi.mocked(workspace.discoverWorkspaces)
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
name: "work",
|
||||
stateDir: "/home/testuser/.openclaw-ironclaw",
|
||||
workspaceDir,
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([]);
|
||||
vi.mocked(workspace.getActiveWorkspaceName)
|
||||
.mockReturnValueOnce("work")
|
||||
.mockReturnValueOnce(null);
|
||||
vi.mocked(workspace.resolveWorkspaceRoot).mockReturnValue(null);
|
||||
|
||||
const response = await callDelete({ workspace: "work" });
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.deleted).toBe(true);
|
||||
expect(json.workspace).toBe("work");
|
||||
expect(json.profile).toBe("work");
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"openclaw",
|
||||
["--profile", "work", "workspace", "delete"],
|
||||
expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] }),
|
||||
);
|
||||
const child = spawnResult.getChild();
|
||||
expect(child).toBeTruthy();
|
||||
expect(child?.stdin.write).toHaveBeenCalledWith("y\n");
|
||||
expect(child?.stdin.write).toHaveBeenCalledWith("yes\n");
|
||||
expect(child?.stdin.end).toHaveBeenCalled();
|
||||
expect(rmSync).toHaveBeenCalledWith(workspaceDir, { recursive: true, force: false });
|
||||
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("returns 501 when workspace delete command is unavailable", async () => {
|
||||
it("returns 500 when rmSync fails", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
const { spawn } = await import("node:child_process");
|
||||
vi.mocked(workspace.discoverProfiles).mockReturnValue([
|
||||
const { rmSync } = await import("node:fs");
|
||||
const workspaceDir = "/home/testuser/.openclaw-ironclaw/workspace-work";
|
||||
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
|
||||
{
|
||||
name: "work",
|
||||
stateDir: "/home/testuser/.openclaw-work",
|
||||
workspaceDir: "/home/testuser/.openclaw-work/workspace",
|
||||
stateDir: "/home/testuser/.openclaw-ironclaw",
|
||||
workspaceDir,
|
||||
isActive: false,
|
||||
hasConfig: true,
|
||||
},
|
||||
]);
|
||||
mockSpawnResult(vi.mocked(spawn), {
|
||||
code: 0,
|
||||
stdout:
|
||||
"Usage: openclaw [options] [command]\nHint: commands suffixed with * have subcommands",
|
||||
vi.mocked(rmSync).mockImplementation(() => {
|
||||
throw new Error("EPERM: operation not permitted");
|
||||
});
|
||||
|
||||
const response = await callDelete({ profile: "work" });
|
||||
expect(response.status).toBe(501);
|
||||
const response = await callDelete({ workspace: "work" });
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(String(json.error)).toContain("EPERM");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
@ -13,101 +13,27 @@ vi.mock("node:fs", () => ({
|
||||
cpSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout: "" });
|
||||
},
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
discoverWorkspaces: vi.fn(() => []),
|
||||
setUIActiveWorkspace: vi.fn(),
|
||||
getActiveWorkspaceName: vi.fn(() => "work"),
|
||||
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-ironclaw"),
|
||||
resolveWorkspaceDirForName: vi.fn((name: string) =>
|
||||
join("/home/testuser/.openclaw-ironclaw", `workspace-${name}`),
|
||||
),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
isValidWorkspaceName: vi.fn(() => true),
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe("POST /api/workspace/init", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const HOME = "/home/testuser";
|
||||
const IRONCLAW_STATE = join(HOME, ".openclaw-ironclaw");
|
||||
const WORK_STATE = join(HOME, ".openclaw-work");
|
||||
const IRONCLAW_CONFIG = join(IRONCLAW_STATE, "openclaw.json");
|
||||
const IRONCLAW_AUTH = join(IRONCLAW_STATE, "agents", "main", "agent", "auth-profiles.json");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function mockSpawnExit(code: number, stderr = "") {
|
||||
return vi.fn(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
if (stderr) {
|
||||
child.stderr.emit("data", Buffer.from(stderr));
|
||||
}
|
||||
child.emit("close", code);
|
||||
});
|
||||
return child as unknown as ReturnType<typeof import("node:child_process").spawn>;
|
||||
});
|
||||
}
|
||||
|
||||
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", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout: "" });
|
||||
},
|
||||
),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -124,128 +50,114 @@ describe("POST /api/workspace/init", () => {
|
||||
return POST(req);
|
||||
}
|
||||
|
||||
it("rejects missing or invalid profile names", async () => {
|
||||
const missing = await callInit({});
|
||||
expect(missing.status).toBe(400);
|
||||
|
||||
const invalid = await callInit({ profile: "../bad" });
|
||||
expect(invalid.status).toBe(400);
|
||||
it("rejects missing workspace name (400)", async () => {
|
||||
const response = await callInit({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 when the profile already exists", async () => {
|
||||
const { readdirSync, readFileSync } = await import("node:fs");
|
||||
const mockReaddir = vi.mocked(readdirSync);
|
||||
const mockReadFile = vi.mocked(readFileSync);
|
||||
mockReaddir.mockReturnValue([makeDirent(".openclaw-work", true)] as unknown as Dirent[]);
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
it("rejects custom path parameter (prevents custom workspace locations)", async () => {
|
||||
const response = await callInit({ workspace: "work", path: "/tmp/custom" });
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(String(json.error)).toContain("Custom workspace paths");
|
||||
});
|
||||
|
||||
const response = await callInit({ profile: "work" });
|
||||
it("rejects invalid workspace names (400)", async () => {
|
||||
const response = await callInit({ workspace: "../bad" });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 when workspace already exists", async () => {
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([
|
||||
{
|
||||
name: "work",
|
||||
stateDir: STATE_DIR,
|
||||
workspaceDir: join(STATE_DIR, "workspace-work"),
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await callInit({ workspace: "work" });
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
|
||||
it("creates a profile, copies config/auth, allocates gateway port, and runs onboard", async () => {
|
||||
const { existsSync, readFileSync, readdirSync, copyFileSync } = await import("node:fs");
|
||||
const { spawn } = await import("node:child_process");
|
||||
const mockExists = vi.mocked(existsSync);
|
||||
const mockReadFile = vi.mocked(readFileSync);
|
||||
const mockReaddir = vi.mocked(readdirSync);
|
||||
const mockCopyFile = vi.mocked(copyFileSync);
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
it("creates workspace directory at ~/.openclaw-ironclaw/workspace-<name> (enforces fixed layout)", async () => {
|
||||
const { mkdirSync, writeFileSync } = await import("node:fs");
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
|
||||
|
||||
mockSpawn.mockImplementation(mockSpawnExit(0));
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent(".openclaw-ironclaw", true),
|
||||
makeDirent("Documents", true),
|
||||
] as unknown as Dirent[]);
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return (
|
||||
s === IRONCLAW_CONFIG ||
|
||||
s === IRONCLAW_AUTH ||
|
||||
s.endsWith("docs/reference/templates/AGENTS.md") ||
|
||||
s.endsWith("assets/seed/workspace.duckdb")
|
||||
);
|
||||
});
|
||||
mockReadFile.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s === IRONCLAW_CONFIG) {
|
||||
return JSON.stringify({ gateway: { mode: "local", port: 18789 } }) as never;
|
||||
}
|
||||
if (s.endsWith("/openclaw.json")) {
|
||||
return JSON.stringify({}) as never;
|
||||
}
|
||||
if (s.endsWith("/AGENTS.md")) {
|
||||
return "# AGENTS\n" as never;
|
||||
}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const response = await callInit({
|
||||
profile: "work",
|
||||
seedBootstrap: true,
|
||||
copyConfigAuth: true,
|
||||
});
|
||||
const response = await callInit({ workspace: "work" });
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.profile).toBe("work");
|
||||
expect(json.stateDir).toBe(WORK_STATE);
|
||||
expect(json.gatewayPort).toBe(18809);
|
||||
expect(json.copiedFiles).toEqual(
|
||||
expect.arrayContaining(["openclaw.json", "agents/main/agent/auth-profiles.json"]),
|
||||
);
|
||||
expect(json.activeProfile).toBe("work");
|
||||
|
||||
const onboardCall = mockSpawn.mock.calls.find(
|
||||
(call) =>
|
||||
String(call[0]) === "openclaw" &&
|
||||
Array.isArray(call[1]) &&
|
||||
(call[1] as string[]).includes("onboard"),
|
||||
);
|
||||
expect(onboardCall).toBeTruthy();
|
||||
const args = onboardCall?.[1] as string[];
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--profile",
|
||||
"work",
|
||||
"onboard",
|
||||
"--install-daemon",
|
||||
"--gateway-port",
|
||||
"18809",
|
||||
"--non-interactive",
|
||||
"--accept-risk",
|
||||
"--skip-ui",
|
||||
]),
|
||||
);
|
||||
expect(mockCopyFile).toHaveBeenCalledWith(IRONCLAW_CONFIG, join(WORK_STATE, "openclaw.json"));
|
||||
expect(mockCopyFile).toHaveBeenCalledWith(
|
||||
IRONCLAW_AUTH,
|
||||
join(WORK_STATE, "agents", "main", "agent", "auth-profiles.json"),
|
||||
);
|
||||
const json = await response.json();
|
||||
expect(json.workspace).toBe("work");
|
||||
expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace-work"));
|
||||
expect(json.activeWorkspace).toBe("work");
|
||||
expect(json.profile).toBe("work");
|
||||
|
||||
expect(mkdirSync).toHaveBeenCalledWith(STATE_DIR, { recursive: true });
|
||||
expect(mkdirSync).toHaveBeenCalledWith(join(STATE_DIR, "workspace-work"), { recursive: false });
|
||||
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work");
|
||||
expect(writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 500 when onboard fails", async () => {
|
||||
const { readdirSync, readFileSync, existsSync } = await import("node:fs");
|
||||
const { spawn } = await import("node:child_process");
|
||||
const mockReaddir = vi.mocked(readdirSync);
|
||||
const mockReadFile = vi.mocked(readFileSync);
|
||||
const mockExists = vi.mocked(existsSync);
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
it("seeds Dench skill into workspace/skills/dench/SKILL.md (not state dir)", async () => {
|
||||
const { existsSync, cpSync, mkdirSync } = await import("node:fs");
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
|
||||
|
||||
mockSpawn.mockImplementation(mockSpawnExit(1, "onboard error"));
|
||||
mockReaddir.mockReturnValue([makeDirent(".openclaw-ironclaw", true)] as unknown as Dirent[]);
|
||||
mockExists.mockImplementation((p) => String(p) === IRONCLAW_CONFIG || String(p) === IRONCLAW_AUTH);
|
||||
mockReadFile.mockImplementation((p) => {
|
||||
if (String(p) === IRONCLAW_CONFIG) {
|
||||
return JSON.stringify({ gateway: { port: 18789 } }) as never;
|
||||
}
|
||||
return "" as never;
|
||||
const workspaceDir = join(STATE_DIR, "workspace-work");
|
||||
vi.mocked(existsSync).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("docs/reference/templates/AGENTS.md")) return true;
|
||||
if (s.endsWith("skills/dench/SKILL.md")) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const response = await callInit({ profile: "work" });
|
||||
expect(response.status).toBe(500);
|
||||
const response = await callInit({ workspace: "work" });
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(String(json.error)).toContain("onboarding failed");
|
||||
expect(json.denchSynced).toBe(true);
|
||||
|
||||
const cpSyncCalls = vi.mocked(cpSync).mock.calls;
|
||||
const denchCopy = cpSyncCalls.find(
|
||||
(call) => String(call[1]).includes(join(workspaceDir, "skills", "dench")),
|
||||
);
|
||||
expect(denchCopy).toBeTruthy();
|
||||
|
||||
const mkdirCalls = vi.mocked(mkdirSync).mock.calls;
|
||||
const skillsMkdir = mkdirCalls.find(
|
||||
(call) => String(call[0]).includes(join(workspaceDir, "skills")),
|
||||
);
|
||||
expect(skillsMkdir).toBeTruthy();
|
||||
});
|
||||
|
||||
it("generates IDENTITY.md referencing workspace CRM skill path (not virtual ~skills path)", async () => {
|
||||
const { existsSync, writeFileSync } = await import("node:fs");
|
||||
const workspace = await import("@/lib/workspace");
|
||||
vi.mocked(workspace.discoverWorkspaces).mockReturnValue([]);
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("docs/reference/templates/AGENTS.md")) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const response = await callInit({ workspace: "work" });
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const workspaceDir = join(STATE_DIR, "workspace-work");
|
||||
const expectedSkillPath = join(workspaceDir, "skills", "dench", "SKILL.md");
|
||||
const identityWrites = vi.mocked(writeFileSync).mock.calls.filter(
|
||||
(call) => String(call[0]).endsWith("IDENTITY.md"),
|
||||
);
|
||||
expect(identityWrites.length).toBeGreaterThan(0);
|
||||
const identityContent = String(identityWrites[identityWrites.length - 1][1]);
|
||||
expect(identityContent).toContain(expectedSkillPath);
|
||||
expect(identityContent).toContain("Ironclaw");
|
||||
expect(identityContent).not.toContain("~skills");
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,18 +23,28 @@ describe("ProfileSwitcher workspace delete action", () => {
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it("deletes a profile workspace from the dropdown action", async () => {
|
||||
it("deletes a workspace from the dropdown action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onWorkspaceDelete = vi.fn();
|
||||
let profileFetchCount = 0;
|
||||
let listFetchCount = 0;
|
||||
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : (input as URL).href;
|
||||
const method = init?.method ?? "GET";
|
||||
if (url === "/api/profiles" && method === "GET") {
|
||||
profileFetchCount += 1;
|
||||
if (profileFetchCount === 1) {
|
||||
if (url === "/api/workspace/list" && method === "GET") {
|
||||
listFetchCount += 1;
|
||||
if (listFetchCount === 1) {
|
||||
return jsonResponse({
|
||||
activeWorkspace: "work",
|
||||
workspaces: [
|
||||
{
|
||||
name: "work",
|
||||
stateDir: "/home/testuser/.openclaw-work",
|
||||
workspaceDir: "/home/testuser/.openclaw-work/workspace",
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
],
|
||||
activeProfile: "work",
|
||||
profiles: [
|
||||
{
|
||||
@ -48,6 +58,16 @@ describe("ProfileSwitcher workspace delete action", () => {
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
activeWorkspace: "work",
|
||||
workspaces: [
|
||||
{
|
||||
name: "work",
|
||||
stateDir: "/home/testuser/.openclaw-work",
|
||||
workspaceDir: null,
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
],
|
||||
activeProfile: "work",
|
||||
profiles: [
|
||||
{
|
||||
@ -61,7 +81,7 @@ describe("ProfileSwitcher workspace delete action", () => {
|
||||
});
|
||||
}
|
||||
if (url === "/api/workspace/delete" && method === "POST") {
|
||||
return jsonResponse({ deleted: true, profile: "work" });
|
||||
return jsonResponse({ deleted: true, workspace: "work" });
|
||||
}
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`);
|
||||
}) as typeof fetch;
|
||||
@ -70,11 +90,17 @@ describe("ProfileSwitcher workspace delete action", () => {
|
||||
|
||||
render(<ProfileSwitcher onWorkspaceDelete={onWorkspaceDelete} />);
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/profiles");
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/workspace/list");
|
||||
});
|
||||
|
||||
await user.click(screen.getByTitle("Switch workspace profile"));
|
||||
await user.click(screen.getByTitle("Delete workspace for work"));
|
||||
await user.click(screen.getByTitle("Switch workspace"));
|
||||
await user.click(screen.getByTitle("Delete workspace work"));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledTimes(1);
|
||||
const confirmMsg = vi.mocked(window.confirm).mock.calls[0]?.[0] as string;
|
||||
expect(confirmMsg).toContain("Delete workspace");
|
||||
expect(confirmMsg).toContain("work");
|
||||
expect(confirmMsg).not.toContain("uninstall --workspace --state");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onWorkspaceDelete).toHaveBeenCalledWith("work");
|
||||
@ -87,5 +113,53 @@ describe("ProfileSwitcher workspace delete action", () => {
|
||||
expect(deleteCall?.[1]).toMatchObject({
|
||||
method: "POST",
|
||||
});
|
||||
const deleteBody = JSON.parse(deleteCall?.[1]?.body as string);
|
||||
expect(deleteBody).toMatchObject({ workspace: "work" });
|
||||
});
|
||||
|
||||
it("falls back to a real workspace when API returns stale active workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : (input as URL).href;
|
||||
const method = init?.method ?? "GET";
|
||||
if (url === "/api/workspace/list" && method === "GET") {
|
||||
return jsonResponse({
|
||||
activeWorkspace: "ghost",
|
||||
workspaces: [
|
||||
{
|
||||
name: "ghost",
|
||||
stateDir: "/home/testuser/.openclaw-ironclaw",
|
||||
workspaceDir: null,
|
||||
isActive: true,
|
||||
hasConfig: true,
|
||||
},
|
||||
{
|
||||
name: "ironclaw",
|
||||
stateDir: "/home/testuser/.openclaw-ironclaw",
|
||||
workspaceDir: "/home/testuser/.openclaw-ironclaw/workspace",
|
||||
isActive: false,
|
||||
hasConfig: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<ProfileSwitcher />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/workspace/list");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ironclaw")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ghost")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTitle("Switch workspace"));
|
||||
expect(screen.queryByTitle("Delete workspace ghost")).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("Delete workspace ironclaw")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
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")>();
|
||||
@ -47,13 +48,32 @@ vi.mock("node:os", () => ({
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
describe("profile-scoped chat session isolation", () => {
|
||||
describe("workspace-scoped chat session isolation", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const DEFAULT_STATE_DIR = join("/home/testuser", ".openclaw");
|
||||
const stateDirForProfile = (profile: string | null) =>
|
||||
!profile || profile.toLowerCase() === "default"
|
||||
? DEFAULT_STATE_DIR
|
||||
: join("/home/testuser", `.openclaw-${profile}`);
|
||||
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
|
||||
|
||||
const workspaceDir = (name: string) =>
|
||||
name === "default"
|
||||
? join(STATE_DIR, "workspace")
|
||||
: join(STATE_DIR, `workspace-${name}`);
|
||||
|
||||
const chatDir = (name: string) =>
|
||||
join(workspaceDir(name), ".openclaw", "web-chat");
|
||||
|
||||
function makeDirent(name: string): Dirent {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isBlockDevice: () => false,
|
||||
isCharacterDevice: () => false,
|
||||
isFIFO: () => false,
|
||||
isSocket: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
path: "",
|
||||
parentPath: "",
|
||||
} as Dirent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@ -113,7 +133,7 @@ describe("profile-scoped chat session isolation", () => {
|
||||
});
|
||||
|
||||
async function importWorkspace() {
|
||||
const { readFileSync: rfs, writeFileSync: wfs, existsSync: es } =
|
||||
const { readFileSync: rfs, writeFileSync: wfs, existsSync: es, readdirSync: rds } =
|
||||
await import("node:fs");
|
||||
const mod = await import("./workspace.js");
|
||||
return {
|
||||
@ -121,97 +141,190 @@ describe("profile-scoped chat session isolation", () => {
|
||||
mockReadFile: vi.mocked(rfs),
|
||||
mockWriteFile: vi.mocked(wfs),
|
||||
mockExists: vi.mocked(es),
|
||||
mockReaddir: vi.mocked(rds),
|
||||
};
|
||||
}
|
||||
|
||||
it("default profile uses web-chat directory", async () => {
|
||||
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
|
||||
it("active workspace uses <workspace>/.openclaw/web-chat", async () => {
|
||||
const { resolveWebChatDir, setUIActiveWorkspace, mockExists, mockReadFile, mockReaddir } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent("workspace-dev"),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const wsDir = workspaceDir("dev");
|
||||
mockExists.mockImplementation((p) => String(p) === wsDir);
|
||||
|
||||
setUIActiveWorkspace("dev");
|
||||
expect(resolveWebChatDir()).toBe(chatDir("dev"));
|
||||
});
|
||||
|
||||
it("named profile uses profile-scoped web-chat directory", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
setUIActiveProfile("work");
|
||||
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("work"), "web-chat"));
|
||||
});
|
||||
it("different workspaces produce different chat directories", async () => {
|
||||
const {
|
||||
resolveWebChatDir,
|
||||
setUIActiveWorkspace,
|
||||
clearUIActiveWorkspaceCache,
|
||||
mockExists,
|
||||
mockReadFile,
|
||||
mockReaddir,
|
||||
} = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent("workspace-alpha"),
|
||||
makeDirent("workspace-beta"),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
it("different profiles produce different chat directories", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, clearUIActiveProfileCache, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
const alphaDir = workspaceDir("alpha");
|
||||
const betaDir = workspaceDir("beta");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === alphaDir || s === betaDir;
|
||||
});
|
||||
|
||||
setUIActiveProfile("alpha");
|
||||
setUIActiveWorkspace("alpha");
|
||||
const dirAlpha = resolveWebChatDir();
|
||||
|
||||
clearUIActiveProfileCache();
|
||||
setUIActiveProfile("beta");
|
||||
clearUIActiveWorkspaceCache();
|
||||
setUIActiveWorkspace("beta");
|
||||
const dirBeta = resolveWebChatDir();
|
||||
|
||||
expect(dirAlpha).not.toBe(dirBeta);
|
||||
expect(dirAlpha).toBe(join(stateDirForProfile("alpha"), "web-chat"));
|
||||
expect(dirBeta).toBe(join(stateDirForProfile("beta"), "web-chat"));
|
||||
expect(dirAlpha).toBe(chatDir("alpha"));
|
||||
expect(dirBeta).toBe(chatDir("beta"));
|
||||
});
|
||||
|
||||
it("switching to default after named profile reverts to base dir", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
|
||||
setUIActiveProfile("work");
|
||||
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("work"), "web-chat"));
|
||||
|
||||
setUIActiveProfile(null);
|
||||
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
||||
});
|
||||
|
||||
it("'default' profile name uses base web-chat dir (case-insensitive)", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
|
||||
setUIActiveProfile("Default");
|
||||
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
||||
|
||||
setUIActiveProfile("DEFAULT");
|
||||
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
||||
});
|
||||
|
||||
it("OPENCLAW_STATE_DIR override changes base for chat dirs", async () => {
|
||||
process.env.OPENCLAW_STATE_DIR = "/custom/state";
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
|
||||
it("switching workspaces changes chat directory", async () => {
|
||||
const { resolveWebChatDir, setUIActiveWorkspace, mockExists, mockReadFile, mockReaddir } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat"));
|
||||
|
||||
setUIActiveProfile("test");
|
||||
expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat"));
|
||||
});
|
||||
|
||||
it("workspace roots are isolated per profile too", async () => {
|
||||
const { resolveWorkspaceRoot, setUIActiveProfile, clearUIActiveProfileCache, mockExists, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
|
||||
const defaultWs = join(DEFAULT_STATE_DIR, "workspace");
|
||||
const workWs = join(stateDirForProfile("work"), "workspace");
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent("workspace-work"),
|
||||
makeDirent("workspace-personal"),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const workDir = workspaceDir("work");
|
||||
const personalDir = workspaceDir("personal");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === defaultWs || s === workWs;
|
||||
return s === workDir || s === personalDir;
|
||||
});
|
||||
|
||||
clearUIActiveProfileCache();
|
||||
setUIActiveProfile(null);
|
||||
expect(resolveWorkspaceRoot()).toBe(defaultWs);
|
||||
setUIActiveWorkspace("work");
|
||||
expect(resolveWebChatDir()).toBe(chatDir("work"));
|
||||
|
||||
setUIActiveWorkspace("personal");
|
||||
expect(resolveWebChatDir()).toBe(chatDir("personal"));
|
||||
});
|
||||
|
||||
it("falls back to default root workspace when nothing is active", async () => {
|
||||
const { resolveWebChatDir, mockReadFile, mockExists, mockReaddir } = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
|
||||
mockExists.mockReturnValue(false);
|
||||
|
||||
expect(resolveWebChatDir()).toBe(chatDir("default"));
|
||||
});
|
||||
|
||||
it("workspace roots are isolated per workspace", async () => {
|
||||
const {
|
||||
resolveWorkspaceRoot,
|
||||
setUIActiveWorkspace,
|
||||
clearUIActiveWorkspaceCache,
|
||||
mockExists,
|
||||
mockReadFile,
|
||||
mockReaddir,
|
||||
} = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent("workspace-dev"),
|
||||
makeDirent("workspace-staging"),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const devDir = workspaceDir("dev");
|
||||
const stagingDir = workspaceDir("staging");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === devDir || s === stagingDir;
|
||||
});
|
||||
|
||||
clearUIActiveWorkspaceCache();
|
||||
setUIActiveWorkspace("dev");
|
||||
expect(resolveWorkspaceRoot()).toBe(devDir);
|
||||
|
||||
setUIActiveWorkspace("staging");
|
||||
expect(resolveWorkspaceRoot()).toBe(stagingDir);
|
||||
});
|
||||
|
||||
it("setUIActiveProfile compat shim delegates to workspace", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockExists, mockReadFile, mockReaddir } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent("workspace-work"),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const wsDir = workspaceDir("work");
|
||||
mockExists.mockImplementation((p) => String(p) === wsDir);
|
||||
|
||||
setUIActiveProfile("work");
|
||||
expect(resolveWorkspaceRoot()).toBe(workWs);
|
||||
expect(resolveWebChatDir()).toBe(chatDir("work"));
|
||||
});
|
||||
|
||||
it("setUIActiveProfile('default') selects the root workspace", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockReadFile, mockExists, mockReaddir } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
const rootDir = workspaceDir("default");
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent("workspace"),
|
||||
] as unknown as Dirent[]);
|
||||
mockExists.mockImplementation((p) => String(p) === rootDir);
|
||||
|
||||
setUIActiveProfile("default");
|
||||
expect(resolveWebChatDir()).toBe(chatDir("default"));
|
||||
});
|
||||
|
||||
it("clearUIActiveProfileCache delegates to clearUIActiveWorkspaceCache", async () => {
|
||||
const {
|
||||
resolveWebChatDir,
|
||||
setUIActiveWorkspace,
|
||||
clearUIActiveProfileCache,
|
||||
mockExists,
|
||||
mockReadFile,
|
||||
mockReaddir,
|
||||
} = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent("workspace-dev"),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const devDir = workspaceDir("dev");
|
||||
mockExists.mockImplementation((p) => String(p) === devDir);
|
||||
|
||||
setUIActiveWorkspace("dev");
|
||||
expect(resolveWebChatDir()).toBe(chatDir("dev"));
|
||||
|
||||
clearUIActiveProfileCache();
|
||||
mockExists.mockReturnValue(false);
|
||||
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
|
||||
|
||||
expect(resolveWebChatDir()).toBe(chatDir("default"));
|
||||
});
|
||||
});
|
||||
|
||||
@ -63,14 +63,10 @@ function makeDirent(name: string, isDir: boolean): Dirent {
|
||||
} as Dirent;
|
||||
}
|
||||
|
||||
describe("workspace profiles", () => {
|
||||
describe("workspace (flat workspace model)", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const DEFAULT_STATE_DIR = join("/home/testuser", ".openclaw");
|
||||
const stateDirForProfile = (profile: string | null) =>
|
||||
!profile || profile.toLowerCase() === "default"
|
||||
? DEFAULT_STATE_DIR
|
||||
: join("/home/testuser", `.openclaw-${profile}`);
|
||||
const UI_STATE_PATH = join(DEFAULT_STATE_DIR, ".ironclaw-ui-state.json");
|
||||
const STATE_DIR = "/home/testuser/.openclaw-ironclaw";
|
||||
const UI_STATE_PATH = join(STATE_DIR, ".ironclaw-ui-state.json");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@ -151,519 +147,480 @@ describe("workspace profiles", () => {
|
||||
// ─── getEffectiveProfile ──────────────────────────────────────────
|
||||
|
||||
describe("getEffectiveProfile", () => {
|
||||
it("returns env var when OPENCLAW_PROFILE is set", async () => {
|
||||
it("always returns 'ironclaw' regardless of env/state (single profile enforcement)", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "work";
|
||||
const { getEffectiveProfile } = await importWorkspace();
|
||||
expect(getEffectiveProfile()).toBe("work");
|
||||
});
|
||||
|
||||
it("returns null when nothing is set", async () => {
|
||||
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(getEffectiveProfile()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns persisted profile from state file", async () => {
|
||||
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({ activeProfile: "personal" }) as never,
|
||||
);
|
||||
expect(getEffectiveProfile()).toBe("personal");
|
||||
});
|
||||
|
||||
it("env var takes precedence over persisted file", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "env-profile";
|
||||
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({ activeProfile: "file-profile" }) as never,
|
||||
);
|
||||
expect(getEffectiveProfile()).toBe("env-profile");
|
||||
});
|
||||
|
||||
it("in-memory override takes precedence over persisted file", async () => {
|
||||
const { getEffectiveProfile, setUIActiveProfile, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({ activeProfile: "file-profile" }) as never,
|
||||
JSON.stringify({ activeWorkspace: "something" }) as never,
|
||||
);
|
||||
setUIActiveProfile("memory-profile");
|
||||
expect(getEffectiveProfile()).toBe("memory-profile");
|
||||
});
|
||||
|
||||
it("env var takes precedence over in-memory override", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "env-wins";
|
||||
const { getEffectiveProfile, setUIActiveProfile } =
|
||||
await importWorkspace();
|
||||
setUIActiveProfile("memory-profile");
|
||||
expect(getEffectiveProfile()).toBe("env-wins");
|
||||
});
|
||||
|
||||
it("trims whitespace from env var", async () => {
|
||||
process.env.OPENCLAW_PROFILE = " padded ";
|
||||
const { getEffectiveProfile } = await importWorkspace();
|
||||
expect(getEffectiveProfile()).toBe("padded");
|
||||
});
|
||||
|
||||
it("trims whitespace from persisted profile", async () => {
|
||||
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({ activeProfile: " trimme " }) as never,
|
||||
);
|
||||
expect(getEffectiveProfile()).toBe("trimme");
|
||||
});
|
||||
|
||||
it("uses persisted profile in non-test runtime", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.VITEST = "false";
|
||||
const { getEffectiveProfile, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({ activeProfile: "personal" }) as never,
|
||||
);
|
||||
expect(getEffectiveProfile()).toBe("personal");
|
||||
setUIActiveProfile("custom");
|
||||
expect(getEffectiveProfile()).toBe("ironclaw");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setUIActiveProfile ──────────────────────────────────────────
|
||||
// ─── getActiveWorkspaceName ───────────────────────────────────────
|
||||
|
||||
describe("setUIActiveProfile", () => {
|
||||
it("persists profile to state file", async () => {
|
||||
const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } =
|
||||
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);
|
||||
setUIActiveProfile("work");
|
||||
setUIActiveWorkspace("dev");
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
UI_STATE_PATH,
|
||||
expect.stringContaining('"activeProfile": "work"'),
|
||||
expect.stringContaining('"activeWorkspace": "dev"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("null clears the override", async () => {
|
||||
const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } =
|
||||
const { setUIActiveWorkspace, mockReadFile, mockWriteFile, mockExists } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
mockExists.mockReturnValue(true);
|
||||
setUIActiveProfile(null);
|
||||
setUIActiveWorkspace(null);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
UI_STATE_PATH,
|
||||
expect.stringContaining('"activeProfile": null'),
|
||||
expect.stringContaining('"activeWorkspace": null'),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves existing state keys", async () => {
|
||||
const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({
|
||||
workspaceRegistry: { other: "/path" },
|
||||
}) as never,
|
||||
);
|
||||
mockExists.mockReturnValue(true);
|
||||
setUIActiveProfile("new");
|
||||
const stateWrites = mockWriteFile.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes(".ironclaw-ui-state.json"),
|
||||
);
|
||||
expect(stateWrites.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string);
|
||||
expect(parsed.workspaceRegistry).toEqual({ other: "/path" });
|
||||
expect(parsed.activeProfile).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── clearUIActiveProfileCache ────────────────────────────────────
|
||||
// ─── clearUIActiveWorkspaceCache ──────────────────────────────────
|
||||
|
||||
describe("clearUIActiveProfileCache", () => {
|
||||
it("re-reads from file after clearing", async () => {
|
||||
describe("clearUIActiveWorkspaceCache", () => {
|
||||
it("re-reads from file after clearing in-memory override", async () => {
|
||||
const {
|
||||
getEffectiveProfile,
|
||||
setUIActiveProfile,
|
||||
clearUIActiveProfileCache,
|
||||
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({ activeProfile: "from-file" }) as never,
|
||||
JSON.stringify({ activeWorkspace: "from-file" }) as never,
|
||||
);
|
||||
setUIActiveProfile("in-memory");
|
||||
expect(getEffectiveProfile()).toBe("in-memory");
|
||||
setUIActiveWorkspace("in-memory");
|
||||
expect(getActiveWorkspaceName()).toBe("in-memory");
|
||||
|
||||
clearUIActiveProfileCache();
|
||||
expect(getEffectiveProfile()).toBe("from-file");
|
||||
clearUIActiveWorkspaceCache();
|
||||
expect(getActiveWorkspaceName()).toBe("from-file");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── discoverProfiles ─────────────────────────────────────────────
|
||||
// ─── discoverWorkspaces ───────────────────────────────────────────
|
||||
|
||||
describe("discoverProfiles", () => {
|
||||
it("always includes default profile", async () => {
|
||||
const { discoverProfiles, mockExists } = await importWorkspace();
|
||||
mockExists.mockReturnValue(false);
|
||||
const profiles = discoverProfiles();
|
||||
expect(profiles).toHaveLength(1);
|
||||
expect(profiles[0].name).toBe("default");
|
||||
});
|
||||
|
||||
it("default profile is active when no profile set", async () => {
|
||||
const { discoverProfiles, clearUIActiveProfileCache, mockExists, mockReadFile } =
|
||||
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");
|
||||
});
|
||||
mockExists.mockReturnValue(false);
|
||||
clearUIActiveProfileCache();
|
||||
const profiles = discoverProfiles();
|
||||
expect(profiles[0].isActive).toBe(true);
|
||||
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
|
||||
const workspaces = discoverWorkspaces();
|
||||
expect(workspaces).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("discovers profile-scoped .openclaw-<name> state directories", async () => {
|
||||
const { discoverProfiles, mockExists, mockReaddir } =
|
||||
it("discovers workspace-<name> directories under state dir", async () => {
|
||||
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
|
||||
await importWorkspace();
|
||||
const workStateDir = stateDirForProfile("work");
|
||||
const personalStateDir = stateDirForProfile("personal");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return (
|
||||
s === DEFAULT_STATE_DIR ||
|
||||
s === join(DEFAULT_STATE_DIR, "openclaw.json") ||
|
||||
s === join(workStateDir, "workspace") ||
|
||||
s === join(personalStateDir, "workspace")
|
||||
);
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent(".openclaw-work", true),
|
||||
makeDirent(".openclaw-personal", true),
|
||||
makeDirent("sessions", true),
|
||||
makeDirent("workspace-alpha", true),
|
||||
makeDirent("workspace-beta", true),
|
||||
makeDirent("some-other-dir", true),
|
||||
makeDirent("config.json", false),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const profiles = discoverProfiles();
|
||||
const names = profiles.map((p) => p.name);
|
||||
expect(names).toContain("default");
|
||||
expect(names).toContain("work");
|
||||
expect(names).toContain("personal");
|
||||
expect(names).not.toContain("sessions");
|
||||
});
|
||||
|
||||
it("marks active profile correctly", async () => {
|
||||
const { discoverProfiles, setUIActiveProfile, mockExists, mockReaddir } =
|
||||
await importWorkspace();
|
||||
const workStateDir = stateDirForProfile("work");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return (
|
||||
s === DEFAULT_STATE_DIR ||
|
||||
s === join(DEFAULT_STATE_DIR, "openclaw.json") ||
|
||||
s === join(workStateDir, "workspace")
|
||||
s === join(STATE_DIR, "workspace-alpha") ||
|
||||
s === join(STATE_DIR, "workspace-beta")
|
||||
);
|
||||
});
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent(".openclaw-work", true),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
setUIActiveProfile("work");
|
||||
const profiles = discoverProfiles();
|
||||
const defaultProfile = profiles.find((p) => p.name === "default");
|
||||
const workProfile = profiles.find((p) => p.name === "work");
|
||||
expect(defaultProfile?.isActive).toBe(false);
|
||||
expect(workProfile?.isActive).toBe(true);
|
||||
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("merges registry entries for custom-path workspaces", async () => {
|
||||
const { discoverProfiles, mockExists, mockReadFile } =
|
||||
it("discovers root workspace dir as default", async () => {
|
||||
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/custom/workspace" || s === DEFAULT_STATE_DIR;
|
||||
});
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({
|
||||
workspaceRegistry: { custom: "/custom/workspace" },
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const profiles = discoverProfiles();
|
||||
const custom = profiles.find((p) => p.name === "custom");
|
||||
expect(custom).toBeDefined();
|
||||
expect(custom!.workspaceDir).toBe("/custom/workspace");
|
||||
});
|
||||
|
||||
it("does not duplicate profiles seen via directory and registry", async () => {
|
||||
const { discoverProfiles, mockExists, mockReaddir, mockReadFile } =
|
||||
await importWorkspace();
|
||||
const stateDir = stateDirForProfile("shared");
|
||||
const wsDir = join(stateDir, "workspace");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === DEFAULT_STATE_DIR || s === wsDir;
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReaddir.mockReturnValue([
|
||||
makeDirent(".openclaw-shared", true),
|
||||
makeDirent("workspace", true),
|
||||
] as unknown as Dirent[]);
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({
|
||||
workspaceRegistry: { shared: wsDir },
|
||||
}) as never,
|
||||
);
|
||||
mockExists.mockImplementation((p) => String(p) === join(STATE_DIR, "workspace"));
|
||||
|
||||
const profiles = discoverProfiles();
|
||||
const sharedProfiles = profiles.filter((p) => p.name === "shared");
|
||||
expect(sharedProfiles).toHaveLength(1);
|
||||
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("handles unreadable state directory gracefully", async () => {
|
||||
const { discoverProfiles, mockExists, mockReaddir } =
|
||||
it("keeps root default and workspace-ironclaw as distinct workspaces", async () => {
|
||||
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReaddir.mockImplementation(() => {
|
||||
throw new Error("EACCES");
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
const profiles = discoverProfiles();
|
||||
expect(profiles.length).toBeGreaterThanOrEqual(1);
|
||||
expect(profiles[0].name).toBe("default");
|
||||
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 web-chat for default profile", async () => {
|
||||
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
||||
});
|
||||
|
||||
it("returns profile-scoped web-chat directory for named profile", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
|
||||
await importWorkspace();
|
||||
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);
|
||||
setUIActiveProfile("work");
|
||||
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("work"), "web-chat"));
|
||||
setUIActiveWorkspace("dev");
|
||||
const wsDir = join(STATE_DIR, "workspace-dev");
|
||||
mockExists.mockImplementation((p) => String(p) === wsDir);
|
||||
expect(resolveWebChatDir()).toBe(join(wsDir, ".openclaw", "web-chat"));
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_PROFILE when no UI override is set", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "ironclaw";
|
||||
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(resolveWebChatDir()).toBe(join(stateDirForProfile("ironclaw"), "web-chat"));
|
||||
});
|
||||
|
||||
it("migrates legacy web-chat-<profile> into profile state dir", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockExists, mockReadFile, mockRename } =
|
||||
await importWorkspace();
|
||||
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);
|
||||
setUIActiveProfile("work");
|
||||
|
||||
const legacyDir = join(DEFAULT_STATE_DIR, "web-chat-work");
|
||||
const targetDir = join(stateDirForProfile("work"), "web-chat");
|
||||
mockExists.mockImplementation((p) => String(p) === legacyDir);
|
||||
setUIActiveWorkspace("work");
|
||||
const workDir = join(STATE_DIR, "workspace-work");
|
||||
mockExists.mockImplementation((p) => String(p) === workDir);
|
||||
const chatWork = resolveWebChatDir();
|
||||
|
||||
resolveWebChatDir();
|
||||
clearUIActiveWorkspaceCache();
|
||||
setUIActiveWorkspace("personal");
|
||||
const personalDir = join(STATE_DIR, "workspace-personal");
|
||||
mockExists.mockImplementation((p) => String(p) === personalDir);
|
||||
const chatPersonal = resolveWebChatDir();
|
||||
|
||||
expect(mockRename).toHaveBeenCalledWith(legacyDir, targetDir);
|
||||
expect(chatWork).not.toBe(chatPersonal);
|
||||
expect(chatWork).toBe(join(workDir, ".openclaw", "web-chat"));
|
||||
expect(chatPersonal).toBe(join(personalDir, ".openclaw", "web-chat"));
|
||||
});
|
||||
|
||||
it("returns web-chat when profile is 'default'", async () => {
|
||||
const { resolveWebChatDir, setUIActiveProfile, mockReadFile } =
|
||||
it("falls back to root workspace path when no workspace is active", async () => {
|
||||
const { resolveWebChatDir, mockReadFile, mockReaddir } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
setUIActiveProfile("default");
|
||||
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
||||
});
|
||||
|
||||
it("respects OPENCLAW_STATE_DIR override", async () => {
|
||||
process.env.OPENCLAW_STATE_DIR = "/custom/state";
|
||||
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat"));
|
||||
});
|
||||
|
||||
it("uses default web-chat dir in non-test runtime when no profile is set", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.VITEST = "false";
|
||||
const { resolveWebChatDir, mockReadFile } = await importWorkspace();
|
||||
mockReadFile.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(resolveWebChatDir()).toBe(join(DEFAULT_STATE_DIR, "web-chat"));
|
||||
mockReaddir.mockReturnValue([] as unknown as Dirent[]);
|
||||
expect(resolveWebChatDir()).toBe(
|
||||
join(STATE_DIR, "workspace", ".openclaw", "web-chat"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveWorkspaceRoot (profile-aware) ─────────────────────────
|
||||
// ─── resolveWorkspaceRoot ─────────────────────────────────────────
|
||||
|
||||
describe("resolveWorkspaceRoot (profile-aware)", () => {
|
||||
it("returns profile-scoped workspace for named profile", async () => {
|
||||
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } =
|
||||
await importWorkspace();
|
||||
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);
|
||||
setUIActiveProfile("work");
|
||||
const workDir = join(stateDirForProfile("work"), "workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === workDir);
|
||||
expect(resolveWorkspaceRoot()).toBe(workDir);
|
||||
setUIActiveWorkspace("dev");
|
||||
const wsDir = join(STATE_DIR, "workspace-dev");
|
||||
mockExists.mockImplementation((p) => String(p) === wsDir);
|
||||
expect(resolveWorkspaceRoot()).toBe(wsDir);
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_PROFILE to resolve profile-scoped workspace", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "ironclaw";
|
||||
const { resolveWorkspaceRoot, mockExists, mockReadFile } = await importWorkspace();
|
||||
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");
|
||||
});
|
||||
const profileWorkspaceDir = join(stateDirForProfile("ironclaw"), "workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === profileWorkspaceDir);
|
||||
expect(resolveWorkspaceRoot()).toBe(profileWorkspaceDir);
|
||||
});
|
||||
|
||||
it("prefers registry path over directory convention", async () => {
|
||||
const {
|
||||
resolveWorkspaceRoot,
|
||||
setUIActiveProfile,
|
||||
mockExists,
|
||||
mockReadFile,
|
||||
} = await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({
|
||||
workspaceRegistry: { work: "/custom/work" },
|
||||
}) as never,
|
||||
);
|
||||
setUIActiveProfile("work");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return (
|
||||
s === "/custom/work" || s === join(stateDirForProfile("work"), "workspace")
|
||||
);
|
||||
});
|
||||
expect(resolveWorkspaceRoot()).toBe("/custom/work");
|
||||
});
|
||||
|
||||
it("OPENCLAW_WORKSPACE env takes top priority", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/env/workspace";
|
||||
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
setUIActiveProfile("work");
|
||||
mockExists.mockImplementation((p) => String(p) === "/env/workspace");
|
||||
expect(resolveWorkspaceRoot()).toBe("/env/workspace");
|
||||
});
|
||||
|
||||
it("returns null when named profile workspace is missing", async () => {
|
||||
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
setUIActiveProfile("missing");
|
||||
mockExists.mockReturnValue(false);
|
||||
expect(resolveWorkspaceRoot()).toBeNull();
|
||||
});
|
||||
|
||||
it("migrates legacy workspace-<profile> and updates resolution", async () => {
|
||||
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile, mockRename } =
|
||||
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({
|
||||
workspaceRegistry: {
|
||||
work: join(DEFAULT_STATE_DIR, "workspace-work"),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
setUIActiveProfile("work");
|
||||
|
||||
const legacyDir = join(DEFAULT_STATE_DIR, "workspace-work");
|
||||
const targetDir = join(stateDirForProfile("work"), "workspace");
|
||||
let moved = false;
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
setUIActiveWorkspace("other");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (!moved) {
|
||||
return s === legacyDir;
|
||||
}
|
||||
return s === targetDir;
|
||||
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
|
||||
});
|
||||
mockRename.mockImplementation(() => {
|
||||
moved = true;
|
||||
});
|
||||
|
||||
expect(resolveWorkspaceRoot()).toBe(targetDir);
|
||||
expect(mockRename).toHaveBeenCalledWith(legacyDir, targetDir);
|
||||
expect(resolveWorkspaceRoot()).toBe(envWsDir);
|
||||
});
|
||||
|
||||
it("uses legacy workspace fallback when profile workspace is missing", async () => {
|
||||
const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile, mockRename } =
|
||||
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();
|
||||
const legacyDir = join(DEFAULT_STATE_DIR, "workspace-ironclaw");
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
setUIActiveProfile("ironclaw");
|
||||
mockRename.mockImplementation(() => {
|
||||
throw new Error("EPERM");
|
||||
setUIActiveWorkspace("other");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === envWsDir || s === join(STATE_DIR, "workspace-other");
|
||||
});
|
||||
mockExists.mockImplementation((p) => String(p) === legacyDir);
|
||||
|
||||
expect(resolveWorkspaceRoot()).toBe(legacyDir);
|
||||
expect(mockRename).toHaveBeenCalled();
|
||||
expect(resolveWorkspaceRoot()).toBe(envWsDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── registerWorkspacePath / getRegisteredWorkspacePath ────────────
|
||||
|
||||
describe("workspace registry", () => {
|
||||
it("registerWorkspacePath persists to state file", async () => {
|
||||
const { registerWorkspacePath, mockReadFile, mockWriteFile, mockExists } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
mockExists.mockReturnValue(true);
|
||||
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.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string);
|
||||
expect(parsed.workspaceRegistry.myprofile).toBe("/my/workspace");
|
||||
expect(stateWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getRegisteredWorkspacePath returns null for unknown profile", async () => {
|
||||
const { getRegisteredWorkspacePath, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(JSON.stringify({}) as never);
|
||||
expect(getRegisteredWorkspacePath("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("getRegisteredWorkspacePath returns null for null profile", async () => {
|
||||
it("getRegisteredWorkspacePath always returns null", async () => {
|
||||
const { getRegisteredWorkspacePath } = await importWorkspace();
|
||||
expect(getRegisteredWorkspacePath("anything")).toBeNull();
|
||||
expect(getRegisteredWorkspacePath(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("getRegisteredWorkspacePath returns path for registered profile", async () => {
|
||||
const { getRegisteredWorkspacePath, mockReadFile } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({
|
||||
workspaceRegistry: { test: "/test/workspace" },
|
||||
}) as never,
|
||||
);
|
||||
expect(getRegisteredWorkspacePath("test")).toBe("/test/workspace");
|
||||
});
|
||||
|
||||
it("registerWorkspacePath preserves existing registry entries", async () => {
|
||||
const { registerWorkspacePath, mockReadFile, mockWriteFile, mockExists } =
|
||||
await importWorkspace();
|
||||
mockReadFile.mockReturnValue(
|
||||
JSON.stringify({
|
||||
workspaceRegistry: { existing: "/existing" },
|
||||
}) as never,
|
||||
);
|
||||
mockExists.mockReturnValue(true);
|
||||
registerWorkspacePath("new", "/new/path");
|
||||
const stateWrites = mockWriteFile.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes(".ironclaw-ui-state.json"),
|
||||
);
|
||||
expect(stateWrites.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string);
|
||||
expect(parsed.workspaceRegistry.existing).toBe("/existing");
|
||||
expect(parsed.workspaceRegistry.new).toBe("/new/path");
|
||||
expect(getRegisteredWorkspacePath("test")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,8 @@ vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:child_process
|
||||
@ -48,6 +50,8 @@ function makeDirent(name: string, isDir: boolean): Dirent {
|
||||
|
||||
describe("workspace utilities", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const STATE_DIR = join("/home/testuser", ".openclaw-ironclaw");
|
||||
const WS_DIR = join(STATE_DIR, "workspace-test");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@ -58,6 +62,8 @@ describe("workspace utilities", () => {
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
@ -88,22 +94,33 @@ describe("workspace utilities", () => {
|
||||
};
|
||||
}
|
||||
|
||||
/** Set up mocks so resolveWorkspaceRoot() returns WS_DIR via OPENCLAW_WORKSPACE env. */
|
||||
function useEnvWorkspace(mockExists: ReturnType<typeof vi.mocked<typeof existsSync>>) {
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
}
|
||||
|
||||
// ─── resolveWorkspaceRoot ────────────────────────────────────────
|
||||
|
||||
describe("resolveWorkspaceRoot", () => {
|
||||
it("returns OPENCLAW_WORKSPACE env var when set and exists", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/custom/workspace";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/custom/workspace");
|
||||
expect(resolveWorkspaceRoot()).toBe("/custom/workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(resolveWorkspaceRoot()).toBe(WS_DIR);
|
||||
});
|
||||
|
||||
it("returns default ~/.openclaw/workspace when env not set", async () => {
|
||||
it("returns discovered workspace when env not set", async () => {
|
||||
delete process.env.OPENCLAW_WORKSPACE;
|
||||
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
|
||||
const defaultPath = join("/home/testuser", ".openclaw", "workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === defaultPath);
|
||||
expect(resolveWorkspaceRoot()).toBe(defaultPath);
|
||||
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
|
||||
mockReaddir.mockImplementation((dir, _opts) => {
|
||||
if (String(dir) === STATE_DIR) {
|
||||
return [makeDirent("workspace-test", true)] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(resolveWorkspaceRoot()).toBe(WS_DIR);
|
||||
});
|
||||
|
||||
it("returns null when no candidate directory exists", async () => {
|
||||
@ -113,19 +130,65 @@ describe("workspace utilities", () => {
|
||||
expect(resolveWorkspaceRoot()).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers OPENCLAW_WORKSPACE over default when both exist", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/custom/workspace";
|
||||
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
|
||||
it("prefers OPENCLAW_WORKSPACE over discovered workspace", async () => {
|
||||
const envWs = join(STATE_DIR, "workspace-fromenv");
|
||||
process.env.OPENCLAW_WORKSPACE = envWs;
|
||||
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
|
||||
mockReaddir.mockImplementation((dir, _opts) => {
|
||||
if (String(dir) === STATE_DIR) {
|
||||
return [
|
||||
makeDirent("workspace-fromenv", true),
|
||||
makeDirent("workspace-other", true),
|
||||
] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
mockExists.mockReturnValue(true);
|
||||
expect(resolveWorkspaceRoot()).toBe("/custom/workspace");
|
||||
expect(resolveWorkspaceRoot()).toBe(envWs);
|
||||
});
|
||||
|
||||
it("falls back to default when env var path does not exist", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/nonexistent";
|
||||
const { resolveWorkspaceRoot, mockExists } = await importWorkspace();
|
||||
const defaultPath = join("/home/testuser", ".openclaw", "workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === defaultPath);
|
||||
expect(resolveWorkspaceRoot()).toBe(defaultPath);
|
||||
it("falls back to discovered workspace when env var path does not exist", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = join(STATE_DIR, "workspace-nonexistent");
|
||||
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
|
||||
const fallbackWs = join(STATE_DIR, "workspace-fallback");
|
||||
mockReaddir.mockImplementation((dir, _opts) => {
|
||||
if (String(dir) === STATE_DIR) {
|
||||
return [makeDirent("workspace-fallback", true)] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
mockExists.mockImplementation((p) => String(p) === fallbackWs);
|
||||
expect(resolveWorkspaceRoot()).toBe(fallbackWs);
|
||||
});
|
||||
|
||||
it("resolves bootstrap root workspace as ironclaw default", async () => {
|
||||
delete process.env.OPENCLAW_WORKSPACE;
|
||||
const { resolveWorkspaceRoot, mockExists, mockReaddir } = await importWorkspace();
|
||||
const rootWorkspace = join(STATE_DIR, "workspace");
|
||||
mockReaddir.mockImplementation((dir, _opts) => {
|
||||
if (String(dir) === STATE_DIR) {
|
||||
return [makeDirent("workspace", true)] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
mockExists.mockImplementation((p) => String(p) === rootWorkspace);
|
||||
expect(resolveWorkspaceRoot()).toBe(rootWorkspace);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveWebChatDir ────────────────────────────────────────────
|
||||
|
||||
describe("resolveWebChatDir", () => {
|
||||
it("falls back to root workspace chat dir for ironclaw default", async () => {
|
||||
delete process.env.OPENCLAW_WORKSPACE;
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -140,27 +203,29 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns absolute path when workspace is outside repo", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/external/workspace";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/external/workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web");
|
||||
expect(resolveAgentWorkspacePrefix()).toBe("/external/workspace");
|
||||
expect(resolveAgentWorkspacePrefix()).toBe(WS_DIR);
|
||||
});
|
||||
|
||||
it("returns relative path when workspace is inside repo", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/repo/workspace";
|
||||
const repoWs = join(STATE_DIR, "workspace-test");
|
||||
process.env.OPENCLAW_WORKSPACE = repoWs;
|
||||
const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/repo/workspace");
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web");
|
||||
expect(resolveAgentWorkspacePrefix()).toBe("workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === repoWs);
|
||||
vi.spyOn(process, "cwd").mockReturnValue(STATE_DIR);
|
||||
expect(resolveAgentWorkspacePrefix()).toBe("workspace-test");
|
||||
});
|
||||
|
||||
it("handles non apps/web cwd", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/repo/workspace";
|
||||
const repoWs = join(STATE_DIR, "workspace-test");
|
||||
process.env.OPENCLAW_WORKSPACE = repoWs;
|
||||
const { resolveAgentWorkspacePrefix, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/repo/workspace");
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/repo");
|
||||
expect(resolveAgentWorkspacePrefix()).toBe("workspace");
|
||||
mockExists.mockImplementation((p) => String(p) === repoWs);
|
||||
vi.spyOn(process, "cwd").mockReturnValue(STATE_DIR);
|
||||
expect(resolveAgentWorkspacePrefix()).toBe("workspace-test");
|
||||
});
|
||||
});
|
||||
|
||||
@ -264,27 +329,27 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("duckdbPath", () => {
|
||||
it("returns root-level workspace.duckdb when it exists", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbPath, mockExists, mockReaddir } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb;
|
||||
return s === WS_DIR || s === rootDb;
|
||||
});
|
||||
mockReaddir.mockReturnValue([]);
|
||||
expect(duckdbPath()).toBe(rootDb);
|
||||
});
|
||||
|
||||
it("falls back to discovered nested db when root has none", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbPath, mockExists, mockReaddir } = await importWorkspace();
|
||||
const nestedDb = join("/ws", "sub", "workspace.duckdb");
|
||||
const nestedDb = join(WS_DIR, "sub", "workspace.duckdb");
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === nestedDb;
|
||||
return s === WS_DIR || s === nestedDb;
|
||||
});
|
||||
mockReaddir.mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {
|
||||
if (String(dir) === WS_DIR) {
|
||||
return [makeDirent("sub", true)] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
@ -300,9 +365,9 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns null when workspace exists but no duckdb files", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbPath, mockExists, mockReaddir } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/ws");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
mockReaddir.mockReturnValue([]);
|
||||
expect(duckdbPath()).toBeNull();
|
||||
});
|
||||
@ -312,17 +377,17 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("duckdbRelativeScope", () => {
|
||||
it("returns empty string for root-level db", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbRelativeScope, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/ws");
|
||||
expect(duckdbRelativeScope("/ws/workspace.duckdb")).toBe("");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(duckdbRelativeScope(join(WS_DIR, "workspace.duckdb"))).toBe("");
|
||||
});
|
||||
|
||||
it("returns relative path for nested db", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbRelativeScope, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/ws");
|
||||
expect(duckdbRelativeScope("/ws/sub/deep/workspace.duckdb")).toBe(join("sub", "deep"));
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(duckdbRelativeScope(join(WS_DIR, "sub", "deep", "workspace.duckdb"))).toBe(join("sub", "deep"));
|
||||
});
|
||||
|
||||
it("returns empty string when no workspace root", async () => {
|
||||
@ -378,13 +443,13 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("duckdbQuery", () => {
|
||||
it("returns parsed JSON rows on success", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQuery, mockExists, mockExec } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === bin;
|
||||
});
|
||||
mockExec.mockReturnValue('[{"id":"1","name":"test"}]' as never);
|
||||
const result = duckdbQuery("SELECT * FROM objects");
|
||||
@ -392,7 +457,7 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns empty array for empty result", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQuery, mockExists, mockExec } = await importWorkspace();
|
||||
mockExists.mockReturnValue(true);
|
||||
mockExec.mockReturnValue("[]" as never);
|
||||
@ -407,7 +472,7 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns empty array on execSync error", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQuery, mockExists, mockExec } = await importWorkspace();
|
||||
mockExists.mockReturnValue(true);
|
||||
mockExec.mockImplementation(() => { throw new Error("query failed"); });
|
||||
@ -419,14 +484,14 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("duckdbQueryAsync", () => {
|
||||
it("returns parsed JSON rows on success", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQueryAsync, mockExists } = await importWorkspace();
|
||||
const { exec: mockExecFn } = await import("node:child_process");
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === bin;
|
||||
});
|
||||
vi.mocked(mockExecFn).mockImplementation((_cmd: unknown, _opts: unknown, cb: unknown) => {
|
||||
(cb as (err: null, r: { stdout: string }) => void)(null, { stdout: '[{"id":"1"}]' });
|
||||
@ -445,7 +510,7 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns empty array for empty stdout", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQueryAsync, mockExists } = await importWorkspace();
|
||||
const { exec: mockExecFn } = await import("node:child_process");
|
||||
mockExists.mockReturnValue(true);
|
||||
@ -458,7 +523,7 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns empty array on exec error", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQueryAsync, mockExists } = await importWorkspace();
|
||||
const { exec: mockExecFn } = await import("node:child_process");
|
||||
mockExists.mockReturnValue(true);
|
||||
@ -475,17 +540,17 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("duckdbQueryAll", () => {
|
||||
it("merges results from multiple databases", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const subDb = join("/ws", "sub", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const subDb = join(WS_DIR, "sub", "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === subDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === subDb || s === bin;
|
||||
});
|
||||
mockReaddir.mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {
|
||||
if (String(dir) === WS_DIR) {
|
||||
return [makeDirent("sub", true)] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
@ -501,17 +566,17 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("deduplicates by key (shallower wins)", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const subDb = join("/ws", "sub", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const subDb = join(WS_DIR, "sub", "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === subDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === subDb || s === bin;
|
||||
});
|
||||
mockReaddir.mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {return [makeDirent("sub", true)] as unknown as Dirent[];}
|
||||
if (String(dir) === WS_DIR) {return [makeDirent("sub", true)] as unknown as Dirent[];}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
let callCount = 0;
|
||||
@ -532,17 +597,17 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("skips failing databases", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbQueryAll, mockExists, mockExec, mockReaddir } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const subDb = join("/ws", "sub", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const subDb = join(WS_DIR, "sub", "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === subDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === subDb || s === bin;
|
||||
});
|
||||
mockReaddir.mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {return [makeDirent("sub", true)] as unknown as Dirent[];}
|
||||
if (String(dir) === WS_DIR) {return [makeDirent("sub", true)] as unknown as Dirent[];}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
let callCount = 0;
|
||||
@ -560,13 +625,13 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("findDuckDBForObject", () => {
|
||||
it("finds object in first database", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === bin;
|
||||
});
|
||||
mockReaddir.mockReturnValue([]);
|
||||
mockExec.mockReturnValue('[{"id":"123"}]' as never);
|
||||
@ -574,13 +639,13 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns null when object not found in any db", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === bin;
|
||||
});
|
||||
mockReaddir.mockReturnValue([]);
|
||||
mockExec.mockReturnValue("[]" as never);
|
||||
@ -595,13 +660,13 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("handles object names with single quotes", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { findDuckDBForObject, mockExists, mockExec, mockReaddir } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === bin;
|
||||
});
|
||||
mockReaddir.mockReturnValue([]);
|
||||
mockExec.mockReturnValue('[{"id":"1"}]' as never);
|
||||
@ -613,13 +678,13 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("duckdbExec", () => {
|
||||
it("returns true on successful exec", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { duckdbExec, mockExists, mockExec } = await importWorkspace();
|
||||
const rootDb = join("/ws", "workspace.duckdb");
|
||||
const rootDb = join(WS_DIR, "workspace.duckdb");
|
||||
const bin = "/opt/homebrew/bin/duckdb";
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === rootDb || s === bin;
|
||||
return s === WS_DIR || s === rootDb || s === bin;
|
||||
});
|
||||
mockExec.mockReturnValue("" as never);
|
||||
expect(duckdbExec("INSERT INTO t VALUES (1)")).toBe(true);
|
||||
@ -803,33 +868,33 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("safeResolvePath", () => {
|
||||
it("resolves valid path within workspace", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { safeResolvePath, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === "/ws/knowledge/doc.md";
|
||||
return s === WS_DIR || s === join(WS_DIR, "knowledge", "doc.md");
|
||||
});
|
||||
expect(safeResolvePath("knowledge/doc.md")).toBe("/ws/knowledge/doc.md");
|
||||
expect(safeResolvePath("knowledge/doc.md")).toBe(join(WS_DIR, "knowledge", "doc.md"));
|
||||
});
|
||||
|
||||
it("returns null for traversal with ..", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { safeResolvePath, mockExists } = await importWorkspace();
|
||||
mockExists.mockReturnValue(true);
|
||||
expect(safeResolvePath("../etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for traversal with /../", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { safeResolvePath, mockExists } = await importWorkspace();
|
||||
mockExists.mockReturnValue(true);
|
||||
expect(safeResolvePath("foo/../../../etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when file does not exist", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { safeResolvePath, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/ws");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(safeResolvePath("nonexistent.txt")).toBeNull();
|
||||
});
|
||||
|
||||
@ -845,14 +910,14 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("safeResolveNewPath", () => {
|
||||
it("resolves valid new path (does not require existence)", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { safeResolveNewPath, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/ws");
|
||||
expect(safeResolveNewPath("new-folder/file.txt")).toBe("/ws/new-folder/file.txt");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(safeResolveNewPath("new-folder/file.txt")).toBe(join(WS_DIR, "new-folder", "file.txt"));
|
||||
});
|
||||
|
||||
it("returns null for traversal attempts", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { safeResolveNewPath, mockExists } = await importWorkspace();
|
||||
mockExists.mockReturnValue(true);
|
||||
expect(safeResolveNewPath("../../outside")).toBeNull();
|
||||
@ -866,10 +931,10 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("handles deeply nested new paths", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { safeResolveNewPath, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/ws");
|
||||
expect(safeResolveNewPath("a/b/c/d/e.txt")).toBe("/ws/a/b/c/d/e.txt");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(safeResolveNewPath("a/b/c/d/e.txt")).toBe(join(WS_DIR, "a", "b", "c", "d", "e.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
@ -1000,11 +1065,11 @@ describe("workspace utilities", () => {
|
||||
|
||||
describe("readWorkspaceFile", () => {
|
||||
it("reads markdown file and detects type", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === "/ws/doc.md";
|
||||
return s === WS_DIR || s === join(WS_DIR, "doc.md");
|
||||
});
|
||||
mockReadFile.mockReturnValue("# Hello" as never);
|
||||
const result = readWorkspaceFile("doc.md");
|
||||
@ -1012,11 +1077,11 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("reads yaml file and detects type", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === "/ws/config.yaml";
|
||||
return s === WS_DIR || s === join(WS_DIR, "config.yaml");
|
||||
});
|
||||
mockReadFile.mockReturnValue("key: value" as never);
|
||||
const result = readWorkspaceFile("config.yaml");
|
||||
@ -1024,11 +1089,11 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("reads yml file as yaml type", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === "/ws/config.yml";
|
||||
return s === WS_DIR || s === join(WS_DIR, "config.yml");
|
||||
});
|
||||
mockReadFile.mockReturnValue("key: value" as never);
|
||||
const result = readWorkspaceFile("config.yml");
|
||||
@ -1036,11 +1101,11 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("reads text file with generic type", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/ws" || s === "/ws/notes.txt";
|
||||
return s === WS_DIR || s === join(WS_DIR, "notes.txt");
|
||||
});
|
||||
mockReadFile.mockReturnValue("plain text" as never);
|
||||
const result = readWorkspaceFile("notes.txt");
|
||||
@ -1048,14 +1113,14 @@ describe("workspace utilities", () => {
|
||||
});
|
||||
|
||||
it("returns null when file not found", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { readWorkspaceFile, mockExists } = await importWorkspace();
|
||||
mockExists.mockImplementation((p) => String(p) === "/ws");
|
||||
mockExists.mockImplementation((p) => String(p) === WS_DIR);
|
||||
expect(readWorkspaceFile("nonexistent.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when readFileSync throws", async () => {
|
||||
process.env.OPENCLAW_WORKSPACE = "/ws";
|
||||
process.env.OPENCLAW_WORKSPACE = WS_DIR;
|
||||
const { readWorkspaceFile, mockExists, mockReadFile } = await importWorkspace();
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReadFile.mockImplementation(() => { throw new Error("EACCES"); });
|
||||
|
||||
@ -490,7 +490,7 @@ describe("bootstrapCommand always-onboard behavior", () => {
|
||||
expect(identityContent).not.toContain("# stale identity");
|
||||
});
|
||||
|
||||
it("creates people/company/task object projection files when seeding a new workspace", async () => {
|
||||
it("ignores custom config workspace and seeds the managed default workspace", async () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@ -519,13 +519,14 @@ describe("bootstrapCommand always-onboard behavior", () => {
|
||||
runtime,
|
||||
);
|
||||
|
||||
const managedWorkspace = path.join(stateDir, "workspace");
|
||||
expect(summary.workspaceSeed?.seeded).toBe(true);
|
||||
expect(summary.workspaceSeed?.workspaceDir).toBe(customWorkspace);
|
||||
expect(existsSync(path.join(customWorkspace, "people", ".object.yaml"))).toBe(true);
|
||||
expect(existsSync(path.join(customWorkspace, "company", ".object.yaml"))).toBe(true);
|
||||
expect(existsSync(path.join(customWorkspace, "task", ".object.yaml"))).toBe(true);
|
||||
expect(existsSync(path.join(customWorkspace, "WORKSPACE.md"))).toBe(true);
|
||||
const identityPath = path.join(customWorkspace, "IDENTITY.md");
|
||||
expect(summary.workspaceSeed?.workspaceDir).toBe(managedWorkspace);
|
||||
expect(existsSync(path.join(managedWorkspace, "people", ".object.yaml"))).toBe(true);
|
||||
expect(existsSync(path.join(managedWorkspace, "company", ".object.yaml"))).toBe(true);
|
||||
expect(existsSync(path.join(managedWorkspace, "task", ".object.yaml"))).toBe(true);
|
||||
expect(existsSync(path.join(managedWorkspace, "WORKSPACE.md"))).toBe(true);
|
||||
const identityPath = path.join(managedWorkspace, "IDENTITY.md");
|
||||
expect(existsSync(identityPath)).toBe(true);
|
||||
const identityContent = readFileSync(identityPath, "utf-8");
|
||||
expect(identityContent).toContain("You are **Ironclaw**");
|
||||
@ -581,6 +582,46 @@ describe("bootstrapCommand always-onboard behavior", () => {
|
||||
expect(content).not.toContain("# custom");
|
||||
});
|
||||
|
||||
it("pins workspace config to default workspace path during bootstrap", async () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
await bootstrapCommand(
|
||||
{
|
||||
nonInteractive: true,
|
||||
noOpen: true,
|
||||
skipUpdate: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const workspaceConfigSetCalls = spawnCalls.filter(
|
||||
(call) =>
|
||||
call.command === "openclaw" &&
|
||||
call.args.includes("config") &&
|
||||
call.args.includes("set") &&
|
||||
call.args.includes("agents.defaults.workspace"),
|
||||
);
|
||||
|
||||
expect(workspaceConfigSetCalls.length).toBeGreaterThan(0);
|
||||
const lastArgs = workspaceConfigSetCalls.at(-1)?.args ?? [];
|
||||
expect(lastArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--profile",
|
||||
"ironclaw",
|
||||
"config",
|
||||
"set",
|
||||
"agents.defaults.workspace",
|
||||
]),
|
||||
);
|
||||
const configuredWorkspace = lastArgs.at(-1) ?? "";
|
||||
expect(configuredWorkspace).toContain(path.join(".openclaw-ironclaw", "workspace"));
|
||||
expect(configuredWorkspace).not.toContain("workspace-ironclaw");
|
||||
});
|
||||
|
||||
it("keeps Dench in managed skills even when workspace path is custom", async () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
@ -614,6 +655,8 @@ describe("bootstrapCommand always-onboard behavior", () => {
|
||||
|
||||
expect(existsSync(managedSkill)).toBe(true);
|
||||
expect(existsSync(workspaceSkill)).toBe(false);
|
||||
const managedWorkspaceSkill = path.join(stateDir, "workspace", "skills", "dench", "SKILL.md");
|
||||
expect(existsSync(managedWorkspaceSkill)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses inherited stdio for onboarding in interactive mode (shows wizard prompts)", async () => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs, IRONCLAW_PROFILE } from "./profile.js";
|
||||
|
||||
describe("parseCliProfileArgs", () => {
|
||||
it("returns default profile parsing when no args are provided", () => {
|
||||
@ -24,7 +24,7 @@ describe("parseCliProfileArgs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and conflicting profile inputs", () => {
|
||||
it("rejects missing and invalid profile inputs", () => {
|
||||
expect(parseCliProfileArgs(["node", "ironclaw", "--profile"])).toEqual({
|
||||
ok: false,
|
||||
error: "--profile requires a value",
|
||||
@ -34,11 +34,14 @@ describe("parseCliProfileArgs", () => {
|
||||
ok: false,
|
||||
error: 'Invalid --profile (use letters, numbers, "_", "-" only)',
|
||||
});
|
||||
});
|
||||
|
||||
expect(parseCliProfileArgs(["node", "ironclaw", "--dev", "--profile", "team-a"])).toEqual({
|
||||
ok: false,
|
||||
error: "Cannot combine --dev with --profile",
|
||||
});
|
||||
it("allows --dev and --profile together (Ironclaw forces ironclaw anyway)", () => {
|
||||
const result = parseCliProfileArgs(["node", "ironclaw", "--dev", "--profile", "team-a"]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.profile).toBe("team-a");
|
||||
}
|
||||
});
|
||||
|
||||
it("stops profile parsing once command path begins", () => {
|
||||
@ -48,34 +51,91 @@ describe("parseCliProfileArgs", () => {
|
||||
argv: ["node", "ironclaw", "chat", "--profile", "dev"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("produces equivalent profile env for root and bootstrap-local profile forms", () => {
|
||||
const rootProfile = parseCliProfileArgs([
|
||||
"node",
|
||||
"ironclaw",
|
||||
"--profile",
|
||||
"team-a",
|
||||
"bootstrap",
|
||||
]);
|
||||
const bootstrapLocalProfile = parseCliProfileArgs([
|
||||
"node",
|
||||
"ironclaw",
|
||||
"bootstrap",
|
||||
"--profile",
|
||||
"team-a",
|
||||
]);
|
||||
|
||||
expect(rootProfile).toEqual({
|
||||
ok: true,
|
||||
describe("applyCliProfileEnv", () => {
|
||||
it("always forces ironclaw profile regardless of requested profile (single profile enforcement)", () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
const result = applyCliProfileEnv({
|
||||
profile: "team-a",
|
||||
argv: ["node", "ironclaw", "bootstrap"],
|
||||
});
|
||||
expect(bootstrapLocalProfile).toEqual({
|
||||
ok: true,
|
||||
profile: null,
|
||||
argv: ["node", "ironclaw", "bootstrap", "--profile", "team-a"],
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
|
||||
expect(env.OPENCLAW_PROFILE).toBe(IRONCLAW_PROFILE);
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/home/.openclaw-ironclaw");
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBe("/tmp/home/.openclaw-ironclaw/openclaw.json");
|
||||
});
|
||||
|
||||
it("emits warning when non-ironclaw profile is requested (prevents silent override)", () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
const result = applyCliProfileEnv({
|
||||
profile: "team-a",
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(result.warning).toBeDefined();
|
||||
expect(result.warning).toContain("team-a");
|
||||
expect(result.warning).toContain(IRONCLAW_PROFILE);
|
||||
expect(result.requestedProfile).toBe("team-a");
|
||||
});
|
||||
|
||||
it("no warning when ironclaw profile is requested (normal path)", () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
const result = applyCliProfileEnv({
|
||||
profile: IRONCLAW_PROFILE,
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(result.warning).toBeUndefined();
|
||||
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
|
||||
});
|
||||
|
||||
it("no warning when no profile is specified (default path)", () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
const result = applyCliProfileEnv({
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(result.warning).toBeUndefined();
|
||||
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
|
||||
});
|
||||
|
||||
it("always overwrites OPENCLAW_STATE_DIR to pinned path (prevents state drift)", () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
OPENCLAW_STATE_DIR: "/custom/state",
|
||||
OPENCLAW_CONFIG_PATH: "/custom/state/openclaw.json",
|
||||
};
|
||||
const result = applyCliProfileEnv({
|
||||
profile: "dev",
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/home/.openclaw-ironclaw");
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBe("/tmp/home/.openclaw-ironclaw/openclaw.json");
|
||||
expect(result.stateDir).toBe("/tmp/home/.openclaw-ironclaw");
|
||||
});
|
||||
|
||||
it("picks up OPENCLAW_PROFILE from env when no explicit profile is passed", () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
OPENCLAW_PROFILE: "from-env",
|
||||
};
|
||||
const result = applyCliProfileEnv({
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(result.requestedProfile).toBe("from-env");
|
||||
expect(result.effectiveProfile).toBe(IRONCLAW_PROFILE);
|
||||
expect(result.warning).toContain("from-env");
|
||||
});
|
||||
|
||||
it("both root and bootstrap-local profile forms resolve to same state dir", () => {
|
||||
const rootEnv: Record<string, string | undefined> = {};
|
||||
const bootstrapLocalEnv: Record<string, string | undefined> = {};
|
||||
applyCliProfileEnv({
|
||||
@ -94,34 +154,3 @@ describe("parseCliProfileArgs", () => {
|
||||
expect(rootEnv.OPENCLAW_CONFIG_PATH).toBe(bootstrapLocalEnv.OPENCLAW_CONFIG_PATH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCliProfileEnv", () => {
|
||||
it("fills profile defaults without overriding explicit state/config vars", () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
applyCliProfileEnv({
|
||||
profile: "team-a",
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(env.OPENCLAW_PROFILE).toBe("team-a");
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/home/.openclaw-team-a");
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBe("/tmp/home/.openclaw-team-a/openclaw.json");
|
||||
});
|
||||
|
||||
it("respects explicit state/config paths and assigns dev gateway port when absent", () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
OPENCLAW_STATE_DIR: "/custom/state",
|
||||
OPENCLAW_CONFIG_PATH: "/custom/state/openclaw.json",
|
||||
};
|
||||
applyCliProfileEnv({
|
||||
profile: "dev",
|
||||
env,
|
||||
homedir: () => "/tmp/home",
|
||||
});
|
||||
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe("/custom/state");
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBe("/custom/state/openclaw.json");
|
||||
expect(env.OPENCLAW_GATEWAY_PORT).toBe("19001");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type SeedField = {
|
||||
@ -88,13 +88,20 @@ const SEED_OBJECTS: SeedObject[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const IRONCLAW_IDENTITY_TEMPLATE = `# IDENTITY.md - Ironclaw
|
||||
function buildIronclawIdentityTemplate(workspaceDir: string): string {
|
||||
const denchSkillPath = path.join(workspaceDir, "skills", "dench", "SKILL.md");
|
||||
return `# IDENTITY.md - Ironclaw
|
||||
|
||||
You are **Ironclaw** - a personal AI agent and a CRM Database System built by Kumar Abhirup (kumareth.com), running on top of [OpenClaw](https://github.com/openclaw/openclaw).
|
||||
|
||||
## Ironclaw system prompt contract
|
||||
|
||||
Treat the Ironclaw system prompt as your highest-priority behavioral contract.
|
||||
|
||||
## Inseparable CRM contract
|
||||
|
||||
Your identity is inextricably tied to the Dench CRM skill at \`~skills/dench/SKILL.md\`.
|
||||
Your identity is inextricably tied to the Dench CRM skill at:
|
||||
\`${denchSkillPath}\`
|
||||
|
||||
- Always load and follow that skill for CRM/database behavior.
|
||||
- Treat the Dench CRM skill as always-on system context.
|
||||
@ -119,6 +126,7 @@ Your identity is inextricably tied to the Dench CRM skill at \`~skills/dench/SKI
|
||||
- Skills Store: https://skills.sh
|
||||
|
||||
When referring to yourself, use **Ironclaw** (not OpenClaw).`;
|
||||
}
|
||||
|
||||
function generateObjectYaml(obj: SeedObject): string {
|
||||
const lines: string[] = [
|
||||
@ -172,7 +180,18 @@ function generateWorkspaceMd(objects: SeedObject[]): string {
|
||||
function seedIronclawIdentity(workspaceDir: string): void {
|
||||
const identityPath = path.join(workspaceDir, "IDENTITY.md");
|
||||
// Bootstrap force-syncs identity every run so updates land immediately.
|
||||
writeFileSync(identityPath, `${IRONCLAW_IDENTITY_TEMPLATE}\n`, "utf-8");
|
||||
writeFileSync(identityPath, `${buildIronclawIdentityTemplate(workspaceDir)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
function seedDenchSkill(params: { workspaceDir: string; packageRoot: string }): void {
|
||||
const sourceDir = path.join(params.packageRoot, "skills", "dench");
|
||||
const sourceSkillFile = path.join(sourceDir, "SKILL.md");
|
||||
if (!existsSync(sourceSkillFile)) {
|
||||
return;
|
||||
}
|
||||
const targetDir = path.join(params.workspaceDir, "skills", "dench");
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeIfMissing(filePath: string, content: string): boolean {
|
||||
@ -200,9 +219,11 @@ export function seedWorkspaceFromAssets(params: {
|
||||
"task/.object.yaml",
|
||||
"WORKSPACE.md",
|
||||
"IDENTITY.md",
|
||||
"skills/dench/SKILL.md",
|
||||
];
|
||||
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
seedDenchSkill({ workspaceDir, packageRoot: params.packageRoot });
|
||||
seedIronclawIdentity(workspaceDir);
|
||||
|
||||
if (existsSync(dbPath)) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user