3913 lines
153 KiB
TypeScript
3913 lines
153 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import type { ComponentProps } from "react";
|
|
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { SettingsModal } from "../SettingsModal";
|
|
import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots";
|
|
import type { PluginUiContributionEntry, SettingsExportData, UpdateCheckResponse } from "../../api";
|
|
|
|
// --- API mocks ---
|
|
const mockFetchSettings = vi.fn();
|
|
const mockFetchSettingsByScope = vi.fn();
|
|
const mockExportSettings = vi.fn();
|
|
const mockUpdateSettings = vi.fn();
|
|
const mockUpdateGlobalSettings = vi.fn();
|
|
const mockFetchAuthStatus = vi.fn();
|
|
const mockLoginProvider = vi.fn();
|
|
const mockLogoutProvider = vi.fn();
|
|
const mockCancelProviderLogin = vi.fn();
|
|
const mockSaveApiKey = vi.fn();
|
|
const mockSubmitProviderManualCode = vi.fn();
|
|
const mockFetchModels = vi.fn();
|
|
const mockFetchCustomProviders = vi.fn();
|
|
const mockCreateCustomProvider = vi.fn();
|
|
const mockUpdateCustomProvider = vi.fn();
|
|
const mockDeleteCustomProvider = vi.fn();
|
|
const mockTestNtfyNotification = vi.fn();
|
|
const mockTestNotification = vi.fn();
|
|
const mockFetchBackups = vi.fn();
|
|
const mockCreateBackup = vi.fn();
|
|
const mockImportSettings = vi.fn();
|
|
const mockFetchMemoryFiles = vi.fn();
|
|
const mockFetchMemoryFile = vi.fn();
|
|
const mockSaveMemoryFile = vi.fn();
|
|
const mockCompactMemory = vi.fn();
|
|
const mockFetchGlobalConcurrency = vi.fn();
|
|
const mockUpdateGlobalConcurrency = vi.fn();
|
|
const mockFetchMemoryBackendStatus = vi.fn();
|
|
const mockTestMemoryRetrieval = vi.fn();
|
|
const mockInstallQmd = vi.fn();
|
|
const mockFetchGitRemotesDetailed = vi.fn();
|
|
const mockFetchDashboardHealth = vi.fn();
|
|
const mockCheckForUpdates = vi.fn();
|
|
const mockFetchRemoteSettings = vi.fn();
|
|
const mockUpdateRemoteSettings = vi.fn();
|
|
const mockFetchRemoteStatus = vi.fn();
|
|
const mockInstallCloudflared = vi.fn();
|
|
const mockStartRemoteTunnel = vi.fn();
|
|
const mockStopRemoteTunnel = vi.fn();
|
|
const mockKillExternalTunnel = vi.fn();
|
|
const mockRegenerateRemotePersistentToken = vi.fn();
|
|
const mockGenerateShortLivedRemoteToken = vi.fn();
|
|
const mockFetchRemoteQr = vi.fn();
|
|
const mockFetchRemoteUrl = vi.fn();
|
|
const mockTriggerMemoryDreams = vi.fn();
|
|
const mockFetchPluginUiSlots = vi.fn();
|
|
const mockFetchDroidCliStatus = vi.fn();
|
|
const mockSetDroidCliEnabled = vi.fn();
|
|
const mockFetchCursorCliStatus = vi.fn();
|
|
const mockSetCursorCliEnabled = vi.fn();
|
|
const mockUseWorkspaceFileBrowser = vi.fn();
|
|
const mockConfirm = vi.fn();
|
|
|
|
vi.mock("../../api", async (importOriginal) => {
|
|
const { createDashboardApiMock } = await import("../../test/mockApi");
|
|
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
|
|
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
|
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
|
|
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
|
|
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
|
exportSettings: (...args: unknown[]) => mockExportSettings(...args),
|
|
importSettings: (...args: unknown[]) => mockImportSettings(...args),
|
|
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
|
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
|
|
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
|
|
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
|
|
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
|
|
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
|
|
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
|
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
|
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
|
|
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
|
|
deleteCustomProvider: (...args: unknown[]) => mockDeleteCustomProvider(...args),
|
|
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
|
|
testNotification: (...args: unknown[]) => mockTestNotification(...args),
|
|
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
|
|
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
|
|
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
|
|
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
|
|
saveMemoryFile: (...args: unknown[]) => mockSaveMemoryFile(...args),
|
|
compactMemory: (...args: unknown[]) => mockCompactMemory(...args),
|
|
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
|
|
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
|
|
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
|
|
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
|
|
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
|
|
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
|
|
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
|
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
|
|
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
|
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
|
|
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
|
|
installCloudflared: (...args: unknown[]) => mockInstallCloudflared(...args),
|
|
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
|
|
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
|
|
killExternalTunnel: (...args: unknown[]) => mockKillExternalTunnel(...args),
|
|
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
|
|
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
|
|
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
|
|
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
|
|
triggerMemoryDreams: (...args: unknown[]) => mockTriggerMemoryDreams(...args),
|
|
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
|
|
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
|
|
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
|
|
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
|
|
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
|
|
});
|
|
});
|
|
|
|
// Mock the hook
|
|
const mockUseMemoryBackendStatus = vi.fn();
|
|
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
|
|
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
|
|
}));
|
|
|
|
const mockUseMobileKeyboard = vi.fn();
|
|
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
|
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useConfirm", () => ({
|
|
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useViewportMode", () => ({
|
|
useViewportMode: () => "mobile",
|
|
}));
|
|
vi.mock("lucide-react", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("lucide-react")>();
|
|
return {
|
|
...actual,
|
|
Globe: () => <span data-testid="icon-globe" />,
|
|
Folder: () => <span data-testid="icon-folder" />,
|
|
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
|
|
Star: ({ size }: { size?: number }) => <span data-testid="icon-star" style={{ width: size, height: size }} />,
|
|
HelpCircle: ({ size }: { size?: number }) => <span data-testid="icon-help-circle" style={{ width: size, height: size }} />,
|
|
Loader2: ({ className }: { className?: string }) => <span data-testid="icon-loader2" className={className} />,
|
|
};
|
|
});
|
|
|
|
vi.mock("../PluginManager", () => ({
|
|
PluginManager: () => <div data-testid="plugin-manager">Plugin manager content</div>,
|
|
}));
|
|
|
|
vi.mock("../PiExtensionsManager", () => ({
|
|
PiExtensionsManager: () => <div data-testid="pi-extensions-manager">Pi extensions content</div>,
|
|
}));
|
|
|
|
|
|
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
|
|
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
|
|
}));
|
|
|
|
vi.mock("../FileBrowser", () => ({
|
|
FileBrowser: ({ onSelectFile }: { onSelectFile: (path: string) => void }) => (
|
|
<div data-testid="mock-overlap-file-browser">
|
|
<button type="button" onClick={() => onSelectFile("README.md")}>Select README.md</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
const noop = () => {};
|
|
|
|
const defaultSettings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: true,
|
|
overlapIgnorePaths: [],
|
|
autoMerge: true,
|
|
mergeStrategy: "direct",
|
|
directMergeCommitStrategy: "auto",
|
|
pushAfterMerge: false,
|
|
pushRemote: "origin",
|
|
verificationFixRetries: 2,
|
|
workflowRevisionForkOnScopeMismatch: true,
|
|
recycleWorktrees: false,
|
|
ephemeralAgentsEnabled: true,
|
|
executorAllowSiblingBranchRename: false,
|
|
worktreeNaming: "random",
|
|
includeTaskIdInCommit: true,
|
|
worktreeInitCommand: "",
|
|
ntfyEnabled: false,
|
|
ntfyTopic: undefined,
|
|
ntfyAccessToken: undefined,
|
|
webhookEnabled: false,
|
|
webhookUrl: undefined,
|
|
webhookFormat: undefined,
|
|
webhookEvents: undefined,
|
|
};
|
|
|
|
function renderModal(props: Partial<ComponentProps<typeof SettingsModal>> = {}) {
|
|
return render(
|
|
<SettingsModal
|
|
onClose={noop}
|
|
addToast={noop}
|
|
initialSection="authentication"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
async function waitForSettingsModalReady() {
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
|
|
});
|
|
}
|
|
|
|
const MODEL_FIXTURE = [
|
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
|
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
|
];
|
|
|
|
describe("SettingsModal", () => {
|
|
it("applies keyboard CSS variables when mobile keyboard is open", async () => {
|
|
mockUseMobileKeyboard.mockReturnValue({
|
|
keyboardOpen: true,
|
|
keyboardOverlap: 250,
|
|
viewportHeight: 400,
|
|
viewportOffsetTop: 50,
|
|
});
|
|
|
|
const { container } = renderModal();
|
|
await waitForSettingsModalReady();
|
|
const modal = container.querySelector(".settings-modal");
|
|
|
|
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
|
|
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
|
|
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
|
|
});
|
|
|
|
it("renders section headings with the shared settings-section-heading class", async () => {
|
|
const { container } = renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const authenticationHeading = screen.getByRole("heading", { name: "Authentication" });
|
|
expect(authenticationHeading).toHaveClass("settings-section-heading");
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /^General$/ }));
|
|
|
|
const generalHeading = screen.getByRole("heading", { name: "General" });
|
|
expect(generalHeading).toHaveClass("settings-section-heading");
|
|
expect(container.querySelectorAll(".settings-section-heading").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("defaults to the global General section when no initialSection is provided", async () => {
|
|
render(
|
|
<SettingsModal
|
|
onClose={noop}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
await waitForSettingsModalReady();
|
|
|
|
const generalNavButton = screen.getByRole("button", { name: /^General$/ });
|
|
expect(generalNavButton).toHaveClass("active");
|
|
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /^Authentication$/ })).not.toHaveClass("active");
|
|
});
|
|
|
|
it("honors an explicit initialSection override", async () => {
|
|
renderModal({ initialSection: "authentication" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("button", { name: /^Authentication$/ })).toHaveClass("active");
|
|
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("maps the legacy pi-extensions initialSection alias to Plugins", async () => {
|
|
renderModal({ initialSection: "pi-extensions" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("button", { name: /^Plugins$/ })).toHaveClass("active");
|
|
expect(screen.getByRole("heading", { name: "Plugins" })).toBeInTheDocument();
|
|
expect(screen.getByRole("tab", { name: "Pi Extensions" })).toHaveAttribute("aria-selected", "true");
|
|
expect(await screen.findByTestId("pi-extensions-manager")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows direct merge commit routing only for direct merges", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /^Merge$/ }));
|
|
expect(screen.getByLabelText("Direct merge commit routing")).toHaveValue("auto");
|
|
|
|
await userEvent.selectOptions(screen.getByLabelText("Auto-completion mode"), "pull-request");
|
|
expect(screen.queryByLabelText("Direct merge commit routing")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("persists the legacy sibling branch rename escape hatch in worktree settings", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /^Worktrees$/ }));
|
|
|
|
const checkbox = screen.getByRole("checkbox", { name: "Allow silent sibling branch rename during executor conflicts" });
|
|
expect(checkbox).not.toBeChecked();
|
|
|
|
await userEvent.click(checkbox);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ executorAllowSiblingBranchRename: true }),
|
|
undefined,
|
|
);
|
|
});
|
|
expect(screen.getByText(/restores the legacy behavior/i)).toBeInTheDocument();
|
|
});
|
|
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clearPluginUiSlotsCache();
|
|
mockUseMobileKeyboard.mockReturnValue({
|
|
keyboardOpen: false,
|
|
keyboardOverlap: 0,
|
|
viewportHeight: null,
|
|
viewportOffsetTop: 0,
|
|
});
|
|
Object.defineProperty(window, "matchMedia", {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
mockFetchSettings.mockResolvedValue(defaultSettings);
|
|
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
|
|
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
|
|
mockConfirm.mockResolvedValue(true);
|
|
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
|
mockFetchCustomProviders.mockResolvedValue({ providers: [] });
|
|
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
|
|
mockUpdateCustomProvider.mockResolvedValue({ provider: {} });
|
|
mockDeleteCustomProvider.mockResolvedValue(undefined);
|
|
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
|
|
mockSaveApiKey.mockResolvedValue(undefined);
|
|
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
|
|
mockTestNotification.mockResolvedValue({ success: true });
|
|
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
|
|
mockFetchMemoryFiles.mockResolvedValue({
|
|
files: [
|
|
{
|
|
path: ".fusion/memory/MEMORY.md",
|
|
label: "Long-term memory",
|
|
layer: "long-term",
|
|
size: 42,
|
|
updatedAt: "2026-04-17T12:00:00.000Z",
|
|
},
|
|
{
|
|
path: ".fusion/memory/DREAMS.md",
|
|
label: "Dreams",
|
|
layer: "dreams",
|
|
size: 21,
|
|
updatedAt: "2026-04-17T12:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
mockFetchMemoryFile.mockImplementation((path: string) =>
|
|
Promise.resolve({
|
|
path,
|
|
content: path.endsWith("DREAMS.md")
|
|
? "## Existing dreams\n- Pattern from daily notes"
|
|
: "## Existing memory\n- Learned pattern",
|
|
}),
|
|
);
|
|
mockSaveMemoryFile.mockResolvedValue({ success: true });
|
|
mockCompactMemory.mockResolvedValue({
|
|
path: ".fusion/memory/DREAMS.md",
|
|
content: "# Compacted Memory\n\nImportant content.",
|
|
});
|
|
mockTestMemoryRetrieval.mockResolvedValue({
|
|
query: "pattern",
|
|
qmdAvailable: true,
|
|
usedFallback: false,
|
|
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
results: [],
|
|
});
|
|
mockInstallQmd.mockResolvedValue({
|
|
success: true,
|
|
qmdAvailable: true,
|
|
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
});
|
|
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
|
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
|
mockCheckForUpdates.mockResolvedValue(undefined);
|
|
mockFetchRemoteSettings.mockResolvedValue({
|
|
settings: {
|
|
remoteActiveProvider: null,
|
|
remoteTailscaleEnabled: false,
|
|
remoteTailscaleHostname: "",
|
|
remoteTailscaleTargetPort: 4040,
|
|
remoteTailscaleAcceptRoutes: false,
|
|
remoteCloudflareEnabled: false,
|
|
remoteCloudflareQuickTunnel: false,
|
|
remoteCloudflareTunnelName: "",
|
|
remoteCloudflareTunnelToken: null,
|
|
remoteCloudflareIngressUrl: "",
|
|
remotePersistentToken: null,
|
|
remoteShortLivedEnabled: false,
|
|
remoteShortLivedTtlMs: 900000,
|
|
remoteShortLivedMaxTtlMs: 86400000,
|
|
remoteRememberLastRunning: false,
|
|
remoteWasRunningOnShutdown: false,
|
|
remoteLastStartedProvider: null,
|
|
},
|
|
});
|
|
mockUpdateRemoteSettings.mockResolvedValue({
|
|
settings: {
|
|
remoteActiveProvider: null,
|
|
remoteTailscaleEnabled: false,
|
|
remoteTailscaleHostname: "",
|
|
remoteTailscaleTargetPort: 4040,
|
|
remoteTailscaleAcceptRoutes: false,
|
|
remoteCloudflareEnabled: false,
|
|
remoteCloudflareQuickTunnel: false,
|
|
remoteCloudflareTunnelName: "",
|
|
remoteCloudflareTunnelToken: null,
|
|
remoteCloudflareIngressUrl: "",
|
|
remotePersistentToken: null,
|
|
remoteShortLivedEnabled: false,
|
|
remoteShortLivedTtlMs: 900000,
|
|
remoteShortLivedMaxTtlMs: 86400000,
|
|
remoteRememberLastRunning: false,
|
|
remoteWasRunningOnShutdown: false,
|
|
remoteLastStartedProvider: null,
|
|
},
|
|
});
|
|
mockFetchRemoteStatus.mockResolvedValue({ provider: null, state: "stopped", url: null, lastError: null });
|
|
mockInstallCloudflared.mockResolvedValue({ success: true, command: "brew install cloudflared" });
|
|
mockStartRemoteTunnel.mockResolvedValue({ state: "starting", provider: "tailscale" });
|
|
mockStopRemoteTunnel.mockResolvedValue({ state: "stopped", provider: null });
|
|
mockKillExternalTunnel.mockResolvedValue({ ok: true });
|
|
mockRegenerateRemotePersistentToken.mockResolvedValue({ token: "token", maskedToken: "****" });
|
|
mockGenerateShortLivedRemoteToken.mockResolvedValue({ token: "short", expiresAt: new Date(Date.now() + 60000).toISOString(), ttlMs: 60000 });
|
|
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
|
|
mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null });
|
|
mockTriggerMemoryDreams.mockResolvedValue({ success: true, summary: "done" });
|
|
mockFetchPluginUiSlots.mockResolvedValue([]);
|
|
mockFetchDroidCliStatus.mockResolvedValue({
|
|
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
|
|
enabled: false,
|
|
extension: { status: "ok" },
|
|
ready: false,
|
|
});
|
|
mockSetDroidCliEnabled.mockResolvedValue({ enabled: true, restartRequired: true });
|
|
mockFetchCursorCliStatus.mockResolvedValue({
|
|
binary: { available: true, version: "0.1.0", binaryPath: "/usr/local/bin/cursor-agent", probeDurationMs: 8 },
|
|
enabled: false,
|
|
extension: null,
|
|
ready: false,
|
|
});
|
|
mockSetCursorCliEnabled.mockResolvedValue({ enabled: true, restartRequired: false });
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
entries: [],
|
|
currentPath: ".",
|
|
setPath: vi.fn(),
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
});
|
|
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
|
|
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
|
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
|
mockFetchMemoryBackendStatus.mockResolvedValue({
|
|
currentBackend: "file",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: true,
|
|
supportsAtomicWrite: true,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
qmdAvailable: true,
|
|
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
});
|
|
mockUseMemoryBackendStatus.mockReturnValue({
|
|
status: {
|
|
currentBackend: "qmd",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: true,
|
|
supportsAtomicWrite: false,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
qmdAvailable: true,
|
|
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
},
|
|
currentBackend: "file",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: true,
|
|
supportsAtomicWrite: true,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
});
|
|
|
|
// jsdom doesn't provide URL.createObjectURL — polyfill it
|
|
if (!URL.createObjectURL) {
|
|
URL.createObjectURL = vi.fn(() => "blob:http://localhost/mock") as any;
|
|
}
|
|
if (!URL.revokeObjectURL) {
|
|
URL.revokeObjectURL = vi.fn() as any;
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("deferred settings fetches", () => {
|
|
it("does not fetch global concurrency until Scheduling is selected", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(mockFetchGlobalConcurrency).not.toHaveBeenCalled();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Scheduling/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchGlobalConcurrency).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("enables memory backend status hook only when Memory section is active", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(mockUseMemoryBackendStatus).toHaveBeenCalled();
|
|
const initialCallHasDisabled = mockUseMemoryBackendStatus.mock.calls.some(
|
|
(call) => call[0]?.enabled === false,
|
|
);
|
|
expect(initialCallHasDisabled).toBe(true);
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Memory/ }));
|
|
|
|
await waitFor(() => {
|
|
const enabledCallSeen = mockUseMemoryBackendStatus.mock.calls.some(
|
|
(call) => call[0]?.enabled === true,
|
|
);
|
|
expect(enabledCallSeen).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Global General", () => {
|
|
it("defaults persistAgentToolOutput checkbox to checked", async () => {
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).toBeChecked();
|
|
});
|
|
|
|
it("reflects persisted unchecked value from global settings", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
persistAgentToolOutput: false,
|
|
});
|
|
mockFetchSettingsByScope.mockResolvedValue({
|
|
global: { ...defaultSettings, persistAgentToolOutput: false },
|
|
project: {},
|
|
});
|
|
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).not.toBeChecked();
|
|
});
|
|
|
|
it("defaults thinking-log checkboxes to unchecked", async () => {
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("checkbox", { name: "Save AI thinking for permanent agents" })).not.toBeChecked();
|
|
expect(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" })).not.toBeChecked();
|
|
});
|
|
|
|
it("falls back to legacy thinking-log flag when granular fields are unset", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
persistAgentThinkingLog: true,
|
|
});
|
|
mockFetchSettingsByScope.mockResolvedValue({
|
|
global: { ...defaultSettings, persistAgentThinkingLog: true },
|
|
project: {},
|
|
});
|
|
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("checkbox", { name: "Save AI thinking for permanent agents" })).toBeChecked();
|
|
expect(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" })).toBeChecked();
|
|
});
|
|
|
|
it("saves persistAgentToolOutput only via global settings payload", async () => {
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("checkbox", { name: "Save tool output in agent logs" }));
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(globalPayload.persistAgentToolOutput).toBe(false);
|
|
if (mockUpdateSettings.mock.calls.length > 0) {
|
|
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(projectPayload.persistAgentToolOutput).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("saves granular thinking-log flags only via global settings payload", async () => {
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("checkbox", { name: "Save AI thinking for permanent agents" }));
|
|
await userEvent.click(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" }));
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(globalPayload.persistAgentThinkingLogPermanent).toBe(true);
|
|
expect(globalPayload.persistAgentThinkingLogEphemeral).toBe(true);
|
|
expect(globalPayload.persistAgentThinkingLog).toBeUndefined();
|
|
if (mockUpdateSettings.mock.calls.length > 0) {
|
|
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(projectPayload.persistAgentThinkingLogPermanent).toBeUndefined();
|
|
expect(projectPayload.persistAgentThinkingLogEphemeral).toBeUndefined();
|
|
expect(projectPayload.persistAgentThinkingLog).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("renders global default tracking repo control", async () => {
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const input = screen.getByLabelText("Global default tracking repo") as HTMLInputElement;
|
|
expect(input.value).toBe("");
|
|
expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("saves global default tracking repo via global settings payload only", async () => {
|
|
renderModal({ initialSection: "global-general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.type(screen.getByLabelText("Global default tracking repo"), "octo/global-default");
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(globalPayload.githubTrackingDefaultRepo).toBe("octo/global-default");
|
|
|
|
if (mockUpdateSettings.mock.calls.length > 0) {
|
|
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(projectPayload.githubTrackingDefaultRepo).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Project General", () => {
|
|
it("renders completion documentation automation control", async () => {
|
|
renderModal({ initialSection: "general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const select = screen.getByLabelText("Completion Documentation Automation") as HTMLSelectElement;
|
|
expect(select).toBeInTheDocument();
|
|
expect(select.value).toBe("off");
|
|
expect(screen.getByRole("option", { name: "Require changeset (.changeset/*.md)" })).toBeInTheDocument();
|
|
expect(screen.getByRole("option", { name: "Require changelog update (existing changelog)" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("saves completion documentation mode through project settings", async () => {
|
|
renderModal({ initialSection: "general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.selectOptions(
|
|
screen.getByLabelText("Completion Documentation Automation"),
|
|
"changeset",
|
|
);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(projectPayload.completionDocumentationMode).toBe("changeset");
|
|
|
|
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
|
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(globalPayload.completionDocumentationMode).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("saves ephemeral agent toggle in project settings payload", async () => {
|
|
renderModal({ initialSection: "general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const ephemeralToggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
|
|
expect(ephemeralToggle.checked).toBe(true);
|
|
|
|
await userEvent.click(ephemeralToggle);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(payload.ephemeralAgentsEnabled).toBe(false);
|
|
});
|
|
|
|
it("keeps ephemeral agent toggle checked when upgrading settings omit the key", async () => {
|
|
const { ephemeralAgentsEnabled: _omitted, ...upgradeSettings } = defaultSettings;
|
|
mockFetchSettings.mockResolvedValueOnce(upgradeSettings);
|
|
mockFetchSettingsByScope.mockResolvedValueOnce({ global: defaultSettings, project: {} });
|
|
|
|
renderModal({ initialSection: "general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const ephemeralToggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
|
|
expect(ephemeralToggle.checked).toBe(true);
|
|
});
|
|
|
|
it("renders and saves GitHub tracking controls in the General section", async () => {
|
|
renderModal({ initialSection: "general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("heading", { name: "GitHub Tracking" })).toBeInTheDocument();
|
|
|
|
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
|
|
const repoInput = screen.getByLabelText("Project default tracking repo") as HTMLInputElement;
|
|
expect(modeSelect.value).toBe("off");
|
|
expect(repoInput.value).toBe("");
|
|
|
|
await userEvent.selectOptions(modeSelect, "new-tasks");
|
|
await userEvent.type(repoInput, "octo/repo");
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(payload.githubTrackingEnabledByDefault).toBe(true);
|
|
expect(payload.githubTrackingDefaultRepo).toBe("octo/repo");
|
|
|
|
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
|
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(globalPayload.githubTrackingDefaultRepo).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("saves GitHub tracking defaults as disabled and clears the repo when emptied", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
githubTrackingEnabledByDefault: true,
|
|
githubTrackingDefaultRepo: "octo/existing",
|
|
});
|
|
|
|
renderModal({ initialSection: "general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
|
|
const repoInput = screen.getByLabelText("Project default tracking repo") as HTMLInputElement;
|
|
|
|
expect(modeSelect.value).toBe("new-tasks");
|
|
expect(repoInput.value).toBe("octo/existing");
|
|
|
|
await userEvent.selectOptions(modeSelect, "off");
|
|
await userEvent.clear(repoInput);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(payload.githubTrackingEnabledByDefault).toBe(false);
|
|
expect(payload.githubTrackingDefaultRepo).toBeUndefined();
|
|
});
|
|
|
|
it("hides summarization model picker when summarization and default tracking are disabled", async () => {
|
|
renderModal({ initialSection: "models" });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Project Models" }));
|
|
|
|
expect(screen.queryByText("Title, commit message, and GitHub tracking issue summarization model")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows summarization model picker for GitHub tracking defaults", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
githubTrackingEnabledByDefault: true,
|
|
});
|
|
|
|
renderModal({ initialSection: "models" });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Project Models" }));
|
|
|
|
expect(screen.getByText("Title, commit message, and GitHub tracking issue summarization model")).toBeInTheDocument();
|
|
});
|
|
|
|
it("always shows GitHub tracking summarization helper copy", async () => {
|
|
renderModal({ initialSection: "general" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(
|
|
screen.getByText(/Tracking issues use this task's title\. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models\./),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Appearance", () => {
|
|
it("renders dashboard font size options with saved value", async () => {
|
|
const onDashboardFontScaleChange = vi.fn();
|
|
renderModal({ dashboardFontScalePct: 110, onDashboardFontScaleChange });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Appearance/ }));
|
|
|
|
const largeButton = screen.getByRole("button", { name: "Large" });
|
|
expect(largeButton).toHaveAttribute("aria-pressed", "true");
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Small" }));
|
|
expect(onDashboardFontScaleChange).toHaveBeenCalledWith(90);
|
|
});
|
|
|
|
it("saves dashboard font scale to global settings", async () => {
|
|
renderModal({ dashboardFontScalePct: 100 });
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Appearance/ }));
|
|
await userEvent.click(screen.getByRole("button", { name: "Largest" }));
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
|
expect(payload).toEqual(expect.objectContaining({ dashboardFontScalePct: 120 }));
|
|
});
|
|
});
|
|
|
|
describe("Project Models", () => {
|
|
it("saves opencode-go startup model sync toggle in global settings", async () => {
|
|
mockFetchModels.mockResolvedValue({
|
|
models: MODEL_FIXTURE,
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Models" }));
|
|
const checkbox = await screen.findByLabelText("Sync opencode-go model list at startup");
|
|
expect(checkbox).toBeChecked();
|
|
|
|
await userEvent.click(checkbox);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ opencodeGoModelSync: false }),
|
|
);
|
|
});
|
|
|
|
it("renders a project-scoped default model lane", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
});
|
|
mockFetchSettingsByScope.mockResolvedValue({
|
|
global: {
|
|
...defaultSettings,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
},
|
|
project: {},
|
|
});
|
|
mockFetchModels.mockResolvedValue({
|
|
models: MODEL_FIXTURE,
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Project Models" }));
|
|
|
|
expect(screen.getByText(/The Project Default Model is the fallback for this project/i)).toBeInTheDocument();
|
|
|
|
const defaultSection = screen.getByLabelText("Project Default Model").closest(".form-group");
|
|
expect(defaultSection).toBeTruthy();
|
|
expect(within(defaultSection as HTMLElement).getByText("Inherited (Global)")).toBeInTheDocument();
|
|
});
|
|
|
|
it("saves the project default model override under project scope keys", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
});
|
|
mockFetchSettingsByScope.mockResolvedValue({
|
|
global: {
|
|
...defaultSettings,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
},
|
|
project: {},
|
|
});
|
|
mockFetchModels.mockResolvedValue({
|
|
models: MODEL_FIXTURE,
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Project Models" }));
|
|
await userEvent.click(screen.getByLabelText("Project Default Model"));
|
|
await userEvent.click(screen.getByText("GPT-4o"));
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProviderOverride: "openai",
|
|
defaultModelIdOverride: "gpt-4o",
|
|
}),
|
|
undefined,
|
|
);
|
|
|
|
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
|
const [globalPayload] = mockUpdateGlobalSettings.mock.calls[0] as [Record<string, unknown>];
|
|
expect(globalPayload).not.toHaveProperty("defaultProviderOverride");
|
|
expect(globalPayload).not.toHaveProperty("defaultModelIdOverride");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("settings header actions", () => {
|
|
it("renders Help and GitHub star controls with shared header sizing contract class hooks", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const headerActions = document.querySelector(".settings-header-actions");
|
|
expect(headerActions).toBeInTheDocument();
|
|
|
|
const starLink = within(headerActions as HTMLElement).getByRole("link", { name: "Star Fusion on GitHub" });
|
|
const helpLink = within(headerActions as HTMLElement).getByRole("link", { name: "Help and discussions" });
|
|
|
|
expect(starLink).toHaveClass("settings-github-star-btn");
|
|
expect(helpLink).toHaveClass("settings-header-help-btn");
|
|
expect(helpLink).toHaveClass("btn", "btn-sm");
|
|
});
|
|
});
|
|
|
|
describe("settings version display", () => {
|
|
it("renders the app version from the health endpoint", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(await screen.findByText("Version 1.2.3")).toBeInTheDocument();
|
|
expect(mockFetchDashboardHealth).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps settings interactive when version lookup fails", async () => {
|
|
const addToast = vi.fn();
|
|
mockFetchDashboardHealth.mockRejectedValueOnce(new Error("health unavailable"));
|
|
render(<SettingsModal onClose={noop} addToast={addToast} />);
|
|
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByText(/^Version\s+/)).not.toBeInTheDocument();
|
|
await userEvent.click(screen.getByText("Scheduling"));
|
|
expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument();
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders check for updates button in header", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("button", { name: "Check for updates" })).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Check Now" })).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Manually check for the latest version right now.")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("clicking check for updates shows up-to-date message", async () => {
|
|
mockCheckForUpdates.mockResolvedValueOnce({
|
|
currentVersion: "1.2.3",
|
|
latestVersion: "1.2.3",
|
|
updateAvailable: false,
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
|
|
|
expect(await screen.findByText("You're up to date ✓")).toBeInTheDocument();
|
|
});
|
|
|
|
it("clicking check for updates shows update available message with runfusion.ai link", async () => {
|
|
mockCheckForUpdates.mockResolvedValueOnce({
|
|
currentVersion: "1.0.0",
|
|
latestVersion: "2.0.0",
|
|
updateAvailable: true,
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
|
|
|
expect(await screen.findByText(/v2.0.0 available/i)).toBeInTheDocument();
|
|
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute("href", "https://runfusion.ai");
|
|
});
|
|
|
|
it("disables button while checking", async () => {
|
|
let resolveCheck: ((result: UpdateCheckResponse) => void) | undefined;
|
|
const pendingCheck = new Promise<UpdateCheckResponse>((resolve) => {
|
|
resolveCheck = resolve;
|
|
});
|
|
mockCheckForUpdates.mockReturnValueOnce(pendingCheck);
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const button = screen.getByRole("button", { name: "Check for updates" });
|
|
expect(button).not.toBeDisabled();
|
|
|
|
fireEvent.click(button);
|
|
await waitFor(() => {
|
|
expect(button).toBeDisabled();
|
|
});
|
|
|
|
resolveCheck?.({
|
|
currentVersion: "1.2.3",
|
|
latestVersion: "1.2.3",
|
|
updateAvailable: false,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
it("clicking version text area triggers update check", async () => {
|
|
mockCheckForUpdates.mockResolvedValueOnce({
|
|
currentVersion: "1.2.3",
|
|
latestVersion: "1.2.3",
|
|
updateAvailable: false,
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const inlineButton = screen.getByRole("button", { name: "Check for updates" });
|
|
expect(within(inlineButton).getByText("Version 1.2.3")).toBeInTheDocument();
|
|
|
|
await userEvent.click(within(inlineButton).getByText("Version 1.2.3"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockCheckForUpdates).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("refresh icon has spinning class while loading", async () => {
|
|
let resolveCheck: ((result: UpdateCheckResponse) => void) | undefined;
|
|
const pendingCheck = new Promise<UpdateCheckResponse>((resolve) => {
|
|
resolveCheck = resolve;
|
|
});
|
|
mockCheckForUpdates.mockReturnValueOnce(pendingCheck);
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const button = screen.getByRole("button", { name: "Check for updates" });
|
|
fireEvent.click(button);
|
|
|
|
await waitFor(() => {
|
|
const spinningIcon = button.querySelector(".spinning");
|
|
expect(spinningIcon).not.toBeNull();
|
|
});
|
|
|
|
resolveCheck?.({
|
|
currentVersion: "1.2.3",
|
|
latestVersion: "1.2.3",
|
|
updateAvailable: false,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(button.querySelector(".spinning")).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("settings export filename", () => {
|
|
it("uses fusion-settings- prefix for exported filename", async () => {
|
|
const mockExportData: SettingsExportData = {
|
|
version: 1,
|
|
exportedAt: "2026-04-04T12:00:00.000Z",
|
|
global: undefined,
|
|
project: { maxConcurrent: 2 },
|
|
};
|
|
mockExportSettings.mockResolvedValue(mockExportData);
|
|
|
|
// Spy on createElement to capture the download link's filename
|
|
const originalCreateElement = document.createElement.bind(document);
|
|
const createdElements: { tagName: string; download: string; href: string }[] = [];
|
|
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
|
const el = originalCreateElement(tagName);
|
|
if (tagName.toLowerCase() === "a") {
|
|
// Capture the download attribute when set
|
|
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLAnchorElement.prototype,
|
|
"download"
|
|
);
|
|
Object.defineProperty(el, "download", {
|
|
set(v: string) {
|
|
createdElements.push({ tagName, download: v, href: (el as HTMLAnchorElement).href });
|
|
origDownloadDescriptor?.set?.call(el, v);
|
|
},
|
|
get() {
|
|
return origDownloadDescriptor?.get?.call(el) ?? "";
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
return el;
|
|
});
|
|
|
|
// Mock URL.createObjectURL and revokeObjectURL
|
|
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
|
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
|
|
renderModal();
|
|
|
|
// Wait for settings to load
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Find and click the Export button
|
|
const exportButton = screen.getByTitle("Export settings to JSON file");
|
|
expect(exportButton).toBeDefined();
|
|
|
|
fireEvent.click(exportButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockExportSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Assert the filename uses fusion-settings- prefix
|
|
expect(createdElements.length).toBeGreaterThanOrEqual(1);
|
|
const anchorElement = createdElements[0];
|
|
expect(anchorElement.download).toMatch(/^fusion-settings-/);
|
|
expect(anchorElement.download).toMatch(/^fusion-settings-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/);
|
|
});
|
|
|
|
it("does not use kb-settings- prefix for exported filename", async () => {
|
|
const mockExportData: SettingsExportData = {
|
|
version: 1,
|
|
exportedAt: "2026-04-04T12:00:00.000Z",
|
|
global: undefined,
|
|
project: { maxConcurrent: 2 },
|
|
};
|
|
mockExportSettings.mockResolvedValue(mockExportData);
|
|
|
|
// Capture filenames set on dynamically-created anchor elements
|
|
const capturedFilenames: string[] = [];
|
|
const originalCreateElement = document.createElement.bind(document);
|
|
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
|
const el = originalCreateElement(tagName);
|
|
if (tagName.toLowerCase() === "a") {
|
|
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLAnchorElement.prototype,
|
|
"download"
|
|
);
|
|
Object.defineProperty(el, "download", {
|
|
set(v: string) {
|
|
capturedFilenames.push(v);
|
|
origDownloadDescriptor?.set?.call(el, v);
|
|
},
|
|
get() {
|
|
return origDownloadDescriptor?.get?.call(el) ?? "";
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
return el;
|
|
});
|
|
|
|
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
|
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTitle("Export settings to JSON file"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockExportSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Negative assertion: filename must NOT use the old kb- prefix
|
|
expect(capturedFilenames.length).toBeGreaterThanOrEqual(1);
|
|
for (const filename of capturedFilenames) {
|
|
expect(filename).not.toMatch(/^kb-settings-/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Authentication provider icon wrappers", () => {
|
|
it("renders stable auth-provider-icon wrappers with branded and fallback SVG behavior", async () => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [
|
|
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
|
|
{ id: "unknown-provider", name: "Unknown Provider", authenticated: false, type: "api_key" },
|
|
],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const openRouterIconWrapper = await screen.findByTestId("auth-provider-icon-openrouter");
|
|
expect(within(openRouterIconWrapper).getByTestId("openrouter-icon")).toBeInTheDocument();
|
|
|
|
const unknownIconWrapper = screen.getByTestId("auth-provider-icon-unknown-provider");
|
|
const fallbackSvg = unknownIconWrapper.querySelector("svg");
|
|
expect(fallbackSvg).toBeInTheDocument();
|
|
expect(fallbackSvg).not.toHaveAttribute("data-testid");
|
|
});
|
|
|
|
it("renders icon wrappers for both authenticated and available provider rows", async () => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [
|
|
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
|
|
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
|
|
{ id: "cloudflare", name: "Cloudflare", authenticated: false, type: "api_key" },
|
|
],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByTestId("auth-provider-icon-github")).toBeInTheDocument();
|
|
expect(screen.getByTestId("auth-provider-icon-openai")).toBeInTheDocument();
|
|
expect(screen.getByTestId("auth-provider-icon-cloudflare")).toBeInTheDocument();
|
|
expect(within(screen.getByTestId("auth-provider-icon-cloudflare")).getByTestId("cloudflare-icon")).toBeInTheDocument();
|
|
expect(screen.getByTestId("auth-status-github")).toHaveTextContent("✓ Active");
|
|
expect(screen.getByTestId("auth-status-openai")).toHaveTextContent("✗ Not connected");
|
|
});
|
|
|
|
it("hides deprecated Google CLI and antigravity auth providers", async () => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [
|
|
{ id: "google", name: "Google", authenticated: false, type: "api_key" },
|
|
{ id: "gemini", name: "Gemini", authenticated: false, type: "api_key" },
|
|
{ id: "google-antigravity", name: "Google Antigravity", authenticated: false, type: "oauth" },
|
|
{ id: "antigravity", name: "Antigravity", authenticated: false, type: "oauth" },
|
|
{ id: "google-gemini-cli", name: "Google Gemini CLI", authenticated: false, type: "cli" },
|
|
],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByTestId("auth-provider-icon-google")).toBeInTheDocument();
|
|
expect(screen.getByTestId("auth-provider-icon-gemini")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("auth-provider-icon-google-antigravity")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("auth-provider-icon-antigravity")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Google Gemini CLI")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("scrolls settings content to top after OAuth login succeeds", async () => {
|
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
mockLoginProvider.mockResolvedValue({ url: "https://example.com/auth", instructions: "" });
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "github", name: "GitHub", authenticated: false, type: "oauth" }],
|
|
});
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "github", name: "GitHub", authenticated: true, type: "oauth" }],
|
|
});
|
|
|
|
vi.spyOn(globalThis, "setInterval").mockImplementation((callback: TimerHandler) => {
|
|
void Promise.resolve().then(() => {
|
|
if (typeof callback === "function") callback();
|
|
});
|
|
return 1 as unknown as ReturnType<typeof setInterval>;
|
|
});
|
|
|
|
const { container } = renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const settingsContent = container.querySelector(".settings-content") as HTMLDivElement;
|
|
expect(settingsContent).toBeInTheDocument();
|
|
const scrollToSpy = vi.fn();
|
|
Object.defineProperty(settingsContent, "scrollTo", {
|
|
value: scrollToSpy,
|
|
writable: true,
|
|
});
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Login" }));
|
|
|
|
await waitFor(() => {
|
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
|
|
});
|
|
expect(openSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns before starting manual-code oauth login and stops when cancelled", async () => {
|
|
vi.spyOn(window, "open").mockImplementation(() => null);
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
|
|
});
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
|
|
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockConfirm).toHaveBeenCalledWith({
|
|
title: "Heads up — manual paste-back required",
|
|
message:
|
|
"After you sign in with Anthropic, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?",
|
|
confirmLabel: "Continue to login",
|
|
cancelLabel: "Cancel",
|
|
});
|
|
});
|
|
expect(mockLoginProvider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("continues manual-code oauth login after confirmation", async () => {
|
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
|
|
});
|
|
mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" });
|
|
mockConfirm.mockResolvedValueOnce(true);
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
|
|
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockConfirm).toHaveBeenCalled();
|
|
expect(mockLoginProvider).toHaveBeenCalledWith("anthropic");
|
|
expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank");
|
|
});
|
|
});
|
|
|
|
it("skips the warning for oauth providers without manual-code fallback", async () => {
|
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "github", name: "GitHub", authenticated: false, type: "oauth" }],
|
|
});
|
|
mockLoginProvider.mockResolvedValueOnce({ url: "https://example.com/auth" });
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Login" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockConfirm).not.toHaveBeenCalled();
|
|
expect(mockLoginProvider).toHaveBeenCalledWith("github");
|
|
expect(openSpy).toHaveBeenCalledWith("https://example.com/auth", "_blank");
|
|
});
|
|
});
|
|
|
|
it("renders Anthropic pasted-code form when login response includes manualCode", async () => {
|
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }],
|
|
});
|
|
mockLoginProvider.mockResolvedValueOnce({
|
|
url: "https://claude.ai/oauth/authorize",
|
|
manualCode: {
|
|
prompt: "Paste the final redirect URL or authorization code",
|
|
placeholder: "http://localhost:*/callback?code=...&state=... or just the code",
|
|
helpText: "After Claude sign-in, copy the full browser URL (or just the code) and paste it here to finish login from this dashboard host.",
|
|
},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
|
|
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
|
|
|
|
expect(await within(anthropicCard).findByText("Paste the final redirect URL or authorization code")).toBeInTheDocument();
|
|
await userEvent.type(within(anthropicCard).getByRole("textbox"), "anthropic-code");
|
|
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Submit code" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSubmitProviderManualCode).toHaveBeenCalledWith("anthropic", "anthropic-code");
|
|
});
|
|
expect(openSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("scrolls the manual-code input into view on mobile focus", async () => {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: query === "(max-width: 768px)" || query === "(pointer: coarse)",
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
|
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
|
|
callback(0);
|
|
return 1;
|
|
});
|
|
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }],
|
|
});
|
|
mockLoginProvider.mockResolvedValueOnce({
|
|
url: "https://claude.ai/oauth/authorize",
|
|
manualCode: {
|
|
prompt: "Paste the final redirect URL or authorization code",
|
|
},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
|
|
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
|
|
|
|
const textarea = await within(anthropicCard).findByRole("textbox");
|
|
const scrollIntoView = vi.fn();
|
|
Object.defineProperty(textarea, "scrollIntoView", {
|
|
value: scrollIntoView,
|
|
writable: true,
|
|
});
|
|
|
|
fireEvent.focus(textarea);
|
|
|
|
await waitFor(() => {
|
|
expect(scrollIntoView).toHaveBeenCalled();
|
|
});
|
|
expect(openSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows cancel action for server-reported pending oauth login", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const copilotCard = screen.getByTestId("auth-provider-icon-github-copilot").closest(".auth-provider-card") as HTMLElement;
|
|
expect(within(copilotCard).getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
|
await userEvent.click(within(copilotCard).getByRole("button", { name: "Cancel" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockCancelProviderLogin).toHaveBeenCalledWith("github-copilot");
|
|
});
|
|
});
|
|
|
|
it("scrolls settings content to top after API key save succeeds", async () => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }],
|
|
});
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: true, type: "api_key" }],
|
|
});
|
|
|
|
const { container } = renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const settingsContent = container.querySelector(".settings-content") as HTMLDivElement;
|
|
expect(settingsContent).toBeInTheDocument();
|
|
const scrollToSpy = vi.fn();
|
|
Object.defineProperty(settingsContent, "scrollTo", {
|
|
value: scrollToSpy,
|
|
writable: true,
|
|
});
|
|
|
|
const openAiCard = screen.getByTestId("auth-provider-icon-openai").closest(".auth-provider-card") as HTMLElement;
|
|
await userEvent.type(within(openAiCard).getByPlaceholderText("Enter API key"), "sk-test-key");
|
|
await userEvent.click(within(openAiCard).getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-test-key");
|
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Droid plugin Settings integration", () => {
|
|
it.each([
|
|
{
|
|
name: "unavailable/not enabled",
|
|
status: {
|
|
binary: { available: false, reason: "`droid` not found on PATH", probeDurationMs: 9 },
|
|
enabled: false,
|
|
extension: { status: "ok" },
|
|
ready: false,
|
|
},
|
|
expectedText: "not found on PATH",
|
|
},
|
|
{
|
|
name: "enabled but not ready",
|
|
status: {
|
|
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
|
|
enabled: true,
|
|
extension: { status: "ok" },
|
|
ready: false,
|
|
},
|
|
expectedText: "Enabled. Validating…",
|
|
},
|
|
{
|
|
name: "connected and ready",
|
|
status: {
|
|
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
|
|
enabled: true,
|
|
extension: { status: "ok" },
|
|
ready: true,
|
|
},
|
|
expectedText: "✓ Active",
|
|
},
|
|
])("renders plugin-driven droid card state: $name", async ({ status }) => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
|
});
|
|
mockFetchPluginUiSlots.mockResolvedValueOnce([
|
|
{
|
|
pluginId: "fusion-plugin-droid-runtime",
|
|
slot: {
|
|
slotId: "settings-provider-card",
|
|
label: "Droid CLI Provider",
|
|
componentPath: "./components/settings-provider-card.js",
|
|
},
|
|
},
|
|
]);
|
|
mockFetchDroidCliStatus.mockResolvedValueOnce(status);
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
|
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
|
|
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
|
|
});
|
|
|
|
it("renders cursor cli auth card in authentication group", async () => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: false, type: "cli" }],
|
|
});
|
|
mockFetchPluginUiSlots.mockResolvedValueOnce([]);
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(await screen.findByTestId("cursor-cli-provider-card")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("disables cursor enable action when binary is unavailable", async () => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: false, type: "cli" }],
|
|
});
|
|
mockFetchCursorCliStatus.mockResolvedValueOnce({
|
|
binary: { available: false, reason: "cursor-agent not found", probeDurationMs: 8 },
|
|
enabled: false,
|
|
extension: null,
|
|
ready: false,
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(await screen.findByText("cursor-agent not found")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Enable" })).toBeDisabled();
|
|
});
|
|
|
|
it("does not render droid auth card when plugin slot is not present", async () => {
|
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
|
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
|
});
|
|
mockFetchPluginUiSlots.mockResolvedValueOnce([]);
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByTestId("droid-cli-provider-card")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Plugins section navigation", () => {
|
|
it("does not render a standalone Pi Extensions sidebar item", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
const sidebar = document.querySelector(".settings-sidebar");
|
|
expect(sidebar).toBeInTheDocument();
|
|
expect(within(sidebar as HTMLElement).queryByRole("button", { name: /Pi Extensions$/ })).not.toBeInTheDocument();
|
|
expect(within(sidebar as HTMLElement).getByRole("button", { name: /Plugins$/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders accessible tab semantics for Plugins subsection controls", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: /Plugins$/ }));
|
|
|
|
const tablist = await screen.findByRole("tablist", { name: "Plugin manager type" });
|
|
const fusionTab = within(tablist).getByRole("tab", { name: "Fusion Plugins" });
|
|
const piTab = within(tablist).getByRole("tab", { name: "Pi Extensions" });
|
|
|
|
expect(fusionTab).toHaveAttribute("aria-selected", "true");
|
|
expect(fusionTab).toHaveAttribute("aria-controls", "plugins-panel-fusion-plugins");
|
|
expect(piTab).toHaveAttribute("aria-selected", "false");
|
|
expect(piTab).toHaveAttribute("aria-controls", "plugins-panel-pi-extensions");
|
|
});
|
|
|
|
it("switches between Fusion Plugins and Pi Extensions managers", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: /Plugins$/ }));
|
|
|
|
expect(await screen.findByTestId("plugin-manager")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("pi-extensions-manager")).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole("tab", { name: "Pi Extensions" }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("pi-extensions-manager")).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByTestId("plugin-manager")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("tabpanel", { name: "Pi Extensions" })).toBeVisible();
|
|
expect(document.getElementById("plugins-panel-fusion-plugins")).toHaveAttribute("hidden");
|
|
});
|
|
});
|
|
|
|
describe("Scheduling overlap ignore paths", () => {
|
|
it("renders existing overlap ignore paths from settings", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
overlapIgnorePaths: ["docs/", "generated/*"],
|
|
});
|
|
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByText("Scheduling"));
|
|
|
|
expect(screen.getByDisplayValue("docs/")).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument();
|
|
});
|
|
|
|
it("supports selecting ignore paths through the browse picker", async () => {
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByText("Scheduling"));
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
|
|
|
expect(await screen.findByRole("dialog", { name: /browse workspace path/i })).toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole("button", { name: "Select README.md" }));
|
|
|
|
expect(screen.getByDisplayValue("README.md")).toBeInTheDocument();
|
|
});
|
|
|
|
it("includes overlapIgnorePaths in save payload", async () => {
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByText("Scheduling"));
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
|
await userEvent.click(await screen.findByRole("button", { name: "Select README.md" }));
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /add ignored path/i }));
|
|
const inputs = screen.getAllByPlaceholderText("docs/");
|
|
await userEvent.type(inputs[1], "generated/*");
|
|
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0];
|
|
expect(payload.overlapIgnorePaths).toEqual(["README.md", "generated/*"]);
|
|
});
|
|
});
|
|
|
|
describe("Number input clearing", () => {
|
|
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
// Open Scheduling section
|
|
fireEvent.click(screen.getByText("Scheduling"));
|
|
|
|
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
|
|
expect(input).toBeDefined();
|
|
|
|
// Clear the input - the input should be empty, not show "0"
|
|
await userEvent.clear(input);
|
|
expect(input.value).toBe("");
|
|
});
|
|
|
|
it("allows clearing globalMaxConcurrent without leaving a stuck zero", async () => {
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
// Open Scheduling section
|
|
fireEvent.click(screen.getByText("Scheduling"));
|
|
|
|
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
|
expect(input).toBeDefined();
|
|
|
|
// Clear the input - the input should be empty, not show "0"
|
|
await userEvent.clear(input);
|
|
expect(input.value).toBe("");
|
|
});
|
|
|
|
it("allows clearing pollIntervalMs without leaving a stuck zero", async () => {
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
// Open Scheduling section
|
|
fireEvent.click(screen.getByText("Scheduling"));
|
|
|
|
const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement;
|
|
expect(input).toBeDefined();
|
|
|
|
// Clear the input - the input should be empty, not show "0"
|
|
await userEvent.clear(input);
|
|
expect(input.value).toBe("");
|
|
});
|
|
|
|
it("allows configuring stale high fan-out escalation threshold in hours", async () => {
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByText("Scheduling"));
|
|
|
|
const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement;
|
|
expect(input).toBeDefined();
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, "3");
|
|
expect(input.value).toBe("3");
|
|
});
|
|
|
|
it("allows clearing maxWorktrees without leaving a stuck zero", async () => {
|
|
renderModal();
|
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
|
|
// Open Worktrees section
|
|
fireEvent.click(screen.getByText("Worktrees"));
|
|
|
|
const input = screen.getByLabelText("Max Worktrees") as HTMLInputElement;
|
|
expect(input).toBeDefined();
|
|
|
|
// Clear the input - the input should be empty, not show "0"
|
|
await userEvent.clear(input);
|
|
expect(input.value).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("Memory section", () => {
|
|
it("renders the Memory section in the sidebar", async () => {
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(await screen.findByText("Memory")).toBeDefined();
|
|
});
|
|
|
|
it("shows the memory toggle with default enabled", async () => {
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Click the Memory section in the sidebar
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
|
expect(checkbox).toBeDefined();
|
|
// Default is enabled, so checkbox should be checked
|
|
expect(checkbox).toBeChecked();
|
|
});
|
|
|
|
it("shows memory toggle unchecked when memoryEnabled is false", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
memoryEnabled: false,
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Click the Memory section in the sidebar
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
|
expect(checkbox).toBeDefined();
|
|
expect(checkbox).not.toBeChecked();
|
|
});
|
|
|
|
it("toggles the memory setting when checkbox is clicked", async () => {
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Click the Memory section in the sidebar
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
|
expect(checkbox).toBeChecked();
|
|
|
|
// Uncheck it
|
|
await userEvent.click(checkbox);
|
|
expect(checkbox).not.toBeChecked();
|
|
|
|
// Check it again
|
|
await userEvent.click(checkbox);
|
|
expect(checkbox).toBeChecked();
|
|
});
|
|
|
|
it("truncates long memory file option labels to keep native dropdown width bounded", async () => {
|
|
mockFetchMemoryFiles.mockResolvedValue({
|
|
files: [
|
|
{
|
|
path: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
|
|
label: "Long-term memory",
|
|
layer: "long-term",
|
|
size: 42,
|
|
updatedAt: "2026-04-17T12:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
const option = await screen.findByRole("option", { name: /Long-term memory/ });
|
|
expect(option.textContent).toContain("…");
|
|
expect(option.textContent).not.toContain("dropdown/can-overflow-on-the-right-edge.md");
|
|
});
|
|
|
|
it("shows only loading copy while backend status is unresolved", async () => {
|
|
mockUseMemoryBackendStatus.mockReturnValue({
|
|
// Simulate stale negative payload while a refresh is still in-flight.
|
|
status: {
|
|
currentBackend: "readonly",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: false,
|
|
supportsAtomicWrite: false,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
qmdAvailable: false,
|
|
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
},
|
|
currentBackend: "readonly",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: false,
|
|
supportsAtomicWrite: false,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
loading: true,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
expect(screen.getByText("Checking memory write access...")).toBeInTheDocument();
|
|
expect(screen.queryByText(/qmd is not installed\. Search will use local files\./i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/Memory is configured with a read-only backend\./i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows read-only warning after backend resolves as non-writable", async () => {
|
|
mockUseMemoryBackendStatus.mockReturnValue({
|
|
status: {
|
|
currentBackend: "readonly",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: false,
|
|
supportsAtomicWrite: false,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
qmdAvailable: true,
|
|
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
},
|
|
currentBackend: "readonly",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: false,
|
|
supportsAtomicWrite: false,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
expect(screen.getByText(/Memory is configured with a read-only backend\./i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("installs qmd from the missing qmd prompt", async () => {
|
|
const addToast = vi.fn();
|
|
const refresh = vi.fn(() => Promise.resolve());
|
|
mockUseMemoryBackendStatus.mockReturnValue({
|
|
status: {
|
|
currentBackend: "qmd",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: true,
|
|
supportsAtomicWrite: false,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
qmdAvailable: false,
|
|
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
},
|
|
currentBackend: "qmd",
|
|
capabilities: {
|
|
readable: true,
|
|
writable: true,
|
|
supportsAtomicWrite: false,
|
|
hasConflictResolution: false,
|
|
persistent: true,
|
|
},
|
|
availableBackends: ["file", "readonly", "qmd"],
|
|
loading: false,
|
|
error: null,
|
|
refresh,
|
|
});
|
|
|
|
renderModal({ addToast });
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: "Install qmd" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockInstallQmd).toHaveBeenCalledWith(undefined);
|
|
});
|
|
expect(refresh).toHaveBeenCalled();
|
|
expect(addToast).toHaveBeenCalledWith("qmd installed successfully", "success");
|
|
});
|
|
|
|
it("loads and shows memory editor content when navigating to Memory", async () => {
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(mockFetchMemoryFiles).not.toHaveBeenCalled();
|
|
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchMemoryFiles).toHaveBeenCalledWith(undefined);
|
|
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
});
|
|
|
|
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
expect(editor.value).toContain("Existing dreams");
|
|
});
|
|
|
|
it("shows loading state while memory is being fetched", async () => {
|
|
let resolveMemory: ((value: { content: string }) => void) | undefined;
|
|
mockFetchMemoryFile.mockReturnValueOnce(
|
|
new Promise<{ content: string }>((resolve) => {
|
|
resolveMemory = resolve;
|
|
})
|
|
);
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
expect(screen.getByText("Loading memory…")).toBeDefined();
|
|
|
|
resolveMemory?.({ content: "# Loaded" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("Editor for .fusion/memory/DREAMS.md")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it("supports editing and saving memory content", async () => {
|
|
const addToast = vi.fn();
|
|
renderModal({ addToast });
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
});
|
|
|
|
const select = await screen.findByLabelText("Memory File");
|
|
await userEvent.selectOptions(select, ".fusion/memory/MEMORY.md");
|
|
|
|
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md");
|
|
fireEvent.change(editor, { target: { value: "# Updated memory\n- Reusable learning" } });
|
|
|
|
const saveButton = await screen.findByRole("button", { name: "Save Memory" });
|
|
await userEvent.click(saveButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockSaveMemoryFile).toHaveBeenCalledWith(
|
|
".fusion/memory/MEMORY.md",
|
|
"# Updated memory\n- Reusable learning",
|
|
undefined,
|
|
);
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
|
|
});
|
|
|
|
it("compacts the selected memory file in the editor", async () => {
|
|
const addToast = vi.fn();
|
|
renderModal({ addToast });
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
const compactButton = await screen.findByRole("button", { name: "Compact Selected File" });
|
|
await userEvent.click(compactButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockCompactMemory).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
});
|
|
|
|
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
expect(editor.value).toContain("Compacted Memory");
|
|
expect(addToast).toHaveBeenCalledWith("Memory file compacted", "success");
|
|
});
|
|
|
|
it("handles empty memory content from API", async () => {
|
|
mockFetchMemoryFile.mockResolvedValueOnce({ path: ".fusion/memory/DREAMS.md", content: "" });
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
expect(editor.value).toBe("");
|
|
});
|
|
|
|
it("switches between memory files in the editor", async () => {
|
|
mockFetchMemoryFile.mockImplementation((path: string) =>
|
|
Promise.resolve({
|
|
path,
|
|
content: path.endsWith("DREAMS.md") ? "# Dreams\n\n- Pattern" : "# Memory\n\n- Durable",
|
|
}),
|
|
);
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(await screen.findByText("Memory"));
|
|
|
|
const select = await screen.findByLabelText("Memory File");
|
|
await userEvent.selectOptions(select, ".fusion/memory/DREAMS.md");
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
});
|
|
|
|
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
expect(editor.value).toContain("Dreams");
|
|
});
|
|
});
|
|
|
|
describe("Merge section", () => {
|
|
it("shows push-after-merge toggle and keeps Push Remote hidden by default", async () => {
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
|
|
const pushAfterMergeToggle = screen.getByRole("checkbox", {
|
|
name: /push to remote after merge/i,
|
|
});
|
|
expect(pushAfterMergeToggle).not.toBeChecked();
|
|
expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("merge option descriptions are hidden behind disclosure by default", async () => {
|
|
renderModal();
|
|
|
|
await waitForSettingsModalReady();
|
|
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
|
|
const autoMergeDescription = screen.getByText(/When enabled, tasks that pass review are automatically merged/i);
|
|
const disclosure = autoMergeDescription.closest("details");
|
|
|
|
expect(disclosure).not.toBeNull();
|
|
expect(disclosure).not.toHaveAttribute("open");
|
|
expect(autoMergeDescription).not.toBeVisible();
|
|
});
|
|
|
|
it("merge option descriptions are revealed when clicking More details", async () => {
|
|
renderModal();
|
|
|
|
await waitForSettingsModalReady();
|
|
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
|
|
const moreDetailsSummaries = screen.getAllByText("More details");
|
|
await userEvent.click(moreDetailsSummaries[0]);
|
|
|
|
expect(screen.getByText(/When enabled, tasks that pass review are automatically merged/i)).toBeVisible();
|
|
});
|
|
|
|
it("loads workflow revision fork checkbox from project settings", async () => {
|
|
renderModal({ initialSection: "merge" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const checkbox = screen.getByRole("checkbox", {
|
|
name: /fork scope-mismatched workflow revisions into follow-up tasks/i,
|
|
});
|
|
expect(checkbox).toBeChecked();
|
|
});
|
|
|
|
it("saves workflow revision fork checkbox changes", async () => {
|
|
renderModal({ initialSection: "merge" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const checkbox = screen.getByRole("checkbox", {
|
|
name: /fork scope-mismatched workflow revisions into follow-up tasks/i,
|
|
});
|
|
await userEvent.click(checkbox);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(payload.workflowRevisionForkOnScopeMismatch).toBe(false);
|
|
});
|
|
|
|
it("shows Push Remote input when push-after-merge is enabled", async () => {
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
await userEvent.click(
|
|
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
|
);
|
|
|
|
expect(screen.getByLabelText("Push Remote")).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText("origin")).toBeInTheDocument();
|
|
});
|
|
|
|
it("includes pushAfterMerge and pushRemote in the save payload", async () => {
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
await userEvent.click(
|
|
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
|
);
|
|
|
|
const pushRemoteInput = screen.getByLabelText("Push Remote");
|
|
await userEvent.clear(pushRemoteInput);
|
|
await userEvent.type(pushRemoteInput, "upstream main");
|
|
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0];
|
|
expect(payload.pushAfterMerge).toBe(true);
|
|
expect(payload.pushRemote).toBe("upstream main");
|
|
});
|
|
|
|
describe("verificationFixRetries", () => {
|
|
it("shows default value 3 when verificationFixRetries is not set", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
verificationFixRetries: undefined,
|
|
});
|
|
|
|
renderModal({ initialSection: "merge" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
|
|
expect(retriesInput.value).toBe("3");
|
|
});
|
|
|
|
it.each([0, 1, 2, 3])("persists valid value %i", async (value) => {
|
|
renderModal({ initialSection: "merge" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
|
|
fireEvent.change(retriesInput, { target: { value: String(value) } });
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(payload.verificationFixRetries).toBe(value);
|
|
});
|
|
|
|
it("clamps out-of-range values", async () => {
|
|
renderModal({ initialSection: "merge" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
|
|
|
|
fireEvent.change(retriesInput, { target: { value: "5" } });
|
|
expect(retriesInput.value).toBe("3");
|
|
|
|
fireEvent.change(retriesInput, { target: { value: "-1" } });
|
|
expect(retriesInput.value).toBe("0");
|
|
});
|
|
|
|
it("saving after clearing input persists undefined and falls back to visible default 3", async () => {
|
|
renderModal({ initialSection: "merge" });
|
|
await waitForSettingsModalReady();
|
|
|
|
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
|
|
await userEvent.clear(retriesInput);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(payload.verificationFixRetries).toBeUndefined();
|
|
expect(retriesInput.value).toBe("3");
|
|
});
|
|
});
|
|
|
|
it("keeps GitHub tracking controls out of Merge and preserves GitHub authentication controls", async () => {
|
|
renderModal({ initialSection: "merge" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByLabelText("Default tracking mode for new tasks")).not.toBeInTheDocument();
|
|
expect(screen.queryByLabelText("Project default tracking repo")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("heading", { name: "GitHub Authentication" })).toBeInTheDocument();
|
|
|
|
const authModeSelect = screen.getByLabelText("GitHub auth mode") as HTMLSelectElement;
|
|
expect(authModeSelect.value).toBe("gh-cli");
|
|
expect(screen.queryByLabelText("GitHub personal access token")).not.toBeInTheDocument();
|
|
|
|
await userEvent.selectOptions(authModeSelect, "token");
|
|
await userEvent.type(screen.getByLabelText("GitHub personal access token"), "ghp_test_token");
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(payload.githubAuthMode).toBe("token");
|
|
expect(payload.githubAuthToken).toBe("ghp_test_token");
|
|
});
|
|
});
|
|
|
|
describe("Experimental Features section", () => {
|
|
const openExperimentalFeaturesSection = async () => {
|
|
const sectionLabel = await screen.findByText("Experimental Features");
|
|
await userEvent.click(sectionLabel);
|
|
};
|
|
|
|
it("renders the Experimental Features section in the sidebar", async () => {
|
|
renderModal();
|
|
|
|
expect(await screen.findByText("Experimental Features")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows known experimental features (Insights, Roadmaps) even when no custom features are configured", async () => {
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
// Known features should always be shown
|
|
expect(screen.getByText("Insights")).toBeInTheDocument();
|
|
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows researchView in the Experimental Features list", async () => {
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
expect(screen.getByLabelText("Research View")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows evalsView in the Experimental Features list", async () => {
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
expect(screen.getByLabelText("Evals View")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows chatRooms in the Experimental Features list", async () => {
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
expect(screen.getByLabelText("Chat Rooms")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows agentOnboarding in the Experimental Features list", async () => {
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
expect(screen.getByLabelText("Planning-style Agent Onboarding")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows a single canonical Dev Server toggle", async () => {
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
const devServerToggles = screen.getAllByLabelText("Dev Server");
|
|
expect(devServerToggles).toHaveLength(1);
|
|
});
|
|
|
|
it("does not render duplicate Dev Server rows when legacy and canonical keys are both present", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { devServer: true, devServerView: true },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
const devServerToggles = screen.getAllByLabelText("Dev Server");
|
|
expect(devServerToggles).toHaveLength(1);
|
|
expect(devServerToggles[0]).toBeChecked();
|
|
});
|
|
|
|
describe("section visibility behind experimental flags", () => {
|
|
it("hides Remote Access nav item when experimentalFeatures.remoteAccess is falsy", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByRole("button", { name: /Remote Access/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows Remote Access nav item when experimentalFeatures.remoteAccess is true", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { remoteAccess: true },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
expect(await screen.findByRole("button", { name: /Remote Access/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows Remote Access in KNOWN_EXPERIMENTAL_FEATURES toggle list", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
expect(screen.getByLabelText("Remote Access")).toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to the first selectable section when opening remote while remoteAccess is disabled", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
renderModal({ initialSection: "remote" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByRole("button", { name: /Remote Access/i })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides research settings nav items when experimentalFeatures.researchView is disabled", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /^Research$/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows research settings nav items when experimentalFeatures.researchView is enabled", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { researchView: true },
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("button", { name: /Research Defaults/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /^Research$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to the first selectable section when opening research settings while researchView is disabled", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
renderModal({ initialSection: "research-global" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides scheduled evals nav item when experimentalFeatures.evalsView is disabled", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByRole("button", { name: /Scheduled Evals/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows scheduled evals nav item when experimentalFeatures.evalsView is enabled", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { evalsView: true },
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("button", { name: /Scheduled Evals/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to the first selectable section when opening scheduled evals while evalsView is disabled", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
renderModal({ initialSection: "scheduled-evals" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.queryByRole("button", { name: /Scheduled Evals/i })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("sends canonical devServerView=false and devServer=null when disabling legacy dev server flag", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { devServer: true },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
const devServerToggle = screen.getByLabelText("Dev Server") as HTMLInputElement;
|
|
expect(devServerToggle).toBeChecked();
|
|
|
|
await userEvent.click(devServerToggle);
|
|
expect(devServerToggle).not.toBeChecked();
|
|
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
|
expect(payload.experimentalFeatures).toEqual({ devServerView: false, devServer: null });
|
|
});
|
|
|
|
it("does not emit legacy alias null deletes when canonical key is absent", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { insights: true },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
|
expect(payload.experimentalFeatures).toEqual({ insights: true });
|
|
expect(payload.experimentalFeatures.devServer).toBeUndefined();
|
|
});
|
|
|
|
it("shows feature flags when experimentalFeatures is set", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { "my-feature": true, "another-feature": false },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
expect(screen.getByText("my-feature")).toBeInTheDocument();
|
|
expect(screen.getByText("another-feature")).toBeInTheDocument();
|
|
});
|
|
|
|
it("feature flags are unchecked when value is false", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { "my-feature": false },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
expect(checkbox.checked).toBe(false);
|
|
});
|
|
|
|
it("feature flags are checked when value is true", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { "my-feature": true },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
expect(checkbox.checked).toBe(true);
|
|
});
|
|
|
|
it("toggling a feature flag updates the form state", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { "my-feature": false },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
expect(checkbox.checked).toBe(false);
|
|
|
|
// Toggle it
|
|
await userEvent.click(checkbox);
|
|
expect(checkbox.checked).toBe(true);
|
|
});
|
|
|
|
it("saving with toggled feature flag includes experimentalFeatures in payload", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { "my-feature": false },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
// Toggle the feature
|
|
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
await userEvent.click(checkbox);
|
|
|
|
// Save
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
|
expect(payload.experimentalFeatures).toEqual({ "my-feature": true });
|
|
});
|
|
|
|
it("shows global scope banner in Experimental Features section", async () => {
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
// Should show global scope indicator
|
|
expect(screen.getByText(/shared across all your fusion projects/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("handles undefined experimentalFeatures (falls back to empty) but still shows known features", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: undefined,
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
// Known features should always be shown regardless of settings
|
|
expect(screen.getByText("Insights")).toBeInTheDocument();
|
|
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
|
|
});
|
|
|
|
it("saves experimentalFeatures with multiple toggled flags", async () => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { "feature-a": true, "feature-b": false },
|
|
});
|
|
|
|
renderModal();
|
|
|
|
await openExperimentalFeaturesSection();
|
|
|
|
// Toggle feature-b to true
|
|
const checkboxB = screen.getByLabelText("feature-b") as HTMLInputElement;
|
|
await userEvent.click(checkboxB);
|
|
|
|
// Save
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
|
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
|
|
});
|
|
|
|
});
|
|
|
|
describe("Remote section", () => {
|
|
beforeEach(() => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { remoteAccess: true },
|
|
});
|
|
});
|
|
|
|
const openRemoteSection = async () => {
|
|
const [remoteSectionButton] = await screen.findAllByRole("button", { name: /Remote Access/i });
|
|
await userEvent.click(remoteSectionButton);
|
|
await screen.findByRole("heading", { name: "Remote Access" });
|
|
};
|
|
|
|
const openAdvancedSettings = async () => {
|
|
const summary = screen.getByText("Advanced Settings");
|
|
await userEvent.click(summary);
|
|
};
|
|
|
|
it("renders remote-status-bar with stopped state and omits share block when not running", async () => {
|
|
mockFetchRemoteStatus.mockResolvedValue({ provider: null, state: "stopped", url: null, lastError: null });
|
|
const { container } = renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
const statusBar = container.querySelector(".remote-status-bar");
|
|
expect(statusBar).toBeInTheDocument();
|
|
expect(statusBar?.className).toContain("remote-status-bar--stopped");
|
|
expect(container.querySelector(".remote-share-block")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders remote-share-block when tunnel is running with a URL", async () => {
|
|
mockFetchRemoteStatus.mockResolvedValue({ provider: "tailscale", state: "running", url: "https://machine.ts.net/", lastError: null });
|
|
const { container } = renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
const statusBar = container.querySelector(".remote-status-bar");
|
|
expect(statusBar).toBeInTheDocument();
|
|
expect(statusBar?.className).toContain("remote-status-bar--running");
|
|
expect(container.querySelector(".remote-share-block")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows provider-specific settings when provider selected and auto-saves on Start Tunnel", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
|
|
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText("Accept routes")).toBeInTheDocument();
|
|
expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument();
|
|
|
|
if (!screen.queryByLabelText("Tunnel name")) {
|
|
const advancedDetails = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }).closest("details") as HTMLDetailsElement;
|
|
advancedDetails.open = true;
|
|
fireEvent(advancedDetails, new Event("toggle"));
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("Tunnel name")).toBeInTheDocument();
|
|
});
|
|
}
|
|
await userEvent.clear(screen.getByLabelText("Tunnel name"));
|
|
await userEvent.type(screen.getByLabelText("Tunnel name"), "cf-team");
|
|
await userEvent.clear(screen.getByLabelText("Tunnel token"));
|
|
await userEvent.type(screen.getByLabelText("Tunnel token"), "cf_token");
|
|
await userEvent.clear(screen.getByLabelText("Ingress URL"));
|
|
await userEvent.type(screen.getByLabelText("Ingress URL"), "https://remote.example.com");
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
remoteActiveProvider: "cloudflare",
|
|
remoteCloudflareEnabled: true,
|
|
remoteCloudflareQuickTunnel: false,
|
|
remoteCloudflareTunnelName: "cf-team",
|
|
remoteCloudflareTunnelToken: "cf_token",
|
|
remoteCloudflareIngressUrl: "https://remote.example.com",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("toggles Cloudflare named tunnel advanced section and persists quick tunnel state", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" });
|
|
|
|
if (!screen.queryByLabelText("Tunnel name")) {
|
|
await userEvent.click(namedTunnelSummary);
|
|
}
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("Tunnel name")).toBeInTheDocument();
|
|
});
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" }));
|
|
await waitFor(() => {
|
|
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined);
|
|
});
|
|
|
|
// Closing <details> is not consistently simulated in jsdom; verify default quick-tunnel state via a fresh render below.
|
|
});
|
|
|
|
it("updates provider selection via radio and shows provider status", async () => {
|
|
mockFetchRemoteStatus
|
|
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
|
|
.mockResolvedValueOnce({ provider: "tailscale", state: "running", url: "https://tail.example", lastError: null });
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
expect(screen.getByLabelText("Tailscale")).toBeChecked();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByText("https://tail.example", { selector: ".remote-status-url" })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows cloudflared available indicator when Cloudflare is selected and cloudflared is installed", async () => {
|
|
mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: true });
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
|
|
expect(await screen.findByText("cloudflared is installed")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Install cloudflared" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders branded Cloudflare icon in Remote provider selector", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
const cloudflareSlot = screen.getByTestId("remote-provider-icon-cloudflare");
|
|
expect(within(cloudflareSlot).getByTestId("remote-cloudflare-option-icon")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows install button when Cloudflare is selected and cloudflared is not available", async () => {
|
|
mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false });
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
|
|
expect(await screen.findByText("cloudflared is not installed")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Install cloudflared" })).toBeInTheDocument();
|
|
expect(screen.getByText("cloudflared must be installed to start the tunnel")).toBeInTheDocument();
|
|
});
|
|
|
|
it("install button triggers install and refreshes status", async () => {
|
|
const addToast = vi.fn();
|
|
mockFetchRemoteStatus
|
|
.mockResolvedValueOnce({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false })
|
|
.mockResolvedValueOnce({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: true });
|
|
mockInstallCloudflared.mockResolvedValueOnce({ success: true, command: "brew install cloudflared" });
|
|
|
|
renderModal({ addToast });
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
|
|
const installButton = await screen.findByRole("button", { name: "Install cloudflared" });
|
|
await userEvent.click(installButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockInstallCloudflared).toHaveBeenCalledWith(undefined);
|
|
});
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("cloudflared installed successfully", "success");
|
|
});
|
|
expect(await screen.findByText("cloudflared is installed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("install button shows error on failure", async () => {
|
|
mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false });
|
|
mockInstallCloudflared.mockResolvedValueOnce({ success: false, command: "brew install cloudflared", error: "Command failed" });
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: "Install cloudflared" }));
|
|
|
|
expect(await screen.findByText("Command failed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows lifecycle state changes for start and stop actions, including error state", async () => {
|
|
mockFetchRemoteStatus
|
|
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
|
|
.mockResolvedValueOnce({ provider: "tailscale", state: "starting", url: null, lastError: null })
|
|
.mockResolvedValueOnce({ provider: "tailscale", state: "running", url: "https://tail.example", lastError: null })
|
|
.mockResolvedValueOnce({ provider: "tailscale", state: "error", url: null, lastError: "Tunnel crashed" });
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
expect(screen.getByRole("button", { name: "Start Tunnel" })).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" }));
|
|
await waitFor(() => {
|
|
expect(mockUpdateRemoteSettings).toHaveBeenCalled();
|
|
expect(mockStartRemoteTunnel).toHaveBeenCalledTimes(1);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByText("starting")).toBeInTheDocument();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: "Stop Tunnel" })).toBeInTheDocument();
|
|
});
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Stop Tunnel" }));
|
|
await waitFor(() => {
|
|
expect(mockStopRemoteTunnel).toHaveBeenCalledTimes(1);
|
|
});
|
|
await userEvent.click(screen.getByRole("button", { name: "Stop Tunnel" }));
|
|
await waitFor(() => {
|
|
expect(mockStopRemoteTunnel).toHaveBeenCalledTimes(2);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Tunnel crashed")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows external tunnel panel with actions when external tunnel is detected", async () => {
|
|
mockFetchRemoteStatus.mockResolvedValue({
|
|
provider: "tailscale",
|
|
state: "stopped",
|
|
url: null,
|
|
lastError: null,
|
|
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
expect(await screen.findByText("External tailscale tunnel detected")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Start Fresh" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Use Existing" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("Start Fresh kills external tunnel before starting", async () => {
|
|
mockFetchRemoteStatus.mockResolvedValue({
|
|
provider: "tailscale",
|
|
state: "stopped",
|
|
url: null,
|
|
lastError: null,
|
|
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: "Start Fresh" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockKillExternalTunnel).toHaveBeenCalledWith(undefined);
|
|
expect(mockStartRemoteTunnel).toHaveBeenCalledWith(undefined);
|
|
});
|
|
});
|
|
|
|
it("regenerates persistent token and surfaces success feedback without exposing raw token text", async () => {
|
|
const addToast = vi.fn();
|
|
renderModal({ addToast });
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
await openAdvancedSettings();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Regenerate persistent token" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockRegenerateRemotePersistentToken).toHaveBeenCalledWith(undefined);
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith("Persistent token regenerated", "success");
|
|
expect(addToast.mock.calls.flat().join(" ")).not.toContain("frt_");
|
|
});
|
|
|
|
it("generates short-lived token using selected TTL and shows URL expiry affordances", async () => {
|
|
const shortLivedExpiry = "2026-04-26T12:00:00.000Z";
|
|
mockGenerateShortLivedRemoteToken.mockResolvedValueOnce({
|
|
token: "frt_short",
|
|
expiresAt: shortLivedExpiry,
|
|
ttlMs: 120000,
|
|
});
|
|
mockFetchRemoteUrl.mockResolvedValueOnce({
|
|
url: "https://remote.example.com/short",
|
|
tokenType: "short-lived",
|
|
expiresAt: shortLivedExpiry,
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
await openAdvancedSettings();
|
|
|
|
await userEvent.selectOptions(screen.getByLabelText("Auth link token type"), "short-lived");
|
|
const ttlInput = screen.getByLabelText("Short-lived TTL (ms)") as HTMLInputElement;
|
|
fireEvent.change(ttlInput, { target: { value: "120000" } });
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Generate short-lived token" }));
|
|
await waitFor(() => {
|
|
expect(mockGenerateShortLivedRemoteToken).toHaveBeenCalledWith(120000, undefined);
|
|
});
|
|
|
|
fireEvent.change(ttlInput, { target: { value: "120000" } });
|
|
await userEvent.click(screen.getByRole("button", { name: "Show URL" }));
|
|
await waitFor(() => {
|
|
expect(mockFetchRemoteUrl).toHaveBeenLastCalledWith({
|
|
projectId: undefined,
|
|
tokenType: "short-lived",
|
|
ttlMs: 120000,
|
|
});
|
|
});
|
|
expect(screen.getByText("https://remote.example.com/short")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders QR image when available and falls back to URL-only presentation when SVG data is absent", async () => {
|
|
mockFetchRemoteQr
|
|
.mockResolvedValueOnce({
|
|
url: "https://remote.example.com/qr-image",
|
|
tokenType: "persistent",
|
|
expiresAt: null,
|
|
format: "image/svg",
|
|
data: "<svg></svg>",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
url: "https://remote.example.com/qr-text",
|
|
tokenType: "persistent",
|
|
expiresAt: null,
|
|
format: "text",
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
await openAdvancedSettings();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Generate QR" }));
|
|
await waitFor(() => {
|
|
expect(mockFetchRemoteQr).toHaveBeenNthCalledWith(1, "image/svg", expect.objectContaining({ tokenType: "persistent" }));
|
|
});
|
|
expect(await screen.findByRole("img", { name: "Remote access QR code" })).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Generate QR" }));
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("img", { name: "Remote access QR code" })).not.toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText("https://remote.example.com/qr-text")).toBeInTheDocument();
|
|
});
|
|
|
|
it("Start Tunnel button is disabled when no provider is selected", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
const startButton = screen.getByRole("button", { name: "Start Tunnel" });
|
|
expect(startButton).toBeDisabled();
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
expect(startButton).not.toBeDisabled();
|
|
});
|
|
|
|
it("no separate Activate Provider or Save Remote Settings buttons exist", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
expect(screen.queryByRole("button", { name: /Activate Provider/i })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /Save Remote Settings/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("only the selected provider's settings are rendered", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
expect(screen.getByLabelText("Accept routes")).toBeInTheDocument();
|
|
expect(screen.queryByText(/Advanced \(Named Tunnel\)/i)).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
expect(screen.getByText(/Advanced \(Named Tunnel\)/i)).toBeInTheDocument();
|
|
expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("sets quick tunnel false when opening Cloudflare advanced details", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
|
|
|
const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" });
|
|
if (!screen.queryByLabelText("Tunnel name")) {
|
|
await userEvent.click(namedTunnelSummary);
|
|
}
|
|
await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" }));
|
|
await waitFor(() => {
|
|
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined);
|
|
});
|
|
|
|
});
|
|
|
|
it("Start Tunnel auto-saves with enabled=true on selected provider before starting", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openRemoteSection();
|
|
|
|
await userEvent.click(screen.getByLabelText("Tailscale"));
|
|
await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
remoteActiveProvider: "tailscale",
|
|
remoteTailscaleEnabled: true,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
expect(mockStartRemoteTunnel).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
|
|
describe("Notifications provider cards", () => {
|
|
const openNotificationsSection = async () => {
|
|
await userEvent.click(await screen.findByRole("button", { name: /Notifications/ }));
|
|
};
|
|
|
|
it("shows ntfy and webhook provider cards in notifications section", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
|
|
expect(screen.getByText("ntfy")).toBeInTheDocument();
|
|
expect(screen.getByText("Webhook")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows ntfy fields when ntfy provider is enabled", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
|
|
expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Dashboard Hostname")).toBeInTheDocument();
|
|
expect(screen.getByText("Notify on events")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByText("Advanced"));
|
|
expect(screen.getByLabelText("Access token (optional)")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows fallback, dreams, and mailbox/room message events for both providers", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
|
|
expect(screen.getByLabelText("Fallback model used (recovered)")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
|
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
|
|
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
|
|
const roomMessageNtfy = screen.getByLabelText("Agent message in room") as HTMLInputElement;
|
|
expect(agentToUserNtfy.checked).toBe(true);
|
|
expect(agentToAgentNtfy.checked).toBe(true);
|
|
expect(roomMessageNtfy.checked).toBe(true);
|
|
|
|
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
|
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
|
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
|
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
|
|
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
|
|
const [roomMessageWebhook] = screen.getAllByLabelText("Agent message in room") as HTMLInputElement[];
|
|
expect(agentToUserWebhook.checked).toBe(true);
|
|
expect(agentToAgentWebhook.checked).toBe(true);
|
|
expect(roomMessageWebhook.checked).toBe(true);
|
|
});
|
|
|
|
it("shows webhook fields when webhook provider is enabled", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
|
|
|
expect(screen.getByLabelText("Webhook URL")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Format")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides ntfy body when ntfy is disabled", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("hides webhook body when webhook is disabled", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
expect(screen.queryByLabelText("Webhook URL")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls testNotification with ntfy provider ID when ntfy test button clicked", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
await userEvent.click(screen.getByText("Advanced"));
|
|
await userEvent.type(screen.getByLabelText("Access token (optional)"), "secret-token");
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Test notification/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockTestNotification).toHaveBeenCalledWith(
|
|
"ntfy",
|
|
expect.objectContaining({
|
|
ntfyEnabled: true,
|
|
ntfyTopic: "test-topic",
|
|
ntfyAccessToken: "secret-token",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
ntfyEnabled: true,
|
|
ntfyTopic: "test-topic",
|
|
ntfyAccessToken: "saved-token",
|
|
});
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
await userEvent.click(screen.getByText("Advanced"));
|
|
const tokenInput = screen.getByLabelText("Access token (optional)");
|
|
await userEvent.clear(tokenInput);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ ntfyAccessToken: null }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("calls testNotification with ntfy message-event config when message test button clicked", async () => {
|
|
const addToast = vi.fn();
|
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
renderModal({ addToast });
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Test message inbox/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockTestNotification).toHaveBeenCalledWith(
|
|
"ntfy",
|
|
{ messageEventType: "message:agent-to-user" },
|
|
undefined,
|
|
);
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
"Message inbox test sent — check your ntfy inbox for the agent-to-user message.",
|
|
"success",
|
|
);
|
|
expect(screen.getByText("Message inbox: Message inbox test sent — check your ntfy inbox for the agent-to-user message.")).toBeInTheDocument();
|
|
expect(screen.getByText("Message inbox: Message inbox test sent — check your ntfy inbox for the agent-to-user message.").closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite");
|
|
});
|
|
|
|
it("calls testNotification with ntfy room-event config when room test button clicked", async () => {
|
|
const addToast = vi.fn();
|
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
renderModal({ addToast });
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Test room reply/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockTestNotification).toHaveBeenCalledWith(
|
|
"ntfy",
|
|
{ messageEventType: "message:room" },
|
|
undefined,
|
|
);
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
"Room reply test sent — check your ntfy inbox for the room reply.",
|
|
"success",
|
|
);
|
|
expect(screen.getByText("Room reply: Room reply test sent — check your ntfy inbox for the room reply.")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows ntfy room-specific failure copy when room test fails", async () => {
|
|
const addToast = vi.fn();
|
|
mockTestNotification.mockResolvedValueOnce({ success: false, error: "boom" });
|
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
renderModal({ addToast });
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /Test room reply/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Failed to send room reply test", "error");
|
|
});
|
|
expect(screen.getByText("Room reply: Failed to send room reply test")).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls testNotification with webhook provider ID when webhook test button clicked", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
|
await userEvent.type(screen.getByLabelText("Webhook URL"), "https://hooks.example.com/test");
|
|
|
|
const webhookCard = screen.getByText("Webhook").closest(".notification-provider-card") as HTMLElement;
|
|
await userEvent.click(within(webhookCard).getByRole("button", { name: /Test notification/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockTestNotification).toHaveBeenCalledWith(
|
|
"webhook",
|
|
expect.objectContaining({ webhookUrl: "https://hooks.example.com/test" }),
|
|
undefined,
|
|
);
|
|
});
|
|
expect(within(webhookCard).getByText("Test notification sent — check your webhook endpoint!")).toBeInTheDocument();
|
|
expect(within(webhookCard).getByText("Test notification sent — check your webhook endpoint!").closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite");
|
|
});
|
|
|
|
it("preserves existing ntfy settings in backward compat", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
ntfyEnabled: true,
|
|
ntfyTopic: "my-existing-topic",
|
|
ntfyEvents: ["in-review", "failed"],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openNotificationsSection();
|
|
|
|
expect(screen.getByLabelText("ntfy Topic")).toHaveValue("my-existing-topic");
|
|
const inReview = screen.getByLabelText("Task completed (in-review)") as HTMLInputElement;
|
|
const failed = screen.getByLabelText("Task failed") as HTMLInputElement;
|
|
const merged = screen.getByLabelText("Task merged") as HTMLInputElement;
|
|
const fallbackUsed = screen.getByLabelText("Fallback model used (recovered)") as HTMLInputElement;
|
|
const dreamsProcessed = screen.getByLabelText("DREAMS.md entry added") as HTMLInputElement;
|
|
expect(inReview.checked).toBe(true);
|
|
expect(failed.checked).toBe(true);
|
|
expect(merged.checked).toBe(false);
|
|
expect(fallbackUsed.checked).toBe(false);
|
|
expect(dreamsProcessed.checked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("scheduled eval settings section", () => {
|
|
const openScheduledEvalsSection = async () => {
|
|
await userEvent.click(await screen.findByRole("button", { name: /Scheduled Evals/i }));
|
|
};
|
|
|
|
it("renders controls and disables interval controls when evals are disabled", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { evalsView: true },
|
|
evalSettings: {
|
|
enabled: false,
|
|
intervalMs: 86_400_000,
|
|
followUpPolicy: "suggest-only",
|
|
retentionDays: 30,
|
|
},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openScheduledEvalsSection();
|
|
|
|
expect(await screen.findByRole("heading", { name: "Scheduled Evals" })).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Enable scheduled eval runs for this project")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Interval (ms)")).toBeDisabled();
|
|
expect(screen.getByLabelText("Follow-up Policy")).toBeDisabled();
|
|
expect(screen.getByLabelText("Retention (days)")).toBeDisabled();
|
|
expect(screen.getByText(/inherit the project validator lane model settings/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("saves edited project eval settings payload", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { evalsView: true },
|
|
evalSettings: {
|
|
enabled: true,
|
|
intervalMs: 86_400_000,
|
|
followUpPolicy: "suggest-only",
|
|
retentionDays: 30,
|
|
},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openScheduledEvalsSection();
|
|
|
|
fireEvent.change(screen.getByLabelText("Interval (ms)"), { target: { value: "120000" } });
|
|
await userEvent.type(screen.getByLabelText("Evaluator Provider"), "openai");
|
|
await userEvent.type(screen.getByLabelText("Evaluator Model"), "gpt-5");
|
|
await userEvent.selectOptions(screen.getByLabelText("Follow-up Policy"), "auto-create");
|
|
fireEvent.change(screen.getByLabelText("Retention (days)"), { target: { value: "14" } });
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
evalSettings: expect.objectContaining({
|
|
enabled: true,
|
|
intervalMs: 120000,
|
|
evaluatorProvider: "openai",
|
|
evaluatorModelId: "gpt-5",
|
|
followUpPolicy: "auto-create",
|
|
retentionDays: 14,
|
|
}),
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("clears evaluator provider and model as unset when left blank", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { evalsView: true },
|
|
evalSettings: {
|
|
enabled: true,
|
|
intervalMs: 86_400_000,
|
|
evaluatorProvider: "openai",
|
|
evaluatorModelId: "gpt-5",
|
|
followUpPolicy: "suggest-only",
|
|
retentionDays: 30,
|
|
},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openScheduledEvalsSection();
|
|
|
|
await userEvent.clear(screen.getByLabelText("Evaluator Provider"));
|
|
await userEvent.clear(screen.getByLabelText("Evaluator Model"));
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
evalSettings: expect.objectContaining({
|
|
evaluatorProvider: undefined,
|
|
evaluatorModelId: undefined,
|
|
}),
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("memory backups settings", () => {
|
|
it("renders memory backup fields with defaults and saves changes", async () => {
|
|
renderModal({ initialSection: "backups" });
|
|
await waitForSettingsModalReady();
|
|
|
|
expect(screen.getByRole("heading", { name: "Memory Backups" })).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Memory Backup Schedule (Cron)")).toHaveValue("0 3 * * *");
|
|
expect(screen.getByLabelText("Memory Retention Count")).toHaveValue(null);
|
|
expect(screen.getByLabelText("Memory Backup Directory")).toHaveValue(".fusion/backups/memory");
|
|
expect(screen.getByLabelText("Memory Backup Scope")).toHaveValue("all");
|
|
|
|
await userEvent.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 userEvent.selectOptions(screen.getByLabelText("Memory Backup Scope"), "agents");
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
memoryBackupEnabled: true,
|
|
memoryBackupSchedule: "0 5 * * *",
|
|
memoryBackupRetention: 21,
|
|
memoryBackupDir: ".fusion/backups/custom-memory",
|
|
memoryBackupScope: "agents",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("research settings sections", () => {
|
|
beforeEach(() => {
|
|
mockFetchSettings.mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { researchView: true },
|
|
});
|
|
});
|
|
|
|
const openResearchGlobalSection = async () => {
|
|
await userEvent.click(await screen.findByRole("button", { name: /Research Defaults/i }));
|
|
};
|
|
|
|
const openResearchProjectSection = async () => {
|
|
await userEvent.click(await screen.findByRole("button", { name: /^Research$/i }));
|
|
};
|
|
|
|
it("saves global research defaults through updateGlobalSettings only", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchGlobalSection();
|
|
|
|
await userEvent.click(screen.getByText(/Advanced — external search providers/i));
|
|
const providerSelect = await screen.findByLabelText("Search Provider");
|
|
fireEvent.change(providerSelect, { target: { value: "tavily" } });
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
researchGlobalDefaults: expect.objectContaining({ searchProvider: "tavily" }),
|
|
}),
|
|
);
|
|
});
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ researchSettings: expect.anything() }),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("shows web search as always on in project research settings", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchProjectSection();
|
|
|
|
const webSearch = await screen.findByRole("checkbox", { name: /Web Search/i });
|
|
expect(webSearch).toBeChecked();
|
|
expect(webSearch).toBeDisabled();
|
|
expect(screen.getByText("Always on")).toBeInTheDocument();
|
|
expect(screen.getByText(/Web search is always enabled\. Configure the search provider under Research Defaults\./i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not mutate enabledSources.webSearch when toggling other sources", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { researchView: true },
|
|
researchSettings: {
|
|
enabled: true,
|
|
enabledSources: {
|
|
webSearch: false,
|
|
pageFetch: true,
|
|
github: false,
|
|
localDocs: true,
|
|
llmSynthesis: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchProjectSection();
|
|
|
|
const webSearch = await screen.findByRole("checkbox", { name: /Web Search/i });
|
|
expect(webSearch).toBeChecked();
|
|
await userEvent.click(webSearch);
|
|
expect(webSearch).toBeChecked();
|
|
|
|
await userEvent.click(screen.getByRole("checkbox", { name: "Page Fetch" }));
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
researchSettings: expect.objectContaining({
|
|
enabledSources: expect.objectContaining({
|
|
webSearch: false,
|
|
pageFetch: false,
|
|
}),
|
|
}),
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("saves project research settings through updateSettings only", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchProjectSection();
|
|
|
|
await userEvent.click(screen.getByLabelText("Enable research in this project"));
|
|
const maxConcurrent = await screen.findByLabelText("Max Concurrent Runs");
|
|
fireEvent.change(maxConcurrent, { target: { value: "4" } });
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
researchSettings: expect.objectContaining({
|
|
enabled: false,
|
|
limits: expect.objectContaining({ maxConcurrentRuns: 4 }),
|
|
}),
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ researchGlobalDefaults: expect.anything() }),
|
|
);
|
|
});
|
|
|
|
it("blocks save and shows inline error for invalid research limits", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchProjectSection();
|
|
|
|
const maxConcurrent = await screen.findByLabelText("Max Concurrent Runs");
|
|
fireEvent.change(maxConcurrent, { target: { value: "0" } });
|
|
await userEvent.click(screen.getByText("Save"));
|
|
|
|
expect(await screen.findByText("Research max concurrent runs must be at least 1.")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows builtin research provider guidance by default", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { researchView: true },
|
|
researchGlobalDefaults: {},
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchGlobalSection();
|
|
|
|
expect(await screen.findByText(/Built-in \(uses agent web tools\)/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/Research defaults are incomplete/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps external provider settings collapsed by default and reveals on expand", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { researchView: true },
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchGlobalSection();
|
|
|
|
const details = screen.getByText(/Advanced — external search providers/i).closest("details");
|
|
expect(details).not.toHaveAttribute("open");
|
|
|
|
await userEvent.click(screen.getByText(/Advanced — external search providers/i));
|
|
expect(details).toHaveAttribute("open");
|
|
expect(await screen.findByLabelText("SearXNG URL")).toBeInTheDocument();
|
|
expect(screen.getByText(/Open Authentication Settings/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps default max sources outside advanced details and groups provider controls", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchGlobalSection();
|
|
|
|
const details = screen.getByText(/Advanced — external search providers/i).closest("details");
|
|
const maxSourcesInput = screen.getByLabelText("Default Max Sources Per Run");
|
|
expect(details).toBeTruthy();
|
|
expect(maxSourcesInput.closest("details")).toBeNull();
|
|
expect(details).not.toContainElement(maxSourcesInput);
|
|
|
|
const builtInRadio = screen.getByLabelText(/Built-in \(uses agent web tools\)/i);
|
|
const providerGroup = builtInRadio.closest(".settings-research-provider-group");
|
|
expect(providerGroup).toBeTruthy();
|
|
expect(providerGroup).toContainElement(details);
|
|
expect(providerGroup).toContainElement(screen.getByText(/No API key required\./i));
|
|
});
|
|
|
|
it("groups project limits fields in one grid and keeps validation error visible", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchProjectSection();
|
|
|
|
const maxConcurrent = screen.getByLabelText("Max Concurrent Runs");
|
|
const maxSources = screen.getByLabelText("Max Sources Per Run");
|
|
const maxDuration = screen.getByLabelText("Max Duration (ms)");
|
|
const requestTimeout = screen.getByLabelText("Request Timeout (ms)");
|
|
|
|
const limitsGrid = maxConcurrent.closest(".settings-research-limits-grid");
|
|
expect(limitsGrid).toBeTruthy();
|
|
expect(maxSources.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
|
expect(maxDuration.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
|
expect(requestTimeout.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
|
|
|
fireEvent.change(maxConcurrent, { target: { value: "0" } });
|
|
await userEvent.click(screen.getByText("Save"));
|
|
expect(await screen.findByText("Research max concurrent runs must be at least 1.")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows missing credentials warning and routes CTA to Authentication", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { researchView: true },
|
|
researchGlobalDefaults: {
|
|
searchProvider: "brave",
|
|
},
|
|
});
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [
|
|
{ id: "brave", name: "Brave Search", type: "api_key", authenticated: false },
|
|
{ id: "tavily", name: "Tavily", type: "api_key", authenticated: true },
|
|
],
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openResearchGlobalSection();
|
|
|
|
expect(await screen.findByText(/Missing credentials for the selected research provider/i)).toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole("button", { name: "Open Authentication" }));
|
|
expect(await screen.findByRole("heading", { name: "Authentication" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to first visible section when initial section is unavailable", async () => {
|
|
renderModal({ initialSection: "unknown-section" as any });
|
|
await waitForSettingsModalReady();
|
|
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("memory dream trigger", () => {
|
|
const openMemorySection = async () => {
|
|
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
|
|
await userEvent.click(memorySectionButton);
|
|
};
|
|
|
|
it("shows Dream Now button when dreams are enabled", async () => {
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
memoryEnabled: true,
|
|
memoryDreamsEnabled: true,
|
|
memoryDreamsSchedule: "0 4 * * *",
|
|
});
|
|
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openMemorySection();
|
|
|
|
expect(await screen.findByRole("button", { name: "Dream Now" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("triggers dream processing from Dream Now button", async () => {
|
|
const addToast = vi.fn();
|
|
mockFetchSettings.mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
memoryEnabled: true,
|
|
memoryDreamsEnabled: true,
|
|
});
|
|
mockTriggerMemoryDreams.mockResolvedValueOnce({ success: true, summary: "done" });
|
|
|
|
renderModal({ addToast });
|
|
await waitForSettingsModalReady();
|
|
await openMemorySection();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: "Dream Now" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockTriggerMemoryDreams).toHaveBeenCalledWith(undefined);
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
|
|
});
|
|
|
|
it("hides Dream Now button when dreams are disabled", async () => {
|
|
renderModal();
|
|
await waitForSettingsModalReady();
|
|
await openMemorySection();
|
|
|
|
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
describe("plugin structured contribution contract fixtures", () => {
|
|
it("represents all required structured surfaces without componentPath", () => {
|
|
const contributions: PluginUiContributionEntry[] = [
|
|
{ pluginId: "plugin-a", contribution: { surface: "settings-provider-card", contributionId: "a", providerId: "openai", title: "OpenAI", providerType: "api_key" } },
|
|
{ pluginId: "plugin-a", contribution: { surface: "settings-config-section", contributionId: "b", sectionId: "openai", title: "OpenAI config", pluginSettingKeys: ["openai.apiKey"] } },
|
|
{ pluginId: "plugin-a", contribution: { surface: "onboarding-provider-card", contributionId: "c", providerId: "openai", title: "OpenAI", providerType: "api_key" } },
|
|
{ pluginId: "plugin-a", contribution: { surface: "onboarding-setup-help", contributionId: "d", title: "Help", body: "Use API key", bodyFormat: "text" } },
|
|
{ pluginId: "plugin-a", contribution: { surface: "onboarding-provider-recommendation", contributionId: "e", providerId: "openai", title: "Recommended", reason: "Fast setup" } },
|
|
{ pluginId: "plugin-a", contribution: { surface: "post-onboarding-recommendation", contributionId: "f", title: "Next step", description: "Enable memory" } },
|
|
];
|
|
|
|
expect(contributions).toHaveLength(6);
|
|
for (const entry of contributions) {
|
|
expect("componentPath" in entry.contribution).toBe(false);
|
|
}
|
|
});
|
|
});
|