Files
CherryHQ-cherry-studio/tests/renderer.setup.ts
SuYao 43f2a6bc54 refactor(ui): overhaul icon system and migrate Avatar to shadcn/radix (#12858)
### What this PR does

Before this PR:
- Provider/model icons were scattered image imports (PNG/WebP) with no
unified API
- Avatar primitive was based on HeroUI with hardcoded `shadow-lg` and
`border-[0.5px]`
- Full-bleed and padded avatar variants used different rendering
approaches
- Multiple files duplicated IIFE patterns for rendering CompoundIcon vs
string logos
- No type-safe icon catalogs

After this PR:
- **Compound Icon API**: Each icon exposes `.Color`, `.Mono`, and
`.Avatar` sub-components via a unified `CompoundIcon` interface
- **Auto-generated catalogs**: `PROVIDER_ICON_CATALOG` and
`MODEL_ICON_CATALOG` with `resolveProviderIcon` / `resolveModelIcon`
helpers
- **SVG pipeline**: Codegen processes SVGs → generates Color/Mono/Avatar
components
- **Avatar migrated to shadcn/radix**: Replaced HeroUI Avatar with
`Avatar` + `AvatarFallback` pattern, removed hardcoded shadow/border
- **EmojiAvatar moved**: From `primitives/Avatar/` to
`composites/EmojiAvatar/`
- **LogoAvatar component**: Reusable component replacing repeated IIFE
patterns across 5+ files
- **getMCPProviderLogo helper**: Centralized MCP provider icon mapping
- 80+ monochrome icon components, stroke attribute support, deprecated
logos cleanup

<img width="714" height="820" alt="image"
src="https://github.com/user-attachments/assets/a3f14348-5781-494a-8c3b-1f40391e2ec0"
/>

<img width="1008" height="593" alt="image"
src="https://github.com/user-attachments/assets/8ba7fa42-fa33-4e49-ba76-647ba1438e0c"
/>

### Why we need it and why it was done in this way

The v2 refactoring requires moving away from HeroUI toward shadcn/radix
primitives, and needs a scalable, type-safe icon system to replace
scattered image imports. The compound icon pattern (`Icon.Color`,
`Icon.Mono`, `Icon.Avatar`) provides a consistent API while enabling
tree-shaking. The Avatar primitive now uses radix-based `Avatar` +
`AvatarFallback`, aligning with the project's shadcn migration.

The following tradeoffs were made:
- Each icon is a separate TSX file for tree-shaking and lazy loading
support
- Avatar components use `AvatarFallback` to render icons — no image
loading overhead

The following alternatives were considered:
- Runtime SVG color manipulation — rejected for better performance and
consistency
- Keeping HeroUI Avatar — rejected as it conflicts with v2 shadcn
migration goals

### Breaking changes

- Avatar primitive API changed: `HeroUI Avatar` → shadcn `Avatar` +
`AvatarFallback` + `AvatarImage`
- `EmojiAvatar` moved from `primitives/Avatar` to
`composites/EmojiAvatar`
- `shadow-lg` and `border-[0.5px]` removed from generated avatars — now
opt-in via `className`

### Special notes for your reviewer

- ~214 files changed, but the bulk are auto-generated avatar/icon
components under `packages/ui/src/components/icons/`
- Key files to review:
- `packages/ui/src/components/primitives/avatar.tsx` — new shadcn Avatar
primitive
- `packages/ui/scripts/codegen.ts` — avatar generation using
AvatarFallback
- `src/renderer/src/components/Icons/LogoAvatar.tsx` — reusable logo
renderer
- Renderer files using the new Avatar API (Sidebar, UserPopup,
ModelAvatar, etc.)

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: N/A - internal component changes

### Release note

```release-note
NONE
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2026-03-16 17:13:25 +08:00

236 lines
8.3 KiB
TypeScript

import '@testing-library/jest-dom/vitest'
import { createRequire } from 'node:module'
import { styleSheetSerializer } from 'jest-styled-components/serializer'
import { expect, vi } from 'vitest'
const require = createRequire(import.meta.url)
const bufferModule = require('buffer')
if (!bufferModule.SlowBuffer) {
bufferModule.SlowBuffer = bufferModule.Buffer
}
expect.addSnapshotSerializer(styleSheetSerializer)
// Mock LoggerService globally for renderer tests
vi.mock('@logger', async () => {
const { MockRendererLoggerService, mockRendererLoggerService } = await import('./__mocks__/RendererLoggerService')
return {
LoggerService: MockRendererLoggerService,
loggerService: mockRendererLoggerService
}
})
// Mock PreferenceService globally for renderer tests
vi.mock('@data/PreferenceService', async () => {
const { MockPreferenceService } = await import('./__mocks__/renderer/PreferenceService')
return MockPreferenceService
})
// Mock DataApiService globally for renderer tests
vi.mock('@data/DataApiService', async () => {
const { MockDataApiService } = await import('./__mocks__/renderer/DataApiService')
return MockDataApiService
})
// Mock CacheService globally for renderer tests
vi.mock('@data/CacheService', async () => {
const { MockCacheService } = await import('./__mocks__/renderer/CacheService')
return MockCacheService
})
// Mock useDataApi hooks globally for renderer tests
vi.mock('@data/hooks/useDataApi', async () => {
const { MockUseDataApi } = await import('./__mocks__/renderer/useDataApi')
return MockUseDataApi
})
// Mock usePreference hooks globally for renderer tests
vi.mock('@data/hooks/usePreference', async () => {
const { MockUsePreference } = await import('./__mocks__/renderer/usePreference')
return MockUsePreference
})
// Mock useCache hooks globally for renderer tests
vi.mock('@data/hooks/useCache', async () => {
const { MockUseCache } = await import('./__mocks__/renderer/useCache')
return MockUseCache
})
// Mock PreferenceService globally for renderer tests
vi.mock('@data/PreferenceService', async () => {
const { MockPreferenceService } = await import('./__mocks__/renderer/PreferenceService')
return MockPreferenceService
})
// Mock DataApiService globally for renderer tests
vi.mock('@data/DataApiService', async () => {
const { MockDataApiService } = await import('./__mocks__/renderer/DataApiService')
return MockDataApiService
})
// Mock CacheService globally for renderer tests
vi.mock('@data/CacheService', async () => {
const { MockCacheService } = await import('./__mocks__/renderer/CacheService')
return MockCacheService
})
// Mock useDataApi hooks globally for renderer tests
vi.mock('@data/hooks/useDataApi', async () => {
const { MockUseDataApi } = await import('./__mocks__/renderer/useDataApi')
return MockUseDataApi
})
// Mock usePreference hooks globally for renderer tests
vi.mock('@data/hooks/usePreference', async () => {
const { MockUsePreference } = await import('./__mocks__/renderer/usePreference')
return MockUsePreference
})
// Mock useCache hooks globally for renderer tests
vi.mock('@data/hooks/useCache', async () => {
const { MockUseCache } = await import('./__mocks__/renderer/useCache')
return MockUseCache
})
// Mock uuid globally for renderer tests
let uuidCounter = 0
vi.mock('uuid', () => ({
v4: () => 'test-uuid-' + ++uuidCounter
}))
vi.mock('axios', () => {
const defaultAxiosMock = {
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
// You can add other axios methods like put, delete etc. as needed
}
const isAxiosError = (error: unknown): error is { isAxiosError?: boolean } =>
Boolean((error as { isAxiosError?: boolean } | undefined)?.isAxiosError)
return {
default: defaultAxiosMock,
isAxiosError
}
})
// Mock ResizeObserver for jsdom environment
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
)
vi.stubGlobal('electron', {
ipcRenderer: {
on: vi.fn(),
send: vi.fn(),
invoke: vi.fn().mockResolvedValue(undefined)
}
})
vi.stubGlobal('api', {
file: {
read: vi.fn().mockResolvedValue('[]'),
writeWithId: vi.fn().mockResolvedValue(undefined)
}
})
// Mock @cherrystudio/ui globally for renderer tests
vi.mock('@cherrystudio/ui', () => {
const React = require('react')
return {
Button: ({ children, onPress, disabled, isDisabled, startContent, ...props }) =>
React.createElement(
'button',
{ ...props, onClick: onPress, disabled: disabled || isDisabled },
startContent,
children
),
Tooltip: ({ children, title, content, mouseEnterDelay, ...props }) => {
// Support both old (title) and new (content) API
const tooltipText = content || title
return React.createElement(
'div',
{
...props,
'data-testid': 'tooltip',
...(tooltipText && { 'data-title': tooltipText }),
'data-mouse-enter-delay': mouseEnterDelay
},
children,
tooltipText ? React.createElement('div', { 'data-testid': 'tooltip-content' }, tooltipText) : null
)
},
CodeEditor: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'code-editor' }, children),
Flex: ({ children, ...props }) => React.createElement('div', { ...props, 'data-testid': 'flex' }, children),
ExpandableText: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'expandable-text' }, children),
// Add other commonly used UI components
Box: ({ children, ...props }) => React.createElement('div', { ...props, 'data-testid': 'box' }, children),
Center: ({ children, ...props }) => React.createElement('div', { ...props, 'data-testid': 'center' }, children),
ColFlex: ({ children, ...props }) => React.createElement('div', { ...props, 'data-testid': 'col-flex' }, children),
RowFlex: ({ children, ...props }) => React.createElement('div', { ...props, 'data-testid': 'row-flex' }, children),
SpaceBetweenRowFlex: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'space-between-row-flex' }, children),
Ellipsis: ({ children, ...props }) => React.createElement('div', { ...props, 'data-testid': 'ellipsis' }, children),
TextBadge: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'text-badge' }, children),
HelpTooltip: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'help-tooltip' }, children),
InfoTooltip: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'info-tooltip' }, children),
Scrollbar: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'scrollbar' }, children),
Avatar: ({ children, src, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'avatar' }, src ? null : children),
EmojiAvatar: ({ children, ...props }) =>
React.createElement('div', { ...props, 'data-testid': 'emoji-avatar' }, children),
Switch: ({ isSelected, onValueChange, ...props }) =>
React.createElement('input', {
...props,
type: 'checkbox',
checked: isSelected,
onChange: (e) => onValueChange?.(e.target.checked),
'data-testid': 'switch'
}),
// Icon registry stubs
PROVIDER_ICON_CATALOG: {},
MODEL_ICON_CATALOG: {},
resolveProviderIcon: () => undefined,
resolveModelIcon: () => undefined,
resolveModelToProviderIcon: () => undefined,
resolveIcon: () => undefined
}
})
if (typeof globalThis.localStorage === 'undefined' || typeof (globalThis.localStorage as any).getItem !== 'function') {
let store = new Map<string, string>()
const localStorageMock = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value))
},
removeItem: (key: string) => {
store.delete(key)
},
clear: () => {
store.clear()
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size
}
}
vi.stubGlobal('localStorage', localStorageMock)
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
}
}