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:
gsxdsm
2026-07-26 17:42:39 -07:00
parent d193dcdbb0
commit 01b27b13f1
8 changed files with 139 additions and 0 deletions

View File

@@ -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} />);

View File

@@ -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} />);

View File

@@ -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();

View File

@@ -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");

View File

@@ -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} />

View File

@@ -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());

View File

@@ -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();

View File

@@ -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);
}