fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -0,0 +1,165 @@
// tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts
// 파일 전사 취소가 실행 중인 ffmpeg 를 곧바로 종료하고 서비스를 idle 로 돌린다.
// 취소된 작업의 늦은 정리(상태 되돌리기·임시 폴더 삭제)가 다음 작업을 덮지 않는다.
// + 드롭 경로·시작 실패 분류·늦은 응답 무시(렌더러 순수 모델)
import { EventEmitter } from 'events'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import {
StartRequestTracker,
classifyStartFailure,
resolveDroppedFilePath,
} from '../../../src/renderer/components/file-drop-model'
type FakeProcess = EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
exitCode: number | null
killed: boolean
kill: () => boolean
args: string[]
}
const hoisted = vi.hoisted(() => ({
spawn: vi.fn(),
procs: [] as unknown[],
/** true 면 WAV 변환(ffmpeg -i ... out.wav)이 끝나지 않고 매달린다 */
hangConvert: true,
}))
vi.mock('child_process', () => ({ spawn: hoisted.spawn }))
vi.mock('../../../src/main/utils/paths', () => ({ getFfmpegPath: () => '/bundled/ffmpeg.exe' }))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ ensure: vi.fn() }) }))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn(() => undefined) }))
vi.mock('../../../src/main/services/stt/STTManager', () => ({
getSTTManager: () => ({ transcribe: vi.fn(async () => ({ text: 'hello', segments: [] })) }),
}))
vi.mock('../../../src/main/services/HistoryService', () => ({ getHistoryService: () => ({ create: vi.fn() }) }))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null }))
function fakeSpawn(_command: string, args: string[]): FakeProcess {
const proc = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
exitCode: null as number | null,
killed: false,
args,
kill(): boolean {
proc.killed = true
queueMicrotask(() => proc.emit('close', null))
return true
},
})
hoisted.procs.push(proc)
const isConvert = args[args.length - 1].endsWith('.wav') && !args.includes('null')
queueMicrotask(() => {
if (isConvert && hoisted.hangConvert) return
if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:10.00'))
if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm'))
proc.exitCode = 0
proc.emit('close', 0)
})
return proc
}
describe('FileTranscriptionService 취소', () => {
let inputFile: string
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
hoisted.procs = []
hoisted.hangConvert = true
hoisted.spawn.mockImplementation(fakeSpawn)
inputFile = path.join(os.tmpdir(), `d3ro-ft-cancel-${Date.now()}.mp4`)
fs.writeFileSync(inputFile, 'x')
})
afterEach(() => {
fs.rmSync(inputFile, { force: true })
})
it('변환 중 취소하면 ffmpeg 를 종료하고, 곧바로 idle 이 되어 새 작업을 받는다', async () => {
const mod = await import('../../../src/main/services/FileTranscriptionService')
mod.resetFileTranscriptionServiceForTests()
const service = mod.getFileTranscriptionService()
const first = service.startTranscription(inputFile, 'en')
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
const convert = hoisted.procs[0] as FakeProcess
const firstTempDir = path.dirname(convert.args[convert.args.length - 1])
service.cancel()
expect(convert.killed).toBe(true)
expect(service.state).toBe('idle')
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
expect(fs.existsSync(firstTempDir)).toBe(false)
// 새 작업은 거부되지 않고 끝까지 간다
hoisted.hangConvert = false
const result = await service.startTranscription(inputFile, 'en')
expect(result.fullText).toBe('hello')
})
it('취소된 작업의 늦은 정리가 다음 작업의 상태·jobId 를 지우지 않는다', async () => {
const mod = await import('../../../src/main/services/FileTranscriptionService')
mod.resetFileTranscriptionServiceForTests()
const service = mod.getFileTranscriptionService()
const first = service.startTranscription(inputFile, 'en')
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
service.cancel()
// 이전 작업이 정리되기 전에 새 작업을 시작한다(여전히 변환 중에 매달림)
const second = service.startTranscription(inputFile, 'en')
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
await new Promise((resolve) => setTimeout(resolve, 20))
const info = service.getStateInfo()
expect(info.state).toBe('converting')
expect(info.jobId).not.toBeNull()
service.cancel()
await expect(second).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
expect(service.getStateInfo()).toMatchObject({ state: 'idle', jobId: null })
})
})
describe('FileDropZone 모델', () => {
it('드롭한 파일 경로는 getPathForFile 로 얻고, 못 얻으면 null(대화상자로 떨어지지 않는다)', () => {
const file = { name: 'meeting.mp3' } as File
expect(resolveDroppedFilePath(file, () => 'C:\\rec\\meeting.mp3')).toBe('C:\\rec\\meeting.mp3')
expect(resolveDroppedFilePath(file, () => '')).toBeNull()
expect(
resolveDroppedFilePath(file, () => {
throw new Error('not a real file')
}),
).toBeNull()
})
it('취소·대화상자 닫기는 오류로 보이지 않는다', () => {
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'Transcription cancelled' })).toBe('cancelled')
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'File selection cancelled' })).toBe('cancelled')
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionFFmpegFailed, message: 'ffmpeg exited' })).toBe('error')
})
it('취소·새 시작 뒤에는 이전 요청의 늦은 응답을 무시한다', () => {
const tracker = new StartRequestTracker()
const first = tracker.begin()
tracker.invalidate()
const second = tracker.begin()
expect(tracker.isCurrent(first)).toBe(false)
expect(tracker.isCurrent(second)).toBe(true)
})
})