test(dashboard): trim permutation residue and fake the settings auto-save debounce

Slow-test trim, DELETE + dashboard OPTIMIZE tiers. Deletions follow the
docs/testing.md taxonomy exactly — 40 tests removed across
TaskDetailModal.rendering (134→129), QuickEntryBox (300→280),
TerminalModal (247→240), ModelOnboardingModal (205→197), and
AgentDetailView.settings (27→25): byte-identical field-presence twins,
CSS-class permutation residue, and strict-subset interaction copies,
with every FN-tagged describe and user-noticeable behavior kept and
condensed matrices converted to it.each with all cases retained. The
real payoff is the FN-2707 latency pass: SettingsModal's real 500ms
auto-save debounce (and AgentDetailView's 700ms config debounce) now
flush under fake timers via a shared flushSettingsAutoSave harness
export — SettingsModal splits 102.3s→63.6s. Also fixed (not appeased) a
pre-existing race the speedup exposed: nav-button lookups now
findByRole after the settings promise resolves, file-wide. Verified:
1235 tests green across the touched set, tsconfig.app.json typecheck
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-15 21:31:29 -07:00
parent d4d13e2fa5
commit 447e1963ef
11 changed files with 644 additions and 1354 deletions

View File

@@ -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<typeof userEvent.setup>) => {
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
@@ -119,12 +141,14 @@ describe("Budget Settings", () => {
});
});
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(
<AgentDetailView
agentId="agent-001"
@@ -144,6 +168,8 @@ describe("Budget Settings", () => {
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(
<AgentDetailView
agentId="agent-001"
@@ -205,7 +231,7 @@ describe("Budget Settings", () => {
defaultThinkingLevelOverride: "high",
} as any);
const user = userEvent.setup();
const user = setupUser();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(
<AgentDetailView
agentId="agent-001"
@@ -256,7 +282,7 @@ describe("Budget Settings", () => {
it("converts usage threshold percentage to fraction when saving", async () => {
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
const user = setupUser();
render(
<AgentDetailView
agentId="agent-001"
@@ -292,7 +318,7 @@ describe("Budget Settings", () => {
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
const user = setupUser();
render(
<AgentDetailView
agentId="agent-001"
@@ -332,7 +358,7 @@ describe("Budget Settings", () => {
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
const user = setupUser();
render(
<AgentDetailView
agentId="agent-001"
@@ -358,85 +384,57 @@ describe("Budget Settings", () => {
});
});
it("shows validation error for non-numeric token budget", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
@@ -447,58 +445,24 @@ describe("Budget Settings", () => {
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
@@ -532,7 +496,7 @@ describe("Budget Settings", () => {
nextResetAt: null,
});
const user = userEvent.setup();
const user = setupUser();
render(
<AgentDetailView
agentId="agent-001"
@@ -561,7 +525,7 @@ describe("Budget Settings", () => {
nextResetAt: null,
});
const user = userEvent.setup();
const user = setupUser();
render(
<AgentDetailView
agentId="agent-001"
@@ -578,36 +542,9 @@ describe("Budget Settings", () => {
});
});
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
@@ -670,13 +607,15 @@ describe("Config autosave", () => {
};
it("auto-saves after debounce without clicking Save Settings", async () => {
const user = userEvent.setup();
const user = setupUser();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
beginAutosaveFakeTimers();
const select = screen.getByLabelText("Heartbeat Prompt Template") as HTMLSelectElement;
await user.selectOptions(select, "");
await advanceAutosaveDebounce();
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalled();

View File

@@ -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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
@@ -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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={null}
projectId="proj_123" />,
);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
rerender(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={createdTaskMock}
projectId="proj_123" />,
);
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(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={null}
projectId="proj_123" />,
);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
rerender(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={createdTaskMock}
projectId="proj_123" />,
);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);

View File

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

View File

@@ -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 () => {

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 <instance>\/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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(payload.githubTrackingDefaultRepo).toBe("octo/repo");

View File

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

View File

@@ -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({

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>).worktreeCopyFiles !== undefined,
),
).toBe(true);
});
await flushSettingsAutoSave();
vi.useRealTimers();
expect(
mockUpdateSettings.mock.calls.some(
(call) => (call[0] as Record<string, unknown>).worktreeCopyFiles !== undefined,
),
).toBe(true);
const payload = mockUpdateSettings.mock.calls
.map((call) => call[0] as Record<string, unknown>)
.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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 });

View File

@@ -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<T>(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.

View File

@@ -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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: "FN-001" })}
task={makeTask({ sourceType, sourceParentTaskId: parentId })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -550,8 +555,8 @@ describe("TaskDetailModal", () => {
/>,
);
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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType: "api", sourceParentTaskId: "FN-PLANNER" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-01T12:00:00.000Z",
updatedAt: "2026-05-02T12:00:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
@@ -1080,6 +1045,27 @@ describe("TaskDetailModal", () => {
timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created Invalid Date");
expect(timestamps).toHaveTextContent("Updated just now");
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-01T12:00:00.000Z",
updatedAt: "2026-05-02T12:00:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
@@ -1494,7 +1482,7 @@ describe("TaskDetailModal", () => {
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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// 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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt: "# Some prompt content" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("PROMPT.md")).toBeNull();
});
it("renders Review and Comments tabs", () => {
render(
<TaskDetailModal
@@ -1890,34 +1846,15 @@ describe("TaskDetailModal", () => {
});
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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review", status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// 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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review", status: "stuck-killed" })}
task={makeTask({ column: "in-review", status })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -2157,11 +2094,10 @@ describe("TaskDetailModal", () => {
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(<TaskDetailModal initialTab="definition" task={ideasTask()} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}, 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(<TaskDetailModal initialTab="definition" task={ideasTask()} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}, 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(<TaskDetailContent embedded initialTab="definition" task={ideasTask()} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}, 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(<TaskDetailContent embedded initialTab="definition" task={ideasTask()} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
if (surface === "modal") {
render(<TaskDetailModal initialTab="definition" task={ideasTask()} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
} else {
render(<TaskDetailContent embedded initialTab="definition" task={ideasTask()} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}
}, onMoveTask);
});

View File

@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -4529,17 +4496,6 @@ describe("TerminalModal — mobile layout contract", () => {
});
});
it("preserves header/footer structure: tabs and title in header, actions in footer", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -4552,19 +4508,6 @@ describe("TerminalModal — mobile layout contract", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("clear button is clickable with many tabs", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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);
});
});