From efbeacff54a3fb4e7d466a72183c4076efde81de Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Mon, 2 Mar 2026 18:34:12 -0800 Subject: [PATCH] test(web): update workspace init tests for new profile-based flow --- apps/web/app/api/workspace/init/route.test.ts | 278 ++++++++++-------- 1 file changed, 155 insertions(+), 123 deletions(-) diff --git a/apps/web/app/api/workspace/init/route.test.ts b/apps/web/app/api/workspace/init/route.test.ts index 4829de2cfbc..fff4b1bdcb7 100644 --- a/apps/web/app/api/workspace/init/route.test.ts +++ b/apps/web/app/api/workspace/init/route.test.ts @@ -1,3 +1,6 @@ +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"; vi.mock("node:fs", () => ({ @@ -7,6 +10,7 @@ vi.mock("node:fs", () => ({ writeFileSync: vi.fn(), mkdirSync: vi.fn(), copyFileSync: vi.fn(), + cpSync: vi.fn(), })); vi.mock("node:child_process", () => ({ @@ -20,18 +24,55 @@ vi.mock("node:child_process", () => ({ cb(null, { stdout: "" }); }, ), + spawn: vi.fn(), })); vi.mock("node:os", () => ({ homedir: vi.fn(() => "/home/testuser"), })); -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - describe("POST /api/workspace/init", () => { const originalEnv = { ...process.env }; - const STATE_DIR = join("/home/testuser", ".openclaw"); + 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; + }; + 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; + }); + } beforeEach(() => { vi.resetModules(); @@ -49,6 +90,7 @@ describe("POST /api/workspace/init", () => { writeFileSync: vi.fn(), mkdirSync: vi.fn(), copyFileSync: vi.fn(), + cpSync: vi.fn(), })); vi.mock("node:child_process", () => ({ execSync: vi.fn(() => ""), @@ -61,6 +103,7 @@ describe("POST /api/workspace/init", () => { cb(null, { stdout: "" }); }, ), + spawn: vi.fn(), })); vi.mock("node:os", () => ({ homedir: vi.fn(() => "/home/testuser"), @@ -81,139 +124,128 @@ describe("POST /api/workspace/init", () => { return POST(req); } - it("creates default workspace directory", async () => { - const mockMkdir = vi.mocked(mkdirSync); - const response = await callInit({}); - expect(response.status).toBe(200); - expect(mockMkdir).toHaveBeenCalledWith( - join(STATE_DIR, "workspace"), - { recursive: true }, - ); - const json = await response.json(); - expect(json.profile).toBe("default"); - expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace")); + 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("creates profile-specific workspace directory", async () => { - const mockMkdir = vi.mocked(mkdirSync); + 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"); + }); + const response = await callInit({ profile: "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); + + 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, + }); expect(response.status).toBe(200); - expect(mockMkdir).toHaveBeenCalledWith( - join(STATE_DIR, "workspace-work"), - { recursive: true }, - ); const json = await response.json(); expect(json.profile).toBe("work"); - }); - - it("rejects invalid profile names", async () => { - const response = await callInit({ profile: "invalid profile!" }); - expect(response.status).toBe(400); - const json = await response.json(); - expect(json.error).toContain("Invalid profile name"); - }); - - it("allows alphanumeric, hyphens, and underscores in profile names", async () => { - const response = await callInit({ profile: "my-work_1" }); - expect(response.status).toBe(200); - const json = await response.json(); - expect(json.profile).toBe("my-work_1"); - }); - - it("accepts 'default' as profile name", async () => { - const response = await callInit({ profile: "default" }); - expect(response.status).toBe(200); - const json = await response.json(); - expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace")); - }); - - it("seeds bootstrap files when seedBootstrap is not false", async () => { - const mockWrite = vi.mocked(writeFileSync); - await callInit({}); - const writtenPaths = mockWrite.mock.calls.map((c) => c[0] as string); - const bootstrapFiles = writtenPaths.filter( - (p) => - p.endsWith("AGENTS.md") || - p.endsWith("SOUL.md") || - p.endsWith("TOOLS.md") || - p.endsWith("IDENTITY.md") || - p.endsWith("USER.md") || - p.endsWith("HEARTBEAT.md") || - p.endsWith("BOOTSTRAP.md"), + 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(bootstrapFiles.length).toBeGreaterThan(0); - }); + expect(json.activeProfile).toBe("work"); - it("returns seeded files list", async () => { - const response = await callInit({}); - const json = await response.json(); - expect(Array.isArray(json.seededFiles)).toBe(true); - }); - - it("skips bootstrap seeding when seedBootstrap is false", async () => { - const mockWrite = vi.mocked(writeFileSync); - const callsBefore = mockWrite.mock.calls.length; - await callInit({ seedBootstrap: false }); - const bootstrapWrites = mockWrite.mock.calls - .slice(callsBefore) - .filter((c) => { - const p = c[0] as string; - return p.endsWith(".md") && !p.endsWith("workspace-state.json"); - }); - expect(bootstrapWrites).toHaveLength(0); - }); - - it("does not overwrite existing bootstrap files (idempotent)", async () => { - const mockExist = vi.mocked(existsSync); - const wsDir = join(STATE_DIR, "workspace"); - mockExist.mockImplementation((p) => { - const s = String(p); - return s === join(wsDir, "AGENTS.md") || s === join(wsDir, "SOUL.md"); - }); - - const response = await callInit({}); - const json = await response.json(); - expect(json.seededFiles).not.toContain("AGENTS.md"); - expect(json.seededFiles).not.toContain("SOUL.md"); - }); - - it("handles custom workspace path", async () => { - const mockMkdir = vi.mocked(mkdirSync); - const response = await callInit({ - profile: "custom", - path: "/my/custom/workspace", - }); - expect(response.status).toBe(200); - expect(mockMkdir).toHaveBeenCalledWith("/my/custom/workspace", { - recursive: true, - }); - const json = await response.json(); - expect(json.workspaceDir).toBe("/my/custom/workspace"); - }); - - it("resolves tilde in custom path", async () => { - const mockMkdir = vi.mocked(mkdirSync); - await callInit({ profile: "tilde", path: "~/my-workspace" }); - expect(mockMkdir).toHaveBeenCalledWith( - join("/home/testuser", "my-workspace"), - { recursive: true }, + 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"), ); }); - it("auto-switches to new profile after creation", async () => { - const response = await callInit({ profile: "newprofile" }); - const json = await response.json(); - expect(json.activeProfile).toBe("newprofile"); - }); + 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("handles mkdir failure with 500", async () => { - const mockMkdir = vi.mocked(mkdirSync); - mockMkdir.mockImplementation(() => { - throw new Error("EACCES: permission denied"); + 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 response = await callInit({ profile: "fail" }); + + const response = await callInit({ profile: "work" }); expect(response.status).toBe(500); const json = await response.json(); - expect(json.error).toContain("Failed to create workspace directory"); + expect(String(json.error)).toContain("onboarding failed"); }); });