feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -137,7 +137,7 @@ describe('CustomInstructionService', () => {
const created = svc.create({
name: 'To Delete',
description: '',
prompt: '',
prompt: 'prompt',
icon: ''
})
@ -177,7 +177,7 @@ describe('CustomInstructionService', () => {
svc.update('builtin-translate', { prompt: 'modified prompt' })
// 사용자 명령어 추가
svc.create({ name: 'User', description: '', prompt: '', icon: '' })
svc.create({ name: 'User', description: '', prompt: 'prompt', icon: '' })
svc.resetBuiltins()

View file

@ -0,0 +1,378 @@
// tests/main/services/STTManager.test.ts
// STTManager 및 오디오 변환 유틸리티 단위 테스트
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager'
import { pcmToWav, createProbeWav } from '../../../src/main/services/stt/audio-utils'
import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService'
import { OpenAIDriver } from '../../../src/main/services/stt/drivers/OpenAIDriver'
import { GroqDriver } from '../../../src/main/services/stt/drivers/GroqDriver'
import { DeepgramDriver } from '../../../src/main/services/stt/drivers/DeepgramDriver'
import { AssemblyAIDriver } from '../../../src/main/services/stt/drivers/AssemblyAIDriver'
import { GoogleDriver } from '../../../src/main/services/stt/drivers/GoogleDriver'
import { CustomDriver } from '../../../src/main/services/stt/drivers/CustomDriver'
import { D3ROCloudDriver } from '../../../src/main/services/stt/drivers/D3ROCloudDriver'
describe('STTManager & Multi-provider Drivers', () => {
beforeEach(() => {
initInMemoryConfig()
resetSTTManagerForTests()
vi.restoreAllMocks()
})
afterEach(() => {
resetInMemoryConfig()
resetSTTManagerForTests()
vi.restoreAllMocks()
})
describe('Audio Utilities (PCM to WAV)', () => {
it('pcmToWav converts 16kHz mono PCM buffer into valid RIFF WAV with 44-byte header', () => {
const pcmData = Buffer.alloc(3200) // 100ms at 16kHz 16-bit
pcmData.fill(128)
const wav = pcmToWav(pcmData, 16000, 1, 16)
expect(wav.length).toBe(3200 + 44)
// Check RIFF header
expect(wav.toString('ascii', 0, 4)).toBe('RIFF')
expect(wav.readUInt32LE(4)).toBe(wav.length - 8)
expect(wav.toString('ascii', 8, 12)).toBe('WAVE')
expect(wav.toString('ascii', 12, 16)).toBe('fmt ')
expect(wav.readUInt32LE(16)).toBe(16) // Subchunk1Size
expect(wav.readUInt16LE(20)).toBe(1) // AudioFormat PCM
expect(wav.readUInt16LE(22)).toBe(1) // NumChannels 1
expect(wav.readUInt32LE(24)).toBe(16000) // SampleRate 16000
expect(wav.readUInt16LE(34)).toBe(16) // BitsPerSample 16
expect(wav.toString('ascii', 36, 40)).toBe('data')
expect(wav.readUInt32LE(40)).toBe(3200) // DataSize
})
it('createProbeWav produces non-empty WAV probe for latency testing', () => {
const probe = createProbeWav(300, 16000)
expect(probe.length).toBeGreaterThan(44)
expect(probe.toString('ascii', 0, 4)).toBe('RIFF')
expect(probe.toString('ascii', 8, 12)).toBe('WAVE')
})
})
describe('STTManager Provider Management', () => {
it('getProviders returns 8 providers with metadata', () => {
const mgr = getSTTManager()
const providers = mgr.getProviders()
expect(providers.length).toBe(8)
const ids = providers.map((p) => p.id)
expect(ids).toContain('local')
expect(ids).toContain('d3ro-cloud')
expect(ids).toContain('openai')
expect(ids).toContain('groq')
expect(ids).toContain('deepgram')
expect(ids).toContain('assemblyai')
expect(ids).toContain('google')
expect(ids).toContain('custom')
})
it('setProvider and getActiveProvider roundtrip', () => {
const mgr = getSTTManager()
expect(mgr.getActiveProvider()).toBe('local')
mgr.setProvider('openai')
expect(mgr.getActiveProvider()).toBe('openai')
mgr.setProvider('groq')
expect(mgr.getActiveProvider()).toBe('groq')
})
it('setProviderConfig and getProviderConfig roundtrip', () => {
const mgr = getSTTManager()
mgr.setProviderConfig('openai', {
apiKey: 'sk-test-12345',
modelId: 'whisper-1',
})
const cfg = mgr.getProviderConfig('openai')
expect(cfg.apiKey).toBe('sk-test-12345')
expect(cfg.modelId).toBe('whisper-1')
})
it('testConnection for local returns ready immediately', async () => {
const mgr = getSTTManager()
const result = await mgr.testConnection({ provider: 'local' })
expect(result.success).toBe(true)
expect(result.latencyMs).toBe(0)
})
})
describe('OpenAIDriver', () => {
it('transcribes audio via OpenAI Whisper format with mocked 200 response', async () => {
const driver = new OpenAIDriver()
const mockAudio = Buffer.alloc(16000 * 2) // 1 second PCM
const mockResponse = {
text: '안녕하세요 D3RO 음성 인식입니다.',
language: 'ko',
duration: 1.0,
segments: [
{ text: '안녕하세요 D3RO 음성 인식입니다.', start: 0, end: 1.0, avg_logprob: -0.1 },
],
}
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
})
)
const result = await driver.transcribe(mockAudio, { language: 'ko' }, { apiKey: 'sk-mock-key' })
expect(result.text).toBe('안녕하세요 D3RO 음성 인식입니다.')
expect(result.language).toBe('ko')
expect(result.segments.length).toBe(1)
})
it('throws error when apiKey is missing', async () => {
const driver = new OpenAIDriver()
await expect(driver.transcribe(Buffer.alloc(100))).rejects.toThrow('API Key')
})
it('testConnection returns success: true on valid API response', async () => {
const driver = new OpenAIDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'OK' }),
})
)
const result = await driver.testConnection({ apiKey: 'sk-mock-key' })
expect(result.success).toBe(true)
expect(result.message).toContain('연결 성공')
})
})
describe('GroqDriver', () => {
it('transcribes audio via Groq Whisper LPU with mocked response', async () => {
const driver = new GroqDriver()
const mockAudio = Buffer.alloc(16000 * 2)
const mockResponse = {
text: 'Groq ultra-fast transcription.',
language: 'en',
duration: 1.0,
segments: [{ text: 'Groq ultra-fast transcription.', start: 0, end: 1.0 }],
}
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
})
)
const result = await driver.transcribe(mockAudio, { language: 'en' }, { apiKey: 'gsk_mock_key' })
expect(result.text).toBe('Groq ultra-fast transcription.')
expect(result.language).toBe('en')
})
})
describe('DeepgramDriver', () => {
it('transcribes audio via Deepgram Nova-3 format with mocked response', async () => {
const driver = new DeepgramDriver()
const mockAudio = Buffer.alloc(16000 * 2)
const mockResponse = {
metadata: { duration: 1.0 },
results: {
channels: [
{
alternatives: [
{
transcript: 'Deepgram Nova 3 speech recognition test.',
confidence: 0.99,
detected_language: 'en',
words: [
{ word: 'Deepgram', start: 0, end: 0.3, confidence: 0.99 },
{ word: 'Nova', start: 0.3, end: 0.5, confidence: 0.99 },
],
},
],
},
],
},
}
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
})
)
const result = await driver.transcribe(mockAudio, { language: 'en' }, { apiKey: 'mock_deepgram_key' })
expect(result.text).toBe('Deepgram Nova 3 speech recognition test.')
expect(result.segments.length).toBe(2)
})
})
describe('GoogleDriver (Gemini Flash Audio)', () => {
it('transcribes audio via Gemini Flash Audio format with mocked response', async () => {
const driver = new GoogleDriver()
const mockAudio = Buffer.alloc(16000 * 2)
const mockResponse = {
candidates: [
{
content: {
parts: [{ text: 'Google Gemini 2.0 Flash 전사 결과입니다.' }],
},
},
],
}
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
})
)
const result = await driver.transcribe(mockAudio, { language: 'ko' }, { apiKey: 'mock_google_key' })
expect(result.text).toBe('Google Gemini 2.0 Flash 전사 결과입니다.')
})
})
describe('AssemblyAIDriver', () => {
it('uploads audio and polls transcript with mocked response', async () => {
const driver = new AssemblyAIDriver()
const mockAudio = Buffer.alloc(16000 * 2)
vi.stubGlobal(
'fetch',
vi.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ upload_url: 'https://cdn.assemblyai.com/test.wav' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: 'transcript-job-123', status: 'queued' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
id: 'transcript-job-123',
status: 'completed',
text: 'AssemblyAI transcription completed.',
audio_duration: 1.0,
words: [{ text: 'AssemblyAI', start: 0, end: 500, confidence: 0.98 }],
}),
})
)
const result = await driver.transcribe(mockAudio, { language: 'en' }, { apiKey: 'mock_assembly_key' })
expect(result.text).toBe('AssemblyAI transcription completed.')
expect(result.segments.length).toBe(1)
})
})
describe('CustomDriver', () => {
it('transcribes audio via custom OpenAI-compatible endpoint', async () => {
const driver = new CustomDriver()
const mockAudio = Buffer.alloc(16000 * 2)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Custom endpoint transcription.' }),
})
)
const result = await driver.transcribe(
mockAudio,
{ language: 'en' },
{ baseUrl: 'http://localhost:8000/v1', modelId: 'whisper-1' }
)
expect(result.text).toBe('Custom endpoint transcription.')
})
})
describe('D3ROCloudDriver', () => {
it('transcribes audio via D3RO Cloud STT Gateway endpoint without requiring individual provider keys', async () => {
const driver = new D3ROCloudDriver()
const mockAudio = Buffer.alloc(16000 * 2)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
text: 'D3RO 클라우드 매니지드 전사 결과입니다.',
language: 'ko',
durationSeconds: 1.0,
provider: 'groq',
}),
})
)
const result = await driver.transcribe(
mockAudio,
{ language: 'ko' },
{ baseUrl: 'http://localhost:5000' }
)
expect(result.text).toBe('D3RO 클라우드 매니지드 전사 결과입니다.')
expect(result.language).toBe('ko')
})
})
describe('STTManager Auto-Fallback', () => {
it('falls back to Local Whisper if cloud STT fails and fallback is enabled', async () => {
const mgr = getSTTManager()
mgr.setProvider('openai')
mgr.setProviderConfig('openai', { apiKey: 'sk-invalid-key' })
configSet('sttFallbackToLocal', true)
// Mock cloud driver failing
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => 'Unauthorized',
})
)
// Mock LocalSTTService transcribe
const mockLocalResult = {
text: '로컬 Whisper 폴백 성공',
language: 'ko',
duration: 1.0,
processingTime: 100,
segments: [],
}
const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService')
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue(mockLocalResult)
const result = await mgr.transcribe(Buffer.alloc(16000 * 2))
expect(result.text).toBe('로컬 Whisper 폴백 성공')
})
})
})

