diff --git a/package.json b/package.json index 77a26ebc..f6c2b48b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "build": "electron-vite build", "preview": "electron-vite preview", "dist": "electron-vite build --mode production && electron-builder --mac --dir", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "doctor": "bash scripts/doctor.sh", "postinstall": "electron-builder install-app-deps && bash scripts/patch-dev-icon.sh" }, @@ -64,6 +67,9 @@ "react-dom": "^19.0.0", "tailwindcss": "^4.2.1", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "@vitest/coverage-v8": "^4.1.0", + "jsdom": "^29.0.0", + "vitest": "^4.1.0" } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6cf443b3..90fd63c4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -52,6 +52,7 @@ export default function App() { tabs: s.tabs.map((t, i) => (i === 0 ? { ...t, id: tabId } : t)), activeTabId: tabId, })) + useSessionStore.getState().restoreLastSession(tabId).catch(() => {}) }).catch(() => {}) } }) diff --git a/src/renderer/components/SettingsPopover.tsx b/src/renderer/components/SettingsPopover.tsx index f184fb0a..e199c88a 100644 --- a/src/renderer/components/SettingsPopover.tsx +++ b/src/renderer/components/SettingsPopover.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { motion } from 'framer-motion' -import { DotsThree, Bell, ArrowsOutSimple, Moon } from '@phosphor-icons/react' +import { DotsThree, Bell, ArrowsOutSimple, Moon, FolderOpen } from '@phosphor-icons/react' import { useThemeStore } from '../theme' import { useSessionStore } from '../stores/sessionStore' import { usePopoverLayer } from './PopoverLayer' @@ -50,6 +50,8 @@ export function SettingsPopover() { const setThemeMode = useThemeStore((s) => s.setThemeMode) const expandedUI = useThemeStore((s) => s.expandedUI) const setExpandedUI = useThemeStore((s) => s.setExpandedUI) + const useLastFolder = useThemeStore((s) => s.useLastFolder) + const setUseLastFolder = useThemeStore((s) => s.setUseLastFolder) const isExpanded = useSessionStore((s) => s.isExpanded) const popoverLayer = usePopoverLayer() const colors = useColors() @@ -204,6 +206,26 @@ export function SettingsPopover() {
+ {/* Restore last folder */} +
+
+
+ +
+ Restore last folder +
+
+ +
+
+ +
+ {/* Theme */}
diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx index 819dab86..4a699b43 100644 --- a/src/renderer/components/StatusBar.tsx +++ b/src/renderer/components/StatusBar.tsx @@ -345,7 +345,7 @@ export function StatusBar() { disabled={isRunning} > - {tab.hasChosenDirectory ? compactPath(tab.workingDirectory) : '—'} + {compactPath(tab.workingDirectory)} {hasExtraDirs && ( +{tab.additionalDirs.length} )} diff --git a/src/renderer/stores/sessionStore.test.ts b/src/renderer/stores/sessionStore.test.ts new file mode 100644 index 00000000..bd331ec4 --- /dev/null +++ b/src/renderer/stores/sessionStore.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { loadLastSession, useSessionStore } from './sessionStore' +import { useThemeStore } from '../theme' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function resetStore() { + const homeDir = '/home/user' + useSessionStore.setState({ + tabs: [{ + id: 'tab-1', + claudeSessionId: null, + status: 'idle', + activeRequestId: null, + hasUnread: false, + currentActivity: '', + permissionQueue: [], + permissionDenied: null, + attachments: [], + messages: [], + title: 'New Tab', + lastResult: null, + sessionModel: null, + sessionTools: [], + sessionMcpServers: [], + sessionSkills: [], + sessionVersion: null, + queuedPrompts: [], + workingDirectory: homeDir, + hasChosenDirectory: false, + additionalDirs: [], + }], + activeTabId: 'tab-1', + isExpanded: false, + staticInfo: { version: '1.0', email: null, subscriptionType: null, projectPath: homeDir, homePath: homeDir }, + preferredModel: null, + permissionMode: 'ask', + }) +} + +// ─── Last-session persistence ───────────────────────────────────────────────── + +describe('loadLastSession', () => { + it('returns null when localStorage is empty', () => { + expect(loadLastSession()).toBeNull() + }) + + it('returns null for malformed JSON', () => { + localStorage.setItem('clui-last-session', 'not-json') + expect(loadLastSession()).toBeNull() + }) + + it('returns null when folder field is missing', () => { + localStorage.setItem('clui-last-session', JSON.stringify({ other: 'value' })) + expect(loadLastSession()).toBeNull() + }) + + it('returns null when folder is not a string', () => { + localStorage.setItem('clui-last-session', JSON.stringify({ folder: 42 })) + expect(loadLastSession()).toBeNull() + }) + + it('returns the folder when data is valid', () => { + localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/projects/myapp' })) + expect(loadLastSession()).toEqual({ folder: '/projects/myapp' }) + }) +}) + +// ─── setBaseDirectory ───────────────────────────────────────────────────────── + +describe('setBaseDirectory', () => { + beforeEach(resetStore) + + it('updates workingDirectory and hasChosenDirectory on the active tab', () => { + useSessionStore.getState().setBaseDirectory('/new/path') + const tab = useSessionStore.getState().tabs[0] + expect(tab.workingDirectory).toBe('/new/path') + expect(tab.hasChosenDirectory).toBe(true) + }) + + it('clears claudeSessionId and additionalDirs', () => { + useSessionStore.setState((s) => ({ + tabs: s.tabs.map((t) => ({ ...t, claudeSessionId: 'old-session', additionalDirs: ['/extra'] })), + })) + useSessionStore.getState().setBaseDirectory('/new/path') + const tab = useSessionStore.getState().tabs[0] + expect(tab.claudeSessionId).toBeNull() + expect(tab.additionalDirs).toEqual([]) + }) + + it('persists the folder to localStorage', () => { + useSessionStore.getState().setBaseDirectory('/saved/dir') + const stored = JSON.parse(localStorage.getItem('clui-last-session')!) + expect(stored.folder).toBe('/saved/dir') + }) + + it('calls resetTabSession on the main process', () => { + useSessionStore.getState().setBaseDirectory('/any/path') + expect(window.clui.resetTabSession).toHaveBeenCalledWith('tab-1') + }) +}) + +// ─── restoreLastSession ──────────────────────────────────────────────────────── + +describe('restoreLastSession', () => { + beforeEach(() => { + resetStore() + useThemeStore.setState({ useLastFolder: true }) + }) + + it('does nothing when useLastFolder is false', async () => { + useThemeStore.setState({ useLastFolder: false }) + localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/saved' })) + await useSessionStore.getState().restoreLastSession('tab-1') + expect(useSessionStore.getState().tabs[0].workingDirectory).toBe('/home/user') + }) + + it('does nothing when localStorage has no saved session', async () => { + await useSessionStore.getState().restoreLastSession('tab-1') + expect(useSessionStore.getState().tabs[0].workingDirectory).toBe('/home/user') + expect(useSessionStore.getState().tabs[0].hasChosenDirectory).toBe(false) + }) + + it('restores the folder from localStorage', async () => { + localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/restored/project' })) + await useSessionStore.getState().restoreLastSession('tab-1') + const tab = useSessionStore.getState().tabs[0] + expect(tab.workingDirectory).toBe('/restored/project') + expect(tab.hasChosenDirectory).toBe(true) + }) + + it('only updates the target tab, not others', async () => { + useSessionStore.setState((s) => ({ + tabs: [ + ...s.tabs, + { ...s.tabs[0], id: 'tab-2', workingDirectory: '/other' }, + ], + })) + localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/restored' })) + await useSessionStore.getState().restoreLastSession('tab-1') + const tab2 = useSessionStore.getState().tabs.find((t) => t.id === 'tab-2')! + expect(tab2.workingDirectory).toBe('/other') + }) +}) + +// ─── handleNormalizedEvent — text streaming ─────────────────────────────────── + +describe('handleNormalizedEvent: text_chunk', () => { + beforeEach(resetStore) + + it('appends a new assistant message for the first chunk', () => { + useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'Hello' }) + const msgs = useSessionStore.getState().tabs[0].messages + expect(msgs).toHaveLength(1) + expect(msgs[0]).toMatchObject({ role: 'assistant', content: 'Hello' }) + }) + + it('concatenates subsequent chunks onto the last assistant message', () => { + useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'Hello' }) + useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: ' world' }) + const msgs = useSessionStore.getState().tabs[0].messages + expect(msgs).toHaveLength(1) + expect(msgs[0].content).toBe('Hello world') + }) + + it('starts a new message after a tool call', () => { + useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'Before' }) + // Simulate a tool message in between + useSessionStore.setState((s) => ({ + tabs: s.tabs.map((t) => ({ + ...t, + messages: [...t.messages, { id: 'tool-1', role: 'tool' as const, content: '', toolName: 'Bash', toolStatus: 'running' as const, timestamp: Date.now() }], + })), + })) + useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'After' }) + const msgs = useSessionStore.getState().tabs[0].messages + expect(msgs).toHaveLength(3) + expect(msgs[2]).toMatchObject({ role: 'assistant', content: 'After' }) + }) +}) + +// ─── handleNormalizedEvent — task_complete ──────────────────────────────────── + +describe('handleNormalizedEvent: task_complete', () => { + beforeEach(resetStore) + + it('sets status to completed and stores lastResult', () => { + useSessionStore.setState((s) => ({ + tabs: s.tabs.map((t) => ({ ...t, status: 'running' as const })), + })) + useSessionStore.getState().handleNormalizedEvent('tab-1', { + type: 'task_complete', + result: 'All done', + costUsd: 0.005, + durationMs: 2000, + numTurns: 3, + usage: {}, + sessionId: 'sess-abc', + }) + const tab = useSessionStore.getState().tabs[0] + expect(tab.status).toBe('completed') + expect(tab.lastResult?.totalCostUsd).toBe(0.005) + expect(tab.lastResult?.sessionId).toBe('sess-abc') + expect(tab.activeRequestId).toBeNull() + expect(tab.permissionQueue).toEqual([]) + }) + + it('marks hasUnread when the tab is not the active expanded tab', () => { + useSessionStore.setState({ isExpanded: false }) + useSessionStore.getState().handleNormalizedEvent('tab-1', { + type: 'task_complete', + result: '', + costUsd: 0, + durationMs: 0, + numTurns: 1, + usage: {}, + sessionId: 'sess-x', + }) + expect(useSessionStore.getState().tabs[0].hasUnread).toBe(true) + }) +}) + +// ─── handleNormalizedEvent — session_init ───────────────────────────────────── + +describe('handleNormalizedEvent: session_init', () => { + beforeEach(resetStore) + + it('stores sessionId and model on the tab', () => { + useSessionStore.getState().handleNormalizedEvent('tab-1', { + type: 'session_init', + sessionId: 'new-sess-1', + tools: ['Bash'], + model: 'claude-sonnet-4-6', + mcpServers: [], + skills: [], + version: '1.0', + }) + const tab = useSessionStore.getState().tabs[0] + expect(tab.claudeSessionId).toBe('new-sess-1') + expect(tab.sessionModel).toBe('claude-sonnet-4-6') + }) + + it('does not change status for warmup inits', () => { + useSessionStore.setState((s) => ({ + tabs: s.tabs.map((t) => ({ ...t, status: 'connecting' as const })), + })) + useSessionStore.getState().handleNormalizedEvent('tab-1', { + type: 'session_init', + sessionId: 'warmup-sess', + tools: [], + model: 'claude-sonnet-4-6', + mcpServers: [], + skills: [], + version: '1.0', + isWarmup: true, + }) + expect(useSessionStore.getState().tabs[0].status).toBe('connecting') + }) +}) + +// ─── addDirectory / removeDirectory ────────────────────────────────────────── + +describe('addDirectory / removeDirectory', () => { + beforeEach(resetStore) + + it('adds a directory without duplicates', () => { + useSessionStore.getState().addDirectory('/extra/dir') + useSessionStore.getState().addDirectory('/extra/dir') + expect(useSessionStore.getState().tabs[0].additionalDirs).toEqual(['/extra/dir']) + }) + + it('removes a directory', () => { + useSessionStore.getState().addDirectory('/a') + useSessionStore.getState().addDirectory('/b') + useSessionStore.getState().removeDirectory('/a') + expect(useSessionStore.getState().tabs[0].additionalDirs).toEqual(['/b']) + }) +}) diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index 3bd31893..24c7ea1e 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -67,6 +67,7 @@ interface State { addDirectory: (dir: string) => void removeDirectory: (dir: string) => void setBaseDirectory: (dir: string) => void + restoreLastSession: (tabId: string) => Promise addAttachments: (attachments: Attachment[]) => void removeAttachment: (attachmentId: string) => void clearAttachments: () => void @@ -75,6 +76,24 @@ interface State { handleError: (tabId: string, error: EnrichedError) => void } +// ─── Last-session persistence ─── + +const LAST_SESSION_KEY = 'clui-last-session' + +function persistLastSession(folder: string): void { + try { localStorage.setItem(LAST_SESSION_KEY, JSON.stringify({ folder })) } catch {} +} + +export function loadLastSession(): { folder: string } | null { + try { + const raw = localStorage.getItem(LAST_SESSION_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + if (typeof parsed?.folder !== 'string') return null + return { folder: parsed.folder } + } catch { return null } +} + let msgCounter = 0 const nextMsgId = () => `msg-${++msgCounter}` @@ -467,6 +486,7 @@ export const useSessionStore = create((set, get) => ({ setBaseDirectory: (dir) => { const { activeTabId } = get() + persistLastSession(dir) window.clui.resetTabSession(activeTabId) set((s) => ({ tabs: s.tabs.map((t) => @@ -483,6 +503,19 @@ export const useSessionStore = create((set, get) => ({ })) }, + restoreLastSession: async (tabId) => { + if (!useThemeStore.getState().useLastFolder) return + const lastSession = loadLastSession() + if (!lastSession) return + set((s) => ({ + tabs: s.tabs.map((t) => + t.id === tabId + ? { ...t, workingDirectory: lastSession.folder, hasChosenDirectory: true } + : t + ), + })) + }, + // ─── Attachment management ─── addAttachments: (attachments) => { diff --git a/src/renderer/theme.test.ts b/src/renderer/theme.test.ts new file mode 100644 index 00000000..edbfa00d --- /dev/null +++ b/src/renderer/theme.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useThemeStore } from './theme' + +const SETTINGS_KEY = 'clui-settings' + +function readStored() { + const raw = localStorage.getItem(SETTINGS_KEY) + return raw ? JSON.parse(raw) : null +} + +// Re-apply defaults between tests since the store is a singleton +function resetThemeStore() { + useThemeStore.setState({ + themeMode: 'dark', + isDark: true, + soundEnabled: true, + expandedUI: false, + accentColor: '#d97757', + useLastFolder: true, + _systemIsDark: true, + }) + localStorage.clear() +} + +// ─── loadSettings defaults ──────────────────────────────────────────────────── + +describe('settings defaults', () => { + beforeEach(resetThemeStore) + + it('useLastFolder defaults to true when nothing is stored', () => { + expect(useThemeStore.getState().useLastFolder).toBe(true) + }) + + it('soundEnabled defaults to true', () => { + expect(useThemeStore.getState().soundEnabled).toBe(true) + }) +}) + +// ─── setUseLastFolder ───────────────────────────────────────────────────────── + +describe('setUseLastFolder', () => { + beforeEach(resetThemeStore) + + it('updates the in-memory store value', () => { + useThemeStore.getState().setUseLastFolder(false) + expect(useThemeStore.getState().useLastFolder).toBe(false) + }) + + it('persists the value to localStorage', () => { + useThemeStore.getState().setUseLastFolder(false) + expect(readStored()?.useLastFolder).toBe(false) + }) + + it('round-trips true back correctly', () => { + useThemeStore.getState().setUseLastFolder(false) + useThemeStore.getState().setUseLastFolder(true) + expect(useThemeStore.getState().useLastFolder).toBe(true) + expect(readStored()?.useLastFolder).toBe(true) + }) +}) + +// ─── Other settings persist useLastFolder alongside them ───────────────────── + +describe('useLastFolder is preserved when other settings change', () => { + beforeEach(resetThemeStore) + + it('survives a setSoundEnabled call', () => { + useThemeStore.getState().setUseLastFolder(false) + useThemeStore.getState().setSoundEnabled(false) + expect(readStored()?.useLastFolder).toBe(false) + }) + + it('survives a setThemeMode call', () => { + useThemeStore.getState().setUseLastFolder(false) + useThemeStore.getState().setThemeMode('light') + expect(readStored()?.useLastFolder).toBe(false) + }) + + it('survives a setAccentColor call', () => { + useThemeStore.getState().setUseLastFolder(false) + useThemeStore.getState().setAccentColor('#ff0000') + expect(readStored()?.useLastFolder).toBe(false) + }) +}) + +// ─── setThemeMode ───────────────────────────────────────────────────────────── + +describe('setThemeMode', () => { + beforeEach(resetThemeStore) + + it('sets isDark=true for dark mode', () => { + useThemeStore.getState().setThemeMode('dark') + expect(useThemeStore.getState().isDark).toBe(true) + }) + + it('sets isDark=false for light mode', () => { + useThemeStore.getState().setThemeMode('light') + expect(useThemeStore.getState().isDark).toBe(false) + }) + + it('persists themeMode to localStorage', () => { + useThemeStore.getState().setThemeMode('light') + expect(readStored()?.themeMode).toBe('light') + }) + + it('resolves system mode based on _systemIsDark', () => { + useThemeStore.setState({ _systemIsDark: false }) + useThemeStore.getState().setThemeMode('system') + expect(useThemeStore.getState().isDark).toBe(false) + }) +}) + +// ─── setAccentColor ─────────────────────────────────────────────────────────── + +describe('setAccentColor', () => { + beforeEach(resetThemeStore) + + it('stores the custom hex in-memory and in localStorage', () => { + useThemeStore.getState().setAccentColor('#abcdef') + expect(useThemeStore.getState().accentColor).toBe('#abcdef') + expect(readStored()?.accentColor).toBe('#abcdef') + }) +}) + +// ─── setSoundEnabled ────────────────────────────────────────────────────────── + +describe('setSoundEnabled', () => { + beforeEach(resetThemeStore) + + it('persists false to localStorage', () => { + useThemeStore.getState().setSoundEnabled(false) + expect(useThemeStore.getState().soundEnabled).toBe(false) + expect(readStored()?.soundEnabled).toBe(false) + }) +}) diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts index bfe363c6..4880a426 100644 --- a/src/renderer/theme.ts +++ b/src/renderer/theme.ts @@ -277,12 +277,14 @@ interface ThemeState { themeMode: ThemeMode soundEnabled: boolean expandedUI: boolean + useLastFolder: boolean /** OS-reported dark mode — used when themeMode is 'system' */ _systemIsDark: boolean setIsDark: (isDark: boolean) => void setThemeMode: (mode: ThemeMode) => void setSoundEnabled: (enabled: boolean) => void setExpandedUI: (expanded: boolean) => void + setUseLastFolder: (enabled: boolean) => void /** Called by OS theme change listener — updates system value */ setSystemTheme: (isDark: boolean) => void } @@ -308,7 +310,7 @@ function applyTheme(isDark: boolean): void { const SETTINGS_KEY = 'clui-settings' -function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean } { +function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; useLastFolder: boolean } { try { const raw = localStorage.getItem(SETTINGS_KEY) if (raw) { @@ -317,13 +319,14 @@ function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expanded themeMode: ['light', 'dark'].includes(parsed.themeMode) ? parsed.themeMode : 'dark', soundEnabled: typeof parsed.soundEnabled === 'boolean' ? parsed.soundEnabled : true, expandedUI: typeof parsed.expandedUI === 'boolean' ? parsed.expandedUI : false, + useLastFolder: typeof parsed.useLastFolder === 'boolean' ? parsed.useLastFolder : true, } } } catch {} - return { themeMode: 'dark', soundEnabled: true, expandedUI: false } + return { themeMode: 'dark', soundEnabled: true, expandedUI: false, useLastFolder: true } } -function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean }): void { +function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; useLastFolder: boolean }): void { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } catch {} } @@ -335,6 +338,7 @@ export const useThemeStore = create((set, get) => ({ themeMode: saved.themeMode, soundEnabled: saved.soundEnabled, expandedUI: saved.expandedUI, + useLastFolder: saved.useLastFolder, _systemIsDark: true, setIsDark: (isDark) => { set({ isDark }) @@ -344,15 +348,19 @@ export const useThemeStore = create((set, get) => ({ const resolved = mode === 'system' ? get()._systemIsDark : mode === 'dark' set({ themeMode: mode, isDark: resolved }) applyTheme(resolved) - saveSettings({ themeMode: mode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI }) + saveSettings({ themeMode: mode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI, useLastFolder: get().useLastFolder }) }, setSoundEnabled: (enabled) => { set({ soundEnabled: enabled }) - saveSettings({ themeMode: get().themeMode, soundEnabled: enabled, expandedUI: get().expandedUI }) + saveSettings({ themeMode: get().themeMode, soundEnabled: enabled, expandedUI: get().expandedUI, useLastFolder: get().useLastFolder }) }, setExpandedUI: (expanded) => { set({ expandedUI: expanded }) - saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: expanded }) + saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: expanded, useLastFolder: get().useLastFolder }) + }, + setUseLastFolder: (enabled) => { + set({ useLastFolder: enabled }) + saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI, useLastFolder: enabled }) }, setSystemTheme: (isDark) => { set({ _systemIsDark: isDark }) diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..5c0f48f1 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,59 @@ +import { vi, beforeEach } from 'vitest' + +// ─── Mock localStorage (vitest 4.x jsdom requires explicit impl) ─── +const localStorageStore: Record = {} +const localStorageMock = { + getItem: (key: string) => localStorageStore[key] ?? null, + setItem: (key: string, value: string) => { localStorageStore[key] = String(value) }, + removeItem: (key: string) => { delete localStorageStore[key] }, + clear: () => { Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]) }, + key: (i: number) => Object.keys(localStorageStore)[i] ?? null, + get length() { return Object.keys(localStorageStore).length }, +} +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true, configurable: true }) + +// ─── Mock Audio API — must use function syntax to support `new Audio()` ─── +global.Audio = vi.fn().mockImplementation(function (this: any) { + this.volume = 1.0 + this.currentTime = 0 + this.play = vi.fn().mockResolvedValue(undefined) +}) as unknown as typeof Audio + +// ─── Mock window.clui (Electron IPC bridge) ─── +const mockClui = { + resetTabSession: vi.fn(), + createTab: vi.fn().mockResolvedValue({ tabId: 'mock-tab-id' }), + prompt: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(true), + loadSession: vi.fn().mockResolvedValue([]), + listSessions: vi.fn().mockResolvedValue([]), + isVisible: vi.fn().mockResolvedValue(false), + setPermissionMode: vi.fn(), + respondPermission: vi.fn().mockResolvedValue(true), + closeTab: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue({ + version: '1.0.0', + auth: {}, + mcpServers: [], + projectPath: '/home/user', + homePath: '/home/user', + }), + getAutoStart: vi.fn().mockResolvedValue({ enabled: false, startMinimized: false }), + setAutoStart: vi.fn().mockResolvedValue({ enabled: false, startMinimized: false }), + setShortcut: vi.fn().mockResolvedValue({ primary: { ok: true }, secondary: { ok: true } }), + getShortcut: vi.fn().mockResolvedValue({ primary: 'Alt+Space', secondary: 'CommandOrControl+Shift+K' }), + getTheme: vi.fn().mockResolvedValue({ isDark: true }), + onThemeChange: vi.fn().mockReturnValue(() => {}), + hideWindow: vi.fn(), + setIgnoreMouseEvents: vi.fn(), +} + +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'clui', { value: mockClui, writable: true, configurable: true }) +} + +// ─── Reset mocks and localStorage between tests ─── +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..31cc5097 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, +})