useCache, useSharedCache and usePersistCache setters now accept a React-style functional updater `(prev) => next` in addition to a concrete value. The updater resolves `prev` from the latest stored value at write time rather than the render-time snapshot, making read-modify-write correct across an `await` — the root cause of the keep-alive overwrite race behind #16460. `prev` is typed shallow-readonly (`ReadonlyValue<T>`), so mutating it in place and returning the same reference — which the CacheService `isEqual` short-circuit would otherwise swallow silently — is a compile error. Concrete-value calls are unchanged, so existing consumers keep compiling. The renderer useCache mock mirrors the functional branch with the same default fallback; docs and hook tests updated. Consumer call-site adoption lands separately.
Test Mocks
Unified test mocks for the project, organized by process type and globally configured in test setup files.
Overview
Available Mocks
| Process | Mock | Description |
|---|---|---|
| Renderer | CacheService |
Three-tier cache (memory/shared/persist) |
| Renderer | DataApiService |
HTTP client for Data API |
| Renderer | PreferenceService |
User preferences |
| Renderer | useDataApi |
Data API hooks (useQuery, useMutation, etc.) |
| Renderer | usePreference |
Preference hooks |
| Renderer | useCache |
Cache hooks |
| Main | application |
Unified mock application factory with application.get() |
| Main | DbService |
Database service with mock db |
| Main | CacheService |
Internal + shared cache |
| Main | DataApiService |
API coordinator |
| Main | PreferenceService |
Preference service |
File Structure
tests/__mocks__/
├── renderer/
│ ├── CacheService.ts
│ ├── DataApiService.ts
│ ├── PreferenceService.ts
│ ├── useDataApi.ts
│ ├── usePreference.ts
│ └── useCache.ts
├── main/
│ ├── application.ts
│ ├── CacheService.ts
│ ├── DataApiService.ts
│ ├── DbService.ts
│ └── PreferenceService.ts
├── RendererLoggerService.ts
└── MainLoggerService.ts
Test Setup
Mocks are globally configured in setup files:
- Renderer:
tests/renderer.setup.ts - Main:
tests/main.setup.ts
Import Path Alias
Use @test-mocks/* to import mock utilities:
import { MockCacheUtils } from '@test-mocks/renderer/CacheService'
import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService'
Renderer Mocks
CacheService
Three-tier cache system with type-safe methods (and casual/dynamic key methods on the Memory tier only).
Methods
| Category | Method | Signature |
|---|---|---|
| Memory (typed) | get |
<K>(key: K) => InferUseCacheValue<K> |
| Memory (typed) | set |
<K>(key: K, value, ttl?) => void |
| Memory (typed) | has |
<K>(key: K) => boolean |
| Memory (typed) | delete |
<K>(key: K) => boolean |
| Memory (typed) | hasTTL |
<K>(key: K) => boolean |
| Memory (casual) | getCasual |
<T>(key: string) => T | undefined |
| Memory (casual) | setCasual |
<T>(key, value, ttl?) => void |
| Memory (casual) | hasCasual |
(key: string) => boolean |
| Memory (casual) | deleteCasual |
(key: string) => boolean |
| Memory (casual) | hasTTLCasual |
(key: string) => boolean |
| Shared (typed) | getShared |
<K>(key: K) => InferSharedCacheValue<K> |
| Shared (typed) | setShared |
<K>(key: K, value, ttl?) => void |
| Shared (typed) | hasShared |
<K>(key: K) => boolean |
| Shared (typed) | deleteShared |
<K>(key: K) => boolean |
| Shared (typed) | hasSharedTTL |
<K>(key: K) => boolean |
| Persist | getPersist |
<K>(key: K) => RendererPersistCacheSchema[K] |
| Persist | setPersist |
<K>(key: K, value) => void |
| Persist | hasPersist |
(key) => boolean |
| Hook mgmt | registerHook |
(key: string) => void |
| Hook mgmt | unregisterHook |
(key: string) => void |
| Ready state | isSharedCacheReady |
() => boolean |
| Ready state | onSharedCacheReady |
(callback) => () => void |
| Lifecycle | subscribe |
(key, callback) => () => void |
| Lifecycle | cleanup |
() => void |
Usage
import { cacheService } from '@data/CacheService'
import { MockCacheUtils } from '@test-mocks/renderer/CacheService'
describe('Cache', () => {
beforeEach(() => MockCacheUtils.resetMocks())
it('basic usage', () => {
cacheService.setCasual('key', { data: 'value' }, 5000)
expect(cacheService.getCasual('key')).toEqual({ data: 'value' })
})
it('with test utilities', () => {
MockCacheUtils.setInitialState({
memory: [['key', 'value']],
shared: [['shared.key', 'shared']],
persist: [['persist.key', 'persist']]
})
})
})
DataApiService
HTTP client with subscriptions and retry configuration.
Methods
| Method | Signature |
|---|---|
get |
(path, options?) => Promise<any> |
post |
(path, options) => Promise<any> |
put |
(path, options) => Promise<any> |
patch |
(path, options) => Promise<any> |
delete |
(path, options?) => Promise<any> |
subscribe |
(options, callback) => () => void |
configureRetry |
(options) => void |
getRetryConfig |
() => RetryOptions |
getRequestStats |
() => { pendingRequests, activeSubscriptions } |
Usage
import { dataApiService } from '@data/DataApiService'
import { MockDataApiUtils } from '@test-mocks/renderer/DataApiService'
describe('API', () => {
beforeEach(() => MockDataApiUtils.resetMocks())
it('basic request', async () => {
const response = await dataApiService.get('/topics')
expect(response.topics).toBeDefined()
})
it('custom response', async () => {
MockDataApiUtils.setCustomResponse('/topics', 'GET', { custom: true })
const response = await dataApiService.get('/topics')
expect(response.custom).toBe(true)
})
it('error simulation', async () => {
MockDataApiUtils.setErrorResponse('/topics', 'GET', new Error('Failed'))
await expect(dataApiService.get('/topics')).rejects.toThrow('Failed')
})
})
useDataApi Hooks
React hooks for data operations.
Hooks
| Hook | Signature | Returns |
|---|---|---|
useQuery |
(path, options?) |
{ data, loading, error, refetch, mutate } |
useMutation |
(method, path, options?) |
{ mutate, loading, error } |
usePaginatedQuery |
(path, options?) |
{ items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset } |
useInvalidateCache |
() |
(keys?) => Promise<any> |
useReadCache |
() |
(path, query?) => TResponse | undefined |
useWriteCache |
() |
async (path, value, query?) => void |
Usage
import { useQuery, useMutation, useReadCache, useWriteCache } from '@data/hooks/useDataApi'
import { MockUseDataApiUtils } from '@test-mocks/renderer/useDataApi'
describe('Hooks', () => {
beforeEach(() => MockUseDataApiUtils.resetMocks())
it('useQuery', () => {
const { data, loading } = useQuery('/topics')
expect(loading).toBe(false)
expect(data).toBeDefined()
})
it('useMutation', async () => {
const { mutate } = useMutation('POST', '/topics')
const result = await mutate({ body: { name: 'New' } })
expect(result.created).toBe(true)
})
it('custom data', () => {
MockUseDataApiUtils.mockQueryData('/topics', { custom: true })
const { data } = useQuery('/topics')
expect(data.custom).toBe(true)
})
it('useReadCache reads seeded values', () => {
// Pre-populate the mock cache (key shape mirrors production:
// omit `query` for [path], pass a non-empty `query` for [path, query]).
MockUseDataApiUtils.seedCache('/topics', { topics: [{ id: 't1' }], total: 1 })
const read = useReadCache()
expect(read('/topics')).toEqual({ topics: [{ id: 't1' }], total: 1 })
})
it('useWriteCache persists to mock store (assertable via getCachedValue)', async () => {
const write = useWriteCache()
await write('/topics', { topics: [], total: 0 })
expect(MockUseDataApiUtils.getCachedValue('/topics')).toEqual({ topics: [], total: 0 })
})
})
Note:
useReadCache/useWriteCacheshare one in-memoryMapunder the hood.resetMocks()clears both call history and the cache store; useclearCache()if you want to drop cache entries without resetting hook mocks.
useCache Hooks
React hooks for cache operations.
| Hook | Signature | Returns |
|---|---|---|
useCache |
(key, initValue?) |
[value, setValue] |
useSharedCache |
(key, initValue?) |
[value, setValue] |
usePersistCache |
(key) |
[value, setValue] |
setValue accepts a concrete value or a functional updater (prev) => next (mirrors production). The mock resolves the updater against the latest mocked value with the same default fallback, so functional-update call sites run unchanged under the mock.
import { useCache } from '@data/hooks/useCache'
const [value, setValue] = useCache('key', 'default')
setValue('new value')
setValue((prev) => prev + '!') // functional updater
usePreference Hooks
React hooks for preferences.
| Hook | Signature | Returns |
|---|---|---|
usePreference |
(key) |
[value, setValue] |
useMultiplePreferences |
(keyMap) |
[values, setValues] |
import { usePreference } from '@data/hooks/usePreference'
const [theme, setTheme] = usePreference('ui.theme')
await setTheme('dark')
Main Process Mocks
Scope
tests/__mocks__/main/ holds mocks for cross-cutting infrastructure only: PreferenceService, CacheService, DbService, DataApiService, plus minimal MainWindowService / WindowManager stubs. All are pre-mocked globally via tests/main.setup.ts.
Do not add files here for feature-specific lifecycle services (e.g., FileProcessingTaskService, KnowledgeRuntimeService). The ServiceOverrides type is deliberately locked to keyof typeof defaultServiceInstances to enforce this boundary. Stub them locally — see Testing Other Lifecycle Services.
| Service category | How to mock |
|---|---|
| Infrastructure (listed above) | Already mocked globally; override via mockApplicationFactory({ Name: {...} }) |
| Feature-specific lifecycle service | Local vi.mock('@application') + MockBaseService in the test file |
| Direct-import singleton (no lifecycle) | vi.mock('path/to/module') directly |
Application Mock (Unified Factory)
All main-process tests get application.get() mocked globally via tests/main.setup.ts. Tests that need custom service instances can override specific services using mockApplicationFactory(overrides).
API
| Export | Description |
|---|---|
mockApplicationFactory(overrides?) |
Returns full mock module { application, serviceList } for vi.mock() |
createMockApplication(overrides?) |
Returns just the mock application object |
defaultServiceInstances |
Default mock instances for all registered services |
Usage
Global setup (already configured in tests/main.setup.ts):
vi.mock('@application', async () => {
const { mockApplicationFactory } = await import('./__mocks__/main/application')
return mockApplicationFactory()
})
Override infrastructure services in individual test files:
const mockDb = { select: vi.fn(), insert: vi.fn() }
vi.mock('@application', async () => {
const { mockApplicationFactory } = await import('@test-mocks/main/application')
return mockApplicationFactory({
DbService: { getDb: () => mockDb }
})
})
For non-infrastructure services, don't override here — use Testing Other Lifecycle Services instead.
Main DbService
Database service providing access to the mock SQLite database.
Methods
| Method | Signature |
|---|---|
getDb |
() => MockDb |
withWriteTx |
<T>(fn: (tx) => Promise<T>) => Promise<T> (passthrough — calls fn(db)) |
isReady |
boolean (getter) |
import { MockMainDbServiceUtils } from '@test-mocks/main/DbService'
beforeEach(() => MockMainDbServiceUtils.resetMocks())
// Use default mock db
MockMainDbServiceUtils.getDefaultMockDb()
// Replace with custom db
MockMainDbServiceUtils.setDb(customMockDb)
withWriteTx: passthrough (async (fn) => fn(this.db)) — no mutex / BUSY retry. Usevi.spyOn(dbServiceInstance, 'withWriteTx')to inject custom behavior. Hand-rolled DbService mocks MUST include this method or production code throwsTypeError: dbService.withWriteTx is not a function.
Main CacheService
Internal cache and cross-window shared cache.
Methods
| Category | Method | Signature |
|---|---|---|
| Lifecycle | initialize |
() => Promise<void> |
| Lifecycle | cleanup |
() => void |
| Internal | get |
<T>(key: string) => T | undefined |
| Internal | set |
<T>(key, value, ttl?) => void |
| Internal | has |
(key: string) => boolean |
| Internal | delete |
(key: string) => boolean |
| Shared | getShared |
<K>(key: K) => SharedCacheSchema[K] | undefined |
| Shared | setShared |
<K>(key: K, value, ttl?) => void |
| Shared | hasShared |
<K>(key: K) => boolean |
| Shared | deleteShared |
<K>(key: K) => boolean |
| Subscription | subscribeChange |
<T>(key, callback) => () => void — returns a fresh vi.fn() unsubscribe stub |
| Subscription | subscribeSharedChange |
<K>(key, callback) => () => void — returns a fresh vi.fn() unsubscribe stub |
Note on subscription mocks:
subscribeChange/subscribeSharedChangeare call-tracking stubs — they do not replicate the real fire semantics. Use them to verifyregisterDisposable(cacheService.subscribeChange(...))wiring and that subscriptions happen, not to simulate callbacks. ThesetShared/deleteSharedmocks also record every call tobroadcastCallsunconditionally (noisEqualshort-circuit), keepinggetBroadcastHistory()consumers backward-compatible.
import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService'
beforeEach(() => MockMainCacheServiceUtils.resetMocks())
MockMainCacheServiceUtils.setCacheValue('key', 'value')
MockMainCacheServiceUtils.setSharedCacheValue('shared.key', 'shared')
Main DataApiService
API coordinator managing ApiServer and IpcAdapter.
Methods
| Method | Signature |
|---|---|
initialize |
() => Promise<void> |
shutdown |
() => Promise<void> |
getSystemStatus |
() => object |
getApiServer |
() => ApiServer |
import { MockMainDataApiServiceUtils } from '@test-mocks/main/DataApiService'
beforeEach(() => MockMainDataApiServiceUtils.resetMocks())
MockMainDataApiServiceUtils.simulateInitializationError(new Error('Failed'))
Main PreferenceService
Preference store with typed keys, seeded from DefaultPreferences.default.
Methods
| Method | Signature |
|---|---|
initialize |
() => Promise<void> |
get |
<K>(key: K) => UnifiedPreferenceType[K] |
set |
<K>(key: K, value) => Promise<void> |
getMultiple |
<K>(keys: K[]) => Record<K, UnifiedPreferenceType[K]> |
setMultiple |
(values) => Promise<void> |
subscribeForWindow |
(windowId, keys) => void |
import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService'
beforeEach(() => MockMainPreferenceServiceUtils.resetMocks())
// Seed a preference value
MockMainPreferenceServiceUtils.setPreferenceValue('ui.theme', 'dark')
// Simulate an external change (fires main-process subscribers)
MockMainPreferenceServiceUtils.simulateExternalPreferenceChange('ui.theme', 'light')
Utilities: setPreferenceValue, getPreferenceValue, setMultiplePreferenceValues, getAllPreferenceValues, simulateWindowSubscription, simulateExternalPreferenceChange, getSubscriptionCounts.
Testing Other Lifecycle Services
Stub feature-specific lifecycle services locally in the test file. A test typically needs three substitutions: @application, BaseService, and lifecycle decorators.
Canonical Setup
import type * as LifecycleModule from '@main/core/lifecycle'
import { getDependencies, getPhase } from '@main/core/lifecycle/decorators'
import { Phase } from '@main/core/lifecycle/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { appGetMock, startTaskMock, getTaskMock } = vi.hoisted(() => ({
appGetMock: vi.fn(),
startTaskMock: vi.fn(),
getTaskMock: vi.fn()
}))
vi.mock('@application', () => ({
application: { get: appGetMock }
}))
vi.mock('@main/core/lifecycle', async (importOriginal) => {
const actual = await importOriginal<typeof LifecycleModule>()
class MockBaseService {
ipcHandle = vi.fn()
protected readonly _disposables: Array<{ dispose: () => void } | (() => void)> = []
protected registerDisposable<T extends { dispose: () => void } | (() => void)>(d: T): T {
this._disposables.push(d)
return d
}
}
return { ...actual, BaseService: MockBaseService }
})
beforeEach(() => {
vi.clearAllMocks()
appGetMock.mockImplementation((name: string) => {
if (name === 'FileProcessingTaskService') {
return { startTask: startTaskMock, getTask: getTaskMock }
}
throw new Error(`Unexpected application.get(${name})`)
})
})
// Import SUT after mocks are declared.
const { FileProcessingService } = await import('../FileProcessingService')
Common Assertions
Drive lifecycle hooks (onInit / onStart / onStop / onDestroy) manually — the container isn't running in tests.
| Target | How |
|---|---|
| Phase | expect(getPhase(MyService)).toBe(Phase.WhenReady) |
| Dependencies | expect(getDependencies(MyService)).toEqual(['OtherService']) |
| Registered IPC channels | const svc = new MyService(); (svc as any).onInit(); (svc as any).ipcHandle.mock.calls.map(c => c[0]) |
| Single IPC handler | ipcHandle.mock.calls.find(c => c[0] === 'channel')?.[1], then invoke |
| Disposables | Drive lifecycle, inspect (svc as any)._disposables |
Reference Implementations
src/main/services/knowledge/__tests__/KnowledgeService.test.ts— dispatch stub + phase/deps + per-channel handler inspectionsrc/main/services/__tests__/ShortcutService.test.ts— richerMockBaseServicewithregisterDisposable+ no-op decorator replacements
Best Practices
- Infrastructure services come pre-mocked; override via
mockApplicationFactory({ Name: {...} }), not ad-hocapplication.getmocks. - Feature-specific lifecycle services are stubbed locally — don't add them to
tests/__mocks__/main/ordefaultServiceInstances. - Each infrastructure mock exposes
MockMain<Name>ServiceUtilswithresetMocks()plus service-specific helpers (seeding values, simulating errors). CallresetMocks()inbeforeEach.
Troubleshooting
| Issue | Solution |
|---|---|
| Mock not applied | Check test runs in correct process (renderer/main in vitest.config.ts) |
| Type errors | Ensure mock matches actual interface, use type assertions if needed |
| State pollution | Call resetMocks() in beforeEach |
| Import issues | Use path aliases (@data/CacheService) not relative paths |