Files
fusion/packages/dashboard/app/components/__tests__/AddNodeModal.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

483 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AddNodeModal } from "../AddNodeModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import { expectStableTyping } from "./typingStability.test-helpers";
describe("AddNodeModal", () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
onDiscoverRemoteProjects: vi.fn().mockResolvedValue({ projects: [] }),
addToast: vi.fn(),
projects: [
{
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "proj-2",
name: "Project Two",
path: "/workspace/project-two",
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
};
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(<AddNodeModal {...defaultProps} />);
const field = screen.getByPlaceholderText("Build Machine") as HTMLInputElement;
await expectStableTyping(field, "build-01", () => screen.getByPlaceholderText("Build Machine"));
});
it("renders when isOpen is true", () => {
render(<AddNodeModal {...defaultProps} />);
expect(screen.getByLabelText("Add Node")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Build Machine")).toBeInTheDocument();
});
it("does not render when isOpen is false", () => {
render(<AddNodeModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("validates required name field", async () => {
render(<AddNodeModal {...defaultProps} />);
// Try to submit without filling name
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Name is required")).toBeInTheDocument();
expect(defaultProps.addToast).not.toHaveBeenCalled();
});
it("validates URL required when type is remote", async () => {
render(<AddNodeModal {...defaultProps} />);
// Switch to remote type
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
// Fill name but not URL
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
// Try to submit
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("URL is required for remote nodes")).toBeInTheDocument();
expect(defaultProps.addToast).not.toHaveBeenCalled();
});
it("validates max concurrent range", async () => {
render(<AddNodeModal {...defaultProps} />);
// Fill name
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
const maxConcurrentInput = screen.getAllByRole("spinbutton")[0];
fireEvent.change(maxConcurrentInput, {
target: { value: "15" },
});
// Try to submit
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Concurrency must be between 1 and 10")).toBeInTheDocument();
});
it("calls onSubmit with correct input on valid submission", async () => {
render(<AddNodeModal {...defaultProps} />);
// Fill form
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: "Test Node",
type: "local",
url: undefined,
apiKey: undefined,
maxConcurrent: 2,
projectMappings: [],
}));
expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success");
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it("shows error toast on submission failure", async () => {
const errorOnSubmit = vi.fn().mockRejectedValue(new Error("Network error"));
render(<AddNodeModal {...defaultProps} onSubmit={errorOnSubmit} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.addToast).toHaveBeenCalledWith("Network error", "error");
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
});
it("resets form on close", () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("handles Escape key to close", () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("toggles between local and remote type", async () => {
render(<AddNodeModal {...defaultProps} />);
// Initially local is selected
const localBtn = screen.getByRole("button", { name: "Local" });
const remoteBtn = screen.getByRole("button", { name: "Remote" });
expect(localBtn).toHaveAttribute("aria-pressed", "true");
expect(remoteBtn).toHaveAttribute("aria-pressed", "false");
// Remote fields container should not be rendered
expect(screen.queryByTestId("remote-fields-container")).not.toBeInTheDocument();
// Switch to remote
fireEvent.click(remoteBtn);
expect(localBtn).toHaveAttribute("aria-pressed", "false");
expect(remoteBtn).toHaveAttribute("aria-pressed", "true");
// Remote fields container should be visible
const remoteFieldsContainer = screen.getByTestId("remote-fields-container");
expect(remoteFieldsContainer).toBeInTheDocument();
// URL and API Key fields should be visible
expect(screen.getByPlaceholderText("https://node.example.com")).toBeInTheDocument();
// Switch back to local
fireEvent.click(localBtn);
expect(localBtn).toHaveAttribute("aria-pressed", "true");
expect(remoteBtn).toHaveAttribute("aria-pressed", "false");
// Remote fields container should be removed again
expect(screen.queryByTestId("remote-fields-container")).not.toBeInTheDocument();
});
it("submit button is disabled while submitting", async () => {
let resolveSubmit: () => void;
const slowSubmit = vi.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveSubmit = resolve;
})
);
render(<AddNodeModal {...defaultProps} onSubmit={slowSubmit} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
// Button should show "Adding..." and be disabled
expect(screen.getByRole("button", { name: "Adding..." })).toBeDisabled();
// Resolve the submit
resolveSubmit!();
});
it("clicking overlay closes modal", () => {
render(<AddNodeModal {...defaultProps} />);
// Click on the overlay (not the modal itself)
fireEvent.pointerDown(document.body);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("shows hint text for max concurrent field", () => {
render(<AddNodeModal {...defaultProps} />);
expect(screen.getByText("Max simultaneous task agents (1–10)")).toBeInTheDocument();
});
it("shows description text", () => {
render(<AddNodeModal {...defaultProps} />);
expect(
screen.getByText("Register an existing Fusion node by providing its connection details and concurrency settings.")
).toBeInTheDocument();
});
it("submits remote node with URL and API key after discovery", async () => {
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
projects: [{
id: "remote-1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
}],
});
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.change(screen.getByLabelText("API Key Mode"), {
target: { value: "provide" },
});
fireEvent.change(screen.getByPlaceholderText("Enter node API key"), {
target: { value: "secret-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
await waitFor(() => {
expect(onDiscoverRemoteProjects).toHaveBeenCalledWith({
url: "https://node.example.com",
apiKey: "secret-key",
});
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: "Remote Node",
type: "remote",
url: "https://node.example.com",
apiKey: "secret-key",
maxConcurrent: 2,
projectMappings: [],
}));
});
});
it("blocks remote submit until discovery succeeds", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Discover remote projects before adding this node.")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
it("validates selected project path is required", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Node With Project" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Path is required")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
it("validates selected project path is absolute", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Node With Project" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
target: { value: "relative/path" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Path must be absolute")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
it("prefills path from exact-name discovery match", async () => {
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
projects: [{
id: "remote-1",
name: "Project One",
path: "/remote/project-one",
status: "active",
isolationMode: "in-process",
}],
});
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
await waitFor(() => {
expect(onDiscoverRemoteProjects).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
expect(screen.getByDisplayValue("/remote/project-one")).toBeInTheDocument();
});
it("leaves ambiguous name matches unresolved", async () => {
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
projects: [
{
id: "remote-1",
name: "Project One",
path: "/remote/project-one-a",
status: "active",
isolationMode: "in-process",
},
{
id: "remote-2",
name: "Project One",
path: "/remote/project-one-b",
status: "paused",
isolationMode: "child-process",
},
],
});
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
await waitFor(() => {
expect(onDiscoverRemoteProjects).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Path is required")).toBeInTheDocument();
});
it("shows discovery error inline", async () => {
const onDiscoverRemoteProjects = vi.fn().mockRejectedValue(new Error("upstream unavailable"));
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
expect(await screen.findByText("upstream unavailable")).toBeInTheDocument();
});
it("shows explicit zero-project discovery state", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
expect(await screen.findByText("No projects discovered on remote node.")).toBeInTheDocument();
});
it("submits selected project mappings", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Node With Project" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
target: { value: "/mnt/node/project-one" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: "Node With Project",
projectMappings: [{ projectId: "proj-1", path: "/mnt/node/project-one" }],
}));
});
});
it("removes path input when project is deselected", () => {
render(<AddNodeModal {...defaultProps} />);
const checkbox = screen.getByRole("checkbox", { name: "Project One" });
fireEvent.click(checkbox);
expect(screen.getByDisplayValue("/workspace/project-one")).toBeInTheDocument();
fireEvent.click(checkbox);
expect(screen.queryByDisplayValue("/workspace/project-one")).not.toBeInTheDocument();
});
});
describe("AddNodeModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<AddNodeModal isOpen onClose={vi.fn()} onSubmit={vi.fn()} onDiscoverRemoteProjects={vi.fn()} addToast={vi.fn()} projects={[]} />);
assertRenderedModalTouchGeometry("add-node", screen.getByTestId("floating-window-add-node").querySelector(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("add-node", () => render(<AddNodeModal isOpen onClose={vi.fn()} onSubmit={vi.fn()} onDiscoverRemoteProjects={vi.fn()} addToast={vi.fn()} projects={[]} />));
});
});