feat(FN-3112): tokenize NodesView border-radius and motion styles

Fixes FN-3112: Updates NodesView CSS to use tokenized border-radius and transition values from the design system instead of hardcoded pixel values, ensuring consistency with the dashboard's established token variables.

Fusion-Task-Id: FN-3112
This commit is contained in:
Fusion
2026-05-04 06:06:44 -07:00
committed by gsxdsm
parent 5c904d8751
commit 5dcb739bbe
17 changed files with 1319 additions and 430 deletions

View File

@@ -14,6 +14,7 @@ vi.mock("lucide-react", () => ({
Square: () => <span data-testid="square-icon">square</span>,
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
Trash2: () => <span data-testid="trash-icon">trash</span>,
Box: () => <span data-testid="box-icon">box</span>,
}));
vi.mock("../../hooks/useNodeSettingsSync", () => ({
@@ -69,6 +70,21 @@ function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): Comput
}
describe("NodeCard", () => {
const managedDockerNode = {
id: "dn-1",
nodeId: "node-1",
name: "Docker Node",
status: "running",
hostConfig: { type: "remote" as const, host: "tcp://docker:2376" },
envVars: {},
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [],
persistentStorage: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("renders node name, type, status, project count, and concurrency", () => {
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
const projects = [
@@ -547,6 +563,59 @@ describe("NodeCard", () => {
});
});
it("does not render docker badge without managed docker data", () => {
render(
<NodeCard
node={makeNode()}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.queryByText("Docker")).not.toBeInTheDocument();
});
it("renders docker badge and metadata with managed docker data", () => {
render(
<NodeCard
node={makeNode({ type: "remote", url: "https://node.example" })}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
managedDockerNode={managedDockerNode}
/>
);
expect(screen.getByText("Docker")).toBeInTheDocument();
expect(screen.getByTestId("box-icon")).toBeInTheDocument();
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
expect(screen.getByText("Remote: tcp://docker:2376")).toBeInTheDocument();
});
it.each([
["running", "Running", "node-card__status--online"],
["stopped", "Stopped", "node-card__status--offline"],
["creating", "Creating", "node-card__status--creating"],
["recreating", "Recreating", "node-card__status--recreating"],
["deleting", "Deleting", "node-card__status--deleting"],
])("maps docker status %s", (status, label, className) => {
render(
<NodeCard
node={makeNode({ type: "remote" })}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
managedDockerNode={{ ...managedDockerNode, status, updatedAt: `${status}` }}
/>
);
expect(screen.getByText(label).className).toContain(className);
});
describe("auth sync indicator", () => {
it("renders auth indicator for remote node with match state", () => {
const node = makeNode({

View File

@@ -1,47 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodeDetailModal } from "../NodeDetailModal";
import type { NodeInfo, ProjectInfo } from "../../api";
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
vi.mock("lucide-react", () => ({
Activity: () => <span data-testid="activity-icon">activity</span>,
Download: () => <span data-testid="download-icon">download</span>,
Pencil: () => <span data-testid="pencil-icon">pencil</span>,
Save: () => <span data-testid="save-icon">save</span>,
Shield: () => <span data-testid="shield-icon">shield</span>,
Upload: () => <span data-testid="upload-icon">upload</span>,
X: () => <span data-testid="x-icon">x</span>,
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
Play: () => <span data-testid="play-icon">play</span>,
Square: () => <span data-testid="square-icon">square</span>,
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
}));
vi.mock("../../hooks/useNodeSettingsSync", () => ({
formatRelativeTime: vi.fn((ts: string | null) => {
if (!ts) return "Never synced";
return "Synced 2m ago";
}),
getSyncStateColor: vi.fn((state: string) => {
switch (state) {
case "synced": return "var(--color-success)";
case "diff": return "var(--warning)";
case "error": return "var(--color-error)";
case "pending": return "var(--warning)";
case "never-synced": return "var(--text-muted)";
default: return "var(--text-muted)";
}
}),
Activity: () => <span>activity</span>,
Download: () => <span>download</span>,
FileText: () => <span>file-text</span>,
Pencil: () => <span>pencil</span>,
Play: () => <span>play</span>,
RotateCcw: () => <span>rotate</span>,
Save: () => <span>save</span>,
Shield: () => <span>shield</span>,
Square: () => <span>square</span>,
Upload: () => <span>upload</span>,
X: () => <span>x</span>,
ChevronDown: () => <span>chevron</span>,
}));
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node-1",
name: "Test Node",
name: "Node 1",
type: "remote",
status: "online",
url: "https://test.example.com",
url: "http://localhost:7777",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -49,261 +32,100 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
};
}
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
function makeDockerNode(overrides: Partial<ManagedDockerNodeInfo> = {}): ManagedDockerNodeInfo {
return {
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
status: "active",
isolationMode: "in-process",
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,
};
}
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
return {
syncState: "synced",
lastSyncAt: new Date(Date.now() - 120000).toISOString(),
diffCount: 0,
...overrides,
};
}
const defaultProps = {
const baseProps = {
isOpen: true,
onClose: vi.fn(),
node: makeNode(),
projects: [],
projects: [] as ProjectInfo[],
onUpdate: vi.fn().mockResolvedValue(undefined),
onHealthCheck: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
};
describe("NodeDetailModal", () => {
beforeEach(() => {
vi.clearAllMocks();
describe("NodeDetailModal docker section", () => {
it("does not render docker section without managedDockerNode", () => {
render(<NodeDetailModal {...baseProps} />);
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
});
describe("basic rendering", () => {
it("renders modal when isOpen is true", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("does not render modal when isOpen is false", () => {
render(<NodeDetailModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders node name in dialog title", () => {
const node = makeNode({ name: "Custom Node Name" });
render(<NodeDetailModal {...defaultProps} node={node} />);
expect(screen.getByRole("dialog", { name: "Node details for Custom Node Name" })).toBeInTheDocument();
});
it("renders Overview, Projects, Health, and Settings Sync sections for remote nodes", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText(/^Assigned Projects \(\d+\)$/)).toBeInTheDocument();
expect(screen.getByText("Health")).toBeInTheDocument();
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
expect(screen.getByText("Settings Sync")).toBeInTheDocument();
});
it("renders Docker Management section for managed docker nodes", () => {
const dockerManagedNode = makeNode({ capabilities: ["docker-managed"] });
render(<NodeDetailModal {...defaultProps} node={dockerManagedNode} />);
expect(screen.getByText("Docker Management")).toBeInTheDocument();
});
it("does not render Settings Sync section for local nodes", () => {
const localNode = makeNode({ type: "local" });
render(<NodeDetailModal {...defaultProps} node={localNode} />);
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText(/^Projects \(\d+\)$/)).toBeInTheDocument();
expect(screen.getByText("Health")).toBeInTheDocument();
expect(screen.queryByText("Settings Sync")).not.toBeInTheDocument();
});
it("renders docker section and disabled lifecycle actions", () => {
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
expect(screen.getByText("Docker Management")).toBeInTheDocument();
const lifecycleButtons = screen.getAllByTitle("Available after FN-3113");
expect(lifecycleButtons).toHaveLength(3);
lifecycleButtons.forEach((button) => expect(button).toBeDisabled());
});
describe("Settings Sync section", () => {
it("renders Push Settings, Pull Settings, and Sync Auth buttons for remote nodes", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Push Settings")).toBeInTheDocument();
expect(screen.getByText("Pull Settings")).toBeInTheDocument();
expect(screen.getByText("Sync Auth")).toBeInTheDocument();
});
it("loads and displays logs", async () => {
const onFetchLogs = vi.fn().mockResolvedValue("line-1\nline-2");
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} onFetchLogs={onFetchLogs} />);
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("displays sync status with last sync time", () => {
const syncStatus = makeSyncStatus({ syncState: "synced" });
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
expect(screen.getByText(/Last sync:/)).toBeInTheDocument();
});
it("renders status card and info grid values", () => {
render(
<NodeDetailModal
{...baseProps}
managedDockerNode={makeDockerNode()}
containerStatus={{ running: true, status: "running", startedAt: "2026-01-01T00:00:00.000Z" }}
/>,
);
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("displays diff count when available", () => {
const syncStatus = makeSyncStatus({ syncState: "diff", diffCount: 3 });
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
// The diff count is displayed with the "Differences:" label
const diffElement = document.querySelector(".node-detail-modal__sync-diff");
expect(diffElement).toBeInTheDocument();
expect(diffElement?.textContent).toContain("Differences:");
expect(diffElement?.textContent).toContain("3");
});
it("refreshes container status", async () => {
const onFetchContainerStatus = vi.fn().mockResolvedValue({ running: false, status: "stopped", exitCode: 1 });
render(
<NodeDetailModal
{...baseProps}
managedDockerNode={makeDockerNode()}
onFetchContainerStatus={onFetchContainerStatus}
/>,
);
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("displays 'Never synced' when lastSyncAt is null", () => {
const syncStatus = makeSyncStatus({ syncState: "never-synced", lastSyncAt: null });
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
expect(screen.getByText(/Never synced/)).toBeInTheDocument();
});
it("Push Settings button calls onPushSettings", async () => {
const onPushSettings = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
});
expect(onPushSettings).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("Pull Settings button calls onPullSettings", async () => {
const onPullSettings = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
});
expect(onPullSettings).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("Sync Auth button calls onSyncAuth", async () => {
const onSyncAuth = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
});
expect(onSyncAuth).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("shows loading state on Push Settings button during operation", async () => {
const onPushSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
});
expect(screen.getByText("Pushing...")).toBeInTheDocument();
});
it("shows loading state on Pull Settings button during operation", async () => {
const onPullSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
});
expect(screen.getByText("Pulling...")).toBeInTheDocument();
});
it("shows loading state on Sync Auth button during operation", async () => {
const onSyncAuth = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
});
expect(screen.getByText("Syncing...")).toBeInTheDocument();
});
it("displays sync error when push operation fails", async () => {
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed: connection refused"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onPushSettings={onPushSettings}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Push failed: connection refused/)).toBeInTheDocument();
});
it("displays sync error when pull operation fails", async () => {
const onPullSettings = vi.fn().mockRejectedValue(new Error("Pull failed: timeout"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onPullSettings={onPullSettings}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Pull failed: timeout/)).toBeInTheDocument();
});
it("displays sync error when auth sync operation fails", async () => {
const onSyncAuth = vi.fn().mockRejectedValue(new Error("Auth sync failed"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onSyncAuth={onSyncAuth}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Auth sync failed/)).toBeInTheDocument();
});
it("dismisses sync error when dismiss button is clicked", async () => {
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed"));
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Push failed/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByLabelText("Dismiss error"));
});
expect(screen.queryByText(/Push failed/)).not.toBeInTheDocument();
});
it("buttons are disabled when no handlers provided", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Push Settings")).toBeDisabled();
expect(screen.getByText("Pull Settings")).toBeDisabled();
expect(screen.getByText("Sync Auth")).toBeDisabled();
});
it("masks sensitive env values and shows read-only mount", () => {
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
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();
});
});

View File

@@ -120,11 +120,47 @@ beforeEach(() => {
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
getContainerStatus: vi.fn().mockResolvedValue({ running: true, status: "running" }),
getLogs: vi.fn().mockResolvedValue(""),
create: vi.fn().mockResolvedValue(undefined),
});
});
describe("NodesView", () => {
it("renders docker stat and passes docker data to matching node card", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [makeNode({ id: "node-1", name: "Alpha", type: "remote", url: "https://alpha.node" })],
}));
mockUseManagedDockerNodes.mockReturnValue({
dockerNodes: [{
id: "mdn-1",
nodeId: "node-1",
name: "Docker Alpha",
status: "running",
hostConfig: { type: "local" },
envVars: {},
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [],
persistentStorage: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
getContainerStatus: vi.fn().mockResolvedValue({ running: true, status: "running" }),
getLogs: vi.fn().mockResolvedValue(""),
create: vi.fn().mockResolvedValue(undefined),
});
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByTestId("nodes-stat-docker").textContent).toContain("1");
expect(screen.getAllByText("Docker").length).toBeGreaterThan(0);
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
});
it("renders node cards and stats", () => {
mockUseProjects.mockReturnValue({
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],

View File

@@ -35,10 +35,10 @@ describe("nodes-view mobile CSS", () => {
const cssContent = loadAllAppCss();
const mobileMediaBlock = extractMobileMediaBlocks(cssContent);
it("defines .nodes-view-header with compact padding on mobile", () => {
it("defines .nodes-view-header mobile wrapping without extra padding", () => {
expect(mobileMediaBlock).toContain(".nodes-view-header");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-header");
expect(block).toMatch(/padding:\s*var\(--space-sm\)\s+var\(--space-md\)/);
expect(block).not.toContain("padding:");
expect(block).toContain("flex-wrap: wrap");
});