mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge F6 into parent: /dashboard/cli-agents page (plan 14)
This commit is contained in:
158
src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx
Normal file
158
src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx
Normal file
@@ -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<string>("");
|
||||
const [detectionFilter, setDetectionFilter] = useState<string>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-main">{t("pageTitle")}</h1>
|
||||
<p className="text-sm text-text-muted mt-1">{t("pageSubtitle")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refetch()}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-black/10 dark:border-white/10 bg-black/[0.02] dark:bg-white/[0.02] hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label={t("refreshDetection")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
refresh
|
||||
</span>
|
||||
{t("refreshDetection")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Concept card */}
|
||||
<CliConceptCard currentType="agent" />
|
||||
|
||||
{/* Comparison card */}
|
||||
<CliComparisonCard currentType="agent" />
|
||||
|
||||
{/* Search + filter bar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-sm">
|
||||
<span
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 material-symbols-outlined text-[16px] text-text-muted pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={detectionFilter}
|
||||
onChange={(e) => setDetectionFilter(e.target.value)}
|
||||
className="px-2.5 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("detectionFilterLabel")}
|
||||
>
|
||||
<option value={DETECTION_ALL}>{t("detectionAll")}</option>
|
||||
<option value={DETECTION_INSTALLED}>{t("detectionInstalled")}</option>
|
||||
<option value={DETECTION_NOT_INSTALLED}>{t("detectionNotInstalled")}</option>
|
||||
</select>
|
||||
|
||||
<span className="text-xs text-text-muted whitespace-nowrap">
|
||||
{t("visibleCount", { count: filteredTools.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tool grid */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{agentTools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="h-[180px] animate-pulse bg-black/[0.04] dark:bg-white/[0.04] rounded-lg"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTools.length === 0 ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-16 gap-3 text-text-muted"
|
||||
data-testid="empty-state"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[40px]" aria-hidden="true">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-sm">{t("emptyState")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredTools.map((tool) => (
|
||||
<CliToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
batchStatus={statuses?.[tool.id] ?? null}
|
||||
detailHref={`/dashboard/cli-agents/${tool.id}`}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/app/(dashboard)/dashboard/cli-agents/page.tsx
Normal file
7
src/app/(dashboard)/dashboard/cli-agents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import CliAgentsPageClient from "./CliAgentsPageClient";
|
||||
|
||||
export default async function CliAgentsPage() {
|
||||
const machineId = await getMachineId();
|
||||
return <CliAgentsPageClient machineId={machineId} />;
|
||||
}
|
||||
263
tests/unit/ui/CliAgentsPage.test.tsx
Normal file
263
tests/unit/ui/CliAgentsPage.test.tsx
Normal file
@@ -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<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & { src: string; alt: string }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt={alt} {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
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;
|
||||
}) => <span data-testid="status-badge">{effectiveConfigStatus}</span>,
|
||||
}));
|
||||
|
||||
// ── 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> = {}): 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<typeof createRoot>[] = [];
|
||||
|
||||
async function renderPage(mockFetchFn?: typeof fetch): Promise<HTMLElement> {
|
||||
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(<CliAgentsPageClient machineId="test-machine" />);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function countAgentCards(container: HTMLElement): number {
|
||||
return Array.from(container.querySelectorAll<HTMLAnchorElement>("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<HTMLAnchorElement>("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<HTMLAnchorElement>("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<typeof vi.fn>).mock.calls.length;
|
||||
expect(callsAfterMount).toBeGreaterThan(0);
|
||||
|
||||
const refreshBtn = container.querySelector<HTMLButtonElement>("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<typeof vi.fn>).mock.calls.length).toBeGreaterThan(
|
||||
callsAfterMount
|
||||
);
|
||||
}, 15000);
|
||||
});
|
||||
Reference in New Issue
Block a user