openclaw/apps/web/app/api/profiles/route.test.ts
kumarabhirup 52707f471d
refactor!: IronClaw v2.0 - external OpenClaw runtime
BREAKING CHANGE: Convert repository to IronClaw-only package with strict
external dependency on globally installed `openclaw` runtime.

### Changes

- Remove entire OpenClaw core source from repository (src/agents/*, src/acp/*,
  src/commands/*, and related modules)
- Implement CLI delegation: non-bootstrap commands now delegate to global
  `openclaw` binary via external contract
- Remove local OpenClaw path resolution from web app; always spawn global
  `openclaw` binary instead of local scripts
- Rename package.json scripts: `pnpm openclaw` → `pnpm ironclaw`,
  `openclaw:rpc` → `ironclaw:rpc`
- Update bootstrap flow to verify and install global OpenClaw when missing
- Migrate web workspace/profile logic to align with OpenClaw state paths
- Add migration contract tests for stream-json, session subscribe, and profile
  resolution behaviors
- Update build/release pipeline for IronClaw-only artifacts
- Update documentation for new peer + global installation model

### Architecture

IronClaw is now strictly a frontend/UI/bootstrap layer:
- `npx ironclaw` bootstraps OpenClaw (if missing), runs guided onboarding
- IronClaw UI serves on localhost:3100
- OpenClaw Gateway runs on standard port 18789
- Communication via stable CLI contracts and Gateway WebSocket protocol only

### Migration

Users must have `openclaw` installed globally:
  npm install -g openclaw

Existing IronClaw profiles and sessions remain compatible through gateway
protocol stability.

Refs: bootstrap_dev_testing, ironclaw_frontend_split, strict-external-openclaw
2026-03-01 16:11:40 -08:00

215 lines
6.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Dirent } from "node:fs";
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"),
}));
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");
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(),
}));
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 ────────────────────────────────────────────
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");
});
it("returns activeProfile", async () => {
const response = await callGet();
const json = await response.json();
expect(json.activeProfile).toBe("default");
});
it("returns stateDir", async () => {
const response = await callGet();
const json = await response.json();
expect(json.stateDir).toBe(STATE_DIR);
});
it("discovers workspace-<name> 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");
});
});
// ─── POST /api/profiles/switch ────────────────────────────────────
describe("POST /api/profiles/switch", () => {
async function callSwitch(body: Record<string, unknown>) {
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("'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");
});
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");
});
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 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();
});
it("returns stateDir in response", async () => {
const { existsSync: es } = await import("node:fs");
vi.mocked(es).mockReturnValue(true);
const response = await callSwitch({ profile: "test" });
const json = await response.json();
expect(json.stateDir).toBe(join("/home/testuser", ".openclaw-test"));
});
});
});