From 01b27b13f15c3602a2a145aaceae752720bb4256 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 17:42:39 -0700 Subject: [PATCH] test(dashboard): add per-character typing coverage to uncovered field surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/AddNodeModal.test.tsx | 14 ++++++++ .../__tests__/ConnectNodeModal.test.tsx | 14 ++++++++ .../__tests__/NodeDetailModal.test.tsx | 15 +++++++++ .../__tests__/QuickEntryBox.test.tsx | 14 ++++++++ .../__tests__/ScriptsModal.test.tsx | 17 ++++++++++ .../__tests__/SubtaskBreakdownModal.test.tsx | 18 ++++++++++ .../__tests__/WorkflowAddStepModal.test.tsx | 14 ++++++++ .../__tests__/typingStability.test-helpers.ts | 33 +++++++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 packages/dashboard/app/components/__tests__/typingStability.test-helpers.ts diff --git a/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx b/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx index e1d4879966..569663e911 100644 --- a/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx @@ -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(); + const field = screen.getByPlaceholderText("Build Machine") as HTMLInputElement; + await expectStableTyping(field, "build-01", () => screen.getByPlaceholderText("Build Machine")); + }); + it("renders when isOpen is true", () => { render(); diff --git a/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx b/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx index 51159f8ec7..bfff143b11 100644 --- a/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx @@ -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(); + const field = screen.getByPlaceholderText("Build Server") as HTMLInputElement; + await expectStableTyping(field, "build-server", () => screen.getByPlaceholderText("Build Server")); + }); + it("renders when open", () => { render(); diff --git a/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx index 058b9cc8b4..c0d89ecad6 100644 --- a/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx @@ -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(); + 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(); expect(screen.queryByText("Docker Management")).not.toBeInTheDocument(); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 05a407246b..e72b3b298a 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -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"); diff --git a/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx b/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx index 87ff29c149..b6cfe43df4 100644 --- a/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx @@ -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 = { @@ -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(); + + 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( diff --git a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx index cb7e68f0a2..fc05d565e3 100644 --- a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx @@ -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()); diff --git a/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx index a0704954d4..a551710ed8 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/typingStability.test-helpers.ts b/packages/dashboard/app/components/__tests__/typingStability.test-helpers.ts new file mode 100644 index 0000000000..f431923917 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/typingStability.test-helpers.ts @@ -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 { + 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); +}