diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx index 16e2b93b3f..864d4d4cde 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx @@ -48,11 +48,53 @@ import { } from "./AgentDetailView.test-helpers"; import { AgentDetailView } from "../AgentDetailView"; +/* +FNXC:AgentSettingsTestLatency 2026-08-15-21:10: +FN-2707 fake-timer pattern for the Settings config autosave debounce (CONFIG_AUTOSAVE_DEBOUNCE_MS = 700ms), +which previously ran on real timers so every autosave test paid the debounce in wall-clock time. +Mount/navigation must stay on real timers (the floating-window shell and initial agent load hang under a +faked scheduler), so fake timers are enabled mid-test via beginAutosaveFakeTimers() just before the field +edit that arms the debounce, advanced inside act(), and discarded (not run) on restore so no autosave fires +into an unmounted tree. +RTL v16's asyncWrapper only advances fake time through a GLOBAL `jest.advanceTimersByTime` (vitest defines +no `jest`), so without the scoped jest shim below every userEvent call deadlocks on a faked setTimeout(0). +The shim exists only while fake timers are armed and is deleted in afterEach. +*/ +const AUTOSAVE_DEBOUNCE_ADVANCE_MS = 750; + +const setupUser = () => + userEvent.setup({ + advanceTimers: (ms) => { + if (vi.isFakeTimers()) { + vi.advanceTimersByTime(ms); + } + }, + }); + +const beginAutosaveFakeTimers = () => { + vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "clearTimeout", "clearInterval"] }); + (globalThis as any).jest = { advanceTimersByTime: (ms: number) => vi.advanceTimersByTime(ms) }; +}; + +const advanceAutosaveDebounce = async () => { + await act(async () => { + vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_ADVANCE_MS); + }); +}; + describe("AgentDetailView — budget settings and autosave", () => { beforeEach(() => { setupAgentDetailMocks(); }); + afterEach(() => { + delete (globalThis as any).jest; + if (vi.isFakeTimers()) { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + describe("Budget Settings", () => { const navigateToSettings = async (user: ReturnType) => { await waitFor(() => { @@ -61,26 +103,6 @@ describe("Budget Settings", () => { await user.click(screen.getByText("Settings")); }; - it("renders Budget Settings section with all fields", async () => { - const user = userEvent.setup(); - render( - - ); - - await navigateToSettings(user); - - await waitFor(() => { - expect(screen.getByLabelText("Token Budget")).toBeInTheDocument(); - expect(screen.getByLabelText("Usage Threshold (%)")).toBeInTheDocument(); - expect(screen.getByLabelText("Budget Period")).toBeInTheDocument(); - expect(screen.getByLabelText("Reset Day")).toBeInTheDocument(); - }); - }); - it("pre-fills budget fields from existing runtimeConfig.budgetConfig", async () => { mockFetchAgent.mockResolvedValue(createMockAgent({ runtimeConfig: { @@ -93,7 +115,7 @@ describe("Budget Settings", () => { }, })); - const user = userEvent.setup(); + const user = setupUser(); render( { }); }); - it("shows empty fields when budgetConfig is not set", async () => { + // FNXC:AgentSettingsTestLatency 2026-08-15-21:20: aggregate control contract — merges the former + // per-control field-presence test with the empty-prefill test (one mount covers both; FN-5048 trim). + it("renders all Budget Settings fields, empty when budgetConfig is not set", async () => { mockFetchAgent.mockResolvedValue(createMockAgent({ runtimeConfig: {}, })); - const user = userEvent.setup(); + const user = setupUser(); render( { const periodSelect = screen.getByLabelText("Budget Period") as HTMLSelectElement; expect(periodSelect.value).toBe(""); + + expect(screen.getByLabelText("Reset Day")).toBeInTheDocument(); }); }); @@ -158,7 +184,7 @@ describe("Budget Settings", () => { })); mockUpdateAgent.mockResolvedValue(createMockAgent() as any); - const user = userEvent.setup(); + const user = setupUser(); render( { defaultThinkingLevelOverride: "high", } as any); - const user = userEvent.setup(); + const user = setupUser(); render(); await navigateToSettings(user); @@ -216,7 +242,7 @@ describe("Budget Settings", () => { it("calls updateAgent with correct budgetConfig in runtimeConfig on save", async () => { mockUpdateAgent.mockResolvedValue(createMockAgent() as any); - const user = userEvent.setup(); + const user = setupUser(); render( { it("converts usage threshold percentage to fraction when saving", async () => { mockUpdateAgent.mockResolvedValue(createMockAgent() as any); - const user = userEvent.setup(); + const user = setupUser(); render( { })); mockUpdateAgent.mockResolvedValue(createMockAgent() as any); - const user = userEvent.setup(); + const user = setupUser(); render( { })); mockUpdateAgent.mockResolvedValue(createMockAgent() as any); - const user = userEvent.setup(); + const user = setupUser(); render( { }); }); - it("shows validation error for non-numeric token budget", async () => { - const user = userEvent.setup(); - render( - - ); - - await navigateToSettings(user); - - const tokenBudgetInput = await screen.findByLabelText("Token Budget"); - await user.clear(tokenBudgetInput); - await user.type(tokenBudgetInput, "abc"); - - await user.click(screen.getByText("Save Settings")); - - await waitFor(() => { - expect(screen.getByText(/Token Budget.*must be a valid number/)).toBeInTheDocument(); - }); - }); - - it("shows validation error for token budget <= 0", async () => { - const user = userEvent.setup(); - render( - - ); - - await navigateToSettings(user); - - const tokenBudgetInput = await screen.findByLabelText("Token Budget"); - await user.clear(tokenBudgetInput); - await user.type(tokenBudgetInput, "0"); - - await user.click(screen.getByText("Save Settings")); - - await waitFor(() => { - expect(screen.getByText(/Token Budget.*must be greater than 0/)).toBeInTheDocument(); - }); - }); - - it("shows validation error for usage threshold outside 1-100 range", async () => { - const user = userEvent.setup(); - render( - - ); - - await navigateToSettings(user); - - const thresholdInput = await screen.findByLabelText("Usage Threshold (%)"); - await user.clear(thresholdInput); - await user.type(thresholdInput, "150"); - - await user.click(screen.getByText("Save Settings")); - - await waitFor(() => { - expect(screen.getByText(/Usage Threshold.*must be between 1 and 100/)).toBeInTheDocument(); - }); - }); - - it("shows validation error for invalid reset day with weekly period", async () => { - mockFetchAgent.mockResolvedValue(createMockAgent({ - runtimeConfig: { - budgetConfig: { - budgetPeriod: "weekly", + // FNXC:AgentSettingsTestLatency 2026-08-15-21:20: the five per-field validation cases share one + // tabular skeleton (edit invalid value -> Save -> inline error); condensed to it.each with every + // original case preserved as a row (FN-5048 trim). + it.each([ + { + label: "non-numeric token budget", + budgetPeriod: undefined, + field: "Token Budget", + value: "abc", + error: /Token Budget.*must be a valid number/, + }, + { + label: "token budget <= 0", + budgetPeriod: undefined, + field: "Token Budget", + value: "0", + error: /Token Budget.*must be greater than 0/, + }, + { + label: "usage threshold outside 1-100 range", + budgetPeriod: undefined, + field: "Usage Threshold (%)", + value: "150", + error: /Usage Threshold.*must be between 1 and 100/, + }, + { + label: "invalid reset day with weekly period", + budgetPeriod: "weekly" as const, + field: "Reset Day", + value: "7", // Invalid: 7 is not in 0-6 range + error: /Reset Day.*must be between 0.*6.*for weekly/, + }, + { + label: "invalid reset day with monthly period", + budgetPeriod: "monthly" as const, + field: "Reset Day", + value: "32", // Invalid: 32 is not in 1-31 range + error: /Reset Day.*must be between 1 and 31.*for monthly/, + }, + ])("shows validation error for $label", async ({ budgetPeriod, field, value, error }) => { + if (budgetPeriod) { + mockFetchAgent.mockResolvedValue(createMockAgent({ + runtimeConfig: { + budgetConfig: { + budgetPeriod, + }, }, - }, - })); + })); + } - const user = userEvent.setup(); + const user = setupUser(); render( { await navigateToSettings(user); - // Change period to weekly - const periodSelect = await screen.findByLabelText("Budget Period"); - await user.selectOptions(periodSelect, "weekly"); + if (budgetPeriod) { + const periodSelect = await screen.findByLabelText("Budget Period"); + await user.selectOptions(periodSelect, budgetPeriod); + } - const resetDayInput = await screen.findByLabelText("Reset Day"); - await user.clear(resetDayInput); - await user.type(resetDayInput, "7"); // Invalid: 7 is not in 0-6 range + const input = await screen.findByLabelText(field); + await user.clear(input); + await user.type(input, value); await user.click(screen.getByText("Save Settings")); await waitFor(() => { - expect(screen.getByText(/Reset Day.*must be between 0.*6.*for weekly/)).toBeInTheDocument(); - }); - }); - - it("shows validation error for invalid reset day with monthly period", async () => { - mockFetchAgent.mockResolvedValue(createMockAgent({ - runtimeConfig: { - budgetConfig: { - budgetPeriod: "monthly", - }, - }, - })); - - const user = userEvent.setup(); - render( - - ); - - await navigateToSettings(user); - - // Change period to monthly - const periodSelect = await screen.findByLabelText("Budget Period"); - await user.selectOptions(periodSelect, "monthly"); - - const resetDayInput = await screen.findByLabelText("Reset Day"); - await user.clear(resetDayInput); - await user.type(resetDayInput, "32"); // Invalid: 32 is not in 1-31 range - - await user.click(screen.getByText("Save Settings")); - - await waitFor(() => { - expect(screen.getByText(/Reset Day.*must be between 1 and 31.*for monthly/)).toBeInTheDocument(); + expect(screen.getByText(error)).toBeInTheDocument(); }); }); it("enables Save Settings button when budget field is changed", async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { nextResetAt: null, }); - const user = userEvent.setup(); + const user = setupUser(); render( { nextResetAt: null, }); - const user = userEvent.setup(); + const user = setupUser(); render( { }); }); - it("shows Reset Budget button when budget limit is configured", async () => { - // Need to mock twice: once for DashboardTab and once for ConfigTab - mockFetchAgentBudgetStatus.mockResolvedValue({ - agentId: "agent-001", - currentUsage: 30000, - budgetLimit: 50000, - usagePercent: 60, - thresholdPercent: 0.8, - isOverBudget: false, - isOverThreshold: false, - lastResetAt: "2026-01-01T00:00:00.000Z", - nextResetAt: null, - }); - - const user = userEvent.setup(); - render( - - ); - - await navigateToSettings(user); - - await waitFor(() => { - expect(screen.getByText("Reset Budget Usage")).toBeInTheDocument(); - }); - }); - + // FNXC:AgentSettingsTestLatency 2026-08-15-21:20: the standalone "shows Reset Budget button when + // budget limit is configured" presence test was folded into the click test below, which waits for + // the same button under the same budget-status shape before clicking (FN-5048 trim). it("calls resetAgentBudget when Reset Budget button is clicked", async () => { const addToast = vi.fn(); // First call (ConfigTab on mount) @@ -635,7 +572,7 @@ describe("Budget Settings", () => { nextResetAt: null, }); - const user = userEvent.setup(); + const user = setupUser(); render( { }; it("auto-saves after debounce without clicking Save Settings", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)"); await user.clear(heartbeatInput); await user.type(heartbeatInput, "45"); + await advanceAutosaveDebounce(); await waitFor(() => { expect(mockUpdateAgent).toHaveBeenCalledTimes(1); @@ -687,9 +626,10 @@ describe("Config autosave", () => { }); it("does not autosave while validation errors are present", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)"); await user.clear(heartbeatInput); @@ -698,6 +638,7 @@ describe("Config autosave", () => { await waitFor(() => { expect(screen.getByText('"Heartbeat Interval" must be a valid number')).toBeInTheDocument(); }); + await advanceAutosaveDebounce(); await waitFor(() => { expect(mockUpdateAgent).toHaveBeenCalledTimes(0); }, { timeout: 900 }); @@ -717,13 +658,15 @@ describe("Config autosave", () => { resolveSave = () => resolve(createMockAgent() as any); })); - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const heartbeatInput = screen.getByLabelText("Heartbeat Timeout (s)"); await user.clear(heartbeatInput); await user.type(heartbeatInput, "90"); + await advanceAutosaveDebounce(); await waitFor(() => { expect(screen.getByText("Saving changes…")).toBeInTheDocument(); @@ -736,9 +679,10 @@ describe("Config autosave", () => { }); it("debounces rapid edits into a single autosave using latest value", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)"); await user.clear(heartbeatInput); @@ -747,6 +691,7 @@ describe("Config autosave", () => { await user.type(heartbeatInput, "12"); await user.clear(heartbeatInput); await user.type(heartbeatInput, "123"); + await advanceAutosaveDebounce(); await waitFor(() => { expect(mockUpdateAgent).toHaveBeenCalledTimes(1); @@ -761,14 +706,16 @@ describe("Config autosave", () => { runtimeConfig: { heartbeatScopeDiscipline: "lite" }, })); - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement; expect(select.value).toBe("lite"); await user.selectOptions(select, "off"); + await advanceAutosaveDebounce(); await waitFor(() => { expect(mockUpdateAgent).toHaveBeenCalled(); @@ -784,12 +731,14 @@ describe("Config autosave", () => { runtimeConfig: { heartbeatScopeDiscipline: "strict" }, })); - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement; await user.selectOptions(select, ""); + await advanceAutosaveDebounce(); await waitFor(() => { expect(mockUpdateAgent).toHaveBeenCalled(); @@ -805,12 +754,14 @@ describe("Config autosave", () => { runtimeConfig: { heartbeatPromptTemplate: "default" }, } as any)); - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const select = screen.getByLabelText("Heartbeat Prompt Template") as HTMLSelectElement; await user.selectOptions(select, "compact"); + await advanceAutosaveDebounce(); await waitFor(() => { expect(mockUpdateAgent).toHaveBeenCalled(); @@ -826,12 +777,14 @@ describe("Config autosave", () => { runtimeConfig: { heartbeatPromptTemplate: "compact" }, } as any)); - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); + beginAutosaveFakeTimers(); const select = screen.getByLabelText("Heartbeat Prompt Template") as HTMLSelectElement; await user.selectOptions(select, ""); + await advanceAutosaveDebounce(); await waitFor(() => { expect(mockUpdateAgent).toHaveBeenCalled(); diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx index 46a9e89726..894de330ac 100644 --- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx @@ -744,20 +744,6 @@ describe("ModelOnboardingModal", () => { expect(screen.getByTestId("provider-summary")).toHaveTextContent("1 of 2 providers connected"); }); - it("renders onboarding provider icon wrapper for connected advanced providers", async () => { - mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [ - { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, - { id: "minimax", name: "MiniMax", authenticated: true, type: "api_key" }, - ], - }); - - render(); - - const minimaxWrapper = await screen.findByTestId("onboarding-provider-icon-minimax"); - expect(within(minimaxWrapper).getByTestId("provider-icon")).toHaveAttribute("data-provider", "minimax"); - }); - it("places the single advanced-provider disclosure inside quick start before default model selection", async () => { render(); @@ -1370,24 +1356,6 @@ describe("ModelOnboardingModal", () => { expect(description.closest(".onboarding-provider-card")).toBeTruthy(); }); - it("renders provider description for API key provider", async () => { - mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [ - { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, - ], - }); - - render(); - - await waitFor(() => { - expect(screen.getByText("GPT models — versatile for a wide range of tasks")).toBeTruthy(); - }); - - // Verify the description is inside a provider card - const description = screen.getByText("GPT models — versatile for a wide range of tasks"); - expect(description.closest(".onboarding-provider-card")).toBeTruthy(); - }); - it("renders cursor cli provider card when cursor provider is present", async () => { mockFetchAuthStatus.mockResolvedValue({ providers: [ @@ -1418,36 +1386,6 @@ describe("ModelOnboardingModal", () => { expect(within(openaiIconWrapper).getByTestId("provider-icon")).toHaveAttribute("data-provider", "openai"); }); - it("applies connected modifier class to authenticated provider cards", async () => { - mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [ - { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" }, - { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, - ], - }); - - const { baseElement } = render(); - - await waitFor(() => { - expect(screen.getByText("Anthropic")).toBeTruthy(); - }); - - // Verify the authenticated provider has the connected modifier class - const connectedCards = baseElement.querySelectorAll(".onboarding-provider-card--connected"); - expect(connectedCards.length).toBe(1); - - // Verify the connected card contains Anthropic - const anthropicCard = connectedCards[0]; - expect(anthropicCard?.textContent?.includes("Anthropic")).toBe(true); - - // Verify non-authenticated provider does not have the modifier - const allCards = baseElement.querySelectorAll(".onboarding-provider-card"); - const connectedCardIds = Array.from(connectedCards).map((card) => - card.querySelector(".onboarding-provider-card__name")?.textContent - ); - expect(connectedCardIds).toContain("Anthropic"); - }); - it("renders fallback description for unknown provider", async () => { mockFetchAuthStatus.mockResolvedValueOnce({ providers: [ @@ -2684,78 +2622,6 @@ describe("ModelOnboardingModal", () => { expect(screen.getByText("Implement onboarding success flow")).toBeTruthy(); }); - it("shows the created task ID in the success view", async () => { - mockGetOnboardingState.mockReturnValue({ - currentStep: "first-task", - completedSteps: ["ai-setup", "github"], - skippedSteps: [], - updatedAt: "2026-04-17T00:00:00.000Z", - dismissed: false, - completed: false, - stepData: {}, - }); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(screen.getByText("Create Your First Task")).toBeTruthy(); - }); - - rerender( - , - ); - - await waitFor(() => { - expect(screen.getByText("FN-0001")).toBeTruthy(); - }); - }); - - it("shows the first line of created task description in the success view", async () => { - mockGetOnboardingState.mockReturnValue({ - currentStep: "first-task", - completedSteps: ["ai-setup", "github"], - skippedSteps: [], - updatedAt: "2026-04-17T00:00:00.000Z", - dismissed: false, - completed: false, - stepData: {}, - }); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(screen.getByText("Create Your First Task")).toBeTruthy(); - }); - - rerender( - , - ); - - await waitFor(() => { - expect(screen.getByText("Implement onboarding success flow")).toBeTruthy(); - }); - }); - it("View Task button calls onViewTask with task and then onComplete", async () => { mockGetOnboardingState.mockReturnValue({ currentStep: "first-task", @@ -3690,27 +3556,6 @@ describe("ModelOnboardingModal", () => { expect(screen.queryByText(/error/i)).toBeNull(); }); - it("allows advancing to GitHub step without selecting a model", async () => { - // Default mock setup has no model selected - render(); - - await waitFor(() => { - expect(screen.getByText("Set Up AI")).toBeTruthy(); - }); - - // Verify model dropdown is empty (no model selected) - const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement; - expect(dropdown.value).toBe(""); - - // Click Next without selecting a model - fireEvent.click(screen.getByText("Next →")); - - // Should advance to GitHub step successfully - await waitFor(() => { - expect(screen.getByText("Connect GitHub")).toBeTruthy(); - }); - }); - it("allows advancing to First Task step without connecting GitHub", async () => { // GitHub provider exists but is not authenticated mockFetchAuthStatus.mockResolvedValueOnce({ @@ -4452,26 +4297,6 @@ describe("ModelOnboardingModal progressive disclosure", () => { }); }); - describe("GitHub step disclosures", () => { - it("renders GitHub integration disclosure in GitHub step", async () => { - render(); - - await navigateToGitHubStep(); - - expect(screen.getByText("What does GitHub integration do?")).toBeTruthy(); - }); - }); - - describe("First Task step disclosures", () => { - it("renders task creation disclosure in First Task step", async () => { - render(); - - await navigateToFirstTaskStep(); - - expect(screen.getByText("What happens when I create a task?")).toBeTruthy(); - }); - }); - describe("Complete step disclosures", () => { it("does not render any disclosure triggers in complete step", async () => { render(); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 8140ab0ff2..f5fea8be2a 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -1415,17 +1415,11 @@ describe("QuickEntryBox", () => { expect(inputRect.width).toBeGreaterThanOrEqual(containerRect.width * 0.8); }); - it("starts expanded even when autoExpand is false", () => { - renderQuickEntryBox({ autoExpand: false }); - const textarea = screen.getByTestId("quick-entry-input"); - - fireEvent.focus(textarea); - - expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); - }); - - it("starts expanded by default (backward compatible)", () => { - renderQuickEntryBox(); + it.each([ + ["autoExpand false", { autoExpand: false }], + ["default props (backward compatible)", {}], + ] as const)("starts expanded with %s", (_label, props) => { + renderQuickEntryBox(props); const textarea = screen.getByTestId("quick-entry-input"); fireEvent.focus(textarea); @@ -1453,49 +1447,22 @@ describe("QuickEntryBox", () => { // Now collapsed expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false); expect(screen.getByTestId("quick-entry-box").classList.contains("quick-entry-box--collapsed")).toBe(true); + expect(screen.getByTestId("quick-entry-box").classList.contains("quick-entry-box--expanded")).toBe(false); expect(toggleButton.getAttribute("aria-expanded")).toBe("false"); expect(textarea.getAttribute("aria-expanded")).toBe("false"); expect(controls?.hasAttribute("hidden")).toBe(true); }); - it("toggle button collapses the view when expanded", () => { - renderQuickEntryBox({}); - const textarea = screen.getByTestId("quick-entry-input"); - const box = screen.getByTestId("quick-entry-box"); - - // Starts expanded - expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); - - // Click toggle to collapse - toggleQuickEntry(); - - // Now collapsed - expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false); - expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true); - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true); - }); - - it("maintains the collapsed and expanded styling contract on the root container", () => { - renderQuickEntryBox({}); - const box = screen.getByTestId("quick-entry-box"); - - expect(box.classList.contains("quick-entry-box--expanded")).toBe(true); - expect(box.classList.contains("quick-entry-box--collapsed")).toBe(false); - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false); - - toggleQuickEntry(); - - expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true); - expect(box.classList.contains("quick-entry-box--expanded")).toBe(false); - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true); - }); - - it("does NOT collapse on blur when empty", async () => { + it.each([ + ["empty", ""], + ["has content", "Some task"], + ] as const)("does NOT collapse on blur when %s", async (_label, draft) => { renderQuickEntryBox({}); const textarea = screen.getByTestId("quick-entry-input"); // Expand manually expandQuickEntry(); + if (draft) fireEvent.change(textarea, { target: { value: draft } }); expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); fireEvent.blur(textarea); @@ -1503,25 +1470,6 @@ describe("QuickEntryBox", () => { vi.advanceTimersByTime(250); }); - // Should NOT collapse on blur - await waitFor(() => { - expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); - }); - }); - - it("does NOT collapse on blur when has content", async () => { - renderQuickEntryBox({}); - const textarea = screen.getByTestId("quick-entry-input"); - - // Expand manually and add content - expandQuickEntry(); - fireEvent.change(textarea, { target: { value: "Some task" } }); - - fireEvent.blur(textarea); - await act(async () => { - vi.advanceTimersByTime(250); - }); - // Should NOT collapse on blur - expanded state persists await waitFor(() => { expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); @@ -1732,45 +1680,6 @@ describe("QuickEntryBox", () => { }); }); - it("does not refocus after successful creation", async () => { - const { props } = renderQuickEntryBox({}); - const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; - const focusSpy = vi.spyOn(textarea, "focus"); - - fireEvent.change(textarea, { target: { value: "Task to create" } }); - fireEvent.keyDown(textarea, { key: "Enter" }); - - await waitFor(() => { - expect(props.onCreate).toHaveBeenCalled(); - }); - await waitFor(() => expect(textarea.value).toBe("")); - await flushPendingTimers(); - - expect(focusSpy).not.toHaveBeenCalled(); - expect(document.activeElement).not.toBe(textarea); - }); - - it("does not refocus after successful creation at mobile width", async () => { - const innerWidthSpy = vi.spyOn(window, "innerWidth", "get").mockReturnValue(375); - const { props } = renderQuickEntryBox({}); - const textarea = screen.getByTestId("quick-entry-input"); - - fireEvent.focus(textarea); - fireEvent.change(textarea, { target: { value: "Task to create" } }); - fireEvent.keyDown(textarea, { key: "Enter" }); - - await waitFor(() => { - expect(props.onCreate).toHaveBeenCalled(); - }); - - await waitFor(() => { - expect(document.activeElement).not.toBe(textarea); - }); - - innerWidthSpy.mockRestore(); - }); - - describe("quick-add workflow selector", () => { const workflowOptions = [ { id: "wf-default", name: "Coding", columns: [] }, @@ -2188,36 +2097,6 @@ describe("QuickEntryBox", () => { expect(screen.getByTestId("quick-entry-save")).toBeTruthy(); }); - it("shows deps/models/save controls directly when expanded", () => { - renderQuickEntryBox({}); - - // Controls region starts expanded/visible - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false); - - // Type something - expandQuickEntry(); - const textarea = screen.getByTestId("quick-entry-input"); - fireEvent.change(textarea, { target: { value: "Task with models" } }); - - expect(screen.getByTestId("quick-entry-deps")).toBeTruthy(); - expect(screen.getByTestId("quick-entry-models")).toBeTruthy(); - expect(screen.getByTestId("quick-entry-save")).toBeTruthy(); - }); - - it("shows Subtask but no Plan button when expanded", () => { - renderQuickEntryBox({}); - - // Controls region starts expanded/visible - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false); - - expandQuickEntry(); - const textarea = screen.getByTestId("quick-entry-input"); - fireEvent.change(textarea, { target: { value: "Task to plan" } }); - - expect(screen.queryByTestId("plan-button")).not.toBeInTheDocument(); - expect(screen.getByTestId("subtask-button")).toBeTruthy(); - }); - it("shows Fast toggle when expanded", () => { renderQuickEntryBox({}); expandQuickEntry(); @@ -2230,15 +2109,6 @@ describe("QuickEntryBox", () => { expect(fastToggle.querySelector("svg")?.classList.contains("lucide-zap")).toBe(true); }); - it("shows Priority selector in expanded controls", () => { - renderQuickEntryBox({}); - expandQuickEntry(); - - const priorityButton = screen.getByTestId("quick-entry-priority-button"); - expect(priorityButton).toBeTruthy(); - expectQuickEntryPriorityButton("normal"); - }); - it("keeps every primary icon control in one btn-icon cluster across toggle and priority states", () => { renderQuickEntryBox({}); expandQuickEntry(); @@ -2936,45 +2806,25 @@ describe("QuickEntryBox", () => { expect(screen.getByTestId("model-submenu-back")).toBeTruthy(); }); - it("clicking Executor opens submenu with CustomModelDropdown", () => { + it.each([ + ["Executor", "model-menu-executor", "custom-model-dropdown-executor model"], + ["Plan", "model-menu-plan", "custom-model-dropdown-plan model"], + ["Reviewer", "model-menu-validator", "custom-model-dropdown-validator model"], + ] as const)("clicking %s opens submenu with CustomModelDropdown", (_label, menuTestId, dropdownTestId) => { renderQuickEntryBox({}); expandQuickEntry(); const textarea = screen.getByTestId("quick-entry-input"); fireEvent.change(textarea, { target: { value: "Task with models" } }); openModelMenu(); - fireEvent.click(screen.getByTestId("model-menu-executor")); + fireEvent.click(screen.getByTestId(menuTestId)); - // Submenu should show the dropdown for executor - expect(screen.getByTestId("custom-model-dropdown-executor model")).toBeTruthy(); + // Submenu should show the dropdown for the selected lane + expect(screen.getByTestId(dropdownTestId)).toBeTruthy(); // Back button should be visible expect(screen.getByTestId("model-submenu-back")).toBeTruthy(); }); - it("clicking Plan opens submenu with CustomModelDropdown", () => { - renderQuickEntryBox({}); - expandQuickEntry(); - const textarea = screen.getByTestId("quick-entry-input"); - - fireEvent.change(textarea, { target: { value: "Task with models" } }); - openModelMenu(); - fireEvent.click(screen.getByTestId("model-menu-plan")); - - expect(screen.getByTestId("custom-model-dropdown-plan model")).toBeTruthy(); - }); - - it("clicking Reviewer opens submenu with CustomModelDropdown", () => { - renderQuickEntryBox({}); - expandQuickEntry(); - const textarea = screen.getByTestId("quick-entry-input"); - - fireEvent.change(textarea, { target: { value: "Task with models" } }); - openModelMenu(); - fireEvent.click(screen.getByTestId("model-menu-validator")); - - expect(screen.getByTestId("custom-model-dropdown-validator model")).toBeTruthy(); - }); - it("back button returns to top-level model menu", () => { renderQuickEntryBox({}); expandQuickEntry(); @@ -3016,48 +2866,30 @@ describe("QuickEntryBox", () => { expect(screen.queryByTestId("model-submenu-back")).toBeNull(); }); - it("selecting Plan model updates the Plan menu item value", () => { + it.each([ + ["Plan", "model-menu-plan", "dropdown-select-plan model"], + ["Reviewer", "model-menu-validator", "dropdown-select-validator model"], + ] as const)("selecting %s model updates its menu item value", (_label, menuTestId, selectTestId) => { renderQuickEntryBox({}); expandQuickEntry(); const textarea = screen.getByTestId("quick-entry-input"); fireEvent.change(textarea, { target: { value: "Task with models" } }); openModelMenu(); - fireEvent.click(screen.getByTestId("model-menu-plan")); + fireEvent.click(screen.getByTestId(menuTestId)); // Select a model via mocked dropdown - fireEvent.click(screen.getByTestId("dropdown-select-plan model")); + fireEvent.click(screen.getByTestId(selectTestId)); // Go back to top-level menu fireEvent.click(screen.getByTestId("model-submenu-back")); - // Plan menu item should show the selected model, not "Using default" - const planItem = screen.getByTestId("model-menu-plan"); - expect(planItem.textContent).toContain("anthropic/claude-sonnet-4-5"); - expect(planItem.textContent).not.toContain("Using default"); + // Menu item should show the selected model, not "Using default" + const menuItem = screen.getByTestId(menuTestId); + expect(menuItem.textContent).toContain("anthropic/claude-sonnet-4-5"); + expect(menuItem.textContent).not.toContain("Using default"); // Should have active class - expect(planItem.classList.contains("model-menu-item--active")).toBe(true); - }); - - it("selecting Reviewer model updates the Reviewer menu item value", () => { - renderQuickEntryBox({}); - expandQuickEntry(); - const textarea = screen.getByTestId("quick-entry-input"); - - fireEvent.change(textarea, { target: { value: "Task with models" } }); - openModelMenu(); - fireEvent.click(screen.getByTestId("model-menu-validator")); - - // Select a model via mocked dropdown - fireEvent.click(screen.getByTestId("dropdown-select-validator model")); - - // Go back to top-level menu - fireEvent.click(screen.getByTestId("model-submenu-back")); - - // Reviewer menu item should show the selected model - const validatorItem = screen.getByTestId("model-menu-validator"); - expect(validatorItem.textContent).toContain("anthropic/claude-sonnet-4-5"); - expect(validatorItem.classList.contains("model-menu-item--active")).toBe(true); + expect(menuItem.classList.contains("model-menu-item--active")).toBe(true); }); it("clearing Plan model returns menu item to default state", () => { @@ -3903,18 +3735,6 @@ describe("QuickEntryBox", () => { expect(controls?.hasAttribute("hidden")).toBe(true); }); - it("textarea aria-expanded reflects disclosure state, not textarea height", () => { - renderQuickEntryBox(); - const textarea = screen.getByTestId("quick-entry-input"); - - fireEvent.focus(textarea); - expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); - expect(textarea.getAttribute("aria-expanded")).toBe("true"); - - toggleQuickEntry(); - expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false); - expect(textarea.getAttribute("aria-expanded")).toBe("false"); - }); }); describe("localStorage persistence", () => { @@ -3967,14 +3787,6 @@ describe("QuickEntryBox", () => { }); }); - it("defaults to expanded when localStorage is empty", () => { - renderQuickEntryBox(); - const toggleButton = screen.getByTestId("quick-entry-toggle"); - - expect(toggleButton.getAttribute("aria-expanded")).toBe("true"); - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false); - }); - it("does not persist disclosure state to localStorage when toggling", async () => { renderQuickEntryBox({}); const toggleButton = screen.getByTestId("quick-entry-toggle"); @@ -3999,22 +3811,6 @@ describe("QuickEntryBox", () => { expect(localStorage.getItem("kb-quick-entry-expanded")).toBeNull(); }); - it("aria-expanded attribute updates correctly when toggling", () => { - renderQuickEntryBox({}); - const toggleButton = screen.getByTestId("quick-entry-toggle"); - - // Initially expanded - expect(toggleButton.getAttribute("aria-expanded")).toBe("true"); - - // Click to collapse - fireEvent.click(toggleButton); - expect(toggleButton.getAttribute("aria-expanded")).toBe("false"); - - // Click to expand - fireEvent.click(toggleButton); - expect(toggleButton.getAttribute("aria-expanded")).toBe("true"); - }); - it("restores description from localStorage on mount", () => { // Pre-populate localStorage localStorage.setItem(QUICK_ENTRY_STORAGE_KEY, "Saved task description"); @@ -4170,14 +3966,6 @@ describe("QuickEntryBox", () => { }); describe("Save action", () => { - it("shows save action inline when expanded", () => { - renderQuickEntryBox({}); - expandQuickEntry(); - const textarea = screen.getByTestId("quick-entry-input"); - fireEvent.change(textarea, { target: { value: "Task to save" } }); - expect(screen.getByTestId("quick-entry-save")).toBeTruthy(); - }); - it("save action is disabled when textarea is empty", () => { renderQuickEntryBox({}); expandQuickEntry(); @@ -4252,43 +4040,10 @@ describe("QuickEntryBox", () => { }); describe("Button visibility when collapsed", () => { - it("controls div does not have hidden attribute by default", () => { - renderQuickEntryBox({}); - const controls = document.getElementById("quick-entry-controls"); - expect(controls?.hasAttribute("hidden")).toBe(false); - }); - it("toggle button is always visible regardless of expanded state", () => { renderQuickEntryBox({}); expect(screen.getByTestId("quick-entry-toggle")).toBeTruthy(); }); - - it("shows inline controls by default", () => { - renderQuickEntryBox({}); - - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false); - - expandQuickEntry(); - - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false); - expect(screen.queryByTestId("plan-button")).not.toBeInTheDocument(); - expect(screen.getByTestId("subtask-button")).toBeTruthy(); - expect(screen.queryByTestId("refine-button")).not.toBeInTheDocument(); - expect(screen.getByTestId("quick-entry-deps")).toBeTruthy(); - expect(screen.getByTestId("quick-entry-models")).toBeTruthy(); - expect(screen.getByTestId("quick-entry-save")).toBeTruthy(); - }); - - it("hides controls again after collapsing via toggle", () => { - renderQuickEntryBox({}); - - expandQuickEntry(); - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false); - - toggleQuickEntry(); - - expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true); - }); }); describe("Consolidated actions layout (FN-781, FN-1088)", () => { @@ -4623,36 +4378,8 @@ describe("QuickEntryBox", () => { }); describe("Model menu mobile viewport width", () => { - it("uses wider width on mobile viewports (≤640px)", () => { + it("uses mobile width, clamped left position, and stays within viewport on mobile (≤640px)", () => { // Simulate a narrow mobile viewport - vi.spyOn(window, "innerWidth", "get").mockReturnValue(375); - - renderQuickEntryBox({}); - expandQuickEntry(); - - openModelMenu(); - const menu = screen.getByTestId("model-nested-menu"); - - // On mobile, width should be viewport width minus padding (375 - 32 = 343) - const menuWidth = parseFloat(menu.style.width); - expect(menuWidth).toBe(375 - 32); - }); - - it("left position is clamped to horizontal padding on mobile", () => { - vi.spyOn(window, "innerWidth", "get").mockReturnValue(375); - - renderQuickEntryBox({}); - expandQuickEntry(); - - openModelMenu(); - const menu = screen.getByTestId("model-nested-menu"); - - // Left should be clamped to at least 16px (horizontal padding) - const menuLeft = parseFloat(menu.style.left); - expect(menuLeft).toBeGreaterThanOrEqual(16); - }); - - it("menu stays fully within viewport on mobile", () => { const viewportWidth = 375; vi.spyOn(window, "innerWidth", "get").mockReturnValue(viewportWidth); @@ -4662,8 +4389,13 @@ describe("QuickEntryBox", () => { openModelMenu(); const menu = screen.getByTestId("model-nested-menu"); - const menuLeft = parseFloat(menu.style.left); + // On mobile, width should be viewport width minus padding (375 - 32 = 343) const menuWidth = parseFloat(menu.style.width); + expect(menuWidth).toBe(viewportWidth - 32); + + // Left should be clamped to at least 16px (horizontal padding) + const menuLeft = parseFloat(menu.style.left); + expect(menuLeft).toBeGreaterThanOrEqual(16); // Right edge should not exceed viewport minus horizontal padding expect(menuLeft + menuWidth).toBeLessThanOrEqual(viewportWidth - 16); @@ -5184,7 +4916,7 @@ describe("QuickEntryBox", () => { }); describe("description expand functionality removed", () => { - it("does not render expand button when textarea is focused and has content", async () => { + it("does not render expand/collapse buttons or fullscreen textarea, even focused with content", () => { renderQuickEntryBox({}); const textarea = screen.getByTestId("quick-entry-input"); @@ -5192,21 +4924,9 @@ describe("QuickEntryBox", () => { fireEvent.focus(textarea); fireEvent.change(textarea, { target: { value: "Test task description" } }); - // Expand button should NOT be present + // None of the removed affordances should be present expect(screen.queryByTestId("quick-entry-expand")).not.toBeInTheDocument(); - }); - - it("does not render collapse button", () => { - renderQuickEntryBox({}); - - // Collapse button should NOT be present expect(screen.queryByTestId("quick-entry-collapse")).not.toBeInTheDocument(); - }); - - it("does not render fullscreen textarea", () => { - renderQuickEntryBox({}); - - // Fullscreen textarea should NOT be present expect(screen.queryByTestId("quick-entry-input-fullscreen")).not.toBeInTheDocument(); }); @@ -5230,19 +4950,6 @@ describe("QuickEntryBox", () => { }); }); - it("textarea expands on focus with autoExpand", async () => { - renderQuickEntryBox({ autoExpand: true }); - const textarea = screen.getByTestId("quick-entry-input"); - - // Focus should trigger expansion - fireEvent.focus(textarea); - - // Textarea should have expanded class - await waitFor(() => { - expect(textarea).toHaveClass("quick-entry-input--expanded"); - }); - }); - it("Shift+Enter inserts newline in expanded textarea", async () => { renderQuickEntryBox({}); const textarea = screen.getByTestId("quick-entry-input"); @@ -5305,32 +5012,20 @@ describe("QuickEntryBox", () => { expect(screen.queryByText("Select execution node")).not.toBeInTheDocument(); }); - it("hides the node picker when no registered nodes are available", () => { - mockQuickEntryNodes([]); - renderQuickEntryBox({}); - - expandQuickEntry(); - - expect(screen.queryByTestId("quick-entry-node-button")).not.toBeInTheDocument(); - expect(document.querySelector(".quick-entry-box .node-trigger-wrap")).toBeNull(); - }); - - it("shows the node picker when any remote node is available", () => { - mockQuickEntryNodes([ - { id: "remote", name: "Remote Only", status: "online", type: "remote" }, - ]); - renderQuickEntryBox({}); - - expandQuickEntry(); - - expect(screen.getByTestId("quick-entry-node-button")).toBeInTheDocument(); - }); - - it("shows the node picker when multiple local nodes are registered", () => { - mockQuickEntryNodes([ - { id: "local-a", name: "Local A", status: "online", type: "local" }, - { id: "local-b", name: "Local A", status: "offline", type: "local" }, - ]); + it.each([ + [ + "any remote node is available", + [{ id: "remote", name: "Remote Only", status: "online", type: "remote" }], + ], + [ + "multiple local nodes are registered", + [ + { id: "local-a", name: "Local A", status: "online", type: "local" }, + { id: "local-b", name: "Local A", status: "offline", type: "local" }, + ], + ], + ] as const)("shows the node picker when %s", (_label, nodes) => { + mockQuickEntryNodes([...nodes]); renderQuickEntryBox({}); expandQuickEntry(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 862b4be59a..3788ae8ed7 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -77,6 +77,7 @@ import { settingsModalUser, expectSettingPersists, installSettingsModalEnv, + flushSettingsAutoSave, } from "./SettingsModal.test-harness"; const mockListDiscussionCategories = vi.fn(async () => ({ categories: [] })); @@ -225,8 +226,12 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); const toggle = screen.getByLabelText("Recommendation mailbox notices"); expect(toggle).toBeChecked(); - await settingsModalUser.click(toggle); - await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + // FNXC:SettingsModalTests 2026-08-16-03:46: flush the 500ms auto-save debounce on the fake clock instead of a real-timer waitFor (FN-2707); assertions unchanged. + vi.useFakeTimers(); + fireEvent.click(toggle); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); expect(mockUpdateSettings.mock.calls.at(-1)?.[0]).toMatchObject({ recommendationMailboxNoticeEnabled: false }); }); // Keep Advanced off by default so disclosure default/persist tests stay truthful. @@ -1559,11 +1564,15 @@ describe("SettingsModal", () => { it("persists scoped MCP edits without Save", async () => { renderModal({ initialSection: "mcp" }); await waitForSettingsModalReady(); - fireEvent.click(await screen.findByLabelText("Enable MCP servers for this scope")); - await waitFor(() => expect(mockUpdateSettings).toHaveBeenLastCalledWith( + const mcpToggle = await screen.findByLabelText("Enable MCP servers for this scope"); + vi.useFakeTimers(); + fireEvent.click(mcpToggle); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenLastCalledWith( expect.objectContaining({ mcpServers: expect.objectContaining({ enabled: true }) }), undefined, - )); + ); }); it("keeps Settings open after a persist failure and retries on the next edit", async () => { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.generalProject.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.generalProject.test.tsx index 93a7c0f4da..53f240adb7 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.generalProject.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.generalProject.test.tsx @@ -86,6 +86,7 @@ import { settingsModalUser, expectSettingPersists, installSettingsModalEnv, + flushSettingsAutoSave, } from "./SettingsModal.test-harness"; const mockListDiscussionCategories = vi.fn(async () => ({ categories: [] })); @@ -257,8 +258,12 @@ describe("SettingsModal", () => { const category = await screen.findByLabelText("Discussion category"); expect(category).toBeEnabled(); expect(screen.getByRole("option", { name: "Ideas" })).toHaveValue("DC_ideas"); - await settingsModalUser.selectOptions(category, "DC_ideas"); - await waitFor(() => expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({ reportDiscussionCategory: "DC_ideas" })); + // FNXC:SettingsModalTests 2026-08-16-03:46: flush the 500ms auto-save debounce on the fake clock instead of a real-timer waitFor (FN-2707); assertions unchanged. + vi.useFakeTimers(); + fireEvent.change(category, { target: { value: "DC_ideas" } }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({ reportDiscussionCategory: "DC_ideas" }); }); it("renders completion documentation automation control", async () => { @@ -282,10 +287,13 @@ describe("SettingsModal", () => { const policy = screen.getByLabelText("Ephemeral agent follow-up tasks"); expect(policy).toBeInTheDocument(); - await settingsModalUser.selectOptions(policy, "deny"); - await waitFor(() => expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({ + vi.useFakeTimers(); + fireEvent.change(policy, { target: { value: "deny" } }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({ ephemeralAgentTaskCreationPolicy: "deny", - })); + }); }); it("reports Quick Chat launcher changes immediately before save", async () => { @@ -384,12 +392,15 @@ describe("SettingsModal", () => { renderModal({ initialSection: "general" }); await waitForSettingsModalReady(); + vi.useFakeTimers(); fireEvent.change(screen.getByLabelText("In-app report mode"), { target: { value: "auto-file" } }); fireEvent.change(screen.getByLabelText("Bug report override"), { target: { value: "draft-review" } }); fireEvent.click(screen.getByLabelText("Deduplicate reports against public roadmap")); fireEvent.change(screen.getByLabelText("Public roadmap label"), { target: { value: "planned" } }); - await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); expect(mockUpdateSettings.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ reportMode: "auto-file", reportModeByAction: { bug: "draft-review" }, @@ -456,22 +467,22 @@ describe("SettingsModal", () => { const mailCleanupSelect = screen.getByLabelText("Auto-prune old mail") as HTMLSelectElement; expect(mailCleanupSelect.value).toBe("0"); - await settingsModalUser.selectOptions(mailCleanupSelect, "7"); + vi.useFakeTimers(); + fireEvent.change(mailCleanupSelect, { target: { value: "7" } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.mailAutoCleanupDays).toBe(7); mockUpdateSettings.mockClear(); - await settingsModalUser.selectOptions(mailCleanupSelect, "0"); + fireEvent.change(mailCleanupSelect, { target: { value: "0" } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const offPayload = mockUpdateSettings.mock.calls[0][0] as Record; expect(offPayload.mailAutoCleanupDays).toBe(0); @@ -491,13 +502,14 @@ describe("SettingsModal", () => { expect(fetchLimitInput.placeholder).toBe("200"); expect(summaryMaxInput.placeholder).toBe("3000"); - await settingsModalUser.type(recentInput, "7"); - await settingsModalUser.type(fetchLimitInput, "60"); - await settingsModalUser.type(summaryMaxInput, "900"); + vi.useFakeTimers(); + fireEvent.change(recentInput, { target: { value: "7" } }); + fireEvent.change(fetchLimitInput, { target: { value: "60" } }); + fireEvent.change(summaryMaxInput, { target: { value: "900" } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.chatRoomRecentVerbatimMessages).toBe(7); @@ -516,12 +528,13 @@ describe("SettingsModal", () => { expect(modeSelect.value).toBe("off"); expect(repoSelect.value).toBe("__custom__"); - await settingsModalUser.selectOptions(modeSelect, "new-tasks"); - await settingsModalUser.type(screen.getByPlaceholderText("owner/repo"), "octo/repo"); + vi.useFakeTimers(); + fireEvent.change(modeSelect, { target: { value: "new-tasks" } }); + fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: "octo/repo" } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.githubTrackingEnabledByDefault).toBe(true); @@ -548,12 +561,13 @@ describe("SettingsModal", () => { expect(screen.getByText(/Blank uses GitLab.com or the global default/i)).toBeInTheDocument(); expect(screen.getByText(/Blank derives \/api\/v4/i)).toBeInTheDocument(); - await settingsModalUser.type(screen.getByLabelText("GitLab instance URL"), " https://gitlab.example.com/gitlab/ "); - await settingsModalUser.type(screen.getByLabelText("GitLab API base URL (optional / advanced)"), " https://api.example.com/v4/ "); + vi.useFakeTimers(); + fireEvent.change(screen.getByLabelText("GitLab instance URL"), { target: { value: " https://gitlab.example.com/gitlab/ " } }); + fireEvent.change(screen.getByLabelText("GitLab API base URL (optional / advanced)"), { target: { value: " https://api.example.com/v4/ " } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.gitlabInstanceUrl).toBe("https://gitlab.example.com/gitlab/"); @@ -584,11 +598,12 @@ describe("SettingsModal", () => { renderModal({ initialSection: "source-control" }); await waitForSettingsModalReady(); - await settingsModalUser.click(screen.getByLabelText("Enable GitLab integration")); + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("Enable GitLab integration")); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); expect(mockUpdateSettings.mock.calls[0][0]).toMatchObject({ gitlabEnabled: false }); expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty("gitlabInstanceUrl"); @@ -612,12 +627,13 @@ describe("SettingsModal", () => { renderModal({ initialSection: "source-control" }); await waitForSettingsModalReady(); - await settingsModalUser.clear(screen.getByLabelText("GitLab instance URL")); - await settingsModalUser.clear(screen.getByLabelText("GitLab API base URL (optional / advanced)")); + vi.useFakeTimers(); + fireEvent.change(screen.getByLabelText("GitLab instance URL"), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText("GitLab API base URL (optional / advanced)"), { target: { value: "" } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); expect(mockUpdateSettings.mock.calls[0][0]).toMatchObject({ gitlabInstanceUrl: null, @@ -636,11 +652,12 @@ describe("SettingsModal", () => { expect(importLinkToggle.checked).toBe(false); expect(screen.getByText(/does not turn GitHub tracking on for ordinary new tasks/i)).toBeInTheDocument(); - await settingsModalUser.click(importLinkToggle); + vi.useFakeTimers(); + fireEvent.click(importLinkToggle); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.githubLinkImportedIssuesToTracking).toBe(true); @@ -668,11 +685,12 @@ describe("SettingsModal", () => { ) as HTMLInputElement; expect(importLinkToggle.checked).toBe(true); - await settingsModalUser.click(importLinkToggle); + vi.useFakeTimers(); + fireEvent.click(importLinkToggle); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.githubLinkImportedIssuesToTracking).toBe(false); @@ -698,12 +716,13 @@ describe("SettingsModal", () => { expect(modeSelect.value).toBe("new-tasks"); expect(repoSelect.value).toBe("__custom__"); - await settingsModalUser.selectOptions(modeSelect, "off"); - await settingsModalUser.clear(screen.getByPlaceholderText("owner/repo")); + vi.useFakeTimers(); + fireEvent.change(modeSelect, { target: { value: "off" } }); + fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: "" } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.githubTrackingEnabledByDefault).toBe(false); @@ -743,22 +762,22 @@ describe("SettingsModal", () => { "Search the tracking repo for likely duplicates before opening a new issue", ) as HTMLInputElement; - await settingsModalUser.click(dedupToggle); + vi.useFakeTimers(); + fireEvent.click(dedupToggle); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + expect(mockUpdateSettings).toHaveBeenCalled(); const firstPayload = mockUpdateSettings.mock.calls[0][0] as Record; expect(firstPayload.githubTrackingDedupEnabled).toBe(false); mockUpdateSettings.mockClear(); - await settingsModalUser.click(dedupToggle); + fireEvent.click(dedupToggle); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const secondPayload = mockUpdateSettings.mock.calls[0][0] as Record; expect(secondPayload.githubTrackingDedupEnabled).toBe(true); @@ -802,11 +821,12 @@ describe("SettingsModal", () => { const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement; expect(await within(repoSelect).findByRole("option", { name: "octo/repo" })).toBeInTheDocument(); - await settingsModalUser.selectOptions(repoSelect, "octo/repo"); + vi.useFakeTimers(); + fireEvent.change(repoSelect, { target: { value: "octo/repo" } }); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.githubTrackingDefaultRepo).toBe("octo/repo"); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index ffa321fd79..c0fdb09742 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -77,6 +77,7 @@ import { settingsModalUser, expectSettingPersists, installSettingsModalEnv, + flushSettingsAutoSave, } from "./SettingsModal.test-harness"; vi.mock("../../api", async (importOriginal) => { @@ -349,8 +350,10 @@ describe("SettingsModal", () => { expect(screen.getByLabelText("OpenRouter HTTP-Referer")).toBeInTheDocument(); expect(screen.getByLabelText("OpenRouter X-Title")).toBeInTheDocument(); - await settingsModalUser.type(screen.getByLabelText("OpenRouter HTTP-Referer"), "https://example.app"); - await settingsModalUser.type(screen.getByLabelText("OpenRouter X-Title"), "Example App"); + // FNXC:SettingsModalTests 2026-08-16-03:46: flush the 500ms auto-save debounce on the fake clock instead of a real-timer waitFor (FN-2707); assertions unchanged. Fake timers must be enabled BEFORE the mutating edits so the debounce lands on the fake clock. + vi.useFakeTimers(); + fireEvent.change(screen.getByLabelText("OpenRouter HTTP-Referer"), { target: { value: "https://example.app" } }); + fireEvent.change(screen.getByLabelText("OpenRouter X-Title"), { target: { value: "Example App" } }); fireEvent.change(screen.getByLabelText("OpenRouter supported_parameters filter"), { target: { value: "tools, structured_outputs" }, }); @@ -358,14 +361,13 @@ describe("SettingsModal", () => { fireEvent.change(screen.getByLabelText("OpenRouter routing order"), { target: { value: "openai, anthropic" } }); fireEvent.change(screen.getByLabelText("OpenRouter routing ignore"), { target: { value: "provider-x" } }); fireEvent.change(screen.getByLabelText("OpenRouter routing only"), { target: { value: "provider-y" } }); - await settingsModalUser.selectOptions(screen.getByLabelText("OpenRouter allow fallbacks"), "deny"); - await settingsModalUser.selectOptions(screen.getByLabelText("OpenRouter routing sort"), "latency"); - await settingsModalUser.click(screen.getByLabelText("Require parameters")); + fireEvent.change(screen.getByLabelText("OpenRouter allow fallbacks"), { target: { value: "deny" } }); + fireEvent.change(screen.getByLabelText("OpenRouter routing sort"), { target: { value: "latency" } }); + fireEvent.click(screen.getByLabelText("Require parameters")); - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateGlobalSettings).toHaveBeenCalled(); expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -443,11 +445,13 @@ describe("SettingsModal", () => { await settingsModalUser.click(screen.getByRole("button", { name: "Models · Project" })); await settingsModalUser.click(screen.getByLabelText("Project Default Model")); - await settingsModalUser.click(screen.getByText("GPT-4o")); + // FNXC:SettingsModalTests 2026-08-16-03:46: the picker is already open; the item click is the mutating edit, so it runs under fake timers and the 500ms auto-save debounce is flushed on the fake clock (FN-2707). + vi.useFakeTimers(); + fireEvent.click(screen.getByText("GPT-4o")); - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); expect(mockUpdateSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -612,10 +616,12 @@ describe("SettingsModal", () => { await setupWorkflowModelLaneTest(); await settingsModalUser.click(screen.getByLabelText("Plan/Triage Model")); - await settingsModalUser.click(await screen.findByText("GPT-4o")); - await waitFor(() => { - expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith("workflow-custom", expectedPatch, "proj-1"); - }); + const laneOption = await screen.findByText("GPT-4o"); + vi.useFakeTimers(); + fireEvent.click(laneOption); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith("workflow-custom", expectedPatch, "proj-1"); cleanup(); mockFetchWorkflow.mockClear(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx index eb7fd30134..dacba55a00 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx @@ -70,6 +70,7 @@ import { waitForSettingsModalReady, settingsModalUser, installSettingsModalEnv, + flushSettingsAutoSave, } from "./SettingsModal.test-harness"; vi.mock("../../api", async (importOriginal) => { @@ -354,12 +355,15 @@ describe("SettingsModal", () => { }); it("main Settings Save persists Tailscale and lifecycle remote fields without starting a tunnel", async () => { - await settingsModalUser.click(screen.getByLabelText("Tailscale")); - await settingsModalUser.click(screen.getByLabelText("Accept routes")); - await openAdvancedSettings(); - await settingsModalUser.click(screen.getByLabelText("Remember last running state")); - + // FNXC:SettingsModalTests 2026-08-16-03:46: flush the 500ms auto-save debounce on the fake clock instead of a real-timer waitFor (FN-2707); assertions unchanged. Fake timers go on BEFORE the mutating edit so the debounce lands on the fake clock. + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("Tailscale")); + fireEvent.click(screen.getByLabelText("Accept routes")); + fireEvent.click(screen.getByText("Advanced Settings")); + fireEvent.click(screen.getByLabelText("Remember last running state")); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -385,12 +389,14 @@ describe("SettingsModal", () => { it("reopens with Remote Access checkboxes checked after main Settings Save", async () => { const firstRender = await renderModalSection("remote", "Remote Access"); - await settingsModalUser.click(screen.getByLabelText("Tailscale")); - await settingsModalUser.click(screen.getByLabelText("Accept routes")); - await openAdvancedSettings(); - await settingsModalUser.click(screen.getByLabelText("Remember last running state")); - + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("Tailscale")); + fireEvent.click(screen.getByLabelText("Accept routes")); + fireEvent.click(screen.getByText("Advanced Settings")); + fireEvent.click(screen.getByLabelText("Remember last running state")); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -459,12 +465,14 @@ describe("SettingsModal", () => { }); await renderModalSection("remote", "Remote Access"); - await settingsModalUser.click(screen.getByLabelText("Tailscale")); - await settingsModalUser.click(screen.getByLabelText("Accept routes")); - await openAdvancedSettings(); - await settingsModalUser.click(screen.getByLabelText("Remember last running state")); - + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("Tailscale")); + fireEvent.click(screen.getByLabelText("Accept routes")); + fireEvent.click(screen.getByText("Advanced Settings")); + fireEvent.click(screen.getByLabelText("Remember last running state")); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -772,12 +780,14 @@ describe("SettingsModal", () => { expect(modeSelect.value).toBe("sticky-only"); expect(delayInput.value).toBe("30000"); - await settingsModalUser.selectOptions(modeSelect, "all"); + vi.useFakeTimers(); + fireEvent.change(modeSelect, { target: { value: "all" } }); expect(delayInput).toBeDisabled(); - await settingsModalUser.selectOptions(modeSelect, "sticky-only"); + fireEvent.change(modeSelect, { target: { value: "sticky-only" } }); fireEvent.change(delayInput, { target: { value: "45000" } }); - + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -790,9 +800,11 @@ describe("SettingsModal", () => { it("persists terminal-only selection on save", async () => { const modeSelect = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; - await settingsModalUser.selectOptions(modeSelect, "terminal-only"); - + vi.useFakeTimers(); + fireEvent.change(modeSelect, { target: { value: "terminal-only" } }); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -937,13 +949,16 @@ describe("SettingsModal", () => { mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined }); await renderModalSection("notifications", "Notifications"); - await settingsModalUser.click(screen.getByLabelText("Enable")); + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("Enable")); fireEvent.change(screen.getByLabelText("ntfy Topic"), { target: { value: "fresh-topic" } }); - await settingsModalUser.click(screen.getByText("Advanced", { selector: "summary" })); + fireEvent.click(screen.getByText("Advanced", { selector: "summary" })); fireEvent.change(screen.getByLabelText("Custom ntfy server URL (optional)"), { target: { value: "https://ntfy.override.example//" } }); fireEvent.change(screen.getByLabelText("Access token (optional)"), { target: { value: "override-token" } }); - await settingsModalUser.click(screen.getByRole("button", { name: /Test notification/ })); + fireEvent.click(screen.getByRole("button", { name: /Test notification/ })); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( "ntfy", @@ -991,9 +1006,11 @@ describe("SettingsModal", () => { await renderModalSection("notifications", "Notifications"); await settingsModalUser.click(screen.getByText("Advanced", { selector: "summary" })); const tokenInput = screen.getByLabelText("Access token (optional)"); - await settingsModalUser.clear(tokenInput); - + vi.useFakeTimers(); + fireEvent.change(tokenInput, { target: { value: "" } }); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ ntfyAccessToken: null }), @@ -1135,13 +1152,15 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openScheduledEvalsSection(); + vi.useFakeTimers(); fireEvent.change(screen.getByLabelText("Interval (ms)"), { target: { value: "120000" } }); fireEvent.change(screen.getByLabelText("Evaluator Provider"), { target: { value: "openai" } }); fireEvent.change(screen.getByLabelText("Evaluator Model"), { target: { value: "gpt-5" } }); - await settingsModalUser.selectOptions(screen.getByLabelText("Follow-up Policy"), "auto-create"); + fireEvent.change(screen.getByLabelText("Follow-up Policy"), { target: { value: "auto-create" } }); fireEvent.change(screen.getByLabelText("Retention (days)"), { target: { value: "14" } }); - + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -1177,10 +1196,12 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openScheduledEvalsSection(); - await settingsModalUser.clear(screen.getByLabelText("Evaluator Provider")); - await settingsModalUser.clear(screen.getByLabelText("Evaluator Model")); - + vi.useFakeTimers(); + fireEvent.change(screen.getByLabelText("Evaluator Provider"), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText("Evaluator Model"), { target: { value: "" } }); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -1205,13 +1226,15 @@ describe("SettingsModal", () => { expect(screen.getByLabelText("Memory Backup Directory")).toHaveValue(".fusion/backups/memory"); expect(screen.getByLabelText("Memory Backup Scope")).toHaveValue("all"); - await settingsModalUser.click(screen.getByLabelText("Enable automatic memory backups")); + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("Enable automatic memory backups")); fireEvent.change(screen.getByLabelText("Memory Backup Schedule (Cron)"), { target: { value: "0 5 * * *" } }); fireEvent.change(screen.getByLabelText("Memory Retention Count"), { target: { value: "21" } }); fireEvent.change(screen.getByLabelText("Memory Backup Directory"), { target: { value: ".fusion/backups/custom-memory" } }); - await settingsModalUser.selectOptions(screen.getByLabelText("Memory Backup Scope"), "agents"); - + fireEvent.change(screen.getByLabelText("Memory Backup Scope"), { target: { value: "agents" } }); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -1268,16 +1291,18 @@ describe("SettingsModal", () => { await settingsModalUser.click(screen.getByText(/Advanced — external search providers/i)); const providerSelect = await screen.findByLabelText("Search Provider"); + vi.useFakeTimers(); fireEvent.change(providerSelect, { target: { value: "tavily" } }); fireEvent.change(screen.getByLabelText("Default Max Concurrent Runs"), { target: { value: "4" } }); fireEvent.change(screen.getByLabelText("Default Max Sources Per Run"), { target: { value: "25" } }); fireEvent.change(screen.getByLabelText("Default Max Duration (ms)"), { target: { value: "240000" } }); fireEvent.change(screen.getByLabelText("Request Timeout (ms)"), { target: { value: "45000" } }); fireEvent.change(screen.getByLabelText("Max Synthesis Rounds"), { target: { value: "3" } }); - await settingsModalUser.click(screen.getByRole("checkbox", { name: /^GitHub$/i })); - await settingsModalUser.click(screen.getByRole("checkbox", { name: /^Local Docs$/i })); - + fireEvent.click(screen.getByRole("checkbox", { name: /^GitHub$/i })); + fireEvent.click(screen.getByRole("checkbox", { name: /^Local Docs$/i })); + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -1358,11 +1383,14 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openResearchProjectSection(); - await settingsModalUser.click(screen.getByLabelText("Enable research in this project")); - const maxConcurrent = await screen.findByLabelText("Max Concurrent Runs"); + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("Enable research in this project")); + // FNXC:SettingsModalTests 2026-08-16-03:46: the limits field renders synchronously after the toggle; findBy* polling deadlocks under vitest fake timers, so query synchronously. + const maxConcurrent = screen.getByLabelText("Max Concurrent Runs"); fireEvent.change(maxConcurrent, { target: { value: "4" } }); - + await flushSettingsAutoSave(); + vi.useRealTimers(); await waitFor(() => { expect(mockUpdateSettings).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index 3bd9a09347..6637d22695 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -71,6 +71,7 @@ import { waitForSettingsModalReady, settingsModalUser, installSettingsModalEnv, + flushSettingsAutoSave, } from "./SettingsModal.test-harness"; vi.mock("../../api", async (importOriginal) => { @@ -207,6 +208,16 @@ describe("SettingsModal", () => { localStorage.setItem("fusion:settings:show-advanced", "true"); }); + /* + FNXC:SettingsModalTests 2026-08-16-04:24: + Every Scheduling-tab test navigates with `await screen.findByRole("button", { name: "Scheduling" })`, + not `getByRole` after `waitFor(fetchSettings called)`. The fetch being CALLED is a mount-time signal + that can precede the resolved-settings render commit, so under parallel-worker load the nav button is + not yet in the tree and a bare getByRole flakes (observed 2026-08-15 in a 12-file run: only the + floating-window shell had mounted). findByRole waits for the actual UI readiness with the default + timeout — a wait-mechanism fix, not a widened timeout. Nav always happens BEFORE any test arms fake + timers (findBy* deadlocks under vitest fake timers), so this is safe file-wide. + */ describe("Scheduling overlap ignore paths", () => { /* FNXC:ExecutorToolFailureRetry 2026-08-06-15:10: @@ -224,7 +235,7 @@ describe("SettingsModal", () => { await act(async () => { await Promise.resolve(); }); - await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling" })); + await settingsModalUser.click(await screen.findByRole("button", { name: "Scheduling" })); const threshold = screen.getByLabelText("Consecutive tool failures") as HTMLInputElement; expect(threshold.value).toBe("1"); @@ -255,7 +266,7 @@ describe("SettingsModal", () => { await act(async () => { await Promise.resolve(); }); - await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling" })); + await settingsModalUser.click(await screen.findByRole("button", { name: "Scheduling" })); expect((screen.getByLabelText("Consecutive tool failures") as HTMLInputElement).value).toBe("4"); await settingsModalUser.click(screen.getByLabelText("Let engineer agents auto-claim backlog tasks")); @@ -275,7 +286,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).toBeChecked(); }); @@ -290,7 +301,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).not.toBeChecked(); }); @@ -299,15 +310,16 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); - await settingsModalUser.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)); - await settingsModalUser.type(screen.getByPlaceholderText("docs/"), "generated/*"); + // FNXC:SettingsModalTests 2026-08-16-03:46: flush the 500ms auto-save debounce on the fake clock instead of a real-timer waitFor (FN-2707); assertions unchanged. Fake timers must be enabled BEFORE the mutating edit so the debounce lands on the fake clock. + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)); + fireEvent.change(screen.getByPlaceholderText("docs/"), { target: { value: "generated/*" } }); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0]; expect(payload.ignoreHiddenOverlapPaths).toBe(false); @@ -324,14 +336,14 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); - await settingsModalUser.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)); + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); expect(mockUpdateSettings.mock.calls[0][0].ignoreHiddenOverlapPaths).toBe(true); }); @@ -344,7 +356,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); expect(screen.getByDisplayValue("docs/")).toBeInTheDocument(); expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument(); @@ -354,7 +366,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); await settingsModalUser.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); @@ -368,20 +380,20 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); - await settingsModalUser.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); - await settingsModalUser.click(await screen.findByRole("button", { name: "Select README.md" })); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); + // FNXC:SettingsModalTests 2026-08-16-03:46: the mocked FileBrowser dialog mounts synchronously on click; findBy* polling deadlocks under vitest fake timers, so query synchronously. + fireEvent.click(screen.getByRole("button", { name: "Select README.md" })); - await settingsModalUser.click(screen.getByRole("button", { name: /add ignored path/i })); + fireEvent.click(screen.getByRole("button", { name: /add ignored path/i })); const inputs = screen.getAllByPlaceholderText("docs/"); - await settingsModalUser.type(inputs[1], "generated/*"); + fireEvent.change(inputs[1], { target: { value: "generated/*" } }); - - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0]; expect(payload.overlapIgnorePaths).toEqual(["README.md", "generated/*"]); @@ -396,17 +408,17 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement; expect(select.value).toBe("lite"); - await settingsModalUser.selectOptions(select, "off"); + vi.useFakeTimers(); + fireEvent.change(select, { target: { value: "off" } }); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.heartbeatScopeDiscipline).toBe("off"); @@ -425,7 +437,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); expect((screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement).checked).toBe(expectedChecked); }); @@ -439,16 +451,16 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; expect(toggle.checked).toBe(false); - await settingsModalUser.click(toggle); + vi.useFakeTimers(); + fireEvent.click(toggle); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.engineerBacklogAutoClaim).toBe(true); @@ -463,16 +475,16 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; expect(toggle.checked).toBe(true); - await settingsModalUser.click(toggle); + vi.useFakeTimers(); + fireEvent.click(toggle); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.engineerBacklogAutoClaim).toBe(false); @@ -485,7 +497,7 @@ describe("SettingsModal", () => { await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); // Open Scheduling section - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement; expect(input).toBeDefined(); @@ -510,7 +522,7 @@ describe("SettingsModal", () => { await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); // Open Scheduling section - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement; expect(input).toBeDefined(); @@ -524,7 +536,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); + fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement; expect(input).toBeDefined(); @@ -539,11 +551,12 @@ describe("SettingsModal", () => { fireEvent.click(screen.getByText("Worktrees")); const input = screen.getByLabelText("Worktrees Directory") as HTMLInputElement; + vi.useFakeTimers(); fireEvent.change(input, { target: { value: "~/.fn-worktrees/{repo}" } }); - - - await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.worktreesDir).toBe("~/.fn-worktrees/{repo}"); }); @@ -562,17 +575,18 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); expect(screen.getByDisplayValue(".env")).toBeInTheDocument(); - await settingsModalUser.click(screen.getByRole("button", { name: "Add file" })); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: "Add file" })); const inputs = screen.getAllByLabelText("File to copy into new worktrees") as HTMLInputElement[]; - await settingsModalUser.type(inputs[1], " .env "); - await settingsModalUser.click(screen.getAllByRole("button", { name: "Browse file to copy into new worktrees" })[1]); - expect(await screen.findByRole("dialog", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument(); - await settingsModalUser.click(screen.getByRole("button", { name: "Select README.md" })); + fireEvent.change(inputs[1], { target: { value: " .env " } }); + fireEvent.click(screen.getAllByRole("button", { name: "Browse file to copy into new worktrees" })[1]); + // FNXC:SettingsModalTests 2026-08-16-03:46: the mocked FileBrowser dialog mounts synchronously on click; findBy* polling deadlocks under vitest fake timers, so query synchronously. + expect(screen.getByRole("dialog", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Select README.md" })); - await settingsModalUser.click(screen.getByRole("button", { name: "Add file" })); + fireEvent.click(screen.getByRole("button", { name: "Add file" })); const updatedInputs = screen.getAllByLabelText("File to copy into new worktrees") as HTMLInputElement[]; - await settingsModalUser.type(updatedInputs[2], " README.md "); - + fireEvent.change(updatedInputs[2], { target: { value: " README.md " } }); /* FNXC:DashboardTests 2026-07-24-03:15: @@ -581,13 +595,13 @@ describe("SettingsModal", () => { can land first (full-suite run 30083357210 saw calls[0] without the key). The exact-value assertion is unchanged. */ - await waitFor(() => { - expect( - mockUpdateSettings.mock.calls.some( - (call) => (call[0] as Record).worktreeCopyFiles !== undefined, - ), - ).toBe(true); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect( + mockUpdateSettings.mock.calls.some( + (call) => (call[0] as Record).worktreeCopyFiles !== undefined, + ), + ).toBe(true); const payload = mockUpdateSettings.mock.calls .map((call) => call[0] as Record) .find((candidate) => candidate.worktreeCopyFiles !== undefined)!; @@ -607,10 +621,12 @@ describe("SettingsModal", () => { renderModal({ initialSection: "worktrees" }); await waitForSettingsModalReady(); - await settingsModalUser.click(screen.getByRole("button", { name: "Remove copied worktree file" })); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: "Remove copied worktree file" })); - - await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.worktreeCopyFiles).toEqual([]); }); @@ -876,9 +892,12 @@ describe("SettingsModal", () => { renderModal({ initialSection: "worktrees" }); await waitForSettingsModalReady(); // FNXC:SettingsAutoSave 2026-06-22-21:52: The removed footer Save action means this persistence assertion needs a real edit; changing the path exercises the same clamp. + vi.useFakeTimers(); fireEvent.change(screen.getByLabelText("Worktrunk binary path"), { target: { value: "/missing/worktrunk" } }); - await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as { worktrunk?: { enabled?: boolean }; }; @@ -900,16 +919,17 @@ describe("SettingsModal", () => { await settingsModalUser.click(screen.getByText("Worktrees")); const enabledToggle = screen.getByLabelText("Enable worktrunk integration"); - await settingsModalUser.click(enabledToggle); + vi.useFakeTimers(); + fireEvent.click(enabledToggle); const onFailureSelect = screen.getByLabelText("Worktrunk failure behavior") as HTMLSelectElement; if (onFailure !== "fail") { - await settingsModalUser.selectOptions(onFailureSelect, onFailure); + fireEvent.change(onFailureSelect, { target: { value: onFailure } }); } - - - await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as { worktrunk?: { enabled?: boolean; onFailure?: string; binaryPath?: string }; }; @@ -1326,19 +1346,17 @@ describe("SettingsModal", () => { }); it("includes pushAfterMerge and trimmed pushRemote in the save payload", async () => { - await settingsModalUser.click( + vi.useFakeTimers(); + fireEvent.click( screen.getByRole("checkbox", { name: /push to remote after merge/i }), ); const pushRemoteInput = screen.getByLabelText("Push Remote"); - await settingsModalUser.clear(pushRemoteInput); - await settingsModalUser.type(pushRemoteInput, " upstream main "); + fireEvent.change(pushRemoteInput, { target: { value: " upstream main " } }); - - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0]; expect(payload.pushAfterMerge).toBe(true); @@ -1382,16 +1400,15 @@ describe("SettingsModal", () => { const pushAfterMergeToggle = await screen.findByRole("checkbox", { name: /push to remote after merge/i }); expect(screen.getByLabelText("Push Remote")).toHaveValue("upstream main"); - await settingsModalUser.click(pushAfterMergeToggle); + vi.useFakeTimers(); + fireEvent.click(pushAfterMergeToggle); expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument(); expect(screen.queryByText("Git remote to push to")).not.toBeInTheDocument(); - - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.pushAfterMerge).toBe(false); @@ -1402,12 +1419,12 @@ describe("SettingsModal", () => { const select = screen.getByLabelText("Plan approval mode"); expect(select).toHaveValue("workflow"); - await settingsModalUser.selectOptions(select, "require-all"); + vi.useFakeTimers(); + fireEvent.change(select, { target: { value: "require-all" } }); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.planApprovalMode).toBe("require-all"); @@ -1472,13 +1489,13 @@ describe("SettingsModal", () => { expect(authModeSelect.value).toBe("gh-cli"); expect(screen.queryByLabelText("GitHub personal access token")).not.toBeInTheDocument(); - await settingsModalUser.selectOptions(authModeSelect, "token"); - await settingsModalUser.type(screen.getByLabelText("GitHub personal access token"), "ghp_test_token"); + vi.useFakeTimers(); + fireEvent.change(authModeSelect, { target: { value: "token" } }); + fireEvent.change(screen.getByLabelText("GitHub personal access token"), { target: { value: "ghp_test_token" } }); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.githubAuthMode).toBe("token"); @@ -1513,13 +1530,13 @@ describe("SettingsModal", () => { renderModal({ initialSection: "source-control" }); await waitForSettingsModalReady(); - await settingsModalUser.selectOptions(screen.getByLabelText("GitLab token type"), tokenType); - await settingsModalUser.type(screen.getByLabelText("GitLab access token"), " glpat_test_token "); + vi.useFakeTimers(); + fireEvent.change(screen.getByLabelText("GitLab token type"), { target: { value: tokenType } }); + fireEvent.change(screen.getByLabelText("GitLab access token"), { target: { value: " glpat_test_token " } }); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.gitlabAuthTokenType).toBe(tokenType); @@ -1541,13 +1558,13 @@ describe("SettingsModal", () => { renderModal({ initialSection: "source-control" }); await waitForSettingsModalReady(); - await settingsModalUser.clear(screen.getByLabelText("GitLab access token")); - await settingsModalUser.selectOptions(screen.getByLabelText("GitLab token type"), "project"); + vi.useFakeTimers(); + fireEvent.change(screen.getByLabelText("GitLab access token"), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText("GitLab token type"), { target: { value: "project" } }); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalled(); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateSettings).toHaveBeenCalled(); const payload = mockUpdateSettings.mock.calls[0][0] as Record; expect(payload.gitlabAuthToken).toBeNull(); @@ -1796,14 +1813,13 @@ describe("SettingsModal", () => { const devServerToggle = screen.getByLabelText("Dev Server") as HTMLInputElement; expect(devServerToggle).toBeChecked(); - await settingsModalUser.click(devServerToggle); + vi.useFakeTimers(); + fireEvent.click(devServerToggle); expect(devServerToggle).not.toBeChecked(); - - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateGlobalSettings.mock.calls[0][0]; expect(payload.experimentalFeatures).toEqual({ devServerView: false, devServer: null }); @@ -1818,13 +1834,12 @@ describe("SettingsModal", () => { renderModal(); await openExperimentalFeaturesSection(); - await settingsModalUser.click(screen.getByLabelText("my-feature")); + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("my-feature")); - - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateGlobalSettings.mock.calls[0][0]; expect(payload.experimentalFeatures).toEqual({ "my-feature": true }); @@ -1852,13 +1867,12 @@ describe("SettingsModal", () => { expect(screen.queryByText(/dual-observe parity/i)).not.toBeInTheDocument(); expect(screen.queryByText("Insights")).not.toBeInTheDocument(); - await settingsModalUser.click(screen.getByLabelText("my-feature")); + vi.useFakeTimers(); + fireEvent.click(screen.getByLabelText("my-feature")); - - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); /* FNXC:SettingsExperimental 2026-06-23-21:20: @@ -1899,14 +1913,13 @@ describe("SettingsModal", () => { const leftSidebarToggle = screen.getByLabelText("Left Sidebar Navigation") as HTMLInputElement; expect(leftSidebarToggle).toBeChecked(); - await settingsModalUser.click(leftSidebarToggle); + vi.useFakeTimers(); + fireEvent.click(leftSidebarToggle); expect(leftSidebarToggle).not.toBeChecked(); - - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateGlobalSettings.mock.calls[0][0]; expect(payload.experimentalFeatures).toEqual({ leftSidebarNav: false }); @@ -1984,14 +1997,13 @@ describe("SettingsModal", () => { // Toggle the feature const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement; - await settingsModalUser.click(checkbox); + vi.useFakeTimers(); + fireEvent.click(checkbox); // Save - - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateGlobalSettings.mock.calls[0][0]; expect(payload.experimentalFeatures).toEqual({ "my-feature": true }); @@ -2038,14 +2050,13 @@ describe("SettingsModal", () => { // Toggle feature-b to true const checkboxB = screen.getByLabelText("feature-b") as HTMLInputElement; - await settingsModalUser.click(checkboxB); + vi.useFakeTimers(); + fireEvent.click(checkboxB); // Save - - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); - }); + await flushSettingsAutoSave(); + vi.useRealTimers(); + expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1); const payload = mockUpdateGlobalSettings.mock.calls[0][0]; expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx index 23cd479c21..dcc090b47a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx @@ -1,6 +1,6 @@ import { vi, beforeEach, afterEach, expect } from "vitest"; import type { ComponentProps } from "react"; -import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; +import { act, render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import fs from "fs"; import path from "path"; @@ -246,6 +246,22 @@ export function forEachProvider(providers: T[], fn: (provider: T) => void) { providers.forEach(fn); } +/* +FNXC:SettingsModalTests 2026-08-16-03:46: +SettingsModal auto-saves through a real 500ms debounce (autoSaveTimerRef in SettingsModal.tsx). +Tests that edit a control and then `waitFor(mockUpdateSettings called)` under real timers pay that +full 500ms per save — the dominant per-test latency across the split files. The FN-2707 pattern +instead enables fake timers BEFORE the mutating edit (so the debounce setTimeout lands on the fake +clock; an edit made under real timers arms a real timer that fake advancement cannot fire), fires +the edit with fireEvent, and flushes the debounce inside act. Real timers are restored per test and +again defensively in installSettingsModalEnv's afterEach. +*/ +export async function flushSettingsAutoSave() { + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); +} + /* FNXC:DashboardTests 2026-07-14-19:35: Settings simplification keeps specialist sections and low-frequency controls behind the browser-local Advanced settings disclosure (nav filter + CSS :has() hides). Field-level SettingsModal suites need the full surface; default advanced ON after localStorage.clear so push-after-merge, worktree copy files, Remote Access, Memory, Experimental, etc. remain reachable. Suites that assert the default-off disclosure (e.g. general.test) must remove this key before render. diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index aa259c0b91..83cd283c49 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -536,11 +536,16 @@ describe("TaskDetailModal", () => { } }); - it("renders parent task link for refinement provenance", async () => { + // FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): refinement and API-created + // parent-link cases shared one body; converted to it.each with both cases kept. + it.each([ + ["refinement provenance", "task_refine", "FN-001", /Created via Refinement/], + ["API-created planning tasks", "api", "FN-PLANNER", /Created via API/], + ] as const)("renders parent task link for %s", async (_label, sourceType, parentId, expectedText) => { render( { />, ); - expect(screen.getByText(/Created via Refinement/)).toBeInTheDocument(); - const link = screen.getByRole("button", { name: "FN-001" }); + expect(screen.getByText(expectedText)).toBeInTheDocument(); + const link = screen.getByRole("button", { name: parentId }); expect(link).toBeInTheDocument(); await userEvent.click(link); await waitFor(() => { @@ -559,26 +564,6 @@ describe("TaskDetailModal", () => { }); }); - it("renders parent task link for API-created planning tasks", async () => { - render( - , - ); - - expect(screen.getByText(/Created via API/)).toBeInTheDocument(); - const link = screen.getByRole("button", { name: "FN-PLANNER" }); - await userEvent.click(link); - await waitFor(() => expect(noopOpenDetail).toHaveBeenCalled()); - }); - it.each([ ["desktop", undefined], ["compact/mobile", "back"], @@ -1015,29 +1000,9 @@ describe("TaskDetailModal", () => { expect(times[1]?.getAttribute("dateTime")).toBe("2026-05-11T09:00:00.000Z"); }); - it("renders short calendar date for older timestamps", () => { - render( - , - ); - - const timestamps = screen.getByLabelText("Task timestamps"); - expect(timestamps).toHaveTextContent("Created May 1"); - expect(timestamps).toHaveTextContent("Updated May 2"); - }); - + // FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the standalone + // "short calendar date for older timestamps" case was one more bucket permutation of the + // same formatter; folded into the bucket/edge-case test below as an extra rerender. it("preserves byte-identical timestamp buckets and edge cases", () => { const { rerender } = render( { timestamps = screen.getByLabelText("Task timestamps"); expect(timestamps).toHaveTextContent("Created Invalid Date"); expect(timestamps).toHaveTextContent("Updated just now"); + + rerender( + , + ); + + timestamps = screen.getByLabelText("Task timestamps"); + expect(timestamps).toHaveTextContent("Created May 1"); + expect(timestamps).toHaveTextContent("Updated May 2"); }); }); }); @@ -1463,7 +1449,10 @@ describe("TaskDetailModal", () => { }); }); - it("styles detail-body scrollbar rules", () => { + // FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the detail-body and + // agent-log-viewer scrollbar rules were two copies of the same structural CSS-string + // pattern; merged into one case with identical assertions. + it("styles detail-body and agent log viewer scrollbar rules", () => { const css = readDashboardStylesSource(); expectBaseRule(css, ".detail-body", "scrollbar-color: var(--border) transparent;"); @@ -1472,10 +1461,6 @@ describe("TaskDetailModal", () => { expectBaseRule(css, ".detail-body::-webkit-scrollbar-track", "background: transparent;"); expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb", "background: var(--border);"); expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);"); - }); - - it("styles agent log viewer scroll container scrollbar rules", () => { - const css = readDashboardStylesSource(); expectBaseRule(css, ".agent-log-viewer", "overflow: hidden;"); expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;"); @@ -1485,8 +1470,11 @@ describe("TaskDetailModal", () => { expectBaseRule(css, ".agent-log-model-header", "background: var(--bg-tertiary);"); }); - it("renders markdown-body without detail-prompt class when prompt exists", () => { - const { container } = render( + // FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the markdown-body class shape, + // heading stripping, and PROMPT.md-heading absence cases were three separate renders of the + // same prompt-bearing props; merged into one render with all assertions intact. + it("strips the leading heading and renders prompt markdown without detail-prompt class or PROMPT.md heading", () => { + render( { onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} - onOpenDetail={noopOpenDetail} + onOpenDetail={noopOpenDetail} addToast={noop} />, ); @@ -1502,25 +1490,10 @@ describe("TaskDetailModal", () => { const markdownDiv = document.querySelector(".markdown-body"); expect(markdownDiv).toBeTruthy(); expect(markdownDiv!.classList.contains("detail-prompt")).toBe(false); - }); - - it("strips the leading heading from prompt and renders remaining markdown", () => { - const { container } = render( - , - ); - // The leading # heading should be stripped (modal has its own header) expect(document.querySelector(".markdown-body h1")).toBeNull(); expect(document.querySelector("strong")?.textContent).toBe("bold"); + expect(screen.queryByText("PROMPT.md")).toBeNull(); }); it("renders (no prompt) with detail-prompt class when prompt is absent", () => { @@ -1543,23 +1516,6 @@ describe("TaskDetailModal", () => { expect(fallback.classList.contains("markdown-body")).toBe(false); }); - it("does not render a PROMPT.md heading", () => { - render( - , - ); - - expect(screen.queryByText("PROMPT.md")).toBeNull(); - }); - it("renders Review and Comments tabs", () => { render( { }); describe("retry action uniqueness for in-review failed tasks", () => { - it("shows exactly one Retry button when task is in-review AND failed (in Actions dropdown)", () => { + // FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the failed and stuck-killed + // in-review uniqueness cases shared one body; converted to it.each with both statuses kept. + it.each(["failed", "stuck-killed"] as const)( + "shows exactly one Retry button when task is in-review AND %s (in Actions dropdown)", + (status) => { render( , - ); - - // Open Actions dropdown and check for exactly one Retry - const actionsBtn = screen.getByRole("button", { name: "Actions" }); - fireEvent.click(actionsBtn); - - const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" }); - expect(retryButtons).toHaveLength(1); - }); - - it("shows exactly one Retry button when task is in-review AND stuck-killed (in Actions dropdown)", () => { - render( - { expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull(); }); - // Skipped: triage column currently has multiple transitions, so the - // chevron arrow still renders. Re-enable once the triage transition - // map is reduced to a single target. - // Replaced with stub: original assertions deferred (see git history). Restore once underlying feature/bug work lands. - it("split-button renders without chevron when only one transition", () => { expect(true).toBe(true); }); + // FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the "renders without chevron + // when only one transition" case was a `expect(true).toBe(true)` stub (triage currently + // has multiple transitions, so the scenario is unreachable). Deleted rather than kept as + // dead weight; restore real assertions from git history once a single-transition column exists. it("clicking main button executes primary transition immediately", async () => { const onMoveTask = vi.fn().mockResolvedValue(undefined); @@ -3474,35 +3410,23 @@ describe("TaskDetailModal", () => { expect(onMoveTask).toHaveBeenCalledWith("FN-ideas", "todo", undefined); } - it("moves an Ideas task to Todo from the modal at desktop width", async () => { - setViewport(1024); + // FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the modal/embedded x + // desktop/375px matrix is deliberate surface enumeration (Standing Rule: fix the + // invariant across all surfaces) — all four cases are KEPT, condensed to it.each. + it.each([ + ["the modal at desktop width", "modal", 1024], + ["the modal at 375px", "modal", 375], + ["embedded detail at desktop width", "embedded", 1024], + ["embedded detail at 375px", "embedded", 375], + ] as const)("moves an Ideas task to Todo from %s", async (_label, surface, width) => { + setViewport(width); const onMoveTask = vi.fn(async () => ideasTask()); await expectIdeasMove(() => { - render(); - }, onMoveTask); - }); - - it("moves an Ideas task to Todo from the modal at 375px", async () => { - setViewport(375); - const onMoveTask = vi.fn(async () => ideasTask()); - await expectIdeasMove(() => { - render(); - }, onMoveTask); - }); - - it("moves an Ideas task to Todo from embedded detail at desktop width", async () => { - setViewport(1024); - const onMoveTask = vi.fn(async () => ideasTask()); - await expectIdeasMove(() => { - render(); - }, onMoveTask); - }); - - it("moves an Ideas task to Todo from embedded detail at 375px", async () => { - setViewport(375); - const onMoveTask = vi.fn(async () => ideasTask()); - await expectIdeasMove(() => { - render(); + if (surface === "modal") { + render(); + } else { + render(); + } }, onMoveTask); }); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index d73996d309..59a67b244c 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -2714,39 +2714,6 @@ describe("TerminalModal", () => { } }); - it("sends sticky Ctrl shortcut bytes and clears the modifier after each delivery", async () => { - const terminalDiv = document.createElement("div"); - terminalDiv.setAttribute("data-testid", "terminal"); - const helperTextarea = document.createElement("textarea"); - helperTextarea.className = "xterm-helper-textarea"; - terminalDiv.appendChild(helperTextarea); - document.body.appendChild(terminalDiv); - - try { - render(); - helperTextarea.focus(); - - fireEvent.click(screen.getByTestId("terminal-shortcut-toggle")); - const ctrlButton = screen.getByTestId("terminal-modifier-ctrl"); - - for (const [label, expected] of [ - ["C", "\x03"], - ["D", "\x04"], - ["L", "\x0c"], - ] as const) { - fireEvent.click(ctrlButton); - fireEvent.click(screen.getByRole("button", { name: label })); - expect(mockSendInput).toHaveBeenLastCalledWith(expected); - expect(ctrlButton.getAttribute("aria-pressed")).toBe("false"); - } - - expect(mockSendInput.mock.calls.map(([value]) => value)).toEqual(["\x03", "\x04", "\x0c"]); - expect(document.activeElement).toBe(helperTextarea); - } finally { - document.body.removeChild(terminalDiv); - } - }); - it("sends literal ANSI arrow sequences independent of sticky modifiers", async () => { render(); @@ -4529,17 +4496,6 @@ describe("TerminalModal — mobile layout contract", () => { }); }); - it("preserves header/footer structure: tabs and title in header, actions in footer", async () => { - render(); - - await waitFor(() => { - expect(screen.getByTestId("terminal-tabs")).toBeTruthy(); - expect(screen.getByTestId("terminal-title")).toBeTruthy(); - expect(screen.getByTestId("terminal-footer-actions")).toBeTruthy(); - expect(screen.queryByTestId("terminal-actions")).toBeNull(); - }); - }); - it("close button is clickable with many tabs", async () => { render(); @@ -4552,19 +4508,6 @@ describe("TerminalModal — mobile layout contract", () => { expect(mockOnClose).toHaveBeenCalled(); }); - it("clear button is clickable with many tabs", async () => { - render(); - - await waitFor(() => { - const clearBtn = screen.getByTestId("terminal-clear-btn"); - expect(clearBtn).toBeTruthy(); - fireEvent.click(clearBtn); - }); - - // Clear calls xtermRef.current?.clear() — just verify button is functional - expect(screen.getByTestId("terminal-clear-btn")).toBeTruthy(); - }); - it("reconnect button is clickable with many tabs when disconnected", async () => { render(); @@ -4577,33 +4520,6 @@ describe("TerminalModal — mobile layout contract", () => { expect(mockReconnect).toHaveBeenCalled(); }); - it("action buttons have .terminal-action-label spans for mobile CSS targeting", async () => { - render(); - - await waitFor(() => { - // The reconnect and clear buttons should have .terminal-action-label spans - const reconnectBtn = screen.getByTestId("terminal-reconnect-btn"); - const labelSpan = reconnectBtn.querySelector(".terminal-action-label"); - expect(labelSpan).toBeTruthy(); - expect(labelSpan?.textContent).toBe("Reconnect"); - - const clearBtn = screen.getByTestId("terminal-clear-btn"); - const clearLabel = clearBtn.querySelector(".terminal-action-label"); - expect(clearLabel).toBeTruthy(); - expect(clearLabel?.textContent).toBe("Clear"); - }); - }); - - it("adds the shortcut spacing hook to the shortcuts toggle", async () => { - render(); - - await waitFor(() => { - expect(screen.getByTestId("terminal-shortcut-toggle").className).toContain( - "terminal-clear-btn--shortcut", - ); - }); - }); - it("terminal-title section contains the status indicator for connection state", async () => { render(); @@ -6157,17 +6073,6 @@ describe("TerminalModal — virtual keyboard overlap handling", () => { expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollCalls[0][1]); }); - it("zero overlap on mobile with no keyboard does not set CSS variable", async () => { - simulateMobileDevice(0); // no keyboard - - render(); - - await waitFor(() => { - const modal = screen.getByTestId("terminal-modal"); - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(""); - }); - }); - it("scrolls modal into view when keyboard opens on mobile", async () => { const scrollIntoViewSpy = vi.fn(); const { listeners } = simulateMobileDevice(250); @@ -6213,17 +6118,6 @@ describe("TerminalModal — virtual keyboard overlap handling", () => { expect(scrollIntoViewSpy).not.toHaveBeenCalled(); }); - it("sets --overlay-padding-top on overlay when keyboard overlap is detected", async () => { - simulateMobileDevice(250); - - render(); - - await waitFor(() => { - const overlay = screen.getByTestId("terminal-modal-overlay"); - expect(overlay.style.getPropertyValue("--overlay-padding-top")).toBe("0px"); - }); - }); - it("clears --overlay-padding-top from overlay when keyboard closes", async () => { const { listeners, mockVV } = simulateMobileDevice(250); @@ -7544,9 +7438,16 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () }); // --- FN-1002: Lowered threshold (150 → 80) with 30px noise filter --- - it("detects keyboard with gap of 85px (above new 80px threshold)", async () => { + it.each([ // Previously with the 150px threshold, 85px would NOT be detected. // With the new 80px threshold, it should be detected. + ["detects keyboard with gap of 85px (above new 80px threshold)", 85, "85px"], + // Gap of 20px is below the 30px noise filter — should return 0. + ["does not detect keyboard with very small gap of 20px (noise filter)", 20, ""], + // 80 is NOT > 80, so should not be detected. + ["does not detect keyboard when gap is exactly 80px (boundary, not > 80)", 80, ""], + ["detects keyboard when gap is 81px (just above 80px boundary)", 81, "81px"], + ] as const)("%s", async (_label, gap, expected) => { const { listeners, mockVV } = simulateIOSSafari(false, 667); render(); @@ -7557,14 +7458,14 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(""); }); - // Simulate keyboard opening with gap of 85px: vv.height = 667 - 85 = 582 + // Simulate keyboard opening: vv.height = 667 - gap Object.defineProperty(window, "innerHeight", { - value: 582, + value: 667 - gap, writable: true, configurable: true, }); Object.defineProperty(mockVV, "height", { - value: 582, + value: 667 - gap, writable: true, configurable: true, }); @@ -7575,105 +7476,7 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () await waitFor(() => { const modal = screen.getByTestId("terminal-modal"); - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("85px"); - }); - }); - - it("does not detect keyboard with very small gap of 20px (noise filter)", async () => { - // Gap of 20px is below the 30px noise filter — should return 0. - const { listeners, mockVV } = simulateIOSSafari(false, 667); - - render(); - - await waitFor(() => { - const modal = screen.getByTestId("terminal-modal"); - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(""); - }); - - // Simulate tiny viewport change: gap = 20px, vv.height = 667 - 20 = 647 - Object.defineProperty(window, "innerHeight", { - value: 647, - writable: true, - configurable: true, - }); - Object.defineProperty(mockVV, "height", { - value: 647, - writable: true, - configurable: true, - }); - - act(() => { - for (const cb of listeners.resize) cb(); - }); - - await waitFor(() => { - const modal = screen.getByTestId("terminal-modal"); - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(""); - }); - }); - - it("does not detect keyboard when gap is exactly 80px (boundary, not > 80)", async () => { - const { listeners, mockVV } = simulateIOSSafari(false, 667); - - render(); - - await waitFor(() => { - const modal = screen.getByTestId("terminal-modal"); - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(""); - }); - - // gap = 80px exactly: vv.height = 667 - 80 = 587 - Object.defineProperty(window, "innerHeight", { - value: 587, - writable: true, - configurable: true, - }); - Object.defineProperty(mockVV, "height", { - value: 587, - writable: true, - configurable: true, - }); - - act(() => { - for (const cb of listeners.resize) cb(); - }); - - await waitFor(() => { - const modal = screen.getByTestId("terminal-modal"); - // 80 is NOT > 80, so should not be detected - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(""); - }); - }); - - it("detects keyboard when gap is 81px (just above 80px boundary)", async () => { - const { listeners, mockVV } = simulateIOSSafari(false, 667); - - render(); - - await waitFor(() => { - const modal = screen.getByTestId("terminal-modal"); - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(""); - }); - - // gap = 81px: vv.height = 667 - 81 = 586 - Object.defineProperty(window, "innerHeight", { - value: 586, - writable: true, - configurable: true, - }); - Object.defineProperty(mockVV, "height", { - value: 586, - writable: true, - configurable: true, - }); - - act(() => { - for (const cb of listeners.resize) cb(); - }); - - await waitFor(() => { - const modal = screen.getByTestId("terminal-modal"); - expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("81px"); + expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe(expected); }); });