diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3efef9a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.9.0-alpha.0] — 2026-04-06 + +### ⚠️ Alpha Preview + +> **This is early, unstable software.** Expect rough edges, breaking changes, and incomplete features. Do not use in production environments. + +### Added +- **Avatar system** — Per-agent avatar selection with auto-generated (multiavatar lib), default (12 PNG profiles), and custom URL modes; globally toggled from the footer +- **Fleet sidebar soul names** — Agent cards now display identity names fetched from each agent's `IDENTITY.md`, with improved card layout and sizing +- **Footer avatar mode toggle** — Quick dropdown to switch between auto/default/custom avatar sources; persists to localStorage +- **`AgentCreateModal` types** — Added `AgentCreationType` union (`create` | `resume`) for clearer creation vs. resumption flows +- **`AgentSettingsMutationController`** — New React controller hook for batched, optimistic agent settings updates with conflict resolution +- **Fleet hydration derivation** — `deriveDefaultIndex` utility shared across `FooterBar`, `TasksDashboard`, and fleet tiles for consistent avatar mapping across reloads +- **`agentFleetHydration` operation** — Fetches `IDENTITY.md` per agent at hydration time; shows soul names in fleet cards +- **Gateway version display** — Client-side fetch of OpenClaw gateway version displayed in footer +- **E2E test coverage** — Settings panel test using specific heading locator; bootstrap workflow tests + +### Changed +- **Fleet tile avatars** — Increased from 80px to 96px; now fill card height with `object-cover` centering +- **Fleet card layout** — Identity name shown as muted subtitle above agent name; soul name bold/larger; agent name small and muted below +- **`identityName` field** — `agent.identity?.name` is now the single source of truth for display names across all components +- **`FooterBar` avatar toggle** — Now shows whenever agents exist (not only when an agent is running); aligned with global `AvatarModeContext` +- **`AgentAvatar` context** — Now reads `AvatarModeContext` so footer, fleet tiles, and task cards stay in sync +- **Avatar mode context** — Shared globally via `AvatarModeContext.tsx` to prevent stale state from `useEffect` localStorage reads +- **Seed hashing** — Default avatars use `seed + index` blending to ensure all agents get unique default images on each reload + +### Fixed +- **Avatar centering** — `fill` mode avatars now use `relative` positioning with `object-cover` to prevent overflow +- **Avatar sync on reload** — Agents now get consistent (but distinct) default avatar images even after page reload +- **Identity name priority** — Fixed priority chain: `agent.identity?.name` → `agent.identityName` → derived from identity file +- **Footer default avatar** — Footer avatar in default mode now uses shared `deriveDefaultIndex` for correct index calculation +- **Lint errors** — Fixed `setState-in-effect` lint errors and resolved all remaining lint issues + +### Infrastructure +- GitHub Actions CI pipeline for automated testing on push/PR +- Playwright E2E test suite with maximized browser window support +- Vitest unit test suite +- ESLint + TypeScript strict type checking diff --git a/README.md b/README.md index 4711dee..efab007 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@
-# 🎛️ rocCLAW +# ⚠️ rocCLAW — Alpha Preview -**Your focused operator rocclaw for OpenClaw** +> **This is early, unstable software (v0.9.0-alpha).** Expect rough edges, breaking changes, and incomplete features. Do not use in production environments. Feedback welcome! + +**Your focused operator studio for OpenClaw AI agents**

Discord Node.js - GitHub Release + GitHub Release License + Version