View file

@ -2,6 +2,7 @@
// 상태 머신 전이 + 이중 조건 플러시 + accidentalPress 테스트
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { EventEmitter } from 'events'
import { RecognitionState, AudioState } from '@d3ro/core/types'
import { TIMING } from '@d3ro/core/constants'
@ -30,14 +31,20 @@ const mockSTT = {
}
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => mockSTT
getLocalSTTService: () => mockSTT,
resetLocalSTTServiceForTests: () => undefined,
}))
const audioBus = new EventEmitter()
const mockAudio = {
start: vi.fn(() => Promise.resolve()),
stop: vi.fn(() => Promise.resolve()),
on: vi.fn(),
off: vi.fn()
on: vi.fn((ev: string, fn: (...args: unknown[]) => void) => {
audioBus.on(ev, fn)
}),
off: vi.fn((ev: string, fn: (...args: unknown[]) => void) => {
audioBus.off(ev, fn)
}),
}
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
@ -89,7 +96,18 @@ let getVoiceModeService: () => ReturnType<typeof import('../../../src/main/servi
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
audioBus.removeAllListeners()
mockSTT.initialize.mockResolvedValue(undefined as never)
mockSTT.transcribe.mockResolvedValue({
text: '테스트 전사',
segments: [],
language: 'ko',
duration: 2,
processingTime: 500,
} as never)
mockLLM.processText.mockResolvedValue('다듬어진 텍스트' as never)
const mod = await import('../../../src/main/services/VoiceModeService')
mod.resetVoiceModeServiceForTests()
getVoiceModeService = mod.getVoiceModeService
})
@ -146,6 +164,49 @@ describe('VoiceModeService', () => {
})
})
describe('STT 준비 대기', () => {
it('전사가 시작된 뒤에는 readiness timeout 으로 세션을 취소하지 않는다', async () => {
let resolveInit: (() => void) | undefined
mockSTT.initialize.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveInit = resolve
}),
)
mockSTT.transcribe.mockResolvedValue({
text: '안녕하세요',
segments: [],
language: 'ko',
duration: 2,
processingTime: 50,
} as never)
const svc = getVoiceModeService()
let cancelReason: string | null = null
let completed = false
svc.on('session-cancelled', (payload: { reason: string }) => {
cancelReason = payload.reason
})
const completedPromise = new Promise<void>((resolve) => {
svc.once('session-completed', () => {
completed = true
resolve()
})
})
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: Buffer.alloc(16000 * 2) })
await new Promise((r) => setTimeout(r, 850))
await svc.stopSession()
resolveInit?.()
await Promise.race([completedPromise, new Promise((r) => setTimeout(r, 1000))])
expect(cancelReason).not.toBe('timeout')
expect(completed).toBe(true)
})
})
describe('cancelSession', () => {
it('user 취소로 세션을 종료한다', async () => {
const svc = getVoiceModeService()