From 5eb084db9523ae9e47763b881aa107df71b1df5e Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Mon, 2 Mar 2026 18:31:40 -0800 Subject: [PATCH] test(cli): add bootstrap-external unit and bootstrap-command tests --- ...otstrap-external.bootstrap-command.test.ts | 564 ++++++++++++++++++ src/cli/bootstrap-external.test.ts | 270 +++++++++ 2 files changed, 834 insertions(+) create mode 100644 src/cli/bootstrap-external.bootstrap-command.test.ts create mode 100644 src/cli/bootstrap-external.test.ts diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts new file mode 100644 index 00000000000..80fb7082b2e --- /dev/null +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -0,0 +1,564 @@ +import { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import { bootstrapCommand } from "./bootstrap-external.js"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +type SpawnCall = { + command: string; + args: string[]; + options?: { stdio?: unknown }; +}; + +function createTempStateDir(): string { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const dir = path.join(os.tmpdir(), `ironclaw-bootstrap-${suffix}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeBootstrapFixtures(stateDir: string): void { + const config = { + agents: { + defaults: { + model: { primary: "vercel-ai-gateway/anthropic/claude-opus-4.6" }, + }, + }, + gateway: { + mode: "local", + }, + }; + writeFileSync(path.join(stateDir, "openclaw.json"), JSON.stringify(config)); + + const authDir = path.join(stateDir, "agents", "main", "agent"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + path.join(authDir, "auth-profiles.json"), + JSON.stringify({ + profiles: { + "vercel-ai-gateway:default": { + provider: "vercel-ai-gateway", + key: "vck_test_123", + }, + }, + }), + ); +} + +function createMockChild(params: { + code: number; + stdout?: string; + stderr?: string; +}): EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; +} { + 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 (params.stdout) { + child.stdout.emit("data", Buffer.from(params.stdout)); + } + if (params.stderr) { + child.stderr.emit("data", Buffer.from(params.stderr)); + } + child.emit("close", params.code); + }); + + return child; +} + +describe("bootstrapCommand always-onboard behavior", () => { + const originalEnv = { ...process.env }; + const spawnMock = vi.mocked(spawn); + let stateDir = ""; + let spawnCalls: SpawnCall[] = []; + let forceGlobalMissing = false; + let globalDetectCount = 0; + let healthFailuresBeforeSuccess = 0; + let healthCallCount = 0; + let alwaysHealthFail = false; + + beforeEach(() => { + stateDir = createTempStateDir(); + writeBootstrapFixtures(stateDir); + spawnCalls = []; + forceGlobalMissing = false; + globalDetectCount = 0; + healthFailuresBeforeSuccess = 0; + healthCallCount = 0; + alwaysHealthFail = false; + process.env = { + ...originalEnv, + OPENCLAW_PROFILE: "ironclaw", + OPENCLAW_STATE_DIR: stateDir, + VITEST: "true", + }; + + spawnMock.mockImplementation((command, args = [], options) => { + const commandString = String(command); + const argList = Array.isArray(args) ? args.map(String) : []; + spawnCalls.push({ + command: commandString, + args: argList, + options: options as { stdio?: unknown } | undefined, + }); + + if (commandString === "openclaw" && argList[0] === "--version") { + return createMockChild({ code: 0, stdout: "2026.3.1\n" }) as never; + } + if ( + commandString === "npm" && + argList.includes("ls") && + argList.includes("-g") && + argList.includes("openclaw") + ) { + globalDetectCount += 1; + const reportMissing = forceGlobalMissing && globalDetectCount === 1; + return createMockChild({ + code: reportMissing ? 1 : 0, + stdout: reportMissing + ? '{"dependencies":{}}' + : '{"dependencies":{"openclaw":{"version":"2026.3.1"}}}', + }) as never; + } + if (commandString === "npm" && argList.includes("prefix") && argList.includes("-g")) { + return createMockChild({ + code: 0, + stdout: `${path.join(stateDir, "npm-global")}\n`, + }) as never; + } + if (commandString === "npm" && argList.includes("install") && argList.includes("-g")) { + return createMockChild({ code: 0, stdout: "installed\n" }) as never; + } + if ((commandString === "which" || commandString === "where") && argList[0] === "openclaw") { + return createMockChild({ code: 0, stdout: "/usr/local/bin/openclaw\n" }) as never; + } + if ( + commandString === "openclaw" && + argList.includes("config") && + argList.includes("get") && + argList.includes("gateway.mode") + ) { + return createMockChild({ code: 0, stdout: "local\n" }) as never; + } + if (commandString === "openclaw" && argList.includes("health")) { + healthCallCount += 1; + if (alwaysHealthFail || healthCallCount <= healthFailuresBeforeSuccess) { + return createMockChild({ + code: 1, + stderr: "gateway closed (1006 abnormal closure)\n", + }) as never; + } + return createMockChild({ code: 0, stdout: '{"ok":true}\n' }) as never; + } + return createMockChild({ code: 0, stdout: "ok\n" }) as never; + }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ status: 200 }) as unknown as Response), + ); + }); + + afterEach(() => { + process.env = originalEnv; + rmSync(stateDir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("runs onboard every bootstrap even when config already exists (prevents stale auth drift)", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const onboardCalls = spawnCalls.filter( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + expect(onboardCalls).toHaveLength(1); + expect(onboardCalls[0]?.args).toEqual( + expect.arrayContaining([ + "--profile", + "ironclaw", + "onboard", + "--install-daemon", + "--non-interactive", + "--accept-risk", + "--skip-ui", + ]), + ); + expect(onboardCalls[0]?.options?.stdio).toEqual(["ignore", "pipe", "pipe"]); + expect(summary.onboarded).toBe(true); + }); + + it("seeds workspace.duckdb on bootstrap when missing", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const workspaceDir = path.join(stateDir, "workspace"); + const workspaceDbPath = path.join(workspaceDir, "workspace.duckdb"); + expect(existsSync(workspaceDbPath)).toBe(false); + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + expect(existsSync(workspaceDbPath)).toBe(true); + expect(summary.workspaceSeed?.seeded).toBe(true); + expect(summary.workspaceSeed?.reason).toBe("seeded"); + expect(summary.workspaceSeed?.workspaceDir).toBe(workspaceDir); + }); + + it("skips workspace seeding when workspace.duckdb already exists", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const workspaceDir = path.join(stateDir, "workspace"); + const workspaceDbPath = path.join(workspaceDir, "workspace.duckdb"); + const identityPath = path.join(workspaceDir, "IDENTITY.md"); + mkdirSync(workspaceDir, { recursive: true }); + writeFileSync(workspaceDbPath, "existing-db-content", "utf-8"); + writeFileSync(identityPath, "# stale identity\n", "utf-8"); + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + expect(summary.workspaceSeed?.seeded).toBe(false); + expect(summary.workspaceSeed?.reason).toBe("already-exists"); + expect(readFileSync(workspaceDbPath, "utf-8")).toBe("existing-db-content"); + const identityContent = readFileSync(identityPath, "utf-8"); + expect(identityContent).toContain("You are **Ironclaw**"); + expect(identityContent).toContain("~skills/dench/SKILL.md"); + expect(identityContent).not.toContain("# stale identity"); + }); + + it("creates people/company/task object projection files when seeding a new workspace", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const customWorkspace = path.join(stateDir, "seed-projection-workspace"); + writeFileSync( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + agents: { + defaults: { + model: { primary: "vercel-ai-gateway/anthropic/claude-opus-4.6" }, + workspace: customWorkspace, + }, + }, + gateway: { mode: "local" }, + }), + ); + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + 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(existsSync(identityPath)).toBe(true); + const identityContent = readFileSync(identityPath, "utf-8"); + expect(identityContent).toContain("You are **Ironclaw**"); + expect(identityContent).toContain("~skills/dench/SKILL.md"); + }); + + it("installs Dench skill into managed profile skills directory (keeps it out of editable workspace)", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const targetSkill = path.join(stateDir, "skills", "dench", "SKILL.md"); + const workspaceSkill = path.join(stateDir, "workspace", "skills", "dench", "SKILL.md"); + expect(existsSync(targetSkill)).toBe(false); + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + expect(existsSync(targetSkill)).toBe(true); + expect(existsSync(workspaceSkill)).toBe(false); + expect(readFileSync(targetSkill, "utf-8")).toContain("name: database-crm-system"); + }); + + it("replaces existing managed Dench skill on bootstrap (keeps updates in sync)", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const targetDir = path.join(stateDir, "skills", "dench"); + const targetSkill = path.join(targetDir, "SKILL.md"); + mkdirSync(targetDir, { recursive: true }); + writeFileSync(targetSkill, "name: dench\n# custom\n"); + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const content = readFileSync(targetSkill, "utf-8"); + expect(content).toContain("name: database-crm-system"); + expect(content).not.toContain("# custom"); + }); + + it("keeps Dench in managed skills even when workspace path is custom", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const customWorkspace = path.join(stateDir, "custom-workspace-root"); + writeFileSync( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + agents: { + defaults: { + model: { primary: "vercel-ai-gateway/anthropic/claude-opus-4.6" }, + workspace: customWorkspace, + }, + }, + gateway: { mode: "local" }, + }), + ); + const managedSkill = path.join(stateDir, "skills", "dench", "SKILL.md"); + const workspaceSkill = path.join(customWorkspace, "skills", "dench", "SKILL.md"); + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + expect(existsSync(managedSkill)).toBe(true); + expect(existsSync(workspaceSkill)).toBe(false); + }); + + it("uses inherited stdio for onboarding in interactive mode (shows wizard prompts)", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const onboardCalls = spawnCalls.filter( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + expect(onboardCalls).toHaveLength(1); + expect(onboardCalls[0]?.options?.stdio).toBe("inherit"); + expect(onboardCalls[0]?.args).not.toContain("--non-interactive"); + expect(onboardCalls[0]?.args).not.toContain("--accept-risk"); + }); + + it("does not call gateway install/start fallback when onboarding is always used", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const gatewayInstallCalled = spawnCalls.some( + (call) => + call.command === "openclaw" && + call.args.includes("gateway") && + call.args.includes("install"), + ); + const gatewayStartCalled = spawnCalls.some( + (call) => + call.command === "openclaw" && call.args.includes("gateway") && call.args.includes("start"), + ); + + expect(gatewayInstallCalled).toBe(false); + expect(gatewayStartCalled).toBe(false); + }); + + it("installs global OpenClaw even when a local binary already resolves", async () => { + forceGlobalMissing = true; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const globalInstallCalls = spawnCalls.filter( + (call) => + call.command === "npm" && + call.args.includes("install") && + call.args.includes("-g") && + call.args.includes("openclaw@latest"), + ); + expect(globalInstallCalls.length).toBeGreaterThan(0); + expect(summary.installedOpenClawCli).toBe(true); + }); + + it("runs doctor/gateway autofix steps when initial health probe fails", async () => { + healthFailuresBeforeSuccess = 1; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const doctorFixCalled = spawnCalls.some( + (call) => + call.command === "openclaw" && call.args.includes("doctor") && call.args.includes("--fix"), + ); + const gatewayInstallCalled = spawnCalls.some( + (call) => + call.command === "openclaw" && + call.args.includes("gateway") && + call.args.includes("install"), + ); + const gatewayStartCalled = spawnCalls.some( + (call) => + call.command === "openclaw" && call.args.includes("gateway") && call.args.includes("start"), + ); + + expect(doctorFixCalled).toBe(true); + expect(gatewayInstallCalled).toBe(true); + expect(gatewayStartCalled).toBe(true); + expect(summary.gatewayReachable).toBe(true); + expect(summary.gatewayAutoFix?.attempted).toBe(true); + expect(summary.gatewayAutoFix?.recovered).toBe(true); + }); + + it("prints likely gateway cause with log excerpt when autofix cannot recover", async () => { + alwaysHealthFail = true; + mkdirSync(path.join(stateDir, "logs"), { recursive: true }); + writeFileSync( + path.join(stateDir, "logs", "gateway.err.log"), + [ + "unauthorized: gateway token mismatch", + "Invalid config", + "plugins.slots.memory: plugin not found: memory-core", + ].join("\n"), + ); + + const logSpy = vi.fn(); + const runtime: RuntimeEnv = { + log: logSpy, + error: vi.fn(), + exit: vi.fn(), + }; + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + const logMessages = logSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + + expect(summary.gatewayReachable).toBe(false); + expect(summary.gatewayAutoFix?.attempted).toBe(true); + expect(logMessages).toContain("Likely gateway cause:"); + expect(logMessages).toContain("gateway.err.log"); + }); +}); diff --git a/src/cli/bootstrap-external.test.ts b/src/cli/bootstrap-external.test.ts new file mode 100644 index 00000000000..ac9a19b3096 --- /dev/null +++ b/src/cli/bootstrap-external.test.ts @@ -0,0 +1,270 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + buildBootstrapDiagnostics, + checkAgentAuth, + resolveBootstrapRolloutStage, + isLegacyFallbackEnabled, + type BootstrapDiagnostics, +} from "./bootstrap-external.js"; + +function getCheck( + diagnostics: BootstrapDiagnostics, + id: BootstrapDiagnostics["checks"][number]["id"], +) { + const check = diagnostics.checks.find((item) => item.id === id); + expect(check).toBeDefined(); + return check!; +} + +function createTempStateDir(): string { + const dir = path.join( + tmpdir(), + `ironclaw-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeConfig(stateDir: string, config: Record): void { + writeFileSync(path.join(stateDir, "openclaw.json"), JSON.stringify(config)); +} + +function writeAuthProfiles(stateDir: string, profiles: Record): void { + const agentDir = path.join(stateDir, "agents", "main", "agent"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync(path.join(agentDir, "auth-profiles.json"), JSON.stringify(profiles)); +} + +describe("bootstrap-external diagnostics", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = createTempStateDir(); + writeConfig(stateDir, { + agents: { defaults: { model: { primary: "vercel-ai-gateway/anthropic/claude-opus-4.6" } } }, + }); + writeAuthProfiles(stateDir, { + version: 1, + profiles: { + "vercel-ai-gateway:default": { + type: "api_key", + provider: "vercel-ai-gateway", + key: "vck_test_key_1234567890", + }, + }, + }); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + }); + + const baseParams = (dir: string) => ({ + profile: "ironclaw", + openClawCliAvailable: true, + openClawVersion: "2026.3.1", + gatewayPort: 18789, + gatewayUrl: "ws://127.0.0.1:18789", + gatewayProbe: { ok: true as const }, + webPort: 3100, + webReachable: true, + rolloutStage: "default" as const, + legacyFallbackEnabled: false, + stateDir: dir, + env: { HOME: "/home/testuser" }, + }); + + it("reports passing checks including agent-auth when config and keys exist", () => { + const diagnostics = buildBootstrapDiagnostics(baseParams(stateDir)); + + expect(getCheck(diagnostics, "profile").status).toBe("pass"); + expect(getCheck(diagnostics, "gateway").status).toBe("pass"); + expect(getCheck(diagnostics, "agent-auth").status).toBe("pass"); + expect(getCheck(diagnostics, "web-ui").status).toBe("pass"); + expect(diagnostics.hasFailures).toBe(false); + }); + + it("fails agent-auth when auth-profiles.json is missing (catches missing onboard)", () => { + const emptyDir = createTempStateDir(); + writeConfig(emptyDir, { + agents: { defaults: { model: { primary: "vercel-ai-gateway/anthropic/claude-4" } } }, + }); + + try { + const diagnostics = buildBootstrapDiagnostics(baseParams(emptyDir)); + const auth = getCheck(diagnostics, "agent-auth"); + expect(auth.status).toBe("fail"); + expect(auth.detail).toContain("auth-profiles.json"); + expect(auth.remediation).toContain("onboard --install-daemon"); + expect(diagnostics.hasFailures).toBe(true); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } + }); + + it("fails agent-auth when key exists for wrong provider (catches provider mismatch)", () => { + const dir = createTempStateDir(); + writeConfig(dir, { + agents: { defaults: { model: { primary: "anthropic/claude-4" } } }, + }); + writeAuthProfiles(dir, { + profiles: { + "openai:default": { provider: "openai", key: "sk-test" }, + }, + }); + + try { + const diagnostics = buildBootstrapDiagnostics(baseParams(dir)); + const auth = getCheck(diagnostics, "agent-auth"); + expect(auth.status).toBe("fail"); + expect(auth.detail).toContain('"anthropic"'); + expect(auth.remediation).toContain("onboard --install-daemon"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails agent-auth when no model provider is configured", () => { + const dir = createTempStateDir(); + writeConfig(dir, { agents: {} }); + + try { + const diagnostics = buildBootstrapDiagnostics(baseParams(dir)); + const auth = getCheck(diagnostics, "agent-auth"); + expect(auth.status).toBe("fail"); + expect(auth.detail).toContain("No model provider configured"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("surfaces actionable remediation for gateway auth failures", () => { + const diagnostics = buildBootstrapDiagnostics({ + ...baseParams(stateDir), + gatewayProbe: { ok: false as const, detail: "Unauthorized: token mismatch" }, + }); + + const gateway = getCheck(diagnostics, "gateway"); + expect(gateway.status).toBe("fail"); + expect(String(gateway.remediation)).toContain("onboard"); + expect(diagnostics.hasFailures).toBe(true); + }); + + it("marks rollout-stage as warning for beta and includes opt-in guidance", () => { + const diagnostics = buildBootstrapDiagnostics({ + ...baseParams(stateDir), + rolloutStage: "beta", + }); + + const rollout = getCheck(diagnostics, "rollout-stage"); + expect(rollout.status).toBe("warn"); + expect(String(rollout.remediation)).toContain("IRONCLAW_BOOTSTRAP_BETA_OPT_IN"); + }); + + it("fails cutover-gates when enforcement is enabled without gate envs", () => { + const diagnostics = buildBootstrapDiagnostics({ + ...baseParams(stateDir), + env: { HOME: "/home/testuser", IRONCLAW_BOOTSTRAP_ENFORCE_SAFETY_GATES: "1" }, + }); + + expect(getCheck(diagnostics, "cutover-gates").status).toBe("fail"); + expect(diagnostics.hasFailures).toBe(true); + }); + + it("passes cutover-gates when both required gate envs are set", () => { + const diagnostics = buildBootstrapDiagnostics({ + ...baseParams(stateDir), + env: { + HOME: "/home/testuser", + IRONCLAW_BOOTSTRAP_MIGRATION_SUITE_OK: "1", + IRONCLAW_BOOTSTRAP_ONBOARDING_E2E_OK: "1", + }, + }); + + expect(getCheck(diagnostics, "cutover-gates").status).toBe("pass"); + }); +}); + +describe("checkAgentAuth", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = createTempStateDir(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("returns ok when a valid key exists for the requested provider", () => { + writeAuthProfiles(stateDir, { + profiles: { + "vercel-ai-gateway:default": { + provider: "vercel-ai-gateway", + key: "vck_valid_key", + }, + }, + }); + const result = checkAgentAuth(stateDir, "vercel-ai-gateway"); + expect(result.ok).toBe(true); + expect(result.provider).toBe("vercel-ai-gateway"); + }); + + it("returns not ok when auth-profiles.json does not exist", () => { + const result = checkAgentAuth(stateDir, "vercel-ai-gateway"); + expect(result.ok).toBe(false); + expect(result.detail).toContain("auth-profiles.json"); + }); + + it("returns not ok when key exists for a different provider", () => { + writeAuthProfiles(stateDir, { + profiles: { + "openai:default": { provider: "openai", key: "sk-test" }, + }, + }); + const result = checkAgentAuth(stateDir, "anthropic"); + expect(result.ok).toBe(false); + expect(result.detail).toContain('"anthropic"'); + }); + + it("returns not ok when key string is empty", () => { + writeAuthProfiles(stateDir, { + profiles: { + "vercel-ai-gateway:default": { provider: "vercel-ai-gateway", key: "" }, + }, + }); + const result = checkAgentAuth(stateDir, "vercel-ai-gateway"); + expect(result.ok).toBe(false); + }); + + it("returns not ok when provider is undefined", () => { + const result = checkAgentAuth(stateDir, undefined); + expect(result.ok).toBe(false); + expect(result.detail).toContain("No model provider configured"); + }); + + it("returns not ok when profiles object is empty", () => { + writeAuthProfiles(stateDir, { profiles: {} }); + const result = checkAgentAuth(stateDir, "vercel-ai-gateway"); + expect(result.ok).toBe(false); + }); +}); + +describe("bootstrap-external rollout env helpers", () => { + it("resolves rollout stage from ironclaw/openclaw env vars", () => { + expect(resolveBootstrapRolloutStage({ IRONCLAW_BOOTSTRAP_ROLLOUT: "beta" })).toBe("beta"); + expect(resolveBootstrapRolloutStage({ OPENCLAW_BOOTSTRAP_ROLLOUT: "internal" })).toBe( + "internal", + ); + expect(resolveBootstrapRolloutStage({ IRONCLAW_BOOTSTRAP_ROLLOUT: "invalid" })).toBe("default"); + }); + + it("detects legacy fallback via either env namespace", () => { + expect(isLegacyFallbackEnabled({ IRONCLAW_BOOTSTRAP_LEGACY_FALLBACK: "1" })).toBe(true); + expect(isLegacyFallbackEnabled({ OPENCLAW_BOOTSTRAP_LEGACY_FALLBACK: "true" })).toBe(true); + expect(isLegacyFallbackEnabled({})).toBe(false); + }); +});