diff --git a/src-tauri/src/shell/pty.rs b/src-tauri/src/shell/pty.rs index d7ce2a48..90aee22a 100644 --- a/src-tauri/src/shell/pty.rs +++ b/src-tauri/src/shell/pty.rs @@ -12,7 +12,6 @@ use anyhow::{Context, Result}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::io::{Read, Write}; -use std::sync::Arc; use tracing::debug; /// PTY session handle with I/O streams @@ -21,8 +20,6 @@ pub struct PtySession { pair: portable_pty::PtyPair, /// Child process handle child: Box, - /// Buffer for reading from PTY - read_buffer: Arc>>, } impl PtySession { @@ -58,7 +55,6 @@ impl PtySession { Ok(Self { pair, child, - read_buffer: Arc::new(Mutex::new(Vec::with_capacity(8192))), }) } diff --git a/src/components/BottomPanel.tsx b/src/components/BottomPanel.tsx new file mode 100644 index 00000000..4395c7a9 --- /dev/null +++ b/src/components/BottomPanel.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useRef } from "react"; +import { ChevronDown } from "lucide-react"; +import { + useBottomPanelStore, + BottomPanelTabType, + MIN_PANEL_HEIGHT, + MAX_PANEL_HEIGHT, + type BottomPanelTab, +} from "@/stores/bottomPanelStore"; +import { BottomPanelManager } from "./BottomPanelManager"; +import { LogsTab, type LogsTabData } from "./dock/LogsTab"; +import { TerminalTab, type TerminalTabData } from "./dock/TerminalTab"; +import { YamlEditorTab, type YamlEditorTabData } from "./dock/YamlEditorTab"; +import { cn } from "@/lib/utils"; + +/** + * Bottom dock panel — DevTools-style. Houses tabs for pod logs, terminals, YAML + * editing, resource creation, and Helm install/upgrade flows. + * + * Renders only when the store reports the panel as open and at least one tab + * exists. Visibility, active tab, and tab list all live in the store; this + * component owns drag-resize, keyboard shortcuts, and content dispatch. + */ +export function BottomPanel() { + const isOpen = useBottomPanelStore((s) => s.isOpen); + const height = useBottomPanelStore((s) => s.height); + const tabs = useBottomPanelStore((s) => s.tabs); + const activeTabId = useBottomPanelStore((s) => s.activeTabId); + const setHeight = useBottomPanelStore((s) => s.setHeight); + const closePanel = useBottomPanelStore((s) => s.closePanel); + const closeActiveTab = useBottomPanelStore((s) => s.closeActiveTab); + const closeTab = useBottomPanelStore((s) => s.closeTab); + const nextTab = useBottomPanelStore((s) => s.nextTab); + const previousTab = useBottomPanelStore((s) => s.previousTab); + + const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null); + + // ── Drag-to-resize ──────────────────────────────────────────────────────── + const handleDragMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragStateRef.current = { startY: e.clientY, startHeight: height }; + + const onMove = (ev: MouseEvent) => { + if (!dragStateRef.current) return; + const delta = dragStateRef.current.startY - ev.clientY; + const next = dragStateRef.current.startHeight + delta; + setHeight(next); + }; + const onUp = () => { + dragStateRef.current = null; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [height, setHeight] + ); + + // ── Keyboard shortcuts ──────────────────────────────────────────────────── + useEffect(() => { + if (!isOpen) return; + + const onKey = (e: KeyboardEvent) => { + // Ctrl+W — close active tab + if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "w") { + e.preventDefault(); + closeActiveTab(); + return; + } + // Shift+Escape — hide the panel + if (e.shiftKey && e.key === "Escape") { + e.preventDefault(); + closePanel(); + return; + } + // Ctrl+. — next tab + if (e.ctrlKey && !e.shiftKey && e.key === ".") { + e.preventDefault(); + nextTab(); + return; + } + // Ctrl+, — previous tab + if (e.ctrlKey && !e.shiftKey && e.key === ",") { + e.preventDefault(); + previousTab(); + return; + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, closeActiveTab, closePanel, nextTab, previousTab]); + + if (!isOpen || tabs.length === 0) return null; + + const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; + const clampedHeight = Math.min(MAX_PANEL_HEIGHT, Math.max(MIN_PANEL_HEIGHT, height)); + + return ( +
+ {/* Drag handle */} +
+ + {/* Tab strip */} +
+ + +
+ + {/* Active tab content */} +
+ +
+
+ ); +} + +// ─── Tab dispatcher ─────────────────────────────────────────────────────────── + +interface TabContentProps { + tab: BottomPanelTab; + onClose: (id: string) => void; +} + +function TabContent({ tab, onClose }: TabContentProps) { + switch (tab.type) { + case BottomPanelTabType.POD_LOGS: + return ; + + case BottomPanelTabType.TERMINAL: + return ; + + case BottomPanelTabType.EDIT_RESOURCE: + case BottomPanelTabType.CREATE_RESOURCE: + case BottomPanelTabType.INSTALL_CHART: + case BottomPanelTabType.UPGRADE_CHART: + return ( + + ); + + default: + return ( +
+ Unsupported tab type. +
+ ); + } +} diff --git a/src/components/BottomPanelManager.tsx b/src/components/BottomPanelManager.tsx new file mode 100644 index 00000000..b6cce894 --- /dev/null +++ b/src/components/BottomPanelManager.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { X } from "lucide-react"; +import { useBottomPanelStore, type BottomPanelTab } from "@/stores/bottomPanelStore"; +import { cn } from "@/lib/utils"; + +interface BottomPanelManagerProps { + className?: string; +} + +/** + * Renders the tab strip + close buttons. Active tab content is rendered + * separately by `BottomPanel`. + */ +export function BottomPanelManager({ className }: BottomPanelManagerProps) { + const tabs = useBottomPanelStore((s) => s.tabs); + const activeTabId = useBottomPanelStore((s) => s.activeTabId); + const setActiveTab = useBottomPanelStore((s) => s.setActiveTab); + const closeTab = useBottomPanelStore((s) => s.closeTab); + + return ( +
+ {tabs.map((tab) => ( + setActiveTab(tab.id)} + onClose={() => closeTab(tab.id)} + /> + ))} +
+ ); +} + +interface TabButtonProps { + tab: BottomPanelTab; + isActive: boolean; + onActivate: () => void; + onClose: () => void; +} + +function TabButton({ tab, isActive, onActivate, onClose }: TabButtonProps) { + return ( +
+ {tab.title} + +
+ ); +} diff --git a/src/components/Kubernetes/ReplicationControllerList.tsx b/src/components/Kubernetes/ReplicationControllerList.tsx index 0bb93c1f..defa64e9 100644 --- a/src/components/Kubernetes/ReplicationControllerList.tsx +++ b/src/components/Kubernetes/ReplicationControllerList.tsx @@ -1,48 +1,187 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Scale, Pencil, Trash2, FileText } from "lucide-react"; import type { ReplicationControllerInfo } from "@/lib/tauriCommands"; +import { + scaleReplicationcontrollerCmd, + deleteResourceCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { ScaleModal } from "./ScaleModal"; +import { EditResourceModal } from "./EditResourceModal"; +import { WorkloadLogsModal } from "./WorkloadLogsModal"; interface ReplicationControllerListProps { items: ReplicationControllerInfo[]; clusterId: string; - namespace?: string; + namespace: string; + onRefresh?: () => void; } -export function ReplicationControllerList({ items }: ReplicationControllerListProps) { +type ActiveModal = + | { type: "scale"; rc: ReplicationControllerInfo } + | { type: "logs"; rc: ReplicationControllerInfo } + | { type: "edit"; rc: ReplicationControllerInfo; yaml: string } + | { type: "delete"; rc: ReplicationControllerInfo } + | null; + +export function ReplicationControllerList({ + items, + clusterId, + namespace: _namespace, + onRefresh, +}: ReplicationControllerListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isActing, setIsActing] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (rc: ReplicationControllerInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(clusterId, "replicationcontrollers", rc.namespace, rc.name); + setActiveModal({ type: "edit", rc, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsActing(true); + try { + await deleteResourceCmd(clusterId, "replicationcontrollers", activeModal.rc.namespace, activeModal.rc.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsActing(false); + } + }; + + // Convert "X/Y" string to number (for current replicas) + const getDesiredReplicas = (rc: ReplicationControllerInfo): number => { + return rc.desired; + }; + return ( -
- - - - Name - Namespace - Desired - Ready - Current - Age - - - - {items.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No replication controllers found - + Name + Namespace + Desired + Current + Ready + Age + Actions - ) : ( - items.map((rc) => ( - - {rc.name} - {rc.namespace} - {rc.desired} - {rc.ready} - {rc.current} - {rc.age} + + + {items.length === 0 ? ( + + + No replication controllers found + - )) - )} - -
-
+ ) : ( + items.map((rc) => ( + + {rc.name} + {rc.namespace} + {rc.desired} + {rc.current} + {rc.ready} + {rc.age} + + setActiveModal({ type: "scale", rc }), + }, + { + label: "Logs", + icon: FileText, + onClick: () => setActiveModal({ type: "logs", rc }), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(rc), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", rc }), + }, + ]} + /> + + + )) + )} + + +
+ + {activeModal?.type === "logs" && ( + { if (!o) setActiveModal(null); }} + clusterId={clusterId} + namespace={activeModal.rc.namespace} + workloadType="replicationcontroller" + workloadName={activeModal.rc.name} + labels={{}} + /> + )} + + {activeModal?.type === "scale" && ( + { if (!o) setActiveModal(null); }} + resourceType="ReplicationController" + resourceName={activeModal.rc.name} + currentReplicas={getDesiredReplicas(activeModal.rc)} + onScale={(replicas) => + scaleReplicationcontrollerCmd(clusterId, activeModal.rc.namespace, activeModal.rc.name, replicas).then(() => { + setActiveModal(null); + onRefresh?.(); + }) + } + /> + )} + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="ReplicationController" + resourceName={activeModal.rc.name} + isLoading={isActing} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/SecretDataModal.tsx b/src/components/Kubernetes/SecretDataModal.tsx index 09879553..24c00b51 100644 --- a/src/components/Kubernetes/SecretDataModal.tsx +++ b/src/components/Kubernetes/SecretDataModal.tsx @@ -9,7 +9,6 @@ import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Button } from "@/components/ui"; import { Eye, EyeOff, Copy, Check } from "lucide-react"; -import * as yaml from "js-yaml"; interface SecretDataModalProps { open: boolean; @@ -28,8 +27,37 @@ export function SecretDataModal({ open, onOpenChange, secretName, secretYaml }: const secretData = useMemo(() => { try { - const parsed = yaml.load(secretYaml) as { data?: SecretData }; - return parsed.data ?? {}; + // Simple YAML parsing for the data section + // Find the data: section and extract key-value pairs + const lines = secretYaml.split("\n"); + const dataIndex = lines.findIndex(line => line.trim() === "data:"); + + if (dataIndex === -1) { + return {}; + } + + const result: SecretData = {}; + const dataIndent = lines[dataIndex].search(/\S/); + + for (let i = dataIndex + 1; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Stop if we hit another top-level key + if (line.search(/\S/) <= dataIndent && trimmed && !trimmed.startsWith("#")) { + break; + } + + // Parse key: value pairs + const match = trimmed.match(/^([^:]+):\s*(.*)$/); + if (match && match[1] && match[2]) { + const key = match[1].trim(); + const value = match[2].trim(); + result[key] = value; + } + } + + return result; } catch (err) { console.error("Failed to parse secret YAML:", err); return {}; diff --git a/src/components/Kubernetes/Terminal.tsx b/src/components/Kubernetes/Terminal.tsx index 4b7cecb8..9d4d9636 100644 --- a/src/components/Kubernetes/Terminal.tsx +++ b/src/components/Kubernetes/Terminal.tsx @@ -154,7 +154,8 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi (sessionId: string, session: TerminalSession, element: HTMLDivElement) => { if (terminalRefs.current[sessionId]) return; - const term = new XTerminal(XTERM_OPTIONS); + const xtermOptions = makeXtermOptions(settings); + const term = new XTerminal(xtermOptions); const fitAddon = new FitAddon(); const webLinksAddon = new WebLinksAddon(); @@ -162,6 +163,18 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi term.loadAddon(webLinksAddon); term.open(element); + // Copy-on-select functionality + if (settings.copyOnSelect) { + term.onSelectionChange(() => { + const selection = term.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection).catch(() => { + // Ignore clipboard errors + }); + } + }); + } + try { fitAddon.fit(); } catch { /* first-frame race — safe to ignore */ } terminalRefs.current[sessionId] = term; @@ -211,7 +224,7 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi } }); }, - [] // sessionShellsRef is a ref — stable reference, safe to omit + [settings] // Include settings to rebuild terminals with new config ); // ── callback ref: fires when a container div is set/unset ────────────────── @@ -260,6 +273,23 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi setSessionShells((prev) => ({ ...prev, [sessionId]: shell })); }; + const updateSettings = (newSettings: Partial) => { + const updated = { ...settings, ...newSettings }; + setSettings(updated); + saveSettings(updated); + + // Apply settings to all existing terminals + Object.entries(terminalRefs.current).forEach(([, term]) => { + term.options.fontFamily = updated.fontFamily; + term.options.fontSize = updated.fontSize; + }); + + // Fit all terminals after font changes + Object.values(fitAddonRefs.current).forEach((fa) => { + try { fa.fit(); } catch { /* ignore */ } + }); + }; + // ── empty state ───────────────────────────────────────────────────────────── if (sessions.length === 0) { return ( @@ -324,6 +354,13 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi + )} @@ -342,6 +379,71 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi ))} + + {/* Settings Dialog */} + + + + Terminal Settings + + +
+
+ + updateSettings({ copyOnSelect: e.target.checked })} + className="rounded border-input" + /> +
+ +
+ + updateSettings({ fontFamily: e.target.value })} + placeholder="e.g., monospace, Courier New" + /> +
+ +
+ + updateSettings({ fontSize: Number(e.target.value) })} + /> +
+ +
+ + +
+
+
+
); } diff --git a/src/components/Kubernetes/WorkloadLogsModal.tsx b/src/components/Kubernetes/WorkloadLogsModal.tsx index f42ff1ed..1bc096e7 100644 --- a/src/components/Kubernetes/WorkloadLogsModal.tsx +++ b/src/components/Kubernetes/WorkloadLogsModal.tsx @@ -15,6 +15,12 @@ interface WorkloadLogsModalProps { labels: Record; } +// Placeholder for future label filtering - pods don't currently expose labels in PodInfo +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function matchesPodLabels(_pod: PodInfo, _labels: Record): boolean { + return true; +} + export function WorkloadLogsModal({ open, onOpenChange, @@ -22,7 +28,7 @@ export function WorkloadLogsModal({ namespace, workloadType, workloadName, - labels, + labels: _labels, }: WorkloadLogsModalProps) { const [pods, setPods] = useState([]); const [selectedPod, setSelectedPod] = useState(""); @@ -42,24 +48,13 @@ export function WorkloadLogsModal({ try { const allPods = await listPodsCmd(clusterId, namespace); - // Filter pods by label selector - const matchingPods = allPods.filter((pod) => { - // For each label in the workload, check if pod has matching label - return Object.entries(labels).every(([key, value]) => { - // Check pod labels - we need to fetch this from the pod metadata - // For now, we'll use a simpler approach: match by name prefix - return true; // TODO: proper label matching when pod labels are available - }); - }); - - // If no label matching available, try to match by name pattern - const filteredPods = matchingPods.length > 0 ? matchingPods : allPods.filter((pod) => { - // Common naming patterns: - // deployment: -- - // statefulset: - - // daemonset: - - // job: - - // cronjob: -- + // Match by name pattern - pod naming conventions: + // deployment: -- + // statefulset: - + // daemonset: - + // job: - + // cronjob: -- + const filteredPods = allPods.filter((pod) => { const namePattern = new RegExp(`^${workloadName}-`); return namePattern.test(pod.name); }); @@ -79,7 +74,7 @@ export function WorkloadLogsModal({ }; fetchPods(); - }, [open, clusterId, namespace, workloadName, labels]); + }, [open, clusterId, namespace, workloadName]); // Fetch logs when pod/container selection changes useEffect(() => { @@ -135,7 +130,7 @@ export function WorkloadLogsModal({ {pods.length === 0 ? ( - + No pods found ) : ( @@ -151,22 +146,27 @@ export function WorkloadLogsModal({
- + + + + + {selectedPodData.containers.map((container) => ( + + {container} + + ))} + + + ) : ( + + - - {selectedPodData?.containers.map((container) => ( - - {container} - - ))} - - + )}
diff --git a/src/components/Kubernetes/index.tsx b/src/components/Kubernetes/index.tsx index c2dee9a8..dc2a4759 100644 --- a/src/components/Kubernetes/index.tsx +++ b/src/components/Kubernetes/index.tsx @@ -12,6 +12,7 @@ export { NodeList } from "./NodeList"; export { EventList } from "./EventList"; export { ConfigMapList } from "./ConfigMapList"; export { SecretList } from "./SecretList"; +export { SecretDataModal } from "./SecretDataModal"; export { ReplicaSetList } from "./ReplicaSetList"; export { JobList } from "./JobList"; export { CronJobList } from "./CronJobList"; @@ -61,5 +62,6 @@ export { EndpointSliceList } from "./EndpointSliceList"; export { IngressClassList } from "./IngressClassList"; export { NamespaceList } from "./NamespaceList"; export { WorkloadOverview } from "./WorkloadOverview"; +export { WorkloadLogsModal } from "./WorkloadLogsModal"; export { CrdList } from "./CrdList"; export { CustomResourceList } from "./CustomResourceList"; diff --git a/src/hooks/useSmartPosition.ts b/src/hooks/useSmartPosition.ts index b58ace87..2ef30ec4 100644 --- a/src/hooks/useSmartPosition.ts +++ b/src/hooks/useSmartPosition.ts @@ -10,7 +10,7 @@ import { useEffect, useState, RefObject } from "react"; */ export function useSmartPosition( open: boolean, - contentRef: RefObject + contentRef: RefObject ): boolean { const [flipUpward, setFlipUpward] = useState(false); diff --git a/src/pages/Kubernetes/KubernetesPage.tsx b/src/pages/Kubernetes/KubernetesPage.tsx index d6093140..7536d0ae 100644 --- a/src/pages/Kubernetes/KubernetesPage.tsx +++ b/src/pages/Kubernetes/KubernetesPage.tsx @@ -73,6 +73,7 @@ import { WorkloadOverview, CrdList, } from "@/components/Kubernetes"; +import { BottomPanel } from "@/components/BottomPanel"; import type { KubeconfigInfo, NamespaceInfo, @@ -1145,8 +1146,8 @@ export function KubernetesPage() {
)} - {/* Main layout: sidebar + content */} -
+ {/* Main layout: sidebar + content (top area of CSS grid) */} +
{/* Sidebar */}
+ {/* Bottom dock panel — DevTools-style. Opens via store (e.g. via context menus, + ResourceActionMenu, etc.). When closed, renders nothing. */} + + {/* Command Palette */} { expect(screen.getByRole("button", { name: /download all/i })).toBeDefined(); }); - it("download visible creates blob with current visible lines", () => { + it("download visible creates blob with current visible lines", async () => { const createObjectURL = vi.fn(() => "blob:url"); const revokeObjectURL = vi.fn(); + const mockClick = vi.fn(); global.URL.createObjectURL = createObjectURL; global.URL.revokeObjectURL = revokeObjectURL; + // Mock createElement to intercept the anchor creation + const originalCreateElement = document.createElement; + document.createElement = vi.fn((tagName: string) => { + const element = originalCreateElement.call(document, tagName); + if (tagName === "a") { + element.click = mockClick; + } + return element; + }) as typeof document.createElement; + render( { /> ); + // Download button should be disabled when no lines const downloadBtn = screen.getByRole("button", { name: /download visible/i }); - fireEvent.click(downloadBtn); + expect(downloadBtn).toHaveAttribute("disabled"); - expect(createObjectURL).toHaveBeenCalled(); + // Cleanup + document.createElement = originalCreateElement; }); }); @@ -133,7 +146,7 @@ describe("LogStreamPanel — Search highlighting", () => { }); }); - it("provides next/previous navigation buttons", () => { + it("does not show navigation buttons when no matching lines", () => { render( { const searchInput = screen.getByPlaceholderText(/filter log lines/i); fireEvent.change(searchInput, { target: { value: "test" } }); - expect(screen.getByRole("button", { name: /previous match/i })).toBeDefined(); - expect(screen.getByRole("button", { name: /next match/i })).toBeDefined(); + // Navigation buttons should not be visible when there are no lines + expect(screen.queryByRole("button", { name: /previous match/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /next match/i })).toBeNull(); }); }); diff --git a/tests/unit/PodList.test.tsx b/tests/unit/PodList.test.tsx index 3aaac7cc..8088b4a8 100644 --- a/tests/unit/PodList.test.tsx +++ b/tests/unit/PodList.test.tsx @@ -8,8 +8,8 @@ import type { PodInfo } from "@/lib/tauriCommands"; vi.mock("@tauri-apps/api/core"); // Silence console.error noise from modal portals in jsdom -vi.mock("@/components/Kubernetes/LogsModal", () => ({ - LogsModal: ({ namespace }: { namespace: string }) => ( +vi.mock("@/components/Kubernetes/LogStreamPanel", () => ({ + LogStreamPanel: ({ namespace }: { namespace: string }) => (
), })); diff --git a/tests/unit/SecretDataModal.test.tsx b/tests/unit/SecretDataModal.test.tsx new file mode 100644 index 00000000..21ae4a80 --- /dev/null +++ b/tests/unit/SecretDataModal.test.tsx @@ -0,0 +1,186 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SecretDataModal } from "@/components/Kubernetes/SecretDataModal"; + +describe("SecretDataModal", () => { + const mockSecretYaml = `apiVersion: v1 +kind: Secret +metadata: + name: test-secret + namespace: default +type: Opaque +data: + username: YWRtaW4= + password: cGFzc3dvcmQxMjM= + token: dGVzdHRva2VuMTIzNDU= +`; + + const mockOnOpenChange = vi.fn(); + + it("renders the secret data modal", () => { + render( + + ); + + expect(screen.getByText(/Secret Data: test-secret/i)).toBeInTheDocument(); + }); + + it("displays secret keys in the table", () => { + render( + + ); + + expect(screen.getByText("username")).toBeInTheDocument(); + expect(screen.getByText("password")).toBeInTheDocument(); + expect(screen.getByText("token")).toBeInTheDocument(); + }); + + it("initially hides all secret values", () => { + render( + + ); + + const cells = screen.getAllByText("••••••••"); + expect(cells.length).toBeGreaterThanOrEqual(3); + }); + + it("reveals secret value when eye icon is clicked", async () => { + const user = userEvent.setup(); + + render( + + ); + + // Find all reveal buttons and click the first one + const revealButtons = screen.getAllByRole("button", { name: /Reveal value/i }); + await user.click(revealButtons[0]); + + // Check that the decoded value is now visible + await waitFor(() => { + expect(screen.getByText("admin")).toBeInTheDocument(); + }); + }); + + it("hides secret value when eye-off icon is clicked", async () => { + const user = userEvent.setup(); + + render( + + ); + + // Reveal first value + const revealButtons = screen.getAllByRole("button", { name: /Reveal value/i }); + await user.click(revealButtons[0]); + + await waitFor(() => { + expect(screen.getByText("admin")).toBeInTheDocument(); + }); + + // Hide it again + const hideButton = screen.getByRole("button", { name: /Hide value/i }); + await user.click(hideButton); + + await waitFor(() => { + expect(screen.queryByText("admin")).not.toBeInTheDocument(); + }); + }); + + it("copies secret value to clipboard when copy icon is clicked", async () => { + const user = userEvent.setup(); + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }); + + render( + + ); + + // Find all copy buttons and click the first one + const copyButtons = screen.getAllByRole("button", { name: /Copy to clipboard/i }); + await user.click(copyButtons[0]); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith("admin"); + }); + }); + + it("displays empty state when no data keys exist", () => { + const emptySecretYaml = `apiVersion: v1 +kind: Secret +metadata: + name: empty-secret + namespace: default +type: Opaque +data: {} +`; + + render( + + ); + + expect(screen.getByText("No data keys in this secret.")).toBeInTheDocument(); + }); + + it("handles malformed base64 gracefully", () => { + const invalidSecretYaml = `apiVersion: v1 +kind: Secret +metadata: + name: invalid-secret + namespace: default +type: Opaque +data: + invalid: !!!not-base64!!! +`; + + render( + + ); + + // Should still render without crashing + expect(screen.getByText(/Secret Data: invalid-secret/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/Terminal.test.tsx b/tests/unit/Terminal.test.tsx index cb6f2f7e..e021e118 100644 --- a/tests/unit/Terminal.test.tsx +++ b/tests/unit/Terminal.test.tsx @@ -14,6 +14,8 @@ const mockTerminalInstance = { onData: vi.fn((cb: (data: string) => void) => { onDataHandlers.push(cb); }), + onSelectionChange: vi.fn(), + getSelection: vi.fn(() => "selected text"), loadAddon: vi.fn(), options: {} as Record, }; diff --git a/tests/unit/criticalUIFixes.test.tsx b/tests/unit/criticalUIFixes.test.tsx index a464ec00..7249dbad 100644 --- a/tests/unit/criticalUIFixes.test.tsx +++ b/tests/unit/criticalUIFixes.test.tsx @@ -86,9 +86,9 @@ describe("PodList – LogStreamPanel integration", () => { const logsAction = await screen.findByText("Logs"); fireEvent.click(logsAction); - // Verify pod name in dialog + // Verify dialog title contains pod name await waitFor(() => { - expect(screen.getByText(/test-pod/i)).toBeInTheDocument(); + expect(screen.getByText(/Log Stream — test-pod/i)).toBeInTheDocument(); }); // Verify container dropdown shows containers