diff --git a/flocks/config/config.py b/flocks/config/config.py index 9e5fc41fe..b8e03df25 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -349,6 +349,20 @@ class ToolOutputConfig(BaseModel): ) +class ToolFailureConfig(BaseModel): + """Repeated tool-failure handling.""" + + model_config = {"populate_by_name": True} + + disable_on_repeated_failure: bool = Field( + True, + alias="disableOnRepeatedFailure", + description=( + "Disable a standalone custom tool after repeated identical failures." + ), + ) + + class EnterpriseConfig(BaseModel): """Enterprise configuration""" @@ -375,6 +389,10 @@ class UIConfig(BaseModel): max_length=256, description="Relative path to a custom WebUI favicon stored in the user config directory.", ) + theme: Optional[Literal["light", "dark"]] = Field( + None, + description="WebUI theme preference. Persisted server-side so it survives browser/origin changes.", + ) @field_validator("display_name", mode="before") @classmethod @@ -675,6 +693,11 @@ class ConfigInfo(BaseModel): alias="toolOutput", description="Tool output size limits (read, truncation caps).", ) + tool_failure: Optional[ToolFailureConfig] = Field( + None, + alias="toolFailure", + description="Repeated tool-failure handling.", + ) experimental: Optional[ExperimentalConfig] = None # Memory system configuration (added for memory system integration) @@ -1372,7 +1395,13 @@ async def resolve_default_llm(cls) -> Optional[Dict[str, str]]: return None @classmethod - async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> None: + async def update( + cls, + config: ConfigInfo, + project_dir: Optional[Path] = None, + *, + channel_allow_from_deletions: Optional[set[str]] = None, + ) -> None: """ Update configuration @@ -1380,6 +1409,9 @@ async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> config: New configuration project_dir: Deprecated and ignored. Config is always written to the unified user config directory. + channel_allow_from_deletions: Channel IDs whose persisted + allowFrom field should be removed after a successful full + config validation and merge. """ _ = project_dir @@ -1396,6 +1428,13 @@ async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> # Write config_data = merged.model_dump(by_alias=True, exclude_none=True, mode="json") + if channel_allow_from_deletions: + channels = config_data.get("channels") + if isinstance(channels, dict): + for channel_id in channel_allow_from_deletions: + channel_cfg = channels.get(channel_id) + if isinstance(channel_cfg, dict): + channel_cfg.pop("allowFrom", None) config_file.write_text(json.dumps(config_data, indent=2), encoding="utf-8") # Clear cache diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 7f403f92c..885fc2f10 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -19,7 +19,7 @@ import re import xml.etree.ElementTree as ET from pathlib import Path -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Literal from fastapi import APIRouter, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from pydantic import BaseModel, Field @@ -35,6 +35,36 @@ log = Log.create(service="routes.config") +def _channel_allow_from_deletion_ids(config_data: Dict[str, Any]) -> set[str]: + """Return channel IDs whose PATCH explicitly removes allowFrom.""" + channels = config_data.get("channels") + if not isinstance(channels, dict): + return set() + + return { + channel_id + for channel_id, channel_cfg in channels.items() + if isinstance(channel_cfg, dict) + and "allowFrom" in channel_cfg + and channel_cfg.get("allowFrom") is None + } + + +def _normalize_slack_dm_policy(config_data: Dict[str, Any]) -> None: + """Keep Slack allowFrom and dmPolicy aligned for DM access control.""" + channels = config_data.get("channels") + if not isinstance(channels, dict): + return + slack = channels.get("slack") + if not isinstance(slack, dict) or "allowFrom" not in slack: + return + allow_from = slack.get("allowFrom") + if isinstance(allow_from, list) and len(allow_from) > 0: + slack["dmPolicy"] = "allowlist" + else: + slack["dmPolicy"] = "open" + + def _build_model_from_config( provider_id: str, model_id: str, @@ -131,6 +161,7 @@ class UIDisplayResponse(BaseModel): display_name: str = Field(alias="displayName") configured_display_name: Optional[str] = Field(None, alias="configuredDisplayName") favicon_url: Optional[str] = Field(None, alias="faviconUrl") + theme: Optional[Literal["light", "dark"]] = Field(None) class UIConfigUpdateRequest(BaseModel): @@ -139,6 +170,18 @@ class UIConfigUpdateRequest(BaseModel): model_config = {"populate_by_name": True} display_name: Optional[str] = Field(None, alias="displayName") + theme: Optional[Literal["light", "dark"]] = Field(None) + + +class ToolFailurePreference(BaseModel): + """Repeated tool-failure preference exposed to the WebUI.""" + + model_config = {"populate_by_name": True} + + disable_on_repeated_failure: bool = Field( + ..., + alias="disableOnRepeatedFailure", + ) DEFAULT_UI_DISPLAY_NAME = "Flocks" @@ -400,16 +443,24 @@ def _persist_ui_section(data: Dict[str, Any], ui_section: Dict[str, Any]) -> Non ConfigWriter._write_raw(data) +def _effective_tool_failure_preference(config: ConfigInfoModel) -> bool: + if config.tool_failure is None: + return True + return config.tool_failure.disable_on_repeated_failure + + @router.get("/ui-display", response_model=UIDisplayResponse, summary="Get public UI display name") async def get_ui_display() -> UIDisplayResponse: """Return only the effective WebUI display name for public screens.""" try: complete_config = await Config.get() display_name, configured_display_name = _effective_display_name(complete_config) + theme = complete_config.ui.theme if complete_config.ui else None return UIDisplayResponse( displayName=display_name, configuredDisplayName=configured_display_name, faviconUrl=_favicon_url(complete_config), + theme=theme, ) except Exception as e: log.error("config.ui_display.get.error", {"error": str(e)}) @@ -420,7 +471,10 @@ async def get_ui_display() -> UIDisplayResponse: async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse: """Update visible WebUI display preferences.""" try: - ui_config = UIConfig.model_validate({"displayName": request.display_name}) + ui_config = UIConfig.model_validate({ + "displayName": request.display_name, + "theme": request.theme, + }) data = ConfigWriter._read_raw() ui_section = _get_or_create_ui_section(data) @@ -429,6 +483,11 @@ async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse: else: ui_section.pop("displayName", None) + if ui_config.theme is not None: + ui_section["theme"] = ui_config.theme + else: + ui_section.pop("theme", None) + _persist_ui_section(data, ui_section) return await get_ui_display() except Exception as e: @@ -521,6 +580,47 @@ async def reset_ui_favicon() -> UIDisplayResponse: return await get_ui_display() +@router.get( + "/tool-failure", + response_model=ToolFailurePreference, + summary="Get repeated tool-failure preference", +) +async def get_tool_failure_preference() -> ToolFailurePreference: + """Return whether repeated identical failures automatically disable tools.""" + try: + config = await Config.get() + return ToolFailurePreference( + disableOnRepeatedFailure=_effective_tool_failure_preference(config) + ) + except Exception as e: + log.error("config.tool_failure.get.error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch( + "/tool-failure", + response_model=ToolFailurePreference, + summary="Update repeated tool-failure preference", +) +async def update_tool_failure_preference( + request: ToolFailurePreference, +) -> ToolFailurePreference: + """Update only the repeated-failure switch in flocks.json.""" + try: + data = ConfigWriter._read_raw() + existing = data.get("toolFailure", data.get("tool_failure", {})) + section = dict(existing) if isinstance(existing, dict) else {} + section.pop("disable_on_repeated_failure", None) + section["disableOnRepeatedFailure"] = request.disable_on_repeated_failure + data.pop("tool_failure", None) + data["toolFailure"] = section + ConfigWriter._write_raw(data) + return await get_tool_failure_preference() + except Exception as e: + log.error("config.tool_failure.update.error", {"error": str(e)}) + raise HTTPException(status_code=400, detail=str(e)) + + @router.get("", summary="Get configuration") async def get_config() -> Dict[str, Any]: """ @@ -554,6 +654,9 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: flocks.json, so that plaintext secrets never land in that file. """ try: + channel_allow_from_deletions = _channel_allow_from_deletion_ids(config_data) + _normalize_slack_dm_policy(config_data) + # Extract channel sensitive fields into .secret.json before persisting if "channels" in config_data and isinstance(config_data.get("channels"), dict): from flocks.security.channel_secrets import extract_channel_secrets @@ -563,7 +666,10 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: config = ConfigInfoModel.model_validate(config_data) # Update project config - await Config.update(config) + await Config.update( + config, + channel_allow_from_deletions=channel_allow_from_deletions, + ) # Clear cache to reload Config.clear_cache() diff --git a/webui/index.html b/webui/index.html index a62686330..e53b34bbd 100644 --- a/webui/index.html +++ b/webui/index.html @@ -14,6 +14,7 @@ document.documentElement.classList.add('dark'); document.documentElement.style.colorScheme = 'dark'; } else { + document.documentElement.classList.remove('dark'); document.documentElement.style.colorScheme = 'light'; } } catch (error) { diff --git a/webui/src/api/uiConfig.ts b/webui/src/api/uiConfig.ts index 7c5f73da4..e1685986b 100644 --- a/webui/src/api/uiConfig.ts +++ b/webui/src/api/uiConfig.ts @@ -4,10 +4,12 @@ export interface UIDisplayConfig { displayName: string; configuredDisplayName?: string | null; faviconUrl?: string | null; + theme?: 'light' | 'dark' | null; } export interface UIConfigUpdate { displayName?: string | null; + theme?: 'light' | 'dark' | null; } export const uiConfigApi = { diff --git a/webui/src/contexts/ThemeContext.test.tsx b/webui/src/contexts/ThemeContext.test.tsx index 1ec0f1cab..1ced6f406 100644 --- a/webui/src/contexts/ThemeContext.test.tsx +++ b/webui/src/contexts/ThemeContext.test.tsx @@ -4,6 +4,17 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ThemeContext, ThemeProvider } from './ThemeContext'; +const { uiConfigApi } = vi.hoisted(() => ({ + uiConfigApi: { + getDisplay: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('@/api/uiConfig', () => ({ + uiConfigApi, +})); + function ThemeProbe() { const { theme, effectiveTheme, toggleTheme, setTheme, setTemporaryThemeOverride } = useContext(ThemeContext); @@ -46,15 +57,17 @@ function mockPreferredScheme(matchesDark: boolean) { describe('ThemeProvider', () => { beforeEach(() => { + vi.clearAllMocks(); localStorage.clear(); document.documentElement.classList.remove('dark'); document.documentElement.style.colorScheme = ''; mockPreferredScheme(false); + // Default: server has no theme configured yet + uiConfigApi.getDisplay.mockResolvedValue({ displayName: 'Flocks', theme: null }); + uiConfigApi.update.mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }); }); - it('defaults to light when no stored theme exists', async () => { - mockPreferredScheme(true); - + it('defaults to light when no stored or system theme exists', async () => { render( @@ -67,8 +80,7 @@ describe('ThemeProvider', () => { await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('light')); }); - it('prefers the stored theme over system preference', async () => { - localStorage.setItem('flocks_theme', 'light'); + it('defaults to dark when system prefers dark and no stored theme', async () => { mockPreferredScheme(true); render( @@ -77,12 +89,68 @@ describe('ThemeProvider', () => { , ); + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass('dark'); + expect(document.documentElement.style.colorScheme).toBe('dark'); + await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('dark')); + }); + + it('prefers localStorage over system preference', async () => { + localStorage.setItem('flocks_theme', 'light'); + mockPreferredScheme(true); // system is dark, but localStorage says light + + render( + + + , + ); + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); expect(document.documentElement).not.toHaveClass('dark'); expect(document.documentElement.style.colorScheme).toBe('light'); await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('light')); }); + it('adopts server theme when it differs from localStorage', async () => { + localStorage.setItem('flocks_theme', 'light'); + uiConfigApi.getDisplay.mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }); + + render( + + + , + ); + + // Starts with localStorage value (light)... + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); + + // ...then server value (dark) takes over + await waitFor(() => expect(screen.getByTestId('theme-value')).toHaveTextContent('dark')); + expect(document.documentElement).toHaveClass('dark'); + expect(document.documentElement.style.colorScheme).toBe('dark'); + await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('dark')); + }); + + it('keeps localStorage value when server has no theme', async () => { + localStorage.setItem('flocks_theme', 'dark'); + uiConfigApi.getDisplay.mockResolvedValue({ displayName: 'Flocks', theme: null }); + + render( + + + , + ); + + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + + // Wait for server fetch to complete + await waitFor(() => expect(uiConfigApi.getDisplay).toHaveBeenCalled()); + + // Theme should still be dark (server had no value to override with) + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass('dark'); + }); + it('toggles and persists the dark class on the document root', async () => { const user = userEvent.setup(); @@ -106,6 +174,79 @@ describe('ThemeProvider', () => { await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('dark')); }); + it('persists theme to server after user toggle', async () => { + const user = userEvent.setup(); + uiConfigApi.update.mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }); + + render( + + + , + ); + + // Wait for initial server fetch + await waitFor(() => expect(uiConfigApi.getDisplay).toHaveBeenCalled()); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'toggle' })); + }); + + await waitFor(() => { + expect(uiConfigApi.update).toHaveBeenCalledWith({ theme: 'dark' }); + }); + }); + + it('survives localStorage being unavailable', async () => { + const originalStorage = window.localStorage; + // Simulate localStorage throwing on access + Object.defineProperty(window, 'localStorage', { + configurable: true, + writable: true, + value: { + getItem: () => { throw new Error('denied'); }, + setItem: () => { throw new Error('denied'); }, + }, + }); + + render( + + + , + ); + + // Should default to light without crashing + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); + expect(document.documentElement).not.toHaveClass('dark'); + + // Restore + Object.defineProperty(window, 'localStorage', { + configurable: true, + writable: true, + value: originalStorage, + }); + }); + + it('survives server fetch failure', async () => { + localStorage.setItem('flocks_theme', 'dark'); + uiConfigApi.getDisplay.mockRejectedValue(new Error('network error')); + + render( + + + , + ); + + // Starts with localStorage value + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + + // Wait for failed fetch to complete + await waitFor(() => expect(uiConfigApi.getDisplay).toHaveBeenCalled()); + + // Should stay dark (localStorage fallback) + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass('dark'); + }); + it('temporarily overrides the displayed theme without changing the stored preference', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_theme', 'light'); @@ -124,11 +265,11 @@ describe('ThemeProvider', () => { await user.click(screen.getByRole('button', { name: 'temp dark' })); }); - expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); - expect(screen.getByTestId('effective-theme-value')).toHaveTextContent('dark'); + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); // persisted unchanged + expect(screen.getByTestId('effective-theme-value')).toHaveTextContent('dark'); // overridden expect(document.documentElement).toHaveClass('dark'); expect(document.documentElement.style.colorScheme).toBe('dark'); - expect(localStorage.getItem('flocks_theme')).toBe('light'); + expect(localStorage.getItem('flocks_theme')).toBe('light'); // localStorage unchanged await act(async () => { await user.click(screen.getByRole('button', { name: 'clear temp' })); diff --git a/webui/src/contexts/ThemeContext.tsx b/webui/src/contexts/ThemeContext.tsx index 3aaf1089f..88da45684 100644 --- a/webui/src/contexts/ThemeContext.tsx +++ b/webui/src/contexts/ThemeContext.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from 'react'; +import { uiConfigApi } from '@/api/uiConfig'; export type Theme = 'light' | 'dark'; @@ -23,13 +24,36 @@ const ThemeContext = createContext({ function getInitialTheme(): Theme { if (typeof window === 'undefined') return 'light'; - const storage = window.localStorage; - const stored = typeof storage?.getItem === 'function' ? storage.getItem(THEME_STORAGE_KEY) : null; - if (stored === 'light' || stored === 'dark') return stored; + try { + const stored = window.localStorage?.getItem(THEME_STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + + // Match index.html behavior: respect system preference when no stored value. + if ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ) { + return 'dark'; + } + } catch { + // localStorage may be unavailable in restricted browser contexts. + } return 'light'; } +function saveThemeToStorage(theme: Theme): boolean { + try { + if (typeof window.localStorage?.setItem === 'function') { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + return true; + } + } catch { + // Storage full, private browsing, or other restriction — non-fatal. + } + return false; +} + function applyTheme(theme: Theme) { const root = document.documentElement; root.classList.toggle('dark', theme === 'dark'); @@ -39,18 +63,50 @@ function applyTheme(theme: Theme) { export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setThemeState] = useState(getInitialTheme); const [temporaryThemeOverride, setTemporaryThemeOverride] = useState(null); + const [serverThemeLoaded, setServerThemeLoaded] = useState(false); const effectiveTheme = temporaryThemeOverride ?? theme; useLayoutEffect(() => { applyTheme(effectiveTheme); }, [effectiveTheme]); + // Persist to localStorage on every change (fast, sync — used by index.html anti-flash script). useEffect(() => { - if (typeof window.localStorage?.setItem === 'function') { - window.localStorage.setItem(THEME_STORAGE_KEY, theme); - } + saveThemeToStorage(theme); }, [theme]); + // On mount, fetch server-side theme preference (durable across browsers / origins). + useEffect(() => { + let cancelled = false; + uiConfigApi + .getDisplay() + .then((config) => { + if (cancelled) return; + if (config.theme && (config.theme === 'light' || config.theme === 'dark')) { + setThemeState(config.theme); + saveThemeToStorage(config.theme); + } + }) + .catch(() => { + // Server unavailable — localStorage value is the fallback, already applied. + }) + .finally(() => { + if (!cancelled) setServerThemeLoaded(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + // Persist to server on every change (after initial load, to avoid re-saving the fetched value). + useEffect(() => { + if (!serverThemeLoaded) return; + uiConfigApi.update({ theme }).catch(() => { + // Non-critical — localStorage already holds the latest value. + }); + }, [theme, serverThemeLoaded]); + const setTheme = useCallback((nextTheme: Theme) => { setThemeState(nextTheme); }, []); diff --git a/webui/src/pages/Settings/index.test.tsx b/webui/src/pages/Settings/index.test.tsx index 35d5c605d..47890ab97 100644 --- a/webui/src/pages/Settings/index.test.tsx +++ b/webui/src/pages/Settings/index.test.tsx @@ -1,20 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import SettingsPage from './index'; import { ThemeContext, type Theme } from '@/contexts/ThemeContext'; import { ToastProvider } from '@/components/common/Toast'; -const { changeLanguage, flocksproUsersApi, setTheme, useAuth } = vi.hoisted(() => ({ +const { changeLanguage, flocksproUsersApi, setTheme, toolFailureConfigApi, uiConfigApi, useAuth } = vi.hoisted(() => ({ changeLanguage: vi.fn(), flocksproUsersApi: { hasCapability: vi.fn(), }, setTheme: vi.fn(), + toolFailureConfigApi: { + get: vi.fn(), + update: vi.fn(), + }, + uiConfigApi: { + getDisplay: vi.fn().mockResolvedValue({ displayName: 'Flocks', theme: null }), + update: vi.fn().mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }), + }, useAuth: vi.fn(), })); +vi.mock('@/api/uiConfig', () => ({ + uiConfigApi, +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -33,6 +45,10 @@ vi.mock('@/api/flocksproUsers', () => ({ flocksproUsersApi, })); +vi.mock('@/api/toolFailureConfig', () => ({ + toolFailureConfigApi, +})); + vi.mock('@/pages/Config', () => ({ default: () =>
account page
, })); @@ -92,6 +108,10 @@ describe('SettingsPage', () => { beforeEach(() => { vi.clearAllMocks(); flocksproUsersApi.hasCapability.mockResolvedValue(true); + toolFailureConfigApi.get.mockResolvedValue({ disableOnRepeatedFailure: true }); + toolFailureConfigApi.update.mockImplementation(async (disableOnRepeatedFailure: boolean) => ({ + disableOnRepeatedFailure, + })); useAuth.mockReturnValue({ user: { id: 'user-1', @@ -117,6 +137,27 @@ describe('SettingsPage', () => { expect(setTheme).toHaveBeenCalledWith('dark'); }); + it('loads and updates repeated tool failure auto-disable', async () => { + const user = userEvent.setup(); + toolFailureConfigApi.get.mockResolvedValue({ disableOnRepeatedFailure: false }); + + renderSettings('/settings/preferences'); + + const autoDisableSwitch = await screen.findByRole('switch', { + name: 'toolFailureAutoDisable', + }); + await waitFor(() => { + expect(autoDisableSwitch).toHaveAttribute('aria-checked', 'false'); + }); + + await user.click(autoDisableSwitch); + + await waitFor(() => { + expect(toolFailureConfigApi.update).toHaveBeenCalledWith(true); + expect(autoDisableSwitch).toHaveAttribute('aria-checked', 'true'); + }); + }); + it('redirects legacy model and channel settings URLs to workspace pages', async () => { const { unmount } = renderSettings('/settings/models'); @@ -196,7 +237,7 @@ describe('SettingsPage', () => { renderSettings('/settings/flockspro'); expect(await screen.findByRole('heading', { name: 'settingsPreferences' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Flocks' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Flocks Pro' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'auditLogs' })).not.toBeInTheDocument(); }); }); diff --git a/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx b/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx index 926803175..3829a7592 100644 --- a/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx +++ b/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx @@ -9,6 +9,13 @@ const { listWorkspacesMock } = vi.hoisted(() => ({ listWorkspacesMock: vi.fn(), })); +vi.mock('@/api/uiConfig', () => ({ + uiConfigApi: { + getDisplay: vi.fn().mockResolvedValue({ displayName: 'Flocks', theme: null }), + update: vi.fn(), + }, +})); + vi.mock('@/api/webuiContractPages', () => ({ webuiContractPagesAPI: { listWorkspaces: listWorkspacesMock,