diff --git a/apps/web/app/api/profiles/route.test.ts b/apps/web/app/api/profiles/route.test.ts index 67e71de4ab5..9b7c01e8959 100644 --- a/apps/web/app/api/profiles/route.test.ts +++ b/apps/web/app/api/profiles/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; import type { Dirent } from "node:fs"; vi.mock("node:fs", () => ({ @@ -7,6 +8,7 @@ vi.mock("node:fs", () => ({ readdirSync: vi.fn(() => []), writeFileSync: vi.fn(), mkdirSync: vi.fn(), + renameSync: vi.fn(), })); vi.mock("node:child_process", () => ({ @@ -26,26 +28,26 @@ vi.mock("node:os", () => ({ homedir: vi.fn(() => "/home/testuser"), })); -import { join } from "node:path"; - -function makeDirent(name: string, isDir: boolean): Dirent { - return { - name, - isDirectory: () => isDir, - isFile: () => !isDir, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - isSymbolicLink: () => false, - path: "", - parentPath: "", - } as Dirent; -} - describe("profiles API", () => { const originalEnv = { ...process.env }; - const STATE_DIR = join("/home/testuser", ".openclaw"); + 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; + } beforeEach(() => { vi.resetModules(); @@ -55,160 +57,149 @@ describe("profiles API", () => { 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(), - })); - vi.mock("node:child_process", () => ({ - execSync: vi.fn(() => ""), - exec: vi.fn( - ( - _cmd: string, - _opts: unknown, - cb: (err: Error | null, result: { stdout: string }) => void, - ) => { - cb(null, { stdout: "" }); - }, - ), - })); - vi.mock("node:os", () => ({ - homedir: vi.fn(() => "/home/testuser"), - })); }); afterEach(() => { process.env = originalEnv; }); - // ─── GET /api/profiles ──────────────────────────────────────────── + it("lists discovered profiles and includes gateway metadata", async () => { + process.env.OPENCLAW_PROFILE = "work"; + const { existsSync, readFileSync, readdirSync } = await import("node:fs"); + const mockExists = vi.mocked(existsSync); + const mockReadFile = vi.mocked(readFileSync); + const mockReaddir = vi.mocked(readdirSync); - describe("GET /api/profiles", () => { - async function callGet() { - const { GET } = await import("./route.js"); - return GET(); - } - - it("returns profiles list with default profile", async () => { - const response = await callGet(); - expect(response.status).toBe(200); - const json = await response.json(); - expect(json.profiles).toBeDefined(); - expect(json.profiles.length).toBeGreaterThanOrEqual(1); - expect(json.profiles[0].name).toBe("default"); + mockReaddir.mockReturnValue([ + makeDirent(".openclaw-work", true), + makeDirent("Documents", true), + ] as unknown as Dirent[]); + 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") + ); + }); + 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")) { + return JSON.stringify({ gateway: { mode: "local", port: 18789 } }) as never; + } + return "" as never; }); - it("returns activeProfile", async () => { - const response = await callGet(); - const json = await response.json(); - expect(json.activeProfile).toBe("default"); + const { GET } = await import("./route.js"); + const response = await GET(); + expect(response.status).toBe(200); + const json = await response.json(); + + 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(work).toMatchObject({ + name: "work", + stateDir: WORK_STATE_DIR, + workspaceDir: WORK_WORKSPACE_DIR, + isActive: true, + hasConfig: true, + gateway: { + mode: "local", + port: 19001, + url: "ws://127.0.0.1:19001", + }, }); - - it("returns stateDir", async () => { - const response = await callGet(); - const json = await response.json(); - expect(json.stateDir).toBe(STATE_DIR); - }); - - it("discovers workspace- directories", async () => { - const { existsSync: es, readdirSync: rds } = await import("node:fs"); - const devStateDir = join("/home/testuser", ".openclaw-dev"); - const devWorkspaceDir = join(devStateDir, "workspace"); - vi.mocked(es).mockImplementation((p) => { - const s = String(p); - return ( - s === STATE_DIR || - s === devWorkspaceDir - ); - }); - vi.mocked(rds).mockReturnValue([ - makeDirent(".openclaw-dev", true), - ] as unknown as Dirent[]); - - const response = await callGet(); - const json = await response.json(); - const names = json.profiles.map((p: { name: string }) => p.name); - expect(names).toContain("dev"); + 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", + }, }); }); - // ─── POST /api/profiles/switch ──────────────────────────────────── - - describe("POST /api/profiles/switch", () => { - async function callSwitch(body: Record) { - 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(body), - }); - return POST(req); - } - - it("switches to named profile", async () => { - const { writeFileSync: wfs } = await import("node:fs"); - const { existsSync: es } = await import("node:fs"); - vi.mocked(es).mockReturnValue(true); - - const response = await callSwitch({ profile: "work" }); - expect(response.status).toBe(200); - const json = await response.json(); - expect(json.activeProfile).toBe("work"); - - const writeCalls = vi.mocked(wfs).mock.calls; - const stateWrite = writeCalls.find((c) => - (c[0] as string).includes(".ironclaw-ui-state.json"), - ); - expect(stateWrite).toBeDefined(); + it("switches to an existing profile", async () => { + const { existsSync, readdirSync, 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; }); - it("'default' clears the override", async () => { - const { existsSync: es } = await import("node:fs"); - vi.mocked(es).mockReturnValue(true); - - const response = await callSwitch({ profile: "default" }); - expect(response.status).toBe(200); - const json = await response.json(); - expect(json.activeProfile).toBe("default"); + 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" }), }); - it("rejects missing profile name", async () => { - const response = await callSwitch({}); - expect(response.status).toBe(400); - const json = await response.json(); - expect(json.error).toContain("Missing profile name"); + const response = await POST(req); + expect(response.status).toBe(200); + const json = await response.json(); + 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"); + }); + + it("rejects invalid switch profile 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" }), }); + const response = await POST(req); + expect(response.status).toBe(400); + }); - it("rejects invalid profile name characters", async () => { - const response = await callSwitch({ profile: "bad name!" }); - expect(response.status).toBe(400); - const json = await response.json(); - expect(json.error).toContain("Invalid profile name"); + 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 workspace root after switching", async () => { - const { existsSync: es } = await import("node:fs"); - const wsDir = join(STATE_DIR, "workspace-dev"); - vi.mocked(es).mockImplementation((p) => { - const s = String(p); - return s === wsDir || s.includes(".openclaw"); - }); - - const response = await callSwitch({ profile: "dev" }); - const json = await response.json(); - expect(json.workspaceRoot).toBeDefined(); + 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(404); + }); - it("returns stateDir in response", async () => { - const { existsSync: es } = await import("node:fs"); - vi.mocked(es).mockReturnValue(true); + 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 response = await callSwitch({ profile: "test" }); - const json = await response.json(); - expect(json.stateDir).toBe(join("/home/testuser", ".openclaw-test")); + 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); }); });