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:
@@ -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();
|
||||
|
||||
@@ -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" />);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user