mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): add shared CLI UI components — CliToolCard/ConceptCard/ComparisonCard/BaseUrlSelect/ApiKeySelect/ManualConfigModal + useToolBatchStatuses hook (plan 14 F4)
This commit is contained in:
99
src/shared/components/cli/ApiKeySelect.tsx
Normal file
99
src/shared/components/cli/ApiKeySelect.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface ApiKeyEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeySelectProps {
|
||||
keys: ApiKeyEntry[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MANUAL_VALUE = "__manual__";
|
||||
|
||||
export default function ApiKeySelect({
|
||||
keys,
|
||||
value,
|
||||
onChange,
|
||||
label = "API Key",
|
||||
disabled = false,
|
||||
}: ApiKeySelectProps) {
|
||||
const isManual = !keys.some((k) => k.id === value) && value !== "";
|
||||
const [showManual, setShowManual] = useState<boolean>(isManual);
|
||||
const [manualValue, setManualValue] = useState<string>(isManual ? value : "");
|
||||
|
||||
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const val = e.target.value;
|
||||
if (val === MANUAL_VALUE) {
|
||||
setShowManual(true);
|
||||
onChange(manualValue);
|
||||
} else {
|
||||
setShowManual(false);
|
||||
onChange(val);
|
||||
}
|
||||
}
|
||||
|
||||
function handleManualInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
setManualValue(val);
|
||||
onChange(val);
|
||||
}
|
||||
|
||||
const selectValue = showManual ? MANUAL_VALUE : (value || "");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-text-main">{label}</label>
|
||||
)}
|
||||
<select
|
||||
value={selectValue}
|
||||
onChange={handleSelectChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg text-sm",
|
||||
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10",
|
||||
"text-text-main focus:outline-none focus:border-primary/50",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{keys.length === 0 && !showManual && (
|
||||
<option value="" disabled>
|
||||
Nenhuma API key cadastrada
|
||||
</option>
|
||||
)}
|
||||
{keys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
{k.prefix ? ` (${k.prefix}…)` : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value={MANUAL_VALUE}>Inserir manualmente</option>
|
||||
</select>
|
||||
{showManual && (
|
||||
<input
|
||||
type="text"
|
||||
value={manualValue}
|
||||
onChange={handleManualInput}
|
||||
disabled={disabled}
|
||||
placeholder="sk-…"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg text-sm font-mono",
|
||||
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10",
|
||||
"text-text-main focus:outline-none focus:border-primary/50",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/shared/components/cli/BaseUrlSelect.tsx
Normal file
102
src/shared/components/cli/BaseUrlSelect.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface BaseUrlSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
cloudEnabled: boolean;
|
||||
cloudUrl?: string;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type OptionKey = "local" | "cloud" | "custom";
|
||||
|
||||
function getDefaultLocal(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
return "http://localhost:20128";
|
||||
}
|
||||
|
||||
export default function BaseUrlSelect({
|
||||
value,
|
||||
onChange,
|
||||
cloudEnabled,
|
||||
cloudUrl,
|
||||
label = "Base URL",
|
||||
disabled = false,
|
||||
}: BaseUrlSelectProps) {
|
||||
const localUrl = getDefaultLocal();
|
||||
|
||||
function detectOption(val: string): OptionKey {
|
||||
if (val === localUrl) return "local";
|
||||
if (cloudEnabled && cloudUrl && val === cloudUrl) return "cloud";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<OptionKey>(() => detectOption(value));
|
||||
const [customValue, setCustomValue] = useState<string>(
|
||||
detectOption(value) === "custom" ? value : ""
|
||||
);
|
||||
|
||||
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const opt = e.target.value as OptionKey;
|
||||
setSelectedOption(opt);
|
||||
if (opt === "local") {
|
||||
onChange(localUrl);
|
||||
} else if (opt === "cloud" && cloudUrl) {
|
||||
onChange(cloudUrl);
|
||||
} else if (opt === "custom") {
|
||||
onChange(customValue);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
setCustomValue(val);
|
||||
onChange(val);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-text-main">{label}</label>
|
||||
)}
|
||||
<select
|
||||
value={selectedOption}
|
||||
onChange={handleSelectChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg text-sm",
|
||||
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10",
|
||||
"text-text-main focus:outline-none focus:border-primary/50",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<option value="local">Local ({localUrl})</option>
|
||||
{cloudEnabled && cloudUrl && (
|
||||
<option value="cloud">Cloud ({cloudUrl})</option>
|
||||
)}
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
{selectedOption === "custom" && (
|
||||
<input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={handleCustomInput}
|
||||
disabled={disabled}
|
||||
placeholder="https://…"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg text-sm font-mono",
|
||||
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10",
|
||||
"text-text-main focus:outline-none focus:border-primary/50",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/shared/components/cli/CliComparisonCard.tsx
Normal file
82
src/shared/components/cli/CliComparisonCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import type { CliConceptType } from "./CliConceptCard";
|
||||
|
||||
export interface CliComparisonCardProps {
|
||||
currentType: CliConceptType;
|
||||
}
|
||||
|
||||
const TYPE_HREFS: Record<CliConceptType, string> = {
|
||||
code: "/dashboard/cli-code",
|
||||
agent: "/dashboard/cli-agents",
|
||||
acp: "/dashboard/acp-agents",
|
||||
};
|
||||
|
||||
export default function CliComparisonCard({ currentType }: CliComparisonCardProps) {
|
||||
const t = useTranslations("cliCommon");
|
||||
|
||||
const types: CliConceptType[] = ["code", "agent", "acp"];
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-black/5 dark:border-white/5 rounded-lg shadow-sm p-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{types.map((type) => {
|
||||
const isCurrent = type === currentType;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={cn(
|
||||
"flex flex-col gap-2 p-3 rounded-lg",
|
||||
isCurrent
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "bg-black/[0.02] dark:bg-white/[0.02] border border-transparent"
|
||||
)}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex items-center justify-between gap-1 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold uppercase tracking-wider",
|
||||
isCurrent ? "text-primary" : "text-text-muted"
|
||||
)}
|
||||
>
|
||||
{t(`comparison.${type}.title`)}
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/20 text-primary whitespace-nowrap">
|
||||
{t("comparison.thisPage")} ✓
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={TYPE_HREFS[type]}
|
||||
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted hover:text-primary hover:bg-primary/10 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Ver →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[11px] text-text-muted leading-relaxed">
|
||||
{t(`comparison.${type}.desc`)}
|
||||
</p>
|
||||
|
||||
{/* Flow */}
|
||||
<p className="text-[10px] text-text-muted font-mono">
|
||||
{t(`comparison.${type}.flow`)}
|
||||
</p>
|
||||
|
||||
{/* Examples */}
|
||||
<p className="text-[10px] text-text-muted italic">
|
||||
{t(`comparison.${type}.examples`)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/shared/components/cli/CliConceptCard.tsx
Normal file
58
src/shared/components/cli/CliConceptCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export type CliConceptType = "code" | "agent" | "acp";
|
||||
|
||||
export interface CliConceptCardProps {
|
||||
currentType: CliConceptType;
|
||||
}
|
||||
|
||||
const TYPE_HREFS: Record<CliConceptType, string> = {
|
||||
code: "/dashboard/cli-code",
|
||||
agent: "/dashboard/cli-agents",
|
||||
acp: "/dashboard/acp-agents",
|
||||
};
|
||||
|
||||
export default function CliConceptCard({ currentType }: CliConceptCardProps) {
|
||||
const t = useTranslations("cliCommon");
|
||||
|
||||
const types: CliConceptType[] = ["code", "agent", "acp"];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-surface border rounded-lg shadow-sm p-4",
|
||||
"border-primary/30 bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Current type — highlighted */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-primary">
|
||||
{t(`concept.${currentType}.title`)}
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">{t(`concept.${currentType}.phrase`)}</p>
|
||||
<p className="text-[11px] text-text-muted font-mono">{t(`concept.${currentType}.flow`)}</p>
|
||||
</div>
|
||||
|
||||
{/* Other types as chips */}
|
||||
<div className="flex items-center gap-2 flex-wrap pt-1 border-t border-black/5 dark:border-white/5">
|
||||
{types
|
||||
.filter((type) => type !== currentType)
|
||||
.map((type) => (
|
||||
<Link
|
||||
key={type}
|
||||
href={TYPE_HREFS[type]}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
{t(`concept.${type}.title`)} — {t(`concept.${type}.seeOther`)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/shared/components/cli/CliToolCard.tsx
Normal file
152
src/shared/components/cli/CliToolCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
|
||||
import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus";
|
||||
import CliStatusBadge from "@/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface CliToolCardProps {
|
||||
tool: CliCatalogEntry;
|
||||
batchStatus: ToolBatchStatus | null;
|
||||
detailHref: string;
|
||||
hasActiveProviders: boolean;
|
||||
}
|
||||
|
||||
export default function CliToolCard({
|
||||
tool,
|
||||
batchStatus,
|
||||
detailHref,
|
||||
hasActiveProviders,
|
||||
}: CliToolCardProps) {
|
||||
const installed = batchStatus?.detection.installed ?? false;
|
||||
const configStatus = batchStatus?.config.status ?? null;
|
||||
const version = batchStatus?.detection.version ?? "not found";
|
||||
const endpoint = batchStatus?.config.endpoint ?? null;
|
||||
|
||||
const showInstallChips = !installed && tool.configType !== "guide";
|
||||
|
||||
const title = (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Icon / image */}
|
||||
{tool.image ? (
|
||||
<Image
|
||||
src={tool.image}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-md object-contain flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] flex-shrink-0"
|
||||
style={{ color: tool.color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{tool.icon ?? "terminal"}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-text-main text-sm leading-tight truncate">
|
||||
{tool.name}
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted font-mono bg-black/5 dark:bg-white/5 px-1.5 py-0.5 rounded">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted line-clamp-1 mt-0.5">{tool.description}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted flex-shrink-0">
|
||||
chevron_right
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={detailHref}
|
||||
className={cn(
|
||||
"block min-h-[180px]",
|
||||
"bg-surface border border-black/5 dark:border-white/5 rounded-lg shadow-sm",
|
||||
"hover:shadow-md hover:border-primary/30 transition-all",
|
||||
"p-4 flex flex-col gap-3"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{title}
|
||||
|
||||
{/* Status strip */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Detection */}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-[11px] font-medium px-1.5 py-0.5 rounded",
|
||||
installed
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-zinc-500 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{installed ? "✓" : "✗"}</span>
|
||||
{installed ? "Detectado" : "Não detectado"}
|
||||
</span>
|
||||
|
||||
{/* Config status */}
|
||||
{configStatus && (
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={configStatus}
|
||||
batchStatus={null}
|
||||
lastConfiguredAt={batchStatus?.config.lastConfiguredAt ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Endpoint */}
|
||||
{endpoint && (
|
||||
<span className="text-[10px] text-text-muted font-mono truncate max-w-[140px]">
|
||||
{endpoint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badges row */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{tool.baseUrlSupport === "partial" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<span aria-hidden="true">⚠</span> Base URL parcial
|
||||
</span>
|
||||
)}
|
||||
{tool.acpSpawnable === true && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
também ACP
|
||||
</span>
|
||||
)}
|
||||
{showInstallChips && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
|
||||
📋 Manual config
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
|
||||
⬇ Install
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto pt-1 flex items-center justify-between">
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{installed ? "Configurar →" : "Como instalar →"}
|
||||
</span>
|
||||
{!hasActiveProviders && (
|
||||
<span
|
||||
className="text-[10px] text-text-muted italic"
|
||||
title="Conecte um provider em Providers"
|
||||
>
|
||||
Conecte um provider em Providers
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
152
src/shared/components/cli/ManualConfigModal.tsx
Normal file
152
src/shared/components/cli/ManualConfigModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
|
||||
|
||||
export interface ManualConfigModalCustomCode {
|
||||
language: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ManualConfigModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tool: CliCatalogEntry;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
customCode?: ManualConfigModalCustomCode;
|
||||
}
|
||||
|
||||
function interpolate(template: string, vars: Record<string, string>): string {
|
||||
return template
|
||||
.replace(/\{\{baseUrl\}\}/g, vars["baseUrl"] ?? "")
|
||||
.replace(/\{\{apiKey\}\}/g, vars["apiKey"] ?? "")
|
||||
.replace(/\{\{model\}\}/g, vars["model"] ?? "");
|
||||
}
|
||||
|
||||
export default function ManualConfigModal({
|
||||
open,
|
||||
onClose,
|
||||
tool,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
customCode,
|
||||
}: ManualConfigModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const source = customCode ?? tool.codeBlock;
|
||||
const vars: Record<string, string> = { baseUrl, apiKey, model };
|
||||
const rendered = source ? interpolate(source.code, vars) : "";
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rendered);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback: do nothing — clipboard unavailable in non-secure context
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
/* Overlay */
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Configuração manual — ${tool.name}`}
|
||||
className={cn(
|
||||
"relative w-full bg-surface",
|
||||
"border border-black/10 dark:border-white/10",
|
||||
"rounded-xl shadow-2xl",
|
||||
// Desktop: centered, max-w; Mobile: full-screen
|
||||
"max-w-xl sm:max-w-2xl",
|
||||
"max-h-screen sm:max-h-[90vh] overflow-y-auto"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-black/5 dark:border-white/5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-text-main">
|
||||
{tool.name} — Configuração Manual
|
||||
</h2>
|
||||
{source && (
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Linguagem: <span className="font-mono">{source.language}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Fechar"
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
{source ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-text-main">Código de configuração</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all",
|
||||
copied
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main hover:bg-black/10 dark:hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="px-4 py-3 bg-black/5 dark:bg-white/5 rounded-lg font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all border border-black/5 dark:border-white/5 leading-relaxed">
|
||||
{rendered}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">
|
||||
Nenhum bloco de configuração disponível para {tool.name}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Variable summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
|
||||
{(
|
||||
[
|
||||
{ key: "Base URL", val: baseUrl },
|
||||
{ key: "API Key", val: apiKey ? `${apiKey.slice(0, 8)}…` : "(não definida)" },
|
||||
{ key: "Model", val: model },
|
||||
] as Array<{ key: string; val: string }>
|
||||
).map(({ key, val }) => (
|
||||
<div key={key} className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-muted">{key}</span>
|
||||
<span className="text-xs font-mono text-text-main truncate">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/shared/components/cli/index.ts
Normal file
17
src/shared/components/cli/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { default as CliToolCard } from "./CliToolCard";
|
||||
export type { CliToolCardProps } from "./CliToolCard";
|
||||
|
||||
export { default as CliConceptCard } from "./CliConceptCard";
|
||||
export type { CliConceptCardProps, CliConceptType } from "./CliConceptCard";
|
||||
|
||||
export { default as CliComparisonCard } from "./CliComparisonCard";
|
||||
export type { CliComparisonCardProps } from "./CliComparisonCard";
|
||||
|
||||
export { default as BaseUrlSelect } from "./BaseUrlSelect";
|
||||
export type { BaseUrlSelectProps } from "./BaseUrlSelect";
|
||||
|
||||
export { default as ApiKeySelect } from "./ApiKeySelect";
|
||||
export type { ApiKeySelectProps, ApiKeyEntry } from "./ApiKeySelect";
|
||||
|
||||
export { default as ManualConfigModal } from "./ManualConfigModal";
|
||||
export type { ManualConfigModalProps, ManualConfigModalCustomCode } from "./ManualConfigModal";
|
||||
54
src/shared/hooks/cli/useToolBatchStatuses.ts
Normal file
54
src/shared/hooks/cli/useToolBatchStatuses.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ToolBatchStatusMap } from "@/shared/types/cliBatchStatus";
|
||||
|
||||
export interface UseToolBatchStatusesResult {
|
||||
statuses: ToolBatchStatusMap | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useToolBatchStatuses(): UseToolBatchStatusesResult {
|
||||
const [statuses, setStatuses] = useState<ToolBatchStatusMap | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStatuses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/all-statuses");
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => String(res.status));
|
||||
setError(`HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
setStatuses(null);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as ToolBatchStatusMap;
|
||||
setStatuses(data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
setStatuses(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatuses();
|
||||
|
||||
function handleFocus() {
|
||||
void fetchStatuses();
|
||||
}
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [fetchStatuses]);
|
||||
|
||||
return { statuses, loading, error, refetch: fetchStatuses };
|
||||
}
|
||||
160
tests/unit/ui/ApiKeySelect.test.tsx
Normal file
160
tests/unit/ui/ApiKeySelect.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
// @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 { ApiKeyEntry, ApiKeySelectProps } from "@/shared/components/cli/ApiKeySelect";
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { default: ApiKeySelect } = await import("@/shared/components/cli/ApiKeySelect");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderSelect(props: ApiKeySelectProps): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<ApiKeySelect {...props} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
const SAMPLE_KEYS: ApiKeyEntry[] = [
|
||||
{ id: "key1", name: "Production Key", prefix: "sk-prod" },
|
||||
{ id: "key2", name: "Dev Key", prefix: "sk-dev" },
|
||||
];
|
||||
|
||||
// ── 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("ApiKeySelect", () => {
|
||||
it("renders a select element", () => {
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange: vi.fn(),
|
||||
});
|
||||
const select = container.querySelector("select");
|
||||
expect(select).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders all provided keys as options", () => {
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange: vi.fn(),
|
||||
});
|
||||
expect(container.textContent).toContain("Production Key");
|
||||
expect(container.textContent).toContain("Dev Key");
|
||||
});
|
||||
|
||||
it("renders prefix in option text", () => {
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange: vi.fn(),
|
||||
});
|
||||
expect(container.textContent).toContain("sk-prod");
|
||||
});
|
||||
|
||||
it("always includes 'Inserir manualmente' option", () => {
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange: vi.fn(),
|
||||
});
|
||||
expect(container.textContent).toContain("Inserir manualmente");
|
||||
});
|
||||
|
||||
it("does NOT show manual input by default when a known key is selected", () => {
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange: vi.fn(),
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
expect(input).toBeNull();
|
||||
});
|
||||
|
||||
it("shows manual input when value is not in keys list", () => {
|
||||
// Passing a value that's not in the keys array triggers manual mode
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "sk-somemanualvalue",
|
||||
onChange: vi.fn(),
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
});
|
||||
|
||||
it("calls onChange when selecting a different key", () => {
|
||||
const onChange = vi.fn();
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange,
|
||||
});
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
act(() => {
|
||||
select.value = "key2";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith("key2");
|
||||
});
|
||||
|
||||
it("selecting '__manual__' value reveals input", () => {
|
||||
const onChange = vi.fn();
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange,
|
||||
});
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
act(() => {
|
||||
select.value = "__manual__";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders label when provided", () => {
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange: vi.fn(),
|
||||
label: "Auth Key",
|
||||
});
|
||||
expect(container.textContent).toContain("Auth Key");
|
||||
});
|
||||
|
||||
it("disables select when disabled=true", () => {
|
||||
const container = renderSelect({
|
||||
keys: SAMPLE_KEYS,
|
||||
value: "key1",
|
||||
onChange: vi.fn(),
|
||||
disabled: true,
|
||||
});
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
159
tests/unit/ui/BaseUrlSelect.test.tsx
Normal file
159
tests/unit/ui/BaseUrlSelect.test.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
// @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 { BaseUrlSelectProps } from "@/shared/components/cli/BaseUrlSelect";
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { default: BaseUrlSelect } = await import("@/shared/components/cli/BaseUrlSelect");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderSelect(props: BaseUrlSelectProps): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<BaseUrlSelect {...props} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
// Stub window.location.origin
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { origin: "http://localhost:20128" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BaseUrlSelect", () => {
|
||||
it("renders a select element", () => {
|
||||
const container = renderSelect({
|
||||
value: "http://localhost:20128",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: false,
|
||||
});
|
||||
const select = container.querySelector("select");
|
||||
expect(select).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows Local option always", () => {
|
||||
const container = renderSelect({
|
||||
value: "http://localhost:20128",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: false,
|
||||
});
|
||||
expect(container.textContent).toContain("Local");
|
||||
});
|
||||
|
||||
it("shows Cloud option when cloudEnabled=true and cloudUrl is set", () => {
|
||||
const container = renderSelect({
|
||||
value: "http://localhost:20128",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: true,
|
||||
cloudUrl: "https://cloud.example.com",
|
||||
});
|
||||
expect(container.textContent).toContain("Cloud");
|
||||
expect(container.textContent).toContain("cloud.example.com");
|
||||
});
|
||||
|
||||
it("does NOT show Cloud option when cloudEnabled=false", () => {
|
||||
const container = renderSelect({
|
||||
value: "http://localhost:20128",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: false,
|
||||
cloudUrl: "https://cloud.example.com",
|
||||
});
|
||||
// "Cloud" option should not appear in select options
|
||||
const select = container.querySelector("select");
|
||||
const options = Array.from(select?.options ?? []);
|
||||
const cloudOption = options.find((o) => o.value === "cloud");
|
||||
expect(cloudOption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows Custom option always", () => {
|
||||
const container = renderSelect({
|
||||
value: "http://localhost:20128",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: false,
|
||||
});
|
||||
const select = container.querySelector("select");
|
||||
const options = Array.from(select?.options ?? []);
|
||||
const customOption = options.find((o) => o.value === "custom");
|
||||
expect(customOption).toBeDefined();
|
||||
});
|
||||
|
||||
it("reveals custom input when value does not match local or cloud", () => {
|
||||
const container = renderSelect({
|
||||
value: "https://custom.example.com",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: false,
|
||||
});
|
||||
// Since value doesn't match local, it should be in custom mode showing an input
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
});
|
||||
|
||||
it("calls onChange when custom input changes", () => {
|
||||
const onChange = vi.fn();
|
||||
const container = renderSelect({
|
||||
value: "https://custom.example.com",
|
||||
onChange,
|
||||
cloudEnabled: false,
|
||||
});
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input).not.toBeNull();
|
||||
// Use React's synthetic event via nativeInputValueSetter
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
act(() => {
|
||||
nativeInputValueSetter?.call(input, "https://new.example.com");
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith("https://new.example.com");
|
||||
});
|
||||
|
||||
it("renders label when provided", () => {
|
||||
const container = renderSelect({
|
||||
value: "http://localhost:20128",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: false,
|
||||
label: "Endpoint URL",
|
||||
});
|
||||
expect(container.textContent).toContain("Endpoint URL");
|
||||
});
|
||||
|
||||
it("disables select when disabled=true", () => {
|
||||
const container = renderSelect({
|
||||
value: "http://localhost:20128",
|
||||
onChange: vi.fn(),
|
||||
cloudEnabled: false,
|
||||
disabled: true,
|
||||
});
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
113
tests/unit/ui/CliComparisonCard.test.tsx
Normal file
113
tests/unit/ui/CliComparisonCard.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// @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 { CliConceptType } from "@/shared/components/cli/CliConceptCard";
|
||||
|
||||
// ── 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,
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliComparisonCard } = await import("@/shared/components/cli/CliComparisonCard");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderCard(currentType: CliConceptType): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<CliComparisonCard currentType={currentType} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── 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("CliComparisonCard", () => {
|
||||
it("renders 3 columns for all types", () => {
|
||||
const container = renderCard("code");
|
||||
// Each column shows a title key — code, agent, acp
|
||||
expect(container.textContent).toContain("comparison.code.title");
|
||||
expect(container.textContent).toContain("comparison.agent.title");
|
||||
expect(container.textContent).toContain("comparison.acp.title");
|
||||
});
|
||||
|
||||
it("shows Esta página badge for currentType=code column", () => {
|
||||
const container = renderCard("code");
|
||||
// thisPage key gets rendered as "comparison.thisPage ✓"
|
||||
expect(container.textContent).toContain("comparison.thisPage");
|
||||
expect(container.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("shows Esta página badge for currentType=agent column", () => {
|
||||
const container = renderCard("agent");
|
||||
expect(container.textContent).toContain("comparison.thisPage");
|
||||
expect(container.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("shows Esta página badge for currentType=acp column", () => {
|
||||
const container = renderCard("acp");
|
||||
expect(container.textContent).toContain("comparison.thisPage");
|
||||
expect(container.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("renders Ver → links for the non-current columns", () => {
|
||||
const container = renderCard("code");
|
||||
const links = container.querySelectorAll("a");
|
||||
const texts = Array.from(links).map((a) => a.textContent);
|
||||
const verLinks = texts.filter((t) => t?.includes("Ver →"));
|
||||
// 2 non-current columns → 2 links
|
||||
expect(verLinks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("for currentType=code, Ver → links point to agent and acp hrefs", () => {
|
||||
const container = renderCard("code");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-agents");
|
||||
expect(hrefs).toContain("/dashboard/acp-agents");
|
||||
});
|
||||
|
||||
it("current column has primary styling class", () => {
|
||||
const container = renderCard("code");
|
||||
// The current column div has bg-primary/10 class
|
||||
const currentCol = container.querySelector('[class*="primary"]');
|
||||
expect(currentCol).not.toBeNull();
|
||||
});
|
||||
});
|
||||
113
tests/unit/ui/CliConceptCard.test.tsx
Normal file
113
tests/unit/ui/CliConceptCard.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// @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 { CliConceptType } from "@/shared/components/cli/CliConceptCard";
|
||||
|
||||
// ── 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,
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliConceptCard } = await import("@/shared/components/cli/CliConceptCard");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderCard(currentType: CliConceptType): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<CliConceptCard currentType={currentType} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── 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("CliConceptCard", () => {
|
||||
it("renders with currentType=code", () => {
|
||||
const container = renderCard("code");
|
||||
// The card renders (concept keys are shown as raw key strings from mock)
|
||||
expect(container.textContent).toContain("concept.code.title");
|
||||
});
|
||||
|
||||
it("renders with currentType=agent", () => {
|
||||
const container = renderCard("agent");
|
||||
expect(container.textContent).toContain("concept.agent.title");
|
||||
});
|
||||
|
||||
it("renders with currentType=acp", () => {
|
||||
const container = renderCard("acp");
|
||||
expect(container.textContent).toContain("concept.acp.title");
|
||||
});
|
||||
|
||||
it("for currentType=code, card has primary bg class", () => {
|
||||
const container = renderCard("code");
|
||||
// The root card div should have primary/5 styling
|
||||
const card = container.firstElementChild as HTMLElement;
|
||||
expect(card?.className ?? "").toContain("primary");
|
||||
});
|
||||
|
||||
it("for currentType=code, renders chips for agent and acp (not code)", () => {
|
||||
const container = renderCard("code");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-agents");
|
||||
expect(hrefs).toContain("/dashboard/acp-agents");
|
||||
// Should NOT link to itself
|
||||
expect(hrefs).not.toContain("/dashboard/cli-code");
|
||||
});
|
||||
|
||||
it("for currentType=agent, renders chips for code and acp", () => {
|
||||
const container = renderCard("agent");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-code");
|
||||
expect(hrefs).toContain("/dashboard/acp-agents");
|
||||
expect(hrefs).not.toContain("/dashboard/cli-agents");
|
||||
});
|
||||
|
||||
it("for currentType=acp, renders chips for code and agent", () => {
|
||||
const container = renderCard("acp");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-code");
|
||||
expect(hrefs).toContain("/dashboard/cli-agents");
|
||||
expect(hrefs).not.toContain("/dashboard/acp-agents");
|
||||
});
|
||||
});
|
||||
194
tests/unit/ui/CliToolCard.test.tsx
Normal file
194
tests/unit/ui/CliToolCard.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
// @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 { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
|
||||
import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus";
|
||||
|
||||
// ── 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 CliStatusBadge so it doesn't need 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>,
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliToolCard } = await import("@/shared/components/cli/CliToolCard");
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTool(overrides: Partial<CliCatalogEntry> = {}): CliCatalogEntry {
|
||||
return {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
icon: "terminal",
|
||||
color: "#D97757",
|
||||
description: "Anthropic Claude Code CLI",
|
||||
docsUrl: "https://example.com",
|
||||
configType: "env",
|
||||
category: "code",
|
||||
vendor: "Anthropic",
|
||||
acpSpawnable: false,
|
||||
baseUrlSupport: "full",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBatchStatus(overrides: Partial<ToolBatchStatus> = {}): ToolBatchStatus {
|
||||
return {
|
||||
detection: { installed: true, runnable: true, version: "1.2.3" },
|
||||
config: { status: "configured", endpoint: "http://localhost:20128", lastConfiguredAt: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderCard(
|
||||
tool: CliCatalogEntry,
|
||||
batchStatus: ToolBatchStatus | null,
|
||||
detailHref: string,
|
||||
hasActiveProviders: boolean
|
||||
): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<CliToolCard
|
||||
tool={tool}
|
||||
batchStatus={batchStatus}
|
||||
detailHref={detailHref}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── 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("CliToolCard", () => {
|
||||
it("renders tool name", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/dashboard/cli-tools/claude", true);
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
});
|
||||
|
||||
it("links to detailHref", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/dashboard/cli-tools/claude", true);
|
||||
const link = container.querySelector("a");
|
||||
expect(link).not.toBeNull();
|
||||
expect(link!.getAttribute("href")).toBe("/dashboard/cli-tools/claude");
|
||||
});
|
||||
|
||||
it("shows version when installed", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("1.2.3");
|
||||
});
|
||||
|
||||
it("shows 'not found' when not installed", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false, version: undefined },
|
||||
});
|
||||
const container = renderCard(makeTool(), status, "/detail", true);
|
||||
expect(container.textContent).toContain("not found");
|
||||
});
|
||||
|
||||
it("shows 'Configurar →' footer when installed", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("Configurar →");
|
||||
});
|
||||
|
||||
it("shows 'Como instalar →' footer when not installed", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false },
|
||||
});
|
||||
const container = renderCard(makeTool(), status, "/detail", true);
|
||||
expect(container.textContent).toContain("Como instalar →");
|
||||
});
|
||||
|
||||
it("shows partial baseUrl amber badge", () => {
|
||||
const tool = makeTool({ baseUrlSupport: "partial" });
|
||||
const container = renderCard(tool, makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("Base URL parcial");
|
||||
});
|
||||
|
||||
it("shows 'também ACP' badge when acpSpawnable is true", () => {
|
||||
const tool = makeTool({ acpSpawnable: true });
|
||||
const container = renderCard(tool, makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("também ACP");
|
||||
});
|
||||
|
||||
it("shows provider tooltip text when hasActiveProviders is false", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/detail", false);
|
||||
expect(container.textContent).toContain("Conecte um provider em Providers");
|
||||
});
|
||||
|
||||
it("shows install chips when not installed and configType is not guide", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false },
|
||||
});
|
||||
const tool = makeTool({ configType: "custom" });
|
||||
const container = renderCard(tool, status, "/detail", true);
|
||||
expect(container.textContent).toContain("Manual config");
|
||||
expect(container.textContent).toContain("Install");
|
||||
});
|
||||
|
||||
it("does NOT show install chips when configType is guide", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false },
|
||||
});
|
||||
const tool = makeTool({ configType: "guide" });
|
||||
const container = renderCard(tool, status, "/detail", true);
|
||||
expect(container.textContent).not.toContain("Manual config");
|
||||
});
|
||||
|
||||
it("renders gracefully with null batchStatus", () => {
|
||||
const container = renderCard(makeTool(), null, "/detail", true);
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
expect(container.textContent).toContain("not found");
|
||||
});
|
||||
});
|
||||
238
tests/unit/ui/ManualConfigModal.test.tsx
Normal file
238
tests/unit/ui/ManualConfigModal.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// @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 { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
|
||||
import type {
|
||||
ManualConfigModalProps,
|
||||
ManualConfigModalCustomCode,
|
||||
} from "@/shared/components/cli/ManualConfigModal";
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { default: ManualConfigModal } = await import("@/shared/components/cli/ManualConfigModal");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function makeTool(overrides: Partial<CliCatalogEntry> = {}): CliCatalogEntry {
|
||||
return {
|
||||
id: "continue",
|
||||
name: "Continue",
|
||||
icon: "terminal",
|
||||
color: "#7C3AED",
|
||||
description: "Continue AI coding assistant",
|
||||
docsUrl: "https://example.com",
|
||||
configType: "guide",
|
||||
category: "code",
|
||||
vendor: "continue.dev",
|
||||
acpSpawnable: false,
|
||||
baseUrlSupport: "full",
|
||||
codeBlock: {
|
||||
language: "json",
|
||||
code: '{\n "baseURL": "{{baseUrl}}",\n "apiKey": "{{apiKey}}",\n "model": "{{model}}"\n}',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderModal(props: ManualConfigModalProps): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<ManualConfigModal {...props} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
// Mock clipboard
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ManualConfigModal", () => {
|
||||
it("does not render when open=false", () => {
|
||||
const container = renderModal({
|
||||
open: false,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
const dialog = container.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeNull();
|
||||
});
|
||||
|
||||
it("renders when open=true", () => {
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
const dialog = container.querySelector('[role="dialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
});
|
||||
|
||||
it("interpolates {{baseUrl}} in code block", () => {
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
const pre = container.querySelector("pre");
|
||||
expect(pre?.textContent).toContain("http://localhost:20128");
|
||||
expect(pre?.textContent).not.toContain("{{baseUrl}}");
|
||||
});
|
||||
|
||||
it("interpolates {{apiKey}} in code block", () => {
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test-key",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
const pre = container.querySelector("pre");
|
||||
expect(pre?.textContent).toContain("sk-test-key");
|
||||
expect(pre?.textContent).not.toContain("{{apiKey}}");
|
||||
});
|
||||
|
||||
it("interpolates {{model}} in code block", () => {
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
const pre = container.querySelector("pre");
|
||||
expect(pre?.textContent).toContain("claude-sonnet-4-5");
|
||||
expect(pre?.textContent).not.toContain("{{model}}");
|
||||
});
|
||||
|
||||
it("uses customCode when provided instead of tool.codeBlock", () => {
|
||||
const customCode: ManualConfigModalCustomCode = {
|
||||
language: "bash",
|
||||
code: "export BASE_URL={{baseUrl}}",
|
||||
};
|
||||
const tool = makeTool({ codeBlock: undefined });
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool,
|
||||
baseUrl: "https://custom.example.com",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
customCode,
|
||||
});
|
||||
const pre = container.querySelector("pre");
|
||||
expect(pre?.textContent).toContain("https://custom.example.com");
|
||||
expect(pre?.textContent).toContain("export BASE_URL=");
|
||||
});
|
||||
|
||||
it("calls navigator.clipboard.writeText when Copy button is clicked", async () => {
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
|
||||
const copyButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Copy")
|
||||
);
|
||||
expect(copyButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
copyButton!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Copied! feedback after copying", async () => {
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
|
||||
const copyButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Copy")
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
copyButton!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Copied!");
|
||||
});
|
||||
|
||||
it("calls onClose when backdrop is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose,
|
||||
tool: makeTool(),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
|
||||
act(() => {
|
||||
overlay?.click();
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows tool name in header", () => {
|
||||
const container = renderModal({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
tool: makeTool({ name: "My CLI Tool" }),
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
expect(container.textContent).toContain("My CLI Tool");
|
||||
});
|
||||
});
|
||||
219
tests/unit/ui/useToolBatchStatuses.test.tsx
Normal file
219
tests/unit/ui/useToolBatchStatuses.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useToolBatchStatuses } from "@/shared/hooks/cli/useToolBatchStatuses";
|
||||
import type { ToolBatchStatusMap } from "@/shared/types/cliBatchStatus";
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_DATA: ToolBatchStatusMap = {
|
||||
claude: {
|
||||
detection: { installed: true, runnable: true, version: "1.0.0" },
|
||||
config: { status: "configured", endpoint: "http://localhost:20128", lastConfiguredAt: null },
|
||||
},
|
||||
codex: {
|
||||
detection: { installed: false, runnable: false },
|
||||
config: { status: "not_installed", endpoint: null, lastConfiguredAt: null },
|
||||
},
|
||||
};
|
||||
|
||||
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(JSON.stringify(data)),
|
||||
} as Response)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
type HookState = {
|
||||
statuses: ToolBatchStatusMap | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: (() => void) | null;
|
||||
};
|
||||
|
||||
function HookCapture({ onUpdate }: { onUpdate: (s: HookState) => void }) {
|
||||
const { statuses, loading, error, refetch } = useToolBatchStatuses();
|
||||
// Use effect to avoid setState-during-render warnings in tests
|
||||
useEffect(() => {
|
||||
onUpdate({ statuses, loading, error, refetch });
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: ReturnType<typeof createRoot>[] = [];
|
||||
|
||||
async function mountHook(): Promise<{
|
||||
getState: () => HookState;
|
||||
unmount: () => void;
|
||||
}> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
let latest: HookState = { statuses: null, loading: true, error: null, refetch: null };
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookCapture
|
||||
onUpdate={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
getState: () => latest,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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 = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("useToolBatchStatuses", () => {
|
||||
it("starts in loading state then resolves with data", async () => {
|
||||
vi.stubGlobal("fetch", makeFetch(MOCK_DATA));
|
||||
|
||||
const { getState } = await mountHook();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.statuses).not.toBeNull();
|
||||
expect(state.statuses?.["claude"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets error on HTTP 401", async () => {
|
||||
vi.stubGlobal("fetch", makeFetch("Unauthorized", 401));
|
||||
|
||||
const { getState } = await mountHook();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.error).not.toBeNull();
|
||||
expect(state.error).toContain("401");
|
||||
});
|
||||
|
||||
it("sets error on HTTP 500", async () => {
|
||||
vi.stubGlobal("fetch", makeFetch("Internal Server Error", 500));
|
||||
|
||||
const { getState } = await mountHook();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.error).not.toBeNull();
|
||||
expect(state.statuses).toBeNull();
|
||||
});
|
||||
|
||||
it("refetch triggers a new fetch call", async () => {
|
||||
const mockFetch = makeFetch(MOCK_DATA);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const { getState } = await mountHook();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const callsAfterMount = (mockFetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(callsAfterMount).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
getState().refetch?.();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect((mockFetch as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(callsAfterMount);
|
||||
});
|
||||
|
||||
it("registers focus event listener on mount", async () => {
|
||||
const addEventSpy = vi.spyOn(window, "addEventListener");
|
||||
vi.stubGlobal("fetch", makeFetch(MOCK_DATA));
|
||||
|
||||
const { getState } = await mountHook();
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
void getState(); // ensure mounted
|
||||
|
||||
const focusAdds = addEventSpy.mock.calls.filter(([event]) => event === "focus");
|
||||
expect(focusAdds.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("removes focus event listener on unmount", async () => {
|
||||
const removeEventSpy = vi.spyOn(window, "removeEventListener");
|
||||
vi.stubGlobal("fetch", makeFetch(MOCK_DATA));
|
||||
|
||||
const { unmount } = await mountHook();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
const focusRemoves = removeEventSpy.mock.calls.filter(([event]) => event === "focus");
|
||||
expect(focusRemoves.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("sets error when fetch throws a network error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("Network failure")))
|
||||
);
|
||||
|
||||
const { getState } = await mountHook();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.error).not.toBeNull();
|
||||
expect(state.error).toContain("Network failure");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user