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>
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { ScriptsModal } from "../ScriptsModal";
|
|
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
|
import { expectStableTyping } from "./typingStability.test-helpers";
|
|
import type { ScriptEntry } from "../../api";
|
|
|
|
const mockScripts: Record<string, string> = {
|
|
build: "npm run build",
|
|
test: "pnpm test",
|
|
lint: "eslint src --ext .ts,.tsx",
|
|
};
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchScripts: vi.fn(() => Promise.resolve({})),
|
|
addScript: vi.fn(() => Promise.resolve({ name: "new-script", command: "echo hello" })),
|
|
removeScript: vi.fn(() => Promise.resolve()),
|
|
}));
|
|
|
|
import {
|
|
fetchScripts,
|
|
addScript,
|
|
removeScript,
|
|
} from "../../api";
|
|
|
|
const onClose = vi.fn();
|
|
const addToast = vi.fn();
|
|
const onRunScript = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("ScriptsModal", () => {
|
|
it("renders inside its FloatingWindow when visible", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
expect(screen.getByTestId("floating-window-scripts")).toBeInTheDocument();
|
|
});
|
|
|
|
|
|
/*
|
|
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 script name field mounted and focused while typing", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
|
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />);
|
|
|
|
fireEvent.click(await screen.findByTestId("add-script-btn"));
|
|
const field = await screen.findByTestId("script-name-input") as HTMLInputElement;
|
|
await expectStableTyping(field, "lint", () => screen.getByTestId("script-name-input"));
|
|
});
|
|
|
|
it("does not render when closed", () => {
|
|
const { container } = render(
|
|
<ScriptsModal isOpen={false} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
expect(container.innerHTML).toBe("");
|
|
});
|
|
|
|
it("renders list of scripts", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("build")).toBeInTheDocument();
|
|
expect(screen.getByText("test")).toBeInTheDocument();
|
|
expect(screen.getByText("lint")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows empty state when no scripts exist", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
|
|
});
|
|
// Use getAllByText since the header also shows "No scripts defined" text
|
|
expect(screen.getAllByText(/No scripts defined/).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("opens create form when Add Script button is clicked", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
expect(screen.getByTestId("script-name-input")).toBeInTheDocument();
|
|
expect(screen.getByTestId("script-command-input")).toBeInTheDocument();
|
|
});
|
|
|
|
it("submits new script", async () => {
|
|
vi.mocked(fetchScripts)
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
const nameInput = screen.getByTestId("script-name-input");
|
|
const commandInput = screen.getByTestId("script-command-input");
|
|
|
|
fireEvent.change(nameInput, { target: { value: "new-script" } });
|
|
fireEvent.change(commandInput, { target: { value: "echo hello" } });
|
|
|
|
fireEvent.click(screen.getByTestId("script-save-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(addScript).toHaveBeenCalledWith("new-script", "echo hello", undefined);
|
|
expect(addToast).toHaveBeenCalledWith("Script created", "success");
|
|
});
|
|
});
|
|
|
|
it("validates script name (alphanumeric, hyphens, underscores only)", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
const nameInput = screen.getByTestId("script-name-input");
|
|
fireEvent.change(nameInput, { target: { value: "invalid name with spaces" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("script-name-error")).toBeInTheDocument();
|
|
});
|
|
// Verify the error message contains expected text
|
|
expect(screen.getByTestId("script-name-error").textContent).toContain("letters");
|
|
});
|
|
|
|
it("allows valid script names with hyphens and underscores", async () => {
|
|
vi.mocked(fetchScripts)
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
const nameInput = screen.getByTestId("script-name-input");
|
|
const commandInput = screen.getByTestId("script-command-input");
|
|
|
|
fireEvent.change(nameInput, { target: { value: "my-script_v2" } });
|
|
fireEvent.change(commandInput, { target: { value: "echo test" } });
|
|
|
|
fireEvent.click(screen.getByTestId("script-save-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(addScript).toHaveBeenCalledWith("my-script_v2", "echo test", undefined);
|
|
});
|
|
});
|
|
|
|
it("runs script when Run button is clicked", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("run-script-build")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("run-script-build"));
|
|
|
|
expect(onRunScript).toHaveBeenCalledWith("build", "npm run build");
|
|
});
|
|
|
|
it("shows delete confirmation", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("run-script-build")).toBeInTheDocument();
|
|
});
|
|
|
|
// Click delete button
|
|
fireEvent.click(screen.getByTestId("delete-script-build"));
|
|
|
|
// Confirm delete buttons should appear
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("confirm-delete-script-build")).toBeInTheDocument();
|
|
expect(screen.getByTestId("cancel-delete-script-build")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("deletes script when confirmed", async () => {
|
|
vi.mocked(fetchScripts)
|
|
.mockResolvedValueOnce(mockScripts)
|
|
.mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("delete-script-build")).toBeInTheDocument();
|
|
});
|
|
|
|
// Click delete button
|
|
fireEvent.click(screen.getByTestId("delete-script-build"));
|
|
|
|
// Confirm delete
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("confirm-delete-script-build")).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByTestId("confirm-delete-script-build"));
|
|
|
|
await waitFor(() => {
|
|
expect(removeScript).toHaveBeenCalledWith("build", undefined);
|
|
expect(addToast).toHaveBeenCalledWith("Script deleted", "success");
|
|
});
|
|
});
|
|
|
|
it("cancels delete when cancel is clicked", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("delete-script-build")).toBeInTheDocument();
|
|
});
|
|
|
|
// Click delete button
|
|
fireEvent.click(screen.getByTestId("delete-script-build"));
|
|
|
|
// Cancel delete
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("cancel-delete-script-build")).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByTestId("cancel-delete-script-build"));
|
|
|
|
// Script should still be visible, delete button back to normal
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("delete-script-build")).toBeInTheDocument();
|
|
expect(removeScript).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("shows error toast when API call fails", async () => {
|
|
vi.mocked(fetchScripts).mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Network error"), "error");
|
|
});
|
|
});
|
|
|
|
it("cancels form when Cancel button is clicked", async () => {
|
|
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("script-save-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("script-cancel-btn"));
|
|
|
|
// Form should be closed, back to list view
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("edits existing script", async () => {
|
|
vi.mocked(fetchScripts)
|
|
.mockResolvedValueOnce(mockScripts)
|
|
.mockResolvedValueOnce(mockScripts);
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("edit-script-build")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("edit-script-build"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("script-name-input")).toBeInTheDocument();
|
|
expect(screen.getByTestId("script-command-input")).toBeInTheDocument();
|
|
});
|
|
|
|
const commandInput = screen.getByTestId("script-command-input");
|
|
fireEvent.change(commandInput, { target: { value: "npm run build:prod" } });
|
|
|
|
fireEvent.click(screen.getByTestId("script-save-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(addScript).toHaveBeenCalledWith("build", "npm run build:prod", undefined);
|
|
expect(addToast).toHaveBeenCalledWith("Script updated", "success");
|
|
});
|
|
});
|
|
|
|
it("shows validation error when script name is empty", async () => {
|
|
vi.mocked(fetchScripts)
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
const commandInput = screen.getByTestId("script-command-input");
|
|
fireEvent.change(commandInput, { target: { value: "echo test" } });
|
|
|
|
// Try to save with empty name
|
|
fireEvent.click(screen.getByTestId("script-save-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Script name is required", "error");
|
|
});
|
|
});
|
|
|
|
it("shows validation error when command is empty", async () => {
|
|
vi.mocked(fetchScripts)
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({});
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
const nameInput = screen.getByTestId("script-name-input");
|
|
fireEvent.change(nameInput, { target: { value: "test-script" } });
|
|
|
|
// Try to save with empty command
|
|
fireEvent.click(screen.getByTestId("script-save-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Script command is required", "error");
|
|
});
|
|
});
|
|
|
|
it("handles duplicate script name error", async () => {
|
|
vi.mocked(fetchScripts)
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({});
|
|
vi.mocked(addScript).mockRejectedValueOnce(new Error("A script with this name already exists"));
|
|
|
|
render(
|
|
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("add-script-btn"));
|
|
|
|
fireEvent.change(screen.getByTestId("script-name-input"), { target: { value: "test-script" } });
|
|
fireEvent.change(screen.getByTestId("script-command-input"), { target: { value: "echo test" } });
|
|
|
|
fireEvent.click(screen.getByTestId("script-save-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("A script with this name already exists", "error");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ScriptsModal floating geometry", () => {
|
|
it("uses its production header for touch drag and resize", () => {
|
|
render(<ScriptsModal isOpen onClose={onClose} addToast={addToast} onRunScript={onRunScript} />);
|
|
assertRenderedModalTouchGeometry("scripts", screen.getByText("Scripts").closest(".modal-header") as HTMLElement);
|
|
assertModalGeometryRecoveryAndSheetContracts("scripts", () => render(<ScriptsModal isOpen onClose={onClose} addToast={addToast} onRunScript={onRunScript} />));
|
|
});
|
|
});
|