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>
This commit is contained in:
@@ -2,6 +2,7 @@ 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 = {
|
||||
@@ -34,6 +35,19 @@ describe("AddNodeModal", () => {
|
||||
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} />);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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();
|
||||
@@ -40,6 +41,19 @@ describe("ConnectNodeModal", () => {
|
||||
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} />);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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", () => ({
|
||||
@@ -75,6 +76,20 @@ const dockerConfig: DockerNodeConfig = {
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act, createEvent } from "@testing-library/react";
|
||||
import { QuickEntryBox } from "../QuickEntryBox";
|
||||
import { expectStableTyping } from "./typingStability.test-helpers";
|
||||
import { TASK_PRIORITIES, type Task, type TaskPriority } from "@fusion/core";
|
||||
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../../api";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
@@ -570,6 +571,19 @@ describe("QuickEntryBox", () => {
|
||||
expectQuickEntryTestGlobalsRestored();
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
FNXC:TypingStability 2026-07-26-22:15:
|
||||
Per-character typing guard for the board's primary composer. FN-8606 shipped an untypable Planning
|
||||
Mode and Settings because field coverage uses fireEvent.change, which sets a value in one shot and
|
||||
never needs the node to stay mounted. This asserts node identity, accumulated value, and focus.
|
||||
*/
|
||||
it("keeps the composer mounted and focused while typing", async () => {
|
||||
renderQuickEntryBox({});
|
||||
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
await expectStableTyping(textarea, "ship it", () => screen.getByTestId("quick-entry-input"));
|
||||
});
|
||||
|
||||
it("renders textarea with placeholder", () => {
|
||||
renderQuickEntryBox({});
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -2,6 +2,7 @@ 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> = {
|
||||
@@ -41,6 +42,22 @@ describe("ScriptsModal", () => {
|
||||
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} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SubtaskBreakdownModal } from "../SubtaskBreakdownModal";
|
||||
import { expectStableTyping } from "./typingStability.test-helpers";
|
||||
|
||||
const mockStartSubtaskBreakdown = vi.fn();
|
||||
const mockRetrySubtaskSession = vi.fn();
|
||||
@@ -50,6 +51,7 @@ vi.mock("../../hooks/useViewportMode", () => ({
|
||||
isShortViewport: () => false,
|
||||
getViewportMode: () => mockViewportMode.value,
|
||||
isMobileViewport: () => mockViewportMode.value === "mobile",
|
||||
isTabletTouchViewport: (mode?: string) => mode === "tablet",
|
||||
useViewportMode: () => mockViewportMode.value,
|
||||
}));
|
||||
|
||||
@@ -304,6 +306,22 @@ describe("SubtaskBreakdownModal", () => {
|
||||
expect(screen.getByDisplayValue("Do second")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
FNXC:TypingStability 2026-07-26-22:15:
|
||||
Per-character typing guard for subtask editing. Each keystroke updates the shared subtask list, so a
|
||||
remount here would drop focus after one character. fireEvent.change (used elsewhere in this file)
|
||||
cannot observe that; assert node identity, accumulated value, and focus instead.
|
||||
*/
|
||||
it("keeps a subtask title field mounted and focused while typing", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
|
||||
|
||||
const field = await screen.findByDisplayValue("First") as HTMLInputElement;
|
||||
await expectStableTyping(field, " pass", () => screen.getByDisplayValue("First pass"));
|
||||
});
|
||||
|
||||
it("shows reconnecting only during generation, not on persisted subtask review", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MessageSquare, Repeat } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowStepTemplate } from "@fusion/core";
|
||||
import { WorkflowAddStepModal, type AddStepPaletteEntry } from "../WorkflowAddStepModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import { expectStableTyping } from "./typingStability.test-helpers";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSimpleView 2026-07-12-14:30:
|
||||
@@ -53,6 +54,19 @@ function renderModal(disallowContainers: boolean) {
|
||||
describe("WorkflowAddStepModal", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
|
||||
/*
|
||||
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 step search field mounted and focused while typing", async () => {
|
||||
renderModal(false);
|
||||
const field = screen.getByPlaceholderText("Search steps and templates…") as HTMLInputElement;
|
||||
await expectStableTyping(field, "sec", () => screen.getByPlaceholderText("Search steps and templates…"));
|
||||
});
|
||||
|
||||
it("offers containers, fragments, and optional-group inserts for top-level targets", () => {
|
||||
renderModal(false);
|
||||
expect(screen.getByTestId("wf-add-step-loop-loop")).toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect } from "vitest";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
|
||||
/*
|
||||
FNXC:TypingStability 2026-07-26-22:15:
|
||||
Shared assertion for "this field survives being typed into". A text field breaks when the render
|
||||
that follows a keystroke gives it a NEW DOM node: React drops the old element, the browser drops
|
||||
focus with it, and every character after the first lands nowhere. That is how FN-8606 shipped an
|
||||
untypable Planning Mode and Settings.
|
||||
|
||||
`fireEvent.change` cannot observe this — it sets `value` in one shot on a node it already holds and
|
||||
never needs focus — so most of our field coverage is blind to it. This helper types character by
|
||||
character through userEvent and asserts the three things a remount destroys: node identity, the
|
||||
accumulated value, and focus.
|
||||
|
||||
`requery` must re-run the original query (getByPlaceholderText/getByLabelText/...) so identity is
|
||||
compared against what the CURRENT tree renders, not the stale reference.
|
||||
*/
|
||||
export async function expectStableTyping(
|
||||
field: HTMLInputElement | HTMLTextAreaElement,
|
||||
text: string,
|
||||
requery: () => HTMLElement,
|
||||
): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const initialValue = field.value;
|
||||
|
||||
await user.click(field);
|
||||
await user.type(field, text);
|
||||
|
||||
expect(requery()).toBe(field);
|
||||
expect(field.value).toBe(`${initialValue}${text}`);
|
||||
expect(document.activeElement).toBe(field);
|
||||
}
|
||||
Reference in New Issue
Block a user