mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 18:02:17 +03:00
refactor(dashboard,cli): move cards to cli-code/components + add detail pages /cli-code/[id] + /cli-agents/[id] + ToolDetailClient orchestrator (plan 14 F8)
- git mv cli-tools/components → cli-code/components (history preserved, 15 renames)
- Add isExpanded=false + onToggle=()=>{} defaults to all 12 specialized cards
- Create cli-code/[id]/page.tsx: server component, guards category=code
- Create cli-agents/[id]/page.tsx: server component, guards category=agent
- Create cli-code/components/ToolDetailClient.tsx: orchestrator with isExpanded:true always (D23), header with back-link + vendor/category/baseUrl badges
- Delete cli-tools/CLIToolsPageClient.tsx + cli-tools/page.tsx (accordion-based orchestrator replaced)
- Tests: ToolDetailClient smoke (5), cli-code detail page (3), cli-agents detail page (3) — 11/11 passing
This commit is contained in:
14
src/app/(dashboard)/dashboard/cli-agents/[id]/page.tsx
Normal file
14
src/app/(dashboard)/dashboard/cli-agents/[id]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { notFound } from "next/navigation";
|
||||
import ToolDetailClient from "../../cli-code/components/ToolDetailClient";
|
||||
|
||||
export default async function CliAgentsDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const tool = CLI_TOOLS[id];
|
||||
if (!tool || tool.category !== "agent") notFound();
|
||||
return <ToolDetailClient toolId={id} category="agent" />;
|
||||
}
|
||||
14
src/app/(dashboard)/dashboard/cli-code/[id]/page.tsx
Normal file
14
src/app/(dashboard)/dashboard/cli-code/[id]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { notFound } from "next/navigation";
|
||||
import ToolDetailClient from "../components/ToolDetailClient";
|
||||
|
||||
export default async function CliCodeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const tool = CLI_TOOLS[id];
|
||||
if (!tool || tool.category !== "code") notFound();
|
||||
return <ToolDetailClient toolId={id} category="code" />;
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
export default function AntigravityToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
@@ -14,8 +14,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function ClaudeToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
activeProviders,
|
||||
modelMappings,
|
||||
onModelMappingChange,
|
||||
@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function ClineToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
@@ -23,7 +23,7 @@ interface UpdateInfo {
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export default function CliproxyapiToolCard({ isExpanded, onToggle }) {
|
||||
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
|
||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
@@ -10,8 +10,8 @@ import { normalizeCodexBaseUrl } from "@/shared/utils/codexBaseUrl";
|
||||
|
||||
export default function CodexToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
@@ -15,8 +15,8 @@ import { useTranslations } from "next-intl";
|
||||
*/
|
||||
export default function CopilotToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders = [],
|
||||
@@ -24,8 +24,8 @@ interface CustomCliMappingRow {
|
||||
|
||||
export default function CustomCliCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
availableModels = [],
|
||||
@@ -13,8 +13,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
export default function DefaultToolCard({
|
||||
toolId,
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders = [],
|
||||
@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function DroidToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
@@ -25,8 +25,8 @@ const HERMES_ROLES: Role[] = [
|
||||
|
||||
export default function HermesAgentToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders = [],
|
||||
@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function KiloToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
@@ -10,8 +10,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function OpenClawToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
onToggle = () => {},
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { PROVIDER_ID_TO_ALIAS, getModelsByProviderId } from "@/shared/constants/models";
|
||||
import {
|
||||
AntigravityToolCard,
|
||||
ClaudeToolCard,
|
||||
ClineToolCard,
|
||||
CodexToolCard,
|
||||
CopilotToolCard,
|
||||
CustomCliCard,
|
||||
DefaultToolCard,
|
||||
DroidToolCard,
|
||||
HermesAgentToolCard,
|
||||
KiloToolCard,
|
||||
OpenClawToolCard,
|
||||
} from "./index";
|
||||
import CliproxyapiToolCard from "./CliproxyapiToolCard";
|
||||
|
||||
export interface ToolDetailClientProps {
|
||||
toolId: string;
|
||||
category: "code" | "agent";
|
||||
}
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function ToolDetailClient({ toolId, category }: ToolDetailClientProps) {
|
||||
const tool = CLI_TOOLS[toolId];
|
||||
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [dynamicModels, setDynamicModels] = useState<any[]>([]);
|
||||
const [modelMappings, setModelMappings] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchApiKeys = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApiKeys(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching API keys:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCloudSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDynamicModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/v1/models");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDynamicModels(data?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching dynamic models:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetchConnections(),
|
||||
fetchApiKeys(),
|
||||
fetchCloudSettings(),
|
||||
fetchDynamicModels(),
|
||||
]).finally(() => setLoading(false));
|
||||
}, [fetchConnections, fetchApiKeys, fetchCloudSettings, fetchDynamicModels]);
|
||||
|
||||
const getActiveProviders = useCallback(() => {
|
||||
return connections.filter((c) => c.isActive !== false);
|
||||
}, [connections]);
|
||||
|
||||
const getAllAvailableModels = useCallback(() => {
|
||||
const activeProviders = getActiveProviders();
|
||||
const models: any[] = [];
|
||||
const seenModels = new Set<string>();
|
||||
|
||||
activeProviders.forEach((conn) => {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
|
||||
const providerModels = getModelsByProviderId(conn.provider);
|
||||
providerModels.forEach((m: any) => {
|
||||
const modelValue = `${alias}/${m.id}`;
|
||||
if (!seenModels.has(modelValue)) {
|
||||
seenModels.add(modelValue);
|
||||
models.push({
|
||||
value: modelValue,
|
||||
label: `${alias}/${m.id}`,
|
||||
provider: conn.provider,
|
||||
alias,
|
||||
connectionName: conn.name,
|
||||
modelId: m.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const activeAliases = new Set(
|
||||
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
|
||||
);
|
||||
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
|
||||
dynamicModels.forEach((dm) => {
|
||||
const rawId = dm?.id ?? dm;
|
||||
const modelId = typeof rawId === "string" ? rawId : "";
|
||||
if (!modelId || seenModels.has(modelId)) return;
|
||||
const slashIdx = modelId.indexOf("/");
|
||||
if (slashIdx === -1) return;
|
||||
const alias = modelId.substring(0, slashIdx);
|
||||
const bareModel = modelId.substring(slashIdx + 1);
|
||||
if (!activeAliases.has(alias) && !activeProviderIds.has(alias)) return;
|
||||
seenModels.add(modelId);
|
||||
models.push({
|
||||
value: modelId,
|
||||
label: modelId,
|
||||
provider: alias,
|
||||
alias,
|
||||
connectionName: "",
|
||||
modelId: bareModel,
|
||||
});
|
||||
});
|
||||
|
||||
return models;
|
||||
}, [getActiveProviders, dynamicModels]);
|
||||
|
||||
const handleModelMappingChange = useCallback((alias: string, targetModel: string) => {
|
||||
setModelMappings((prev) => {
|
||||
if (prev[alias] === targetModel) return prev;
|
||||
return { ...prev, [alias]: targetModel };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getBaseUrl = useCallback(() => {
|
||||
if (cloudEnabled && CLOUD_URL) return CLOUD_URL;
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
return "";
|
||||
}, [cloudEnabled]);
|
||||
|
||||
if (!tool) return null;
|
||||
|
||||
const activeProviders = getActiveProviders();
|
||||
const availableModels = getAllAvailableModels();
|
||||
const hasActiveProviders = availableModels.length > 0;
|
||||
|
||||
const backCategory = category === "code" ? "/dashboard/cli-code" : "/dashboard/cli-agents";
|
||||
|
||||
// Common props passed to every specialized card.
|
||||
// isExpanded is always true in the detail page (D23).
|
||||
const cardProps: any = {
|
||||
tool,
|
||||
isExpanded: true,
|
||||
onToggle: () => {},
|
||||
baseUrl: getBaseUrl(),
|
||||
apiKeys,
|
||||
batchStatus: null,
|
||||
lastConfiguredAt: null,
|
||||
activeProviders,
|
||||
hasActiveProviders,
|
||||
cloudEnabled,
|
||||
availableModels,
|
||||
};
|
||||
|
||||
const renderCard = () => {
|
||||
switch (toolId) {
|
||||
case "claude":
|
||||
return (
|
||||
<ClaudeToolCard
|
||||
{...cardProps}
|
||||
modelMappings={modelMappings}
|
||||
onModelMappingChange={handleModelMappingChange}
|
||||
/>
|
||||
);
|
||||
case "codex":
|
||||
return <CodexToolCard {...cardProps} />;
|
||||
case "droid":
|
||||
return <DroidToolCard {...cardProps} />;
|
||||
case "openclaw":
|
||||
return <OpenClawToolCard {...cardProps} />;
|
||||
case "cline":
|
||||
return <ClineToolCard {...cardProps} />;
|
||||
case "kilo":
|
||||
return <KiloToolCard {...cardProps} />;
|
||||
case "copilot":
|
||||
return <CopilotToolCard {...cardProps} />;
|
||||
case "hermes-agent":
|
||||
return <HermesAgentToolCard {...cardProps} />;
|
||||
case "antigravity":
|
||||
return <AntigravityToolCard {...cardProps} />;
|
||||
case "cliproxyapi":
|
||||
return <CliproxyapiToolCard isExpanded={true} onToggle={() => {}} />;
|
||||
case "custom":
|
||||
return <CustomCliCard {...cardProps} />;
|
||||
default:
|
||||
if (tool.configType === "mitm") {
|
||||
return <AntigravityToolCard {...cardProps} />;
|
||||
}
|
||||
return <DefaultToolCard toolId={toolId} {...cardProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Back navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={backCategory}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
||||
{category === "code" ? "CLI Code" : "CLI Agents"}
|
||||
</Link>
|
||||
<span className="text-text-muted">/</span>
|
||||
<span className="text-sm font-medium">{tool.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Tool header */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{tool.vendor && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-surface border border-border text-text-muted">
|
||||
{tool.vendor}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
|
||||
{category}
|
||||
</span>
|
||||
{tool.baseUrlSupport && tool.baseUrlSupport !== "none" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<span className="material-symbols-outlined text-[12px]">link</span>
|
||||
{tool.baseUrlSupport === "full" ? "Full base URL" : "Partial base URL"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Specialized card — always expanded */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-24 rounded-xl bg-surface animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
renderCard()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,513 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import {
|
||||
PROVIDER_MODELS,
|
||||
getModelsByProviderId,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
} from "@/shared/constants/models";
|
||||
import {
|
||||
ClaudeToolCard,
|
||||
CodexToolCard,
|
||||
DroidToolCard,
|
||||
OpenClawToolCard,
|
||||
ClineToolCard,
|
||||
KiloToolCard,
|
||||
DefaultToolCard,
|
||||
AntigravityToolCard,
|
||||
CopilotToolCard,
|
||||
CustomCliCard,
|
||||
HermesAgentToolCard,
|
||||
} from "./components";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const AUTO_CONFIGURED_TOOL_IDS = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"droid",
|
||||
"openclaw",
|
||||
"cline",
|
||||
"kilo",
|
||||
"copilot",
|
||||
"hermes-agent",
|
||||
]);
|
||||
const GUIDED_TOOL_IDS = new Set([
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"continue",
|
||||
"opencode",
|
||||
"hermes",
|
||||
"amp",
|
||||
"qwen",
|
||||
]);
|
||||
const MITM_TOOL_IDS = new Set(["antigravity", "kiro"]);
|
||||
const CUSTOM_TOOL_IDS = new Set(["custom"]);
|
||||
|
||||
export default function CLIToolsPageClient({ machineId: _machineId }) {
|
||||
const t = useTranslations("cliTools");
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedTool, setExpandedTool] = useState(null);
|
||||
const [modelMappings, setModelMappings] = useState({});
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [toolStatuses, setToolStatuses] = useState({});
|
||||
const [statusesLoaded, setStatusesLoaded] = useState(false);
|
||||
const [dynamicModels, setDynamicModels] = useState([]);
|
||||
const [activeCategory, setActiveCategory] = useState("auto");
|
||||
const translateOrFallback = useCallback(
|
||||
(key, fallback, values = undefined) => {
|
||||
try {
|
||||
const translated = t(key, values);
|
||||
return translated === key || translated === `cliTools.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
loadCloudSettings();
|
||||
fetchApiKeys();
|
||||
fetchToolStatuses();
|
||||
fetchDynamicModels();
|
||||
}, []);
|
||||
|
||||
const loadCloudSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApiKeys(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching API keys:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchToolStatuses = async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s client timeout
|
||||
const res = await fetch("/api/cli-tools/status", { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setToolStatuses(data || {});
|
||||
}
|
||||
} catch (error) {
|
||||
// Timeout or network error — proceed without statuses
|
||||
console.log("CLI tool status check timed out or failed:", error);
|
||||
} finally {
|
||||
setStatusesLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDynamicModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/v1/models");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDynamicModels(data?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching dynamic models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveProviders = () => {
|
||||
return connections.filter((c) => c.isActive !== false);
|
||||
};
|
||||
|
||||
const getAllAvailableModels = () => {
|
||||
const activeProviders = getActiveProviders();
|
||||
const models = [];
|
||||
const seenModels = new Set();
|
||||
|
||||
// First: add static models from the constants
|
||||
activeProviders.forEach((conn) => {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
|
||||
const providerModels = getModelsByProviderId(conn.provider);
|
||||
providerModels.forEach((m) => {
|
||||
const modelValue = `${alias}/${m.id}`;
|
||||
if (!seenModels.has(modelValue)) {
|
||||
seenModels.add(modelValue);
|
||||
models.push({
|
||||
value: modelValue,
|
||||
label: `${alias}/${m.id}`,
|
||||
provider: conn.provider,
|
||||
alias: alias,
|
||||
connectionName: conn.name,
|
||||
modelId: m.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Second: add dynamic models from /v1/models (fills gaps for Kiro, OpenCode, custom providers)
|
||||
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
|
||||
const activeAliases = new Set(
|
||||
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
|
||||
);
|
||||
dynamicModels.forEach((dm) => {
|
||||
const rawId = dm?.id ?? dm;
|
||||
const modelId = typeof rawId === "string" ? rawId : "";
|
||||
if (!modelId || seenModels.has(modelId)) return;
|
||||
// Parse alias/model format
|
||||
const slashIdx = modelId.indexOf("/");
|
||||
if (slashIdx === -1) return;
|
||||
const alias = modelId.substring(0, slashIdx);
|
||||
const bareModel = modelId.substring(slashIdx + 1);
|
||||
if (!activeAliases.has(alias) && !activeProviderIds.has(alias)) return;
|
||||
seenModels.add(modelId);
|
||||
models.push({
|
||||
value: modelId,
|
||||
label: modelId,
|
||||
provider: alias,
|
||||
alias: alias,
|
||||
connectionName: "",
|
||||
modelId: bareModel,
|
||||
});
|
||||
});
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
const handleModelMappingChange = useCallback((toolId, modelAlias, targetModel) => {
|
||||
setModelMappings((prev) => {
|
||||
// Prevent unnecessary updates if value hasn't changed
|
||||
if (prev[toolId]?.[modelAlias] === targetModel) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[toolId]: {
|
||||
...prev[toolId],
|
||||
[modelAlias]: targetModel,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (cloudEnabled && CLOUD_URL) {
|
||||
return CLOUD_URL;
|
||||
}
|
||||
// Use window.location.origin directly — works correctly in Docker/reverse-proxy
|
||||
// Per @alpgul feedback: don't use baseUrl prop (has port duplication issues)
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
return DEFAULT_DISPLAY_BASE_URL;
|
||||
};
|
||||
|
||||
if (loading || !statusesLoaded) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availableModels = getAllAvailableModels();
|
||||
const hasActiveProviders = availableModels.length > 0;
|
||||
const toolEntries = Object.entries(CLI_TOOLS).filter(([toolId]) => {
|
||||
if (activeCategory === "all") return true;
|
||||
if (activeCategory === "auto") return AUTO_CONFIGURED_TOOL_IDS.has(toolId);
|
||||
if (activeCategory === "guided") return GUIDED_TOOL_IDS.has(toolId);
|
||||
if (activeCategory === "mitm") return MITM_TOOL_IDS.has(toolId);
|
||||
if (activeCategory === "custom") return CUSTOM_TOOL_IDS.has(toolId);
|
||||
return true;
|
||||
});
|
||||
|
||||
const renderToolCard = (toolId, tool) => {
|
||||
const commonProps = {
|
||||
tool,
|
||||
isExpanded: expandedTool === toolId,
|
||||
onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId),
|
||||
baseUrl: getBaseUrl(),
|
||||
apiKeys,
|
||||
batchStatus: toolStatuses[toolId] || null,
|
||||
lastConfiguredAt: toolStatuses[toolId]?.lastConfiguredAt || null,
|
||||
};
|
||||
|
||||
switch (toolId) {
|
||||
case "claude":
|
||||
return (
|
||||
<ClaudeToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
modelMappings={modelMappings[toolId] || {}}
|
||||
onModelMappingChange={(alias, target) =>
|
||||
handleModelMappingChange(toolId, alias, target)
|
||||
}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "codex":
|
||||
return (
|
||||
<CodexToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "droid":
|
||||
return (
|
||||
<DroidToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "openclaw":
|
||||
return (
|
||||
<OpenClawToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "antigravity":
|
||||
return (
|
||||
<AntigravityToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "cline":
|
||||
return (
|
||||
<ClineToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "kilo":
|
||||
return (
|
||||
<KiloToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "copilot":
|
||||
return (
|
||||
<CopilotToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "hermes-agent":
|
||||
return (
|
||||
<HermesAgentToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "custom":
|
||||
return (
|
||||
<CustomCliCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
availableModels={availableModels}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls)
|
||||
if (tool.configType === "mitm") {
|
||||
return (
|
||||
<AntigravityToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DefaultToolCard
|
||||
key={toolId}
|
||||
toolId={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getToolDocsHref = (toolId, tool) => {
|
||||
if (typeof tool.docsUrl === "string" && tool.docsUrl.trim()) {
|
||||
return tool.docsUrl.trim();
|
||||
}
|
||||
return `/docs?section=cli-tools&tool=${toolId}`;
|
||||
};
|
||||
|
||||
const getToolUseCase = (toolId, tool) => {
|
||||
const fallbackDescription = translateOrFallback(`toolDescriptions.${toolId}`, tool.description);
|
||||
return translateOrFallback(`toolUseCases.${toolId}`, fallbackDescription);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<span className="material-symbols-outlined text-[20px]">tips_and_updates</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold">{t("howItWorks")}</h2>
|
||||
<div className="mt-2 grid grid-cols-1 md:grid-cols-3 gap-2 text-xs text-text-muted">
|
||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
|
||||
{t("installationGuide")}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
|
||||
{t("configureEndpoint")}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
|
||||
{t("testConnection")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t("toolCategories")}</h2>
|
||||
<p className="text-xs text-text-muted mt-1">{t("toolCategoriesDesc")}</p>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("visibleToolsCount", { count: toolEntries.length })}
|
||||
</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "auto", label: t("autoConfiguredTab") },
|
||||
{ value: "guided", label: t("guidedClientsTab") },
|
||||
{ value: "mitm", label: t("mitmClientsTab") },
|
||||
{
|
||||
value: "custom",
|
||||
label: translateOrFallback("customCliTab", "Custom CLI"),
|
||||
},
|
||||
{ value: "all", label: t("allToolsTab") },
|
||||
]}
|
||||
value={activeCategory}
|
||||
onChange={setActiveCategory}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!hasActiveProviders && (
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div>
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">
|
||||
{t("noActiveProviders")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("noActiveProvidersDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{toolEntries.map(([toolId, tool]) => {
|
||||
const docsHref = getToolDocsHref(toolId, tool);
|
||||
const isExternalDocs = /^https?:\/\//i.test(docsHref);
|
||||
return (
|
||||
<div key={toolId} className="flex flex-col gap-2.5">
|
||||
{renderToolCard(toolId, tool)}
|
||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-text-muted">
|
||||
{t("whenToUseLabel")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1 break-words">
|
||||
{getToolUseCase(toolId, tool)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={docsHref}
|
||||
target={isExternalDocs ? "_blank" : undefined}
|
||||
rel={isExternalDocs ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
menu_book
|
||||
</span>
|
||||
{t("openToolDocs")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import CLIToolsPageClient from "./CLIToolsPageClient";
|
||||
|
||||
export default async function CLIToolsPage() {
|
||||
const machineId = await getMachineId();
|
||||
return <CLIToolsPageClient machineId={machineId} />;
|
||||
}
|
||||
202
tests/unit/ui/ToolDetailClient.test.tsx
Normal file
202
tests/unit/ui/ToolDetailClient.test.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
// @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";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
// Stub fetch globally
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
// Stub next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub CLI_TOOLS catalog
|
||||
vi.mock("@/shared/constants/cliTools", () => ({
|
||||
CLI_TOOLS: {
|
||||
claude: {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
icon: "terminal",
|
||||
color: "#D97757",
|
||||
category: "code",
|
||||
configType: "env",
|
||||
vendor: "Anthropic",
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
codex: {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
icon: "terminal",
|
||||
color: "#000",
|
||||
category: "code",
|
||||
configType: "custom",
|
||||
vendor: "OpenAI",
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
custom: {
|
||||
id: "custom",
|
||||
name: "Custom CLI",
|
||||
icon: "terminal",
|
||||
color: "#888",
|
||||
category: "code",
|
||||
configType: "custom-builder",
|
||||
vendor: undefined,
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
"hermes-agent": {
|
||||
id: "hermes-agent",
|
||||
name: "Hermes Agent",
|
||||
icon: "terminal",
|
||||
color: "#5865f2",
|
||||
category: "agent",
|
||||
configType: "custom",
|
||||
vendor: "HermesAI",
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
forge: {
|
||||
id: "forge",
|
||||
name: "Forge",
|
||||
icon: "terminal",
|
||||
color: "#888",
|
||||
category: "code",
|
||||
configType: "custom",
|
||||
vendor: undefined,
|
||||
baseUrlSupport: "partial",
|
||||
defaultModels: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub model constants
|
||||
vi.mock("@/shared/constants/models", () => ({
|
||||
PROVIDER_ID_TO_ALIAS: {},
|
||||
getModelsByProviderId: () => [],
|
||||
}));
|
||||
|
||||
// Stub specialized cards — render a testid so we can identify which was rendered
|
||||
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({
|
||||
ClaudeToolCard: () => <div data-testid="ClaudeToolCard" />,
|
||||
CodexToolCard: () => <div data-testid="CodexToolCard" />,
|
||||
DroidToolCard: () => <div data-testid="DroidToolCard" />,
|
||||
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
|
||||
ClineToolCard: () => <div data-testid="ClineToolCard" />,
|
||||
KiloToolCard: () => <div data-testid="KiloToolCard" />,
|
||||
DefaultToolCard: ({ toolId }: { toolId: string }) => (
|
||||
<div data-testid="DefaultToolCard" data-toolid={toolId} />
|
||||
),
|
||||
AntigravityToolCard: () => <div data-testid="AntigravityToolCard" />,
|
||||
CopilotToolCard: () => <div data-testid="CopilotToolCard" />,
|
||||
CustomCliCard: () => <div data-testid="CustomCliCard" />,
|
||||
HermesAgentToolCard: () => <div data-testid="HermesAgentToolCard" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard", () => ({
|
||||
default: () => <div data-testid="CliproxyapiToolCard" />,
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: ToolDetailClient } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderDetail(toolId: string, category: "code" | "agent"): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<ToolDetailClient toolId={toolId} category={category} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ToolDetailClient", () => {
|
||||
it("renders ClaudeToolCard for toolId=claude", async () => {
|
||||
const container = renderDetail("claude", "code");
|
||||
// Wait for async state resolution
|
||||
await act(async () => {});
|
||||
expect(container.querySelector("[data-testid='ClaudeToolCard']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders CodexToolCard for toolId=codex", async () => {
|
||||
const container = renderDetail("codex", "code");
|
||||
await act(async () => {});
|
||||
expect(container.querySelector("[data-testid='CodexToolCard']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders CustomCliCard for toolId=custom", async () => {
|
||||
const container = renderDetail("custom", "code");
|
||||
await act(async () => {});
|
||||
expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => {
|
||||
const container = renderDetail("forge", "code");
|
||||
await act(async () => {});
|
||||
const card = container.querySelector("[data-testid='DefaultToolCard']");
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.getAttribute("data-toolid")).toBe("forge");
|
||||
});
|
||||
|
||||
it("renders nothing (null) for completely unknown toolId", async () => {
|
||||
const container = renderDetail("totally-unknown-xyz", "code");
|
||||
await act(async () => {});
|
||||
// CLI_TOOLS["totally-unknown-xyz"] is undefined → returns null → empty container
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
123
tests/unit/ui/cli-agents-detail-page.test.tsx
Normal file
123
tests/unit/ui/cli-agents-detail-page.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// @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";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/constants/models", () => ({
|
||||
PROVIDER_ID_TO_ALIAS: {},
|
||||
getModelsByProviderId: () => [],
|
||||
}));
|
||||
|
||||
// Stub ToolDetailClient — renders a testid with props
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({
|
||||
default: ({ toolId, category }: { toolId: string; category: string }) => (
|
||||
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliAgentsDetailPage } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-agents/[id]/page"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> {
|
||||
let notFoundThrown = false;
|
||||
let jsx: React.ReactNode | null = null;
|
||||
|
||||
try {
|
||||
jsx = await CliAgentsDetailPage({ params: Promise.resolve({ id }) });
|
||||
} catch (err: any) {
|
||||
if (err?.message === "NOT_FOUND") {
|
||||
notFoundThrown = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
if (!notFoundThrown && jsx) {
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(jsx as React.ReactElement);
|
||||
});
|
||||
}
|
||||
|
||||
return { container, notFound: notFoundThrown };
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliAgentsDetailPage", () => {
|
||||
it("renders ToolDetailClient for /dashboard/cli-agents/hermes-agent (category:agent)", async () => {
|
||||
const { container, notFound } = await renderPage("hermes-agent");
|
||||
expect(notFound).toBe(false);
|
||||
const el = container.querySelector("[data-testid='ToolDetailClient']");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.getAttribute("data-toolid")).toBe("hermes-agent");
|
||||
expect(el!.getAttribute("data-category")).toBe("agent");
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-agents/claude (category:code — cross-category)", async () => {
|
||||
const { notFound } = await renderPage("claude");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-agents/invalid-id (unknown tool)", async () => {
|
||||
const { notFound } = await renderPage("invalid-id-xyz");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
});
|
||||
127
tests/unit/ui/cli-code-detail-page.test.tsx
Normal file
127
tests/unit/ui/cli-code-detail-page.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
// @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";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let notFoundCalled = false;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
notFoundCalled = true;
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/constants/models", () => ({
|
||||
PROVIDER_ID_TO_ALIAS: {},
|
||||
getModelsByProviderId: () => [],
|
||||
}));
|
||||
|
||||
// Stub ToolDetailClient — just renders a testid with the received props
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({
|
||||
default: ({ toolId, category }: { toolId: string; category: string }) => (
|
||||
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliCodeDetailPage } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/[id]/page"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> {
|
||||
let notFoundThrown = false;
|
||||
let jsx: React.ReactNode | null = null;
|
||||
|
||||
try {
|
||||
jsx = await CliCodeDetailPage({ params: Promise.resolve({ id }) });
|
||||
} catch (err: any) {
|
||||
if (err?.message === "NOT_FOUND") {
|
||||
notFoundThrown = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
if (!notFoundThrown && jsx) {
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(jsx as React.ReactElement);
|
||||
});
|
||||
}
|
||||
|
||||
return { container, notFound: notFoundThrown };
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
notFoundCalled = false;
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliCodeDetailPage", () => {
|
||||
it("renders ToolDetailClient for /dashboard/cli-code/claude (category:code)", async () => {
|
||||
const { container, notFound } = await renderPage("claude");
|
||||
expect(notFound).toBe(false);
|
||||
const el = container.querySelector("[data-testid='ToolDetailClient']");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.getAttribute("data-toolid")).toBe("claude");
|
||||
expect(el!.getAttribute("data-category")).toBe("code");
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-code/hermes-agent (category:agent — cross-category)", async () => {
|
||||
const { notFound } = await renderPage("hermes-agent");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-code/invalid-id (unknown tool)", async () => {
|
||||
const { notFound } = await renderPage("invalid-id-xyz");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user