refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -0,0 +1,171 @@
// tests/main/services/stt-fallback-redteam-r1-3.test.ts
// 클라우드 STT 실패 → 초기화되지 않은 로컬 엔진 폴백이 영원히 끝나지 않던 버그의 회귀 테스트.
// - LocalSTTService.transcribe 는 모델이 없으면 스스로 초기화하고, 초기화가 실패하면 reject 한다
// - 동시에 들어온 두 호출이 서로를 덮어쓰지 않고, 각자의 옵션으로 전사된다
// - STTManager 폴백은 로컬이 실패하면 원래 클라우드 에러로 끝난다(무한 대기 없음)
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager'
import {
getLocalSTTService,
resetLocalSTTServiceForTests,
} from '../../../src/main/services/LocalSTTService'
import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService'
import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService'
type LocalInternals = {
_modelReady: boolean
_sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise<TranscriptionResult>
}
function internals(): LocalInternals {
return getLocalSTTService() as unknown as LocalInternals
}
/** 일정 시간 안에 끝나지 않으면 실패시킨다 (무한 대기 회귀 감지) */
function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms)),
])
}
const echo = (buf: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> =>
Promise.resolve({
text: `${buf.length}:${options?.language ?? '-'}:${options?.initialPrompt ?? '-'}`,
segments: [],
language: options?.language ?? 'ko',
duration: 1,
processingTime: 1,
})
describe('LocalSTTService — 준비되지 않은 엔진의 transcribe', () => {
beforeEach(() => {
initInMemoryConfig()
resetLocalSTTServiceForTests()
vi.restoreAllMocks()
})
afterEach(() => {
resetLocalSTTServiceForTests()
resetInMemoryConfig()
vi.restoreAllMocks()
})
it('초기화가 실패하면 대기하지 않고 그 에러로 reject 한다', async () => {
const local = getLocalSTTService()
vi.spyOn(local, 'initialize').mockRejectedValue(
new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'no sidecar'),
)
await expect(settleWithin(local.transcribe(Buffer.alloc(32000)), 1000)).rejects.toMatchObject({
code: ErrorCode.STTSidecarSpawnFailed,
})
})
it('아무도 초기화하지 않았으면 스스로 초기화한 뒤 호출자 옵션으로 전사한다', async () => {
const local = getLocalSTTService()
const init = vi.spyOn(local, 'initialize').mockImplementation(async () => {
internals()._modelReady = true
})
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
const result = await settleWithin(
local.transcribe(Buffer.alloc(100), { language: 'en', initialPrompt: 'hint' }),
1000,
)
expect(init).toHaveBeenCalledTimes(1)
expect(result.text).toBe('100:en:hint')
})
it('동시에 들어온 두 호출은 각자 결과를 받는다 (합쳐지거나 버려지지 않는다)', async () => {
const local = getLocalSTTService()
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => {
release = resolve
})
vi.spyOn(local, 'initialize').mockImplementation(async () => {
await gate
internals()._modelReady = true
})
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
const first = local.transcribe(Buffer.alloc(10), { language: 'ko' })
const second = local.transcribe(Buffer.alloc(20), { language: 'ja' })
release()
const [a, b] = await settleWithin(Promise.all([first, second]), 1000)
expect(a.text).toBe('10:ko:-')
expect(b.text).toBe('20:ja:-')
})
})
describe('STTManager — 클라우드 실패 후 로컬 폴백', () => {
beforeEach(() => {
initInMemoryConfig()
resetSTTManagerForTests()
resetLocalSTTServiceForTests()
vi.restoreAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
resetInMemoryConfig()
resetSTTManagerForTests()
resetLocalSTTServiceForTests()
vi.restoreAllMocks()
})
function failCloud(): void {
const mgr = getSTTManager()
mgr.setProvider('openai')
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
configSet('sttFallbackToLocal', true)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => 'offline' }),
)
}
it('로컬 엔진 초기화가 실패하면 원래 클라우드 에러로 끝난다 (무한 대기 없음)', async () => {
failCloud()
vi.spyOn(getLocalSTTService(), 'initialize').mockRejectedValue(
new D3ROError(ErrorCode.STTModelLoadFailed, 'model missing'),
)
const outcome = settleWithin(getSTTManager().transcribe(Buffer.alloc(32000)), 2000)
await expect(outcome).rejects.toBeInstanceOf(Error)
await expect(outcome).rejects.not.toMatchObject({ message: expect.stringContaining('did not settle') })
})
it('로컬 폴백이 끝나지 않으면 상한 시간에 끊는다', async () => {
failCloud()
vi.spyOn(getLocalSTTService(), 'transcribe').mockReturnValue(new Promise<never>(() => undefined))
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const { LOCAL_FALLBACK_TIMEOUT_MS } = await import('../../../src/main/services/stt/STTManager')
const pending = getSTTManager().transcribe(Buffer.alloc(32000))
const assertion = expect(pending).rejects.toBeInstanceOf(Error)
await vi.advanceTimersByTimeAsync(LOCAL_FALLBACK_TIMEOUT_MS + 1)
await assertion
})
it('ensureReady 는 로컬 공급자에서만 모델을 올린다', async () => {
const mgr = getSTTManager()
const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined)
mgr.setProvider('openai')
await mgr.ensureReady()
expect(init).not.toHaveBeenCalled()
expect(mgr.supportsPartial()).toBe(false)
expect(mgr.getMissingModel()).toBeNull()
mgr.setProvider('local')
await mgr.ensureReady()
expect(init).toHaveBeenCalledTimes(1)
expect(mgr.supportsPartial()).toBe(true)
})
})