import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodeDetailModal } from "../NodeDetailModal";
import type { DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
vi.mock("lucide-react", () => ({
Activity: () => activity,
Download: () => download,
Eye: () => eye,
EyeOff: () => eye-off,
FileText: () => file-text,
Pencil: () => pencil,
Play: () => play,
RotateCcw: () => rotate,
Save: () => save,
Shield: () => shield,
Square: () => square,
Upload: () => upload,
X: () => x,
ChevronDown: () => chevron,
}));
function makeNode(overrides: Partial = {}): NodeInfo {
return {
id: "node-1",
name: "Node 1",
type: "remote",
status: "online",
url: "http://localhost:7777",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeDockerNode(overrides: Partial = {}): ManagedDockerNodeInfo {
return {
id: "mdn-1",
nodeId: "node-1",
name: "Docker Node",
containerId: "abcdef1234567890",
status: "running",
hostConfig: { type: "local" },
envVars: { FUSION_TOKEN: "secret", FUSION_LOG_LEVEL: "info" },
reachableUrl: "http://localhost:7777",
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [{ hostPath: "/host", containerPath: "/data", readOnly: true }],
persistentStorage: true,
resourceSizing: { cpuLimit: "2", memoryLimit: "4GB" },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
const baseProps = {
isOpen: true,
onClose: vi.fn(),
node: makeNode(),
projects: [] as ProjectInfo[],
onUpdate: vi.fn().mockResolvedValue(undefined),
onHealthCheck: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
};
const dockerConfig: DockerNodeConfig = {
image: "runfusion/fusion:latest",
volumeMounts: [{ hostPath: "fusion-data", containerPath: "/app/.fusion", mode: "rw", type: "volume" }],
environment: { FUSION_TOKEN: "secret" },
configVersion: 1,
lastUpdated: "2026-01-01T00:00:00.000Z",
};
describe("NodeDetailModal docker section", () => {
it("does not render docker section without managedDockerNode", () => {
render();
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
});
it("renders docker section and disabled lifecycle actions", () => {
render();
expect(screen.getByText("Docker Management")).toBeInTheDocument();
const lifecycleButtons = screen.getAllByTitle("Available after FN-3113");
expect(lifecycleButtons).toHaveLength(3);
lifecycleButtons.forEach((button) => expect(button).toBeDisabled());
});
it("loads and displays logs", async () => {
const onFetchLogs = vi.fn().mockResolvedValue("line-1\nline-2");
render();
fireEvent.click(screen.getByRole("button", { name: /view logs/i }));
expect(await screen.findByText((content) => content.includes("line-1") && content.includes("line-2"))).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Close logs"));
await waitFor(() => expect(screen.queryByText("Container Logs")).not.toBeInTheDocument());
});
it("renders status card and info grid values", () => {
render(
,
);
expect(screen.getByText("Running")).toBeInTheDocument();
expect(screen.getByText(/Uptime:/)).toBeInTheDocument();
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
expect(screen.getByText("abcdef123456")).toBeInTheDocument();
expect(screen.getByText("Local Docker")).toBeInTheDocument();
expect(screen.getByText("Yes")).toBeInTheDocument();
expect(screen.getByText("7777")).toBeInTheDocument();
expect(screen.getByText("2 / 4GB")).toBeInTheDocument();
});
it("refreshes container status", async () => {
const onFetchContainerStatus = vi.fn().mockResolvedValue({ running: false, status: "stopped", exitCode: 1 });
render(
,
);
fireEvent.click(screen.getByRole("button", { name: /refresh status/i }));
await waitFor(() => expect(onFetchContainerStatus).toHaveBeenCalledWith("mdn-1"));
expect(await screen.findByText("Stopped")).toBeInTheDocument();
expect(screen.getByText("Exit code: 1")).toBeInTheDocument();
});
it("shows an error toast when container status refresh fails", async () => {
const addToast = vi.fn();
const onFetchContainerStatus = vi.fn().mockRejectedValue(new Error("Docker host unreachable"));
render(
,
);
fireEvent.click(screen.getByRole("button", { name: /refresh status/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Docker host unreachable", "error"));
});
it("renders docker config editor and saves", async () => {
const onUpdateDockerConfig = vi.fn().mockResolvedValue({ ...dockerConfig, configVersion: 2 });
const onFetchDockerConfigDiff = vi.fn().mockResolvedValue({ persistedVersion: 1, deployedVersion: null, needsRecreate: true });
render(
,
);
fireEvent.click(screen.getByRole("button", { name: /docker configuration/i }));
expect(screen.getByText(/Config v1/)).toBeInTheDocument();
await waitFor(() => expect(screen.getByText("Needs Recreate")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /save docker config/i }));
await waitFor(() => expect(onUpdateDockerConfig).toHaveBeenCalledWith("node-1", expect.objectContaining({ image: "runfusion/fusion:latest" })));
});
it("shows an error toast when docker config save fails", async () => {
const addToast = vi.fn();
const onUpdateDockerConfig = vi.fn().mockRejectedValue(new Error("Invalid environment variable format"));
render(
,
);
fireEvent.click(screen.getByRole("button", { name: /docker configuration/i }));
fireEvent.click(screen.getByRole("button", { name: /save docker config/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Invalid environment variable format", "error"));
});
it("shows an error toast when logs fetch fails", async () => {
const addToast = vi.fn();
const onFetchLogs = vi.fn().mockRejectedValue(new Error("Timed out while reading container logs"));
render(
,
);
fireEvent.click(screen.getByRole("button", { name: /view logs/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Timed out while reading container logs", "error"));
});
it("masks sensitive env values and shows read-only mount", () => {
render();
fireEvent.click(screen.getByText("Environment Variables"));
expect(screen.getByText("••••••••")).toBeInTheDocument();
expect(screen.getByText("info")).toBeInTheDocument();
fireEvent.click(screen.getByText("Volume Mounts"));
expect(screen.getByText("/host → /data")).toBeInTheDocument();
expect(screen.getByText("Read-only")).toBeInTheDocument();
});
});