diff --git a/src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx new file mode 100644 index 0000000000..6c37f0c152 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { CliToolCard, CliConceptCard, CliComparisonCard } from "@/shared/components/cli"; +import { useToolBatchStatuses } from "@/shared/hooks/cli/useToolBatchStatuses"; + +export interface CliAgentsPageClientProps { + machineId: string; +} + +const DETECTION_ALL = "all"; +const DETECTION_INSTALLED = "installed"; +const DETECTION_NOT_INSTALLED = "not_installed"; + +export default function CliAgentsPageClient({ machineId: _machineId }: CliAgentsPageClientProps) { + const t = useTranslations("cliAgents"); + const { statuses, loading, refetch } = useToolBatchStatuses(); + + const [search, setSearch] = useState(""); + const [detectionFilter, setDetectionFilter] = useState(DETECTION_ALL); + + const agentTools = useMemo( + () => Object.values(CLI_TOOLS).filter((tool) => tool.category === "agent"), + [] + ); + + const hasActiveProviders = useMemo(() => { + if (!statuses) return true; + return Object.values(statuses).some((s) => s.detection.installed); + }, [statuses]); + + const filteredTools = useMemo(() => { + return agentTools.filter((tool) => { + // Search filter + if (search.trim()) { + const q = search.trim().toLowerCase(); + const matchesName = tool.name.toLowerCase().includes(q); + const matchesId = tool.id.toLowerCase().includes(q); + const matchesDesc = tool.description.toLowerCase().includes(q); + const matchesVendor = tool.vendor.toLowerCase().includes(q); + if (!matchesName && !matchesId && !matchesDesc && !matchesVendor) { + return false; + } + } + + // Detection filter + if (detectionFilter !== DETECTION_ALL) { + const batchStatus = statuses?.[tool.id] ?? null; + const installed = batchStatus?.detection.installed ?? false; + if (detectionFilter === DETECTION_INSTALLED && !installed) return false; + if (detectionFilter === DETECTION_NOT_INSTALLED && installed) return false; + } + + return true; + }); + }, [agentTools, search, detectionFilter, statuses]); + + return ( +
+ {/* Page header */} +
+
+

{t("pageTitle")}

+

{t("pageSubtitle")}

+
+ +
+ + {/* Concept card */} + + + {/* Comparison card */} + + + {/* Search + filter bar */} +
+
+ + setSearch(e.target.value)} + placeholder={t("searchPlaceholder")} + className="w-full pl-8 pr-3 py-1.5 text-sm bg-surface border border-black/10 dark:border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50" + aria-label={t("searchPlaceholder")} + /> +
+ + + + + {t("visibleCount", { count: filteredTools.length })} + +
+ + {/* Tool grid */} + {loading ? ( +
+ {agentTools.map((tool) => ( + + ) : filteredTools.length === 0 ? ( +
+ +

{t("emptyState")}

+
+ ) : ( +
+ {filteredTools.map((tool) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-agents/page.tsx b/src/app/(dashboard)/dashboard/cli-agents/page.tsx new file mode 100644 index 0000000000..652333aba5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-agents/page.tsx @@ -0,0 +1,7 @@ +import { getMachineId } from "@/shared/utils/machine"; +import CliAgentsPageClient from "./CliAgentsPageClient"; + +export default async function CliAgentsPage() { + const machineId = await getMachineId(); + return ; +} diff --git a/tests/unit/ui/CliAgentsPage.test.tsx b/tests/unit/ui/CliAgentsPage.test.tsx new file mode 100644 index 0000000000..09c8c073cc --- /dev/null +++ b/tests/unit/ui/CliAgentsPage.test.tsx @@ -0,0 +1,263 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ToolBatchStatusMap } from "@/shared/types/cliBatchStatus"; + +// ── Mocks (declared before any imports that depend on them) ─────────────────── + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.mock("next/image", () => ({ + default: ({ + src, + alt, + ...props + }: React.ImgHTMLAttributes & { src: string; alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +// Stub CliStatusBadge so it doesn't depend on next-intl internals +vi.mock("@/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge", () => ({ + default: ({ + effectiveConfigStatus, + }: { + effectiveConfigStatus: string | null; + batchStatus: null; + lastConfiguredAt: string | null; + }) => {effectiveConfigStatus}, +})); + +// ── Static imports after mocks ──────────────────────────────────────────────── + +const { default: CliAgentsPageClient } = await import( + "@/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient" +); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** 6 agent tool ids from the catalog (§3.2 of plan-14) */ +const AGENT_IDS = [ + "openclaw", + "hermes-agent", + "goose", + "interpreter", + "warp", + "agent-deck", +] as const; + +function makeBatchStatusMap(overrides: Partial = {}): ToolBatchStatusMap { + const base: ToolBatchStatusMap = {}; + for (const id of AGENT_IDS) { + base[id] = { + detection: { installed: true, runnable: true, version: "1.0.0" }, + config: { status: "configured", endpoint: "http://localhost:20128", lastConfiguredAt: null }, + }; + } + return { ...base, ...overrides }; +} + +function makeFetch(data: unknown, status = 200): typeof fetch { + return vi.fn(() => + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + text: () => Promise.resolve(String(data)), + } as Response) + ); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const containers: HTMLElement[] = []; +const roots: ReturnType[] = []; + +async function renderPage(mockFetchFn?: typeof fetch): Promise { + vi.stubGlobal("fetch", mockFetchFn ?? makeFetch(makeBatchStatusMap())); + + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + const root = createRoot(container); + roots.push(root); + + await act(async () => { + root.render(); + await new Promise((r) => setTimeout(r, 100)); + }); + + return container; +} + +function countAgentCards(container: HTMLElement): number { + return Array.from(container.querySelectorAll("a[href]")).filter((a) => + a.getAttribute("href")?.startsWith("/dashboard/cli-agents/") + ).length; +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + act(() => { + while (roots.length > 0) { + roots.pop()?.unmount(); + } + }); + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("CliAgentsPageClient", () => { + it("1. smoke render — mounts without crash and shows page title key", async () => { + const container = await renderPage(); + expect(container.textContent).toContain("pageTitle"); + }, 15000); + + it("2. renders exactly 6 agent tool cards", async () => { + const container = await renderPage(); + expect(countAgentCards(container)).toBe(6); + }, 15000); + + it("3. search filter — 'hermes' shows 1 card (hermes-agent)", async () => { + const container = await renderPage(); + + const input = container.querySelector("input[type='search']") as HTMLInputElement; + expect(input).not.toBeNull(); + + await act(async () => { + // Use native value setter to trigger React's synthetic onChange + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeSetter?.call(input, "hermes"); + input.dispatchEvent(new Event("change", { bubbles: true })); + await new Promise((r) => setTimeout(r, 50)); + }); + + const visibleCards = countAgentCards(container); + expect(visibleCards).toBe(1); + + const remainingHrefs = Array.from( + container.querySelectorAll("a[href]") + ) + .filter((a) => a.getAttribute("href")?.startsWith("/dashboard/cli-agents/")) + .map((a) => a.getAttribute("href") ?? ""); + + expect(remainingHrefs[0]).toContain("hermes"); + }, 15000); + + it("4. detection filter 'not_installed' — shows only non-installed tools", async () => { + // Only hermes-agent is not installed + const map = makeBatchStatusMap({ + "hermes-agent": { + detection: { installed: false, runnable: false }, + config: { status: "not_installed", endpoint: null, lastConfiguredAt: null }, + }, + }); + const container = await renderPage(makeFetch(map)); + + const select = container.querySelector("select") as HTMLSelectElement; + expect(select).not.toBeNull(); + + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value" + )?.set; + nativeSetter?.call(select, "not_installed"); + select.dispatchEvent(new Event("change", { bubbles: true })); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(countAgentCards(container)).toBe(1); + const href = Array.from(container.querySelectorAll("a[href]")) + .find((a) => a.getAttribute("href")?.startsWith("/dashboard/cli-agents/")) + ?.getAttribute("href"); + expect(href).toContain("hermes-agent"); + }, 15000); + + it("5. empty state — shows data-testid='empty-state' when no tools match search", async () => { + const container = await renderPage(); + + await act(async () => { + const input = container.querySelector("input[type='search']") as HTMLInputElement; + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeSetter?.call(input, "zzznothingmatchesxyz"); + input.dispatchEvent(new Event("change", { bubbles: true })); + await new Promise((r) => setTimeout(r, 50)); + }); + + const emptyState = container.querySelector("[data-testid='empty-state']"); + expect(emptyState).not.toBeNull(); + }, 15000); + + it("6. CliConceptCard currentType='agent' — concept.agent.title key is present", async () => { + const container = await renderPage(); + // CliConceptCard renders "concept.agent.title" via the mock translator + expect(container.textContent).toContain("concept.agent.title"); + }, 15000); + + it("7. CliComparisonCard currentType='agent' — comparison.agent.title + Esta página ✓", async () => { + const container = await renderPage(); + // CliComparisonCard renders comparison.agent.title for the current column + expect(container.textContent).toContain("comparison.agent.title"); + // thisPage badge appears for the agent column + expect(container.textContent).toContain("comparison.thisPage"); + expect(container.textContent).toContain("✓"); + }, 15000); + + it("8. refresh button calls refetch — triggers additional fetch call", async () => { + const mockFetchFn = makeFetch(makeBatchStatusMap()); + const container = await renderPage(mockFetchFn); + + const callsAfterMount = (mockFetchFn as ReturnType).mock.calls.length; + expect(callsAfterMount).toBeGreaterThan(0); + + const refreshBtn = container.querySelector("button[aria-label]"); + expect(refreshBtn).not.toBeNull(); + + await act(async () => { + refreshBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 100)); + }); + + expect((mockFetchFn as ReturnType).mock.calls.length).toBeGreaterThan( + callsAfterMount + ); + }, 15000); +});