diff --git a/src/shared/components/cli/ApiKeySelect.tsx b/src/shared/components/cli/ApiKeySelect.tsx new file mode 100644 index 0000000000..1a7d51d9cd --- /dev/null +++ b/src/shared/components/cli/ApiKeySelect.tsx @@ -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(isManual); + const [manualValue, setManualValue] = useState(isManual ? value : ""); + + function handleSelectChange(e: React.ChangeEvent) { + const val = e.target.value; + if (val === MANUAL_VALUE) { + setShowManual(true); + onChange(manualValue); + } else { + setShowManual(false); + onChange(val); + } + } + + function handleManualInput(e: React.ChangeEvent) { + const val = e.target.value; + setManualValue(val); + onChange(val); + } + + const selectValue = showManual ? MANUAL_VALUE : (value || ""); + + return ( +
+ {label && ( + + )} + + {showManual && ( + + )} +
+ ); +} diff --git a/src/shared/components/cli/BaseUrlSelect.tsx b/src/shared/components/cli/BaseUrlSelect.tsx new file mode 100644 index 0000000000..8c7e2e8f2c --- /dev/null +++ b/src/shared/components/cli/BaseUrlSelect.tsx @@ -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(() => detectOption(value)); + const [customValue, setCustomValue] = useState( + detectOption(value) === "custom" ? value : "" + ); + + function handleSelectChange(e: React.ChangeEvent) { + 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) { + const val = e.target.value; + setCustomValue(val); + onChange(val); + } + + return ( +
+ {label && ( + + )} + + {selectedOption === "custom" && ( + + )} +
+ ); +} diff --git a/src/shared/components/cli/CliComparisonCard.tsx b/src/shared/components/cli/CliComparisonCard.tsx new file mode 100644 index 0000000000..27b4ff22e0 --- /dev/null +++ b/src/shared/components/cli/CliComparisonCard.tsx @@ -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 = { + 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 ( +
+
+ {types.map((type) => { + const isCurrent = type === currentType; + return ( +
+ {/* Title */} +
+ + {t(`comparison.${type}.title`)} + + {isCurrent ? ( + + {t("comparison.thisPage")} ✓ + + ) : ( + + Ver → + + )} +
+ + {/* Description */} +

+ {t(`comparison.${type}.desc`)} +

+ + {/* Flow */} +

+ {t(`comparison.${type}.flow`)} +

+ + {/* Examples */} +

+ {t(`comparison.${type}.examples`)} +

+
+ ); + })} +
+
+ ); +} diff --git a/src/shared/components/cli/CliConceptCard.tsx b/src/shared/components/cli/CliConceptCard.tsx new file mode 100644 index 0000000000..b6719957ee --- /dev/null +++ b/src/shared/components/cli/CliConceptCard.tsx @@ -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 = { + 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 ( +
+
+ {/* Current type — highlighted */} +
+ + {t(`concept.${currentType}.title`)} + +

{t(`concept.${currentType}.phrase`)}

+

{t(`concept.${currentType}.flow`)}

+
+ + {/* Other types as chips */} +
+ {types + .filter((type) => type !== currentType) + .map((type) => ( + + {t(`concept.${type}.title`)} — {t(`concept.${type}.seeOther`)} + + ))} +
+
+
+ ); +} diff --git a/src/shared/components/cli/CliToolCard.tsx b/src/shared/components/cli/CliToolCard.tsx new file mode 100644 index 0000000000..ef03983bfa --- /dev/null +++ b/src/shared/components/cli/CliToolCard.tsx @@ -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 = ( +
+ {/* Icon / image */} + {tool.image ? ( + {tool.name} + ) : ( + + )} +
+
+ + {tool.name} + + + {version} + +
+

{tool.description}

+
+ + chevron_right + +
+ ); + + return ( + + {/* Header */} + {title} + + {/* Status strip */} +
+ {/* Detection */} + + + {installed ? "Detectado" : "Não detectado"} + + + {/* Config status */} + {configStatus && ( + + )} + + {/* Endpoint */} + {endpoint && ( + + {endpoint} + + )} +
+ + {/* Badges row */} +
+ {tool.baseUrlSupport === "partial" && ( + + Base URL parcial + + )} + {tool.acpSpawnable === true && ( + + também ACP + + )} + {showInstallChips && ( + <> + + 📋 Manual config + + + ⬇ Install + + + )} +
+ + {/* Footer */} +
+ + {installed ? "Configurar →" : "Como instalar →"} + + {!hasActiveProviders && ( + + Conecte um provider em Providers + + )} +
+ + ); +} diff --git a/src/shared/components/cli/ManualConfigModal.tsx b/src/shared/components/cli/ManualConfigModal.tsx new file mode 100644 index 0000000000..5b41347896 --- /dev/null +++ b/src/shared/components/cli/ManualConfigModal.tsx @@ -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 { + 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 = { 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 */ +
+ + ); +} diff --git a/src/shared/components/cli/index.ts b/src/shared/components/cli/index.ts new file mode 100644 index 0000000000..eea1c9f639 --- /dev/null +++ b/src/shared/components/cli/index.ts @@ -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"; diff --git a/src/shared/hooks/cli/useToolBatchStatuses.ts b/src/shared/hooks/cli/useToolBatchStatuses.ts new file mode 100644 index 0000000000..932dae00c7 --- /dev/null +++ b/src/shared/hooks/cli/useToolBatchStatuses.ts @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/tests/unit/ui/ApiKeySelect.test.tsx b/tests/unit/ui/ApiKeySelect.test.tsx new file mode 100644 index 0000000000..2ca1518734 --- /dev/null +++ b/tests/unit/ui/ApiKeySelect.test.tsx @@ -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(); + }); + 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); + }); +}); diff --git a/tests/unit/ui/BaseUrlSelect.test.tsx b/tests/unit/ui/BaseUrlSelect.test.tsx new file mode 100644 index 0000000000..f87715b50f --- /dev/null +++ b/tests/unit/ui/BaseUrlSelect.test.tsx @@ -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(); + }); + 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); + }); +}); diff --git a/tests/unit/ui/CliComparisonCard.test.tsx b/tests/unit/ui/CliComparisonCard.test.tsx new file mode 100644 index 0000000000..3dbb0e6661 --- /dev/null +++ b/tests/unit/ui/CliComparisonCard.test.tsx @@ -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 & { href: string }) => ( + + {children} + + ), +})); + +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(); + }); + 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(); + }); +}); diff --git a/tests/unit/ui/CliConceptCard.test.tsx b/tests/unit/ui/CliConceptCard.test.tsx new file mode 100644 index 0000000000..8d14b42ac1 --- /dev/null +++ b/tests/unit/ui/CliConceptCard.test.tsx @@ -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 & { href: string }) => ( + + {children} + + ), +})); + +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(); + }); + 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"); + }); +}); diff --git a/tests/unit/ui/CliToolCard.test.tsx b/tests/unit/ui/CliToolCard.test.tsx new file mode 100644 index 0000000000..f0049d2c15 --- /dev/null +++ b/tests/unit/ui/CliToolCard.test.tsx @@ -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 & { href: string }) => ( + + {children} + + ), +})); + +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; + }) => {effectiveConfigStatus}, +})); + +// ── Import after mocks ──────────────────────────────────────────────────────── + +const { default: CliToolCard } = await import("@/shared/components/cli/CliToolCard"); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeTool(overrides: Partial = {}): 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 { + 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( + + ); + }); + 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"); + }); +}); diff --git a/tests/unit/ui/ManualConfigModal.test.tsx b/tests/unit/ui/ManualConfigModal.test.tsx new file mode 100644 index 0000000000..3d7b83d029 --- /dev/null +++ b/tests/unit/ui/ManualConfigModal.test.tsx @@ -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 { + 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(); + }); + 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"); + }); +}); diff --git a/tests/unit/ui/useToolBatchStatuses.test.tsx b/tests/unit/ui/useToolBatchStatuses.test.tsx new file mode 100644 index 0000000000..adf23f88d1 --- /dev/null +++ b/tests/unit/ui/useToolBatchStatuses.test.tsx @@ -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[] = []; + +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( + { + 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).mock.calls.length; + expect(callsAfterMount).toBeGreaterThan(0); + + await act(async () => { + getState().refetch?.(); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect((mockFetch as ReturnType).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"); + }); +});