diff --git a/package.json b/package.json index 78025ab..fca2bc6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@simoncatbot/rocclaw", - "version": "0.1.0", - "description": "Focused operator studio for OpenClaw - Web dashboard for AI agent management", + "version": "0.9.0-alpha.0", + "description": "⚠️ ALPHA PREVIEW — Focused operator studio for OpenClaw AI agents. This is early software — expect rough edges.", "main": "server/index.js", "bin": { "rocclaw": "./server/index.js" @@ -19,7 +19,9 @@ "agent", "dashboard", "chat", - "nextjs" + "nextjs", + "alpha", + "operator-studio" ], "author": "SimonCatBot", "license": "MIT", diff --git a/tests/e2e/agent-avatar-settings.spec.ts b/tests/e2e/agent-avatar-settings.spec.ts new file mode 100644 index 0000000..2696ca5 --- /dev/null +++ b/tests/e2e/agent-avatar-settings.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from "@playwright/test"; +import { stubRocclawRoute } from "./helpers/rocclawRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; + +const FLEET_WITH_TWO_AGENTS = { + seeds: [ + { agentId: "agent-1", name: "Alice", sessionKey: "agent:agent-1:main" }, + { agentId: "agent-2", name: "Bob", sessionKey: "agent:agent-2:main" }, + ], + sessionCreatedAgentIds: [], + sessionSettingsSyncedAgentIds: [], + summaryPatches: [], + suggestedSelectedAgentId: "agent-1", + configSnapshot: null, +}; + +test.beforeEach(async ({ page }) => { + await stubRocclawRoute(page, { + version: 1, + gateway: null, + focused: {}, + avatars: {}, + }); + await stubRuntimeRoutes(page, { + summary: { status: "connected" }, + fleetResult: FLEET_WITH_TWO_AGENTS, + }); +}); + +test("fleet row click applies selected CSS class to clicked agent", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + await expect(page.getByTestId("fleet-agent-row-agent-2")).toBeVisible(); + + // Agent-2 starts unselected + const agent2 = page.getByTestId("fleet-agent-row-agent-2"); + await expect(agent2).not.toHaveClass(/ui-card-selected/); + + // Click selects agent-2 + await agent2.click(); + await expect(agent2).toHaveClass(/ui-card-selected/); + + // Agent-1 is no longer selected + const agent1 = page.getByTestId("fleet-agent-row-agent-1"); + await expect(agent1).not.toHaveClass(/ui-card-selected/); +}); + +test("fleet row click triggers PUT request with focused selectedAgentId", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + + // Capture the focused PUT request + const focusedPut = page.waitForRequest((req) => { + if (!req.url().includes("/api/rocclaw") || req.method() !== "PUT") return false; + try { + const payload = JSON.parse(req.postData() ?? "{}"); + return ( + payload.focused && + Object.values(payload.focused).some( + (entry: unknown) => + typeof entry === "object" && + entry !== null && + "selectedAgentId" in entry && + (entry as { selectedAgentId: string }).selectedAgentId === "agent-2" + ) + ); + } catch { + return false; + } + }); + + await page.getByTestId("fleet-agent-row-agent-2").click(); + const request = await focusedPut; + + const payload = JSON.parse(request.postData() ?? "{}"); + const focusedEntry = Object.values(payload.focused ?? {})[0] as { + selectedAgentId: string; + } | null; + expect(focusedEntry?.selectedAgentId).toBe("agent-2"); +}); + +test("avatar shuffle triggers PUT request with avatar data", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + await page.getByTestId("fleet-agent-row-agent-1").click(); + + // Wait for the inspect panel's AvatarSelector to render — look for shuffle button + await expect(page.getByRole("button", { name: /shuffle/i })).toBeVisible({ timeout: 10000 }); + + // Capture avatar PUT triggered by shuffle + const avatarPut = page.waitForRequest((req) => { + if (!req.url().includes("/api/rocclaw") || req.method() !== "PUT") return false; + try { + const payload = JSON.parse(req.postData() ?? "{}"); + return "avatars" in payload || "avatarSources" in payload; + } catch { + return false; + } + }); + + await page.getByRole("button", { name: /shuffle/i }).click(); + + const request = await avatarPut; + expect(request).toBeDefined(); + const payload = JSON.parse(request.postData() ?? "{}"); + expect(payload.avatars ?? payload.avatarSources).toBeDefined(); +}); diff --git a/tests/e2e/avatar-toggle.spec.ts b/tests/e2e/avatar-toggle.spec.ts new file mode 100644 index 0000000..0dd3d69 --- /dev/null +++ b/tests/e2e/avatar-toggle.spec.ts @@ -0,0 +1,130 @@ +import { expect, test } from "@playwright/test"; +import { stubRocclawRoute } from "./helpers/rocclawRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; + +const FLEET_WITH_TWO_AGENTS = { + seeds: [ + { agentId: "agent-1", name: "Alice", sessionKey: "agent:agent-1:main" }, + { agentId: "agent-2", name: "Bob", sessionKey: "agent:agent-2:main" }, + ], + sessionCreatedAgentIds: [], + sessionSettingsSyncedAgentIds: [], + summaryPatches: [], + suggestedSelectedAgentId: "agent-1", + configSnapshot: null, +}; + +test.beforeEach(async ({ page }) => { + await stubRocclawRoute(page, { + version: 1, + gateway: null, + focused: {}, + avatars: {}, + }); + await stubRuntimeRoutes(page, { + summary: { status: "connected" }, + fleetResult: FLEET_WITH_TWO_AGENTS, + }); +}); + +test("footer avatar toggle opens dropdown with all three mode options", async ({ page }) => { + await page.goto("/"); + + // Wait for fleet to hydrate + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + + // Footer is rendered + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + + // Footer avatar toggle button is present + const avatarToggle = page.getByRole("button", { name: /avatar mode: auto/i }); + await expect(avatarToggle).toBeVisible(); + + // Open the dropdown + await avatarToggle.click(); + + // Dropdown items appear with descriptive accessible names + // Each mode button's accessible name is "{label} {description}" + await expect(page.getByRole("button", { name: /auto procedural/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /default profile/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /custom custom/i })).toBeVisible(); +}); + +test("selecting default mode closes dropdown and updates toggle title", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + + // Open dropdown + await page.getByRole("button", { name: /avatar mode: auto/i }).click(); + + // Select Default mode + await page.getByRole("button", { name: /default profile/i }).click(); + + // Dropdown is closed + await expect(page.getByRole("button", { name: /auto procedural/i })).not.toBeVisible(); + + // Toggle reflects the new mode — re-query to avoid stale locator + await expect( + page.getByRole("button", { name: /avatar mode: default/i }) + ).toHaveAttribute("title", "Avatar mode: Default"); +}); + +test("selecting custom mode persists across page reload", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + + // Select Custom + await page.getByRole("button", { name: /avatar mode: auto/i }).click(); + await page.getByRole("button", { name: /custom custom/i }).click(); + + // Reload — mode is restored from localStorage + await page.reload(); + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + await expect( + page.getByRole("button", { name: /avatar mode: custom/i }) + ).toHaveAttribute("title", "Avatar mode: Custom"); +}); + +test("footer avatar toggle is present even when fleet is empty", async ({ page }) => { + // Override stubs with empty fleet before navigating + await stubRuntimeRoutes(page, { + summary: { status: "connected" }, + fleetResult: { + seeds: [], + sessionCreatedAgentIds: [], + sessionSettingsSyncedAgentIds: [], + summaryPatches: [], + suggestedSelectedAgentId: null, + configSnapshot: null, + }, + }); + + await page.goto("/"); + + // Footer is rendered + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + + // Avatar toggle is still present even when fleet is empty + const avatarToggle = page.getByRole("button", { name: /avatar mode/i }); + await expect(avatarToggle).toBeVisible(); + await expect(avatarToggle).toHaveAttribute("title", "Avatar mode: Auto"); +}); + +test("agent row selection is reflected via CSS class change", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible(); + + // Agent-2 starts unselected + const agent2 = page.getByTestId("fleet-agent-row-agent-2"); + await expect(agent2).not.toHaveClass(/ui-card-selected/); + + // Click selects agent-2 + await agent2.click(); + await expect(agent2).toHaveClass(/ui-card-selected/); + + // Agent-1 is no longer selected + const agent1 = page.getByTestId("fleet-agent-row-agent-1"); + await expect(agent1).not.toHaveClass(/ui-card-selected/); +}); diff --git a/tests/unit/agentAvatar.test.ts b/tests/unit/agentAvatar.test.ts new file mode 100644 index 0000000..f5f6071 --- /dev/null +++ b/tests/unit/agentAvatar.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { deriveDefaultIndex, buildDefaultAvatarUrl } from "@/features/agents/components/AgentAvatar"; + +describe("deriveDefaultIndex", () => { + it("returns a number within [0, 11] for any seed", () => { + for (let i = 0; i < 100; i++) { + const index = deriveDefaultIndex(`agent-${i}`, 0); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(12); + } + }); + + it("produces different indices for different seeds", () => { + const results = new Set(); + for (let i = 0; i < 20; i++) { + results.add(deriveDefaultIndex(`agent-${i}`, 0)); + } + // With 20 agents and 12 options, we expect at least some collisions are likely, + // but most should be unique — at minimum more than 1 unique value + expect(results.size).toBeGreaterThan(1); + }); + + it("different explicitIndex values yield different results for the same seed", () => { + const base = deriveDefaultIndex("same-seed", 0); + const withIndex = deriveDefaultIndex("same-seed", 5); + expect(withIndex).not.toBe(base); + }); + + it("same seed + same index always returns the same value (deterministic)", () => { + const first = deriveDefaultIndex("deterministic-agent", 3); + for (let i = 0; i < 10; i++) { + expect(deriveDefaultIndex("deterministic-agent", 3)).toBe(first); + } + }); + + it("handles empty seed string", () => { + const index = deriveDefaultIndex("", 0); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(12); + }); + + it("handles negative explicitIndex as UNSET (uses seed hash only)", () => { + // -1 is UNSET_INDEX sentinel + const withNeg = deriveDefaultIndex("test-seed", -1); + + // -1 should produce a different result than 0 in most cases + // (because UNSET_INDEX = -1 causes base=0, but hash still varies) + expect(typeof withNeg).toBe("number"); + }); + + it("handles very large explicitIndex by wrapping into range", () => { + const index = deriveDefaultIndex("agent-1", 9999); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(12); + }); +}); + +describe("buildDefaultAvatarUrl", () => { + it("returns a /avatars/profile-N.png path", () => { + const url = buildDefaultAvatarUrl(0); + expect(url).toMatch(/^\/avatars\/profile-(\d+)\.png$/); + }); + + it("returns profile-1.png for index 0", () => { + const url = buildDefaultAvatarUrl(0); + expect(url).toBe("/avatars/profile-1.png"); + }); + + it("returns profile-12.png for index 11", () => { + const url = buildDefaultAvatarUrl(11); + expect(url).toBe("/avatars/profile-12.png"); + }); + + it("wraps index 12 back to profile-1.png", () => { + const url = buildDefaultAvatarUrl(12); + expect(url).toBe("/avatars/profile-1.png"); + }); + + it("wraps negative index to valid range", () => { + const url = buildDefaultAvatarUrl(-1); + expect(url).toMatch(/^\/avatars\/profile-(\d+)\.png$/); + expect(url).not.toBe("/avatars/profile-0.png"); + }); + + it("handles large indices by wrapping", () => { + const url = buildDefaultAvatarUrl(999); + expect(url).toMatch(/^\/avatars\/profile-(\d+)\.png$/); + }); +}); diff --git a/tests/unit/avatarModeContext.test.ts b/tests/unit/avatarModeContext.test.ts new file mode 100644 index 0000000..5100f08 --- /dev/null +++ b/tests/unit/avatarModeContext.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +import { + AvatarModeProvider, + useAvatarMode, + useSetAvatarMode, +} from "@/components/AvatarModeContext"; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: () => { + store = {}; + }, + }; +})(); +Object.defineProperty(window, "localStorage", { value: localStorageMock }); + +describe("AvatarModeContext", () => { + afterEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + describe("useAvatarMode", () => { + it("defaults to 'auto' when nothing is stored", () => { + // @ts-expect-error -- localStorage getItem returns string|null + localStorageMock.getItem.mockReturnValue(null); + const { result } = renderHook(() => useAvatarMode(), { + wrapper: AvatarModeProvider, + }); + expect(result.current).toBe("auto"); + }); + + it("returns stored 'default' mode", () => { + localStorageMock.getItem.mockReturnValue("default"); + const { result } = renderHook(() => useAvatarMode(), { + wrapper: AvatarModeProvider, + }); + expect(result.current).toBe("default"); + }); + + it("returns stored 'custom' mode", () => { + localStorageMock.getItem.mockReturnValue("custom"); + const { result } = renderHook(() => useAvatarMode(), { + wrapper: AvatarModeProvider, + }); + expect(result.current).toBe("custom"); + }); + + it("returns 'auto' for unknown stored values", () => { + localStorageMock.getItem.mockReturnValue("unknown-mode"); + const { result } = renderHook(() => useAvatarMode(), { + wrapper: AvatarModeProvider, + }); + expect(result.current).toBe("auto"); + }); + }); + + describe("useSetAvatarMode", () => { + it("persists mode to localStorage", () => { + const { result } = renderHook(() => useSetAvatarMode(), { + wrapper: AvatarModeProvider, + }); + result.current("default"); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + "rocclaw-footer-avatar-mode", + "default" + ); + }); + + it("persists 'custom' to localStorage", () => { + const { result } = renderHook(() => useSetAvatarMode(), { + wrapper: AvatarModeProvider, + }); + result.current("custom"); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + "rocclaw-footer-avatar-mode", + "custom" + ); + }); + }); + + describe("AvatarModeProvider integration", () => { + it("changing mode via useSetAvatarMode updates useAvatarMode", async () => { + const { result } = renderHook( + () => ({ + mode: useAvatarMode(), + setMode: useSetAvatarMode(), + }), + { wrapper: AvatarModeProvider } + ); + + expect(result.current.mode).toBe("auto"); + result.current.setMode("default"); + + await waitFor(() => { + expect(result.current.mode).toBe("default"); + }); + }); + + it("round-trips: auto → default → custom → auto", async () => { + const { result } = renderHook( + () => ({ mode: useAvatarMode(), setMode: useSetAvatarMode() }), + { wrapper: AvatarModeProvider } + ); + + expect(result.current.mode).toBe("auto"); + + result.current.setMode("default"); + await waitFor(() => expect(result.current.mode).toBe("default")); + + result.current.setMode("custom"); + await waitFor(() => expect(result.current.mode).toBe("custom")); + + result.current.setMode("auto"); + await waitFor(() => expect(result.current.mode).toBe("auto")); + }); + }); +}); diff --git a/tests/unit/avatarSelector.test.ts b/tests/unit/avatarSelector.test.ts new file mode 100644 index 0000000..b257f53 --- /dev/null +++ b/tests/unit/avatarSelector.test.ts @@ -0,0 +1,110 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + AvatarSelector, + type AvatarSelectorValue, +} from "@/features/agents/components/AvatarSelector"; + +// Mock the avatar building utilities +vi.mock("@/lib/avatars/multiavatar", () => ({ + buildAvatarDataUrl: vi.fn((seed: string) => `data:auto+svg;seed=${seed}`), +})); + +vi.mock("@/features/agents/components/AgentAvatar", () => ({ + buildDefaultAvatarUrl: vi.fn((index: number) => `/avatars/profile-${index + 1}.png`), + deriveDefaultIndex: vi.fn((seed: string, i: number) => (seed.length + i) % 12), + AgentAvatar: vi.fn(({ seed, name }: { seed: string; name: string }) => + createElement("img", { src: `data:auto+svg;seed=${seed}`, alt: name }) + ), +})); + +const defaultValue: AvatarSelectorValue = { + avatarSource: "auto", + avatarSeed: "test-agent-seed", + defaultAvatarIndex: 0, + avatarUrl: "", +}; + +describe("AvatarSelector", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const renderSelector = ( + value: AvatarSelectorValue = defaultValue, + onChange = vi.fn(), + name = "avatar" + ) => + render( + createElement(AvatarSelector, { name, value, onChange }) + ); + + it("renders the auto tab as active by default", () => { + renderSelector(); + const activeTab = screen.getByRole("button", { name: "Auto" }); + expect(activeTab).toHaveClass("bg-primary"); + }); + + it("renders a grid of default avatar options", () => { + const { container } = renderSelector({ + ...defaultValue, + avatarSource: "default", + }); + const avatars = container.querySelectorAll("img"); + expect(avatars.length).toBeGreaterThanOrEqual(6); + }); + + it("calls onChange with updated seed when shuffle is clicked", () => { + const onChange = vi.fn(); + renderSelector( + { ...defaultValue, avatarSource: "auto", avatarSeed: "original-seed" }, + onChange + ); + + fireEvent.click(screen.getByRole("button", { name: /shuffle/i })); + expect(onChange).toHaveBeenCalled(); + const call = onChange.mock.calls[0][0] as AvatarSelectorValue; + expect(call.avatarSeed).toBeTruthy(); + }); + + it("calls onChange when a default avatar is selected", async () => { + const onChange = vi.fn(); + const { container } = renderSelector( + { ...defaultValue, avatarSource: "default" }, + onChange + ); + + // Switch to the Default tab first (component defaults to Auto) — this fires onChange once + const defaultTab = screen.getByRole("button", { name: /^default$/i }); + fireEvent.click(defaultTab); + + // Wait for the grid to appear + await new Promise((r) => setTimeout(r, 0)); + + // Click "Avatar 3" button in the grid + const avatarButton = container.querySelector('button[title="Avatar 3"]'); + expect(avatarButton).not.toBeNull(); + fireEvent.click(avatarButton!); + + // onChange fired for tab switch + avatar selection + expect(onChange).toHaveBeenCalled(); + // Find the call with the correct avatar index + const avatarCalls = onChange.mock.calls.filter( + (call) => (call[0] as AvatarSelectorValue).defaultAvatarIndex === 2 + ); + expect(avatarCalls.length).toBeGreaterThan(0); + }); + + it("calls onChange with custom source when custom tab is clicked", () => { + const onChange = vi.fn(); + renderSelector({ ...defaultValue, avatarSource: "auto" }, onChange); + + const customButton = screen.getByRole("button", { name: /custom/i }); + fireEvent.click(customButton); + expect(onChange).toHaveBeenCalled(); + const call = onChange.mock.calls[0][0] as AvatarSelectorValue; + expect(call.avatarSource).toBe("custom"); + }); +}); diff --git a/tests/unit/rocclawSettings.test.ts b/tests/unit/rocclawSettings.test.ts new file mode 100644 index 0000000..2db1919 --- /dev/null +++ b/tests/unit/rocclawSettings.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeROCclawSettings, + mergeROCclawSettings, + resolveFocusedPreference, + resolveAgentAvatarSeed, + resolveAgentAvatarConfig, + + normalizeGatewayKey, +} from "@/lib/rocclaw/settings"; + +describe("normalizeGatewayKey", () => { + it("normalizes https://127.0.0.1:18789 → localhost:18789", () => { + expect(normalizeGatewayKey("https://127.0.0.1:18789/")).toMatch(/localhost/); + }); + + it("preserves non-loopback URLs", () => { + expect(normalizeGatewayKey("https://gateway.example.com:9000")).toBe( + "https://gateway.example.com:9000" + ); + }); + + it("returns null for empty string", () => { + expect(normalizeGatewayKey("")).toBe(null); + }); + + it("trims whitespace", () => { + expect(normalizeGatewayKey(" https://127.0.0.1:18789 ")).toMatch(/localhost/); + }); +}); + +describe("normalizeROCclawSettings", () => { + it("returns defaults when input is null/undefined", () => { + const result = normalizeROCclawSettings(null); + expect(result.gateway).toBe(null); + expect(result.gatewayAutoStart).toBe(true); + expect(result.version).toBe(1); + expect(result.focused).toEqual({}); + expect(result.avatars).toEqual({}); + expect(result.avatarSources).toEqual({}); + }); + + it("accepts minimal valid input", () => { + const result = normalizeROCclawSettings({}); + expect(result.version).toBe(1); + expect(result.gatewayAutoStart).toBe(true); + }); + + it("normalizes loopback gateway URLs", () => { + const result = normalizeROCclawSettings({ + gateway: { url: "https://user:pass@127.0.0.1:18789/", token: "secret" }, + }); + expect(result.gateway?.url).toMatch(/localhost/); + expect(result.gateway?.token).toBe("secret"); + }); + + it("drops gateway with missing URL", () => { + const result = normalizeROCclawSettings({ gateway: { token: "tok" } }); + expect(result.gateway).toBe(null); + }); + + it("normalizes avatar entries with loopback keys", () => { + const result = normalizeROCclawSettings({ + avatars: { + "https://127.0.0.1:18789/": { + "agent-1": "my-seed", + }, + }, + }); + expect(Object.keys(result.avatars)[0]).toMatch(/localhost/); + }); + + it("ignores empty agent IDs in avatars", () => { + const result = normalizeROCclawSettings({ + avatars: { + "https://127.0.0.1:18789/": { + "": "seed", + " ": "seed2", + }, + }, + }); + const normalizedKey = Object.keys(result.avatars)[0]; + expect(Object.keys(result.avatars[normalizedKey])).toHaveLength(0); + }); +}); + +describe("mergeROCclawSettings", () => { + it("merges gateway URL without touching token", () => { + const current = normalizeROCclawSettings({ + gateway: { url: "https://127.0.0.1:18789", token: "old-token" }, + }); + const result = mergeROCclawSettings(current, { + gateway: { url: "https://newhost:9999" }, + }); + expect(result.gateway?.url).toMatch(/newhost/); + expect(result.gateway?.token).toBe("old-token"); + }); + + it("clears gateway when patch sets it to null", () => { + const current = normalizeROCclawSettings({ + gateway: { url: "https://127.0.0.1:18789", token: "tok" }, + }); + const result = mergeROCclawSettings(current, { gateway: null }); + expect(result.gateway).toBe(null); + }); + + it("merges focused preference for a gateway key", () => { + const current = normalizeROCclawSettings({}); + const result = mergeROCclawSettings(current, { + focused: { + "https://127.0.0.1:18789": { filter: "running" }, + }, + }); + expect(result.focused[Object.keys(result.focused)[0]]?.filter).toBe("running"); + }); + + it("deletes focused entry when set to null", () => { + const current = normalizeROCclawSettings({ + focused: { "https://127.0.0.1:18789": { filter: "all" } }, + }); + const key = Object.keys(current.focused)[0]; + const result = mergeROCclawSettings(current, { focused: { [key]: null } }); + expect(result.focused[key]).toBeUndefined(); + }); + + it("deep-merges avatars per agent within a gateway", () => { + const current = normalizeROCclawSettings({ + avatars: { + "https://127.0.0.1:18789": { + "agent-1": "seed-1", + }, + }, + }); + const key = Object.keys(current.avatars)[0]; + const result = mergeROCclawSettings(current, { + avatars: { + [key]: { "agent-2": "seed-2" }, + }, + }); + expect(result.avatars[key]?.["agent-1"]).toBe("seed-1"); + expect(result.avatars[key]?.["agent-2"]).toBe("seed-2"); + }); + + it("deletes an agent avatar when seed is set to null", () => { + const current = normalizeROCclawSettings({ + avatars: { + "https://127.0.0.1:18789": { + "agent-1": "seed-1", + }, + }, + }); + const key = Object.keys(current.avatars)[0]; + const result = mergeROCclawSettings(current, { + avatars: { [key]: { "agent-1": null } }, + }); + expect(result.avatars[key]?.["agent-1"]).toBeUndefined(); + }); + + it("deep-merges avatarSources per agent", () => { + const current = normalizeROCclawSettings({ + avatarSources: { + "https://127.0.0.1:18789": { + "agent-1": { source: "default", defaultIndex: 3 }, + }, + }, + }); + const key = Object.keys(current.avatarSources)[0]; + const result = mergeROCclawSettings(current, { + avatarSources: { + [key]: { "agent-1": { source: "custom", url: "https://example.com/img.png" } }, + }, + }); + expect(result.avatarSources[key]?.["agent-1"]?.source).toBe("custom"); + expect(result.avatarSources[key]?.["agent-1"]?.defaultIndex).toBe(3); // preserved + expect(result.avatarSources[key]?.["agent-1"]?.url).toBe("https://example.com/img.png"); + }); +}); + +describe("resolveFocusedPreference", () => { + it("returns null when no focused settings exist", () => { + const settings = normalizeROCclawSettings({}); + expect(resolveFocusedPreference(settings, "https://127.0.0.1:18789")).toBe(null); + }); + + it("returns the focused preference for a matching gateway URL", () => { + const settings = normalizeROCclawSettings({ + focused: { + "https://127.0.0.1:18789": { filter: "running" }, + }, + }); + const result = resolveFocusedPreference(settings, "https://127.0.0.1:18789"); + expect(result?.filter).toBe("running"); + }); + + it("normalizes loopback URL when resolving", () => { + const settings = normalizeROCclawSettings({ + focused: { + "https://127.0.0.1:18789": { filter: "approvals" }, + }, + }); + const result = resolveFocusedPreference(settings, "https://127.0.0.1:18789"); + expect(result?.filter).toBe("approvals"); + }); +}); + +describe("resolveAgentAvatarSeed", () => { + it("returns null when no avatar settings exist", () => { + const settings = normalizeROCclawSettings({}); + expect(resolveAgentAvatarSeed(settings, "https://127.0.0.1:18789", "agent-1")).toBe(null); + }); + + it("returns the seed for a matching agent", () => { + const settings = normalizeROCclawSettings({ + avatars: { + "https://127.0.0.1:18789": { + "agent-1": "my-seed-value", + }, + }, + }); + const result = resolveAgentAvatarSeed(settings, "https://127.0.0.1:18789", "agent-1"); + expect(result).toBe("my-seed-value"); + }); + + it("returns null for unknown agent", () => { + const settings = normalizeROCclawSettings({ + avatars: { + "https://127.0.0.1:18789": { + "agent-1": "seed", + }, + }, + }); + const result = resolveAgentAvatarSeed(settings, "https://127.0.0.1:18789", "agent-unknown"); + expect(result).toBe(null); + }); +}); + +describe("resolveAgentAvatarConfig", () => { + it("returns null when no config exists", () => { + const settings = normalizeROCclawSettings({}); + expect(resolveAgentAvatarConfig(settings, "https://127.0.0.1:18789", "agent-1")).toBe(null); + }); + + it("returns the full config for a matching agent", () => { + const settings = normalizeROCclawSettings({ + avatarSources: { + "https://127.0.0.1:18789": { + "agent-1": { source: "custom", defaultIndex: 5, url: "https://img.cload/avatar.png" }, + }, + }, + }); + const result = resolveAgentAvatarConfig(settings, "https://127.0.0.1:18789", "agent-1"); + expect(result?.source).toBe("custom"); + expect(result?.defaultIndex).toBe(5); + expect(result?.url).toBe("https://img.cload/avatar.png"); + }); + + it("returns partial config when some fields are missing", () => { + const settings = normalizeROCclawSettings({ + avatarSources: { + "https://127.0.0.1:18789": { + "agent-1": { source: "default" }, + }, + }, + }); + const result = resolveAgentAvatarConfig(settings, "https://127.0.0.1:18789", "agent-1"); + expect(result?.source).toBe("default"); + expect(result?.defaultIndex).toBeUndefined(); + expect(result?.url).toBeUndefined(); + }); +});