test: add Vitest testing framework and main process unit tests

This commit is contained in:
groovyquan committed 2026-04-25 00:22:17 +08:00
1 parent 035f350940
commit 5563f30df1
12 files changed
+1582 -5

No files matched your search

+1089 -3
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -20,7 +20,10 @@
"build:mac": "electron-vite build && electron-builder --mac",
"install:deps": "bash scripts/install-dependencies.sh",
"build:mac:bundled": "node scripts/prepare-bundled.js && electron-vite build && electron-builder --mac --config electron-builder.bundled.yml",
"build:linux": "electron-vite build && electron-builder --linux"
"build:linux": "electron-vite build && electron-builder --linux",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
@@ -34,6 +37,8 @@
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -45,10 +50,12 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"jsdom": "^29.0.2",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.2.6",
"vitest": "^4.1.5"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
describe('agent', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('should log warning when TTS warm-up fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('Connection refused')))
)
const { createAgent } = await import('../services/agent')
await createAgent({
baseUrl: 'http://localhost:11434',
apiKey: '',
modelName: 'qwen3',
ttsPort: 8081,
aiName: 'Test',
systemPrompt: 'You are a test assistant.'
})
expect(warnSpy).toHaveBeenCalledTimes(1)
// logger.warn prefixes with [HH:MM:SS.mmm] [WARN ], message is the second arg
expect(warnSpy.mock.calls[0][1]).toContain('TTS warm-up failed')
warnSpy.mockRestore()
vi.unstubAllGlobals()
})
})
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import fs from 'fs'
import path from 'path'
import os from 'os'
// Mock fs operations so tests don't touch the real filesystem
vi.mock('fs')
vi.mock('path')
vi.mock('os')
// We need to import the module under test AFTER setting up mocks
const { loadConfig, getConfig } = await import('../services/config')
describe('config', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock os.homedir()
vi.mocked(os.homedir).mockReturnValue('/mock/home')
// Mock path.join to behave like real path.join
vi.mocked(path.join).mockImplementation((...args: string[]) => args.join('/'))
})
describe('loadConfig', () => {
it('should return defaults when config file does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const config = loadConfig()
expect(config.aiName).toBe('Kitten')
expect(config.modelName).toBe('qwen3:0.6b')
expect(config.baseUrl).toBe('http://localhost:11434/v1')
expect(config.apiKey).toBe('ollama')
})
it('should return defaults when config file has invalid JSON', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('not valid json')
const config = loadConfig()
// Should fall back to defaults
expect(config.aiName).toBe('Kitten')
})
it('should read and return saved config values', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
version: 1,
aiName: 'TestBot',
systemPrompt: 'You are TestBot',
baseUrl: 'http://test:11434/v1',
apiKey: 'test-key',
modelName: 'test-model'
})
)
const config = loadConfig()
expect(config.aiName).toBe('TestBot')
expect(config.modelName).toBe('test-model')
expect(config.baseUrl).toBe('http://test:11434/v1')
expect(config.apiKey).toBe('test-key')
expect(config.systemPrompt).toBe('You are TestBot')
})
it('should trim whitespace from string fields', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
aiName: ' BotName ',
modelName: ' model-x ',
baseUrl: ' http://example.com '
})
)
const config = loadConfig()
expect(config.aiName).toBe('BotName')
expect(config.modelName).toBe('model-x')
expect(config.baseUrl).toBe('http://example.com')
})
it('should fall back to defaults for missing fields', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
aiName: 'BotName'
// all other fields missing
})
)
const config = loadConfig()
expect(config.aiName).toBe('BotName')
expect(config.modelName).toBe('qwen3:0.6b') // default
expect(config.baseUrl).toBe('http://localhost:11434/v1') // default
expect(config.apiKey).toBe('ollama') // default
expect(config.systemPrompt).toBeTruthy()
})
it('should use defaults when aiName is empty', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
aiName: '',
modelName: 'some-model'
})
)
const config = loadConfig()
expect(config.aiName).toBe('Kitten') // default, not empty
})
it('should have a system prompt that mentions {{AI_NAME}} placeholder', () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const config = loadConfig()
expect(config.systemPrompt).toContain('{{AI_NAME}}')
})
})
describe('getConfig', () => {
it('should be an alias for loadConfig', () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const c1 = loadConfig()
const c2 = getConfig()
expect(c1).toEqual(c2)
})
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { createI18n } from '../../shared/i18n'
describe('i18n', () => {
it('should return Chinese for zh locale', () => {
const t = createI18n('zh-CN')
expect(t('error.asr_empty')).toBe('没听清,能大声一点吗?')
})
it('should return Chinese for short zh locale', () => {
const t = createI18n('zh')
expect(t('error.asr_empty')).toBe('没听清,能大声一点吗?')
})
it('should return English for en locale', () => {
const t = createI18n('en-US')
expect(t('error.asr_empty')).toBe('My ears are sleepy, can you say it louder?')
})
it('should fall back to English for unknown locale', () => {
const t = createI18n('ja-JP')
expect(t('error.asr_empty')).toBe('My ears are sleepy, can you say it louder?')
})
it('should return key as fallback when key does not exist', () => {
const t = createI18n('en')
// Testing fallback for unknown keys
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(t('nonexistent.key' as any)).toBe('nonexistent.key')
})
it('should substitute params', () => {
const t = createI18n('en')
expect(t('test.greeting', { name: 'World' })).toBe('Hello World!')
})
it('should leave raw placeholder when param is missing', () => {
const t = createI18n('en')
expect(t('test.greeting')).toBe('Hello {{name}}!')
})
it('should substitute params in Chinese', () => {
const t = createI18n('zh')
expect(t('test.greeting', { name: '世界' })).toBe('你好 世界!')
})
})
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { logger } from '../services/logger'
describe('logger', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('should export debug, info, warn, error methods', () => {
expect(typeof logger.debug).toBe('function')
expect(typeof logger.info).toBe('function')
expect(typeof logger.warn).toBe('function')
expect(typeof logger.error).toBe('function')
})
it('should call console.error for error level', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
logger.error('test error')
expect(spy).toHaveBeenCalledTimes(1)
expect(spy.mock.calls[0][0]).toContain('[ERROR]')
expect(spy.mock.calls[0][1]).toBe('test error')
spy.mockRestore()
})
it('should call console.warn for warn level', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
logger.warn('test warning')
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()
})
it('should call console.log for info level', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
logger.info('test info')
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()
})
it('debug messages are filtered by default (LOG_LEVEL defaults to info)', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
// At default level (info), debug messages should be suppressed
logger.debug('should not appear')
expect(spy).toHaveBeenCalledTimes(0)
spy.mockRestore()
})
it('should respect LOG_LEVEL env override to debug', async () => {
vi.stubEnv('LOG_LEVEL', 'debug')
vi.resetModules()
const { logger: testLogger } = await import('../services/logger')
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
testLogger.debug('debug msg')
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()
vi.unstubAllEnvs()
})
it('should respect LOG_LEVEL env override to error (suppress info)', async () => {
vi.stubEnv('LOG_LEVEL', 'error')
vi.resetModules()
const { logger: testLogger } = await import('../services/logger')
const infoSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
testLogger.info('info msg')
testLogger.error('error msg')
expect(infoSpy).toHaveBeenCalledTimes(0)
expect(errorSpy).toHaveBeenCalledTimes(1)
infoSpy.mockRestore()
errorSpy.mockRestore()
vi.unstubAllEnvs()
})
})
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import {
SILENCE_ABOVE,
SILENCE_BELOW,
MAX_RECORD_SECONDS,
MIN_VALID_RMS,
POST_SPEECH_DELAY_MS
} from '../services/recorder'
describe('recorder constants', () => {
it('should have silence thresholds as percentage strings', () => {
expect(SILENCE_ABOVE).toBe('1.5%')
expect(SILENCE_BELOW).toBe('1.5%')
})
it('should have a reasonable max recording duration', () => {
expect(MAX_RECORD_SECONDS).toBe(10)
})
it('should have a small minimum RMS threshold', () => {
expect(MIN_VALID_RMS).toBeGreaterThan(0)
expect(MIN_VALID_RMS).toBeLessThan(0.01)
})
it('should have a post-speech delay in milliseconds', () => {
expect(POST_SPEECH_DELAY_MS).toBe(500)
})
})
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { getInstallManifest } from '../services/releases'
describe('getInstallManifest', () => {
it('should return directDownloads and archives', () => {
const manifest = getInstallManifest('/bin', '/models')
expect(manifest.directDownloads).toBeDefined()
expect(manifest.archives).toBeDefined()
expect(manifest.directDownloads.length).toBeGreaterThan(0)
expect(manifest.archives.length).toBeGreaterThan(0)
})
it('should include ASR model config and weights as direct downloads', () => {
const manifest = getInstallManifest('/bin', '/models')
const names = manifest.directDownloads.map((d) => d.name)
expect(names).toContain('ASR Model Config')
expect(names).toContain('ASR Model Weights')
})
it('should include TTS Server, TTS Model, and ASR Server archives', () => {
const manifest = getInstallManifest('/bin', '/models')
const names = manifest.archives.map((a) => a.name)
expect(names).toContain('TTS Server')
expect(names).toContain('TTS Model')
expect(names).toContain('ASR Server & Assets')
})
it('should mark binaries as executable', () => {
const manifest = getInstallManifest('/bin', '/models')
const allEntries = manifest.archives.flatMap((a) => a.entries)
const executables = allEntries.filter((e) => e.executable)
const execNames = executables.map((e) => e.archivePath)
expect(execNames).toContain('kitten-tts-aarch64-macos/kitten-tts-server')
expect(execNames).toContain('asr-macos-aarch64/asr-server')
})
it('should use provided binDir and modelsDir in paths', () => {
const manifest = getInstallManifest('/custom/bin', '/custom/models')
// Check that paths use the provided directories
const ttsServerPath = manifest.archives.find((a) => a.name === 'TTS Server')!.entries[0]
.localPath
expect(ttsServerPath).toBe('/custom/bin/kitten-tts-server')
const configPath = manifest.directDownloads.find(
(d) => d.name === 'ASR Model Config'
)!.localPath
expect(configPath).toBe('/custom/models/qwen3_asr_rs/Qwen3-ASR-0.6B/config.json')
})
it('should have positive sizes for all downloads', () => {
const manifest = getInstallManifest('/bin', '/models')
const allSizes = [
...manifest.directDownloads.map((d) => d.size),
...manifest.archives.map((a) => a.size)
]
for (const size of allSizes) {
expect(size).toBeGreaterThan(0)
}
})
it('should have valid URLs for all downloads', () => {
const manifest = getInstallManifest('/bin', '/models')
const allUrls = [
...manifest.directDownloads.map((d) => d.url),
...manifest.archives.map((a) => a.url)
]
for (const url of allUrls) {
expect(url).toMatch(/^https:\/\//)
}
})
it('should have valid archive types', () => {
const manifest = getInstallManifest('/bin', '/models')
for (const archive of manifest.archives) {
expect(['tar.gz', 'zip']).toContain(archive.type)
}
})
})
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, afterAll } from 'vitest'
import { getMainWindow, setMainWindow } from '../services/window'
import type { BrowserWindow } from 'electron'
describe('window', () => {
afterAll(() => {
setMainWindow(null)
})
it('should return null initially', () => {
const win = getMainWindow()
expect(win).toBeNull()
})
function mockWin(isDestroyed = false): BrowserWindow {
return { isDestroyed: () => isDestroyed } as BrowserWindow
}
it('should return the window set by setMainWindow', () => {
const fakeWin = mockWin()
setMainWindow(fakeWin)
expect(getMainWindow()).toBe(fakeWin)
})
it('should return null after setMainWindow(null)', () => {
setMainWindow(null)
expect(getMainWindow()).toBeNull()
})
it('should update to the latest window', () => {
const win1 = mockWin()
const win2 = mockWin()
setMainWindow(win1)
expect(getMainWindow()).toBe(win1)
setMainWindow(win2)
expect(getMainWindow()).toBe(win2)
expect(getMainWindow()).not.toBe(win1)
})
it('should return null if the window is destroyed', () => {
const destroyedWin = mockWin(true)
setMainWindow(destroyedWin)
expect(getMainWindow()).toBeNull()
})
})
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
test: {
// Include all test files matching these patterns
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
// Exclude output and node_modules
exclude: ['out', 'node_modules']
},
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
}
})
+34
View File
@@ -0,0 +1,34 @@
import { defineProject } from 'vitest/config'
import { resolve } from 'path'
const root = process.cwd()
export default [
defineProject({
test: {
name: 'main',
include: ['src/main/**/*.test.ts'],
environment: 'node'
},
resolve: {
alias: {
'@renderer': resolve(root, 'src/renderer/src'),
'@shared': resolve(root, 'src/shared')
}
}
}),
defineProject({
test: {
name: 'renderer',
include: ['src/renderer/**/*.test.ts', 'src/renderer/**/*.test.tsx'],
environment: 'jsdom',
setupFiles: ['src/renderer/src/test-setup.ts']
},
resolve: {
alias: {
'@renderer': resolve(root, 'src/renderer/src'),
'@shared': resolve(root, 'src/shared')
}
}
})
]