Files
fusion/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx
gsxdsm 01b27b13f1 test(dashboard): add per-character typing coverage to uncovered field surfaces
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>
2026-07-26 17:42:39 -07:00

270 lines
9.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ConnectNodeModal } from "../ConnectNodeModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import { expectStableTyping } from "./typingStability.test-helpers";
import type { NodeInfo } from "../../api";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node_test",
name: "Test Node",
type: "remote",
status: "online",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("ConnectNodeModal", () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onConnected: vi.fn(),
addToast: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
/*
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 node name field mounted and focused while typing", async () => {
render(<ConnectNodeModal {...defaultProps} />);
const field = screen.getByPlaceholderText("Build Server") as HTMLInputElement;
await expectStableTyping(field, "build-server", () => screen.getByPlaceholderText("Build Server"));
});
it("renders when open", () => {
render(<ConnectNodeModal {...defaultProps} />);
expect(screen.getByLabelText("Connect to Node")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Build Server")).toBeInTheDocument();
expect(screen.getByPlaceholderText("192.0.2.10 or my-server.local")).toBeInTheDocument();
});
it("uses its real header as a touch-actuable FloatingWindow drag handle", () => {
render(<ConnectNodeModal {...defaultProps} />);
assertRenderedModalTouchGeometry("connect-node", screen.getByText("Connect to Node").closest(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("connect-node", () => render(<ConnectNodeModal {...defaultProps} />));
});
it("does not render when closed", () => {
render(<ConnectNodeModal {...defaultProps} open={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("validates required name field", async () => {
render(<ConnectNodeModal {...defaultProps} />);
// Fill in host but not name
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "192.168.1.100" },
});
// Try to submit
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByText("Node name is required")).toBeInTheDocument();
expect(defaultProps.addToast).not.toHaveBeenCalled();
});
it("validates required host field", () => {
render(<ConnectNodeModal {...defaultProps} />);
// Get the host input directly
const hostInput = screen.getByPlaceholderText("192.0.2.10 or my-server.local");
expect(hostInput).toBeInTheDocument();
// Host should be empty initially
expect(hostInput).toHaveValue("");
});
it("validates port range", async () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "192.168.1.100" },
});
// Port input is the number input (maxConcurrent is also a number input)
const portInput = screen.getAllByRole("spinbutton")[0];
fireEvent.change(portInput, {
target: { value: "99999" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByText("Port must be between 1 and 65535")).toBeInTheDocument();
});
it("shows URL preview as host and port are filled", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "192.168.1.100" },
});
expect(screen.getByText("http://192.168.1.100:3001")).toBeInTheDocument();
});
it("updates URL preview when port changes", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "my-server.local" },
});
// Port is the first number input
const portInput = screen.getAllByRole("spinbutton")[0];
fireEvent.change(portInput, { target: { value: "8080" } });
expect(screen.getByText("http://my-server.local:8080")).toBeInTheDocument();
});
it("strips protocol from host in URL preview", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "https://my-server.local" },
});
expect(screen.getByText("http://my-server.local:3001")).toBeInTheDocument();
});
it("calls onConnected with registered node on success", async () => {
const node = makeNode({ name: "Test Node", url: "http://192.168.1.100:3001" });
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(node),
});
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "192.168.1.100" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(defaultProps.onConnected).toHaveBeenCalledWith(node);
expect(defaultProps.addToast).toHaveBeenCalledWith('Connected to "Test Node"', "success");
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it("shows error toast on API failure", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: () => Promise.resolve({ error: "Invalid node configuration" }),
});
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "invalid-host" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(defaultProps.addToast).toHaveBeenCalledWith("Invalid node configuration", "error");
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
});
it("resets form on close", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "192.168.1.100" },
});
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("handles Escape key to close", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("disables connect button when host is empty", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
const connectButton = screen.getByRole("button", { name: "Connect" });
expect(connectButton).toBeDisabled();
});
it("uses custom onSubmit if provided", async () => {
const customOnSubmit = vi.fn().mockResolvedValue(makeNode({ name: "Custom Node" }));
const node = makeNode({ name: "Custom Node" });
render(<ConnectNodeModal {...defaultProps} onSubmit={customOnSubmit} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Custom Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.0.2.10 or my-server.local"), {
target: { value: "192.168.1.100" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(customOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Custom Node",
url: "http://192.168.1.100:3001",
})
);
expect(defaultProps.onConnected).toHaveBeenCalledWith(node);
});
// fetch should not have been called
expect(mockFetch).not.toHaveBeenCalled();
});
});