Sweeping the other views for the FN-8606 typing bug found no further breakage, but it did expose why the bug shipped: almost all field coverage uses fireEvent.change, which sets a value in one shot on a node it already holds and never needs the input to stay mounted. A remount is invisible to it. Adds expectStableTyping (types character by character via userEvent, then asserts DOM node identity, accumulated value, and retained focus) and applies it to the uncovered surfaces: the FN-8606-migrated AddNode, ConnectNode, NodeDetail, Scripts, and WorkflowAddStep modals, plus the board's QuickEntryBox composer and SubtaskBreakdownModal title editing. All pass — this is a detection floor, not a fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
9.7 KiB
TypeScript
233 lines
9.7 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { NodeDetailModal } from "../NodeDetailModal";
|
|
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
|
import { expectStableTyping } from "./typingStability.test-helpers";
|
|
import type { DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
|
|
|
|
vi.mock("lucide-react", () => ({
|
|
Activity: () => <span>activity</span>,
|
|
Download: () => <span>download</span>,
|
|
Eye: () => <span>eye</span>,
|
|
EyeOff: () => <span>eye-off</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: "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> = {}): 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", () => {
|
|
|
|
/*
|
|
FNXC:TypingStability 2026-07-26-22:15:
|
|
Per-character typing guard. The FN-8606 floating-window migration made Planning Mode and Settings
|
|
untypable and no test noticed, because field coverage here uses fireEvent.change, which never needs
|
|
the input to stay mounted. This asserts the field keeps its DOM node, value, and focus while typed.
|
|
*/
|
|
it("keeps the API key field mounted and focused while typing", async () => {
|
|
render(<NodeDetailModal {...baseProps} />);
|
|
fireEvent.click(screen.getByRole("button", { name: /Edit/ }));
|
|
const field = screen.getByPlaceholderText("Leave blank to keep unchanged") as HTMLInputElement;
|
|
await expectStableTyping(field, "tok-123", () => screen.getByPlaceholderText("Leave blank to keep unchanged"));
|
|
});
|
|
|
|
it("does not render docker section without managedDockerNode", () => {
|
|
render(<NodeDetailModal {...baseProps} />);
|
|
expect(screen.queryByText("Docker Management")).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());
|
|
});
|
|
|
|
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("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("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("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(
|
|
<NodeDetailModal
|
|
{...baseProps}
|
|
addToast={addToast}
|
|
managedDockerNode={makeDockerNode()}
|
|
onFetchContainerStatus={onFetchContainerStatus}
|
|
/>,
|
|
);
|
|
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(
|
|
<NodeDetailModal
|
|
{...baseProps}
|
|
node={makeNode({ dockerConfig })}
|
|
onUpdateDockerConfig={onUpdateDockerConfig}
|
|
onFetchDockerConfigDiff={onFetchDockerConfigDiff}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<NodeDetailModal
|
|
{...baseProps}
|
|
addToast={addToast}
|
|
node={makeNode({ dockerConfig })}
|
|
onUpdateDockerConfig={onUpdateDockerConfig}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<NodeDetailModal
|
|
{...baseProps}
|
|
addToast={addToast}
|
|
managedDockerNode={makeDockerNode()}
|
|
onFetchLogs={onFetchLogs}
|
|
/>,
|
|
);
|
|
|
|
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(<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();
|
|
});
|
|
});
|
|
|
|
describe("NodeDetailModal floating geometry", () => {
|
|
it("uses its production header for touch drag and resize", () => {
|
|
render(<NodeDetailModal {...baseProps} />);
|
|
assertRenderedModalTouchGeometry("node-detail", screen.getByText("Node Details").closest(".modal-header") as HTMLElement);
|
|
assertModalGeometryRecoveryAndSheetContracts("node-detail", () => render(<NodeDetailModal {...baseProps} />));
|
|
});
|
|
});
|