test: add Vitest testing framework and main process unit tests
This commit is contained in:
1 parent
035f350940
commit
5563f30df1
12 files changed
+1582
-5
No files matched your search
Generated
+1089
-3
File diff suppressed because it is too large.
Load diff
+9
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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('你好 世界!')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
Reference in new issue
Block a user