fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -0,0 +1,194 @@
|
|||
// tests/main/config-effects-overlay-redteam-r2-1.test.ts
|
||||
// - 설정 키별 부수효과는 어느 경로로 바뀌든(범용 config:set 포함) 한 번씩 적용된다
|
||||
// - 제안 오버레이 표시 정책(프레젠터): show→update→hide 전이, 앵커 없음, 예외 격리, 매핑 단일화
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SuggestionState } from '@d3ro/core/input-intelligence'
|
||||
import { ConfigEffects, isEffectiveChange, type ConfigChange } from '../../src/main/config-effects'
|
||||
import {
|
||||
SuggestionOverlayPresenter,
|
||||
toSuggestionOverlayContent,
|
||||
toSuggestionOverlayShowPayload,
|
||||
type SuggestionOverlayView,
|
||||
} from '../../src/main/windows/suggestion-overlay-presenter'
|
||||
|
||||
vi.mock('../../src/main/windows/WindowManager', () => ({
|
||||
showSuggestionOverlay: vi.fn(),
|
||||
updateSuggestionOverlay: vi.fn(),
|
||||
hideSuggestionOverlay: vi.fn(),
|
||||
isSuggestionOverlayVisible: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
function bus() {
|
||||
const listeners = new Set<(change: ConfigChange) => void>()
|
||||
return {
|
||||
subscribe: (listener: (change: ConfigChange) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
emit: (change: ConfigChange) => {
|
||||
for (const listener of [...listeners]) listener(change)
|
||||
},
|
||||
size: () => listeners.size,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConfigEffects', () => {
|
||||
it('범용 config:set 경로의 변경도 효과를 한 번 적용한다(자동 실행·테마)', () => {
|
||||
const b = bus()
|
||||
const effects = new ConfigEffects({ subscribe: b.subscribe })
|
||||
const setLoginItem = vi.fn()
|
||||
const repaint = vi.fn()
|
||||
effects.register('autoLaunch', (value) => setLoginItem(value === true))
|
||||
effects.register('theme', repaint)
|
||||
effects.start()
|
||||
|
||||
b.emit({ key: 'autoLaunch', value: true, previousValue: false })
|
||||
b.emit({ key: 'theme', value: 'nord', previousValue: 'dark' })
|
||||
b.emit({ key: 'language', value: 'en', previousValue: 'ko' })
|
||||
|
||||
expect(setLoginItem).toHaveBeenCalledTimes(1)
|
||||
expect(setLoginItem).toHaveBeenCalledWith(true)
|
||||
expect(repaint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('효과 안에서 같은 값을 다시 저장해도 재귀하지 않는다(AutoLaunchService.setEnabled 는 적용 후 configSet)', () => {
|
||||
const b = bus()
|
||||
const effects = new ConfigEffects({ subscribe: b.subscribe })
|
||||
const apply = vi.fn((value: unknown) => {
|
||||
b.emit({ key: 'autoLaunch', value, previousValue: value })
|
||||
})
|
||||
effects.register('autoLaunch', apply)
|
||||
effects.start()
|
||||
|
||||
b.emit({ key: 'autoLaunch', value: false, previousValue: true })
|
||||
|
||||
expect(apply).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('효과 하나가 던져도 다른 효과는 실행되고 오류를 알린다', () => {
|
||||
const b = bus()
|
||||
const onError = vi.fn()
|
||||
const effects = new ConfigEffects({ subscribe: b.subscribe, onError })
|
||||
const second = vi.fn()
|
||||
effects.register('theme', () => {
|
||||
throw new Error('popup gone')
|
||||
})
|
||||
effects.register('theme', second)
|
||||
effects.start()
|
||||
effects.start() // 멱등
|
||||
|
||||
b.emit({ key: 'theme', value: 'light', previousValue: 'dark' })
|
||||
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('theme', expect.any(Error))
|
||||
expect(b.size()).toBe(1)
|
||||
effects.dispose()
|
||||
expect(b.size()).toBe(0)
|
||||
})
|
||||
|
||||
it('isEffectiveChange: 원시값은 같으면 건너뛰고, 객체는 항상 적용한다', () => {
|
||||
expect(isEffectiveChange({ key: 'k', value: 1, previousValue: 1 })).toBe(false)
|
||||
expect(isEffectiveChange({ key: 'k', value: 'a', previousValue: 'b' })).toBe(true)
|
||||
expect(isEffectiveChange({ key: 'k', value: [1], previousValue: [1] })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function state(overrides: Partial<SuggestionState> = {}): SuggestionState {
|
||||
return {
|
||||
enabled: true,
|
||||
modelId: 'm',
|
||||
modelAvailable: true,
|
||||
visible: true,
|
||||
generating: false,
|
||||
warmingUp: false,
|
||||
partialText: null,
|
||||
candidates: [{ text: '안녕하세요', rank: 0 }],
|
||||
activeIndex: 0,
|
||||
targetTotal: 3,
|
||||
anchor: { x: 1, y: 2, width: 3, height: 4 },
|
||||
anchorKind: 'caret',
|
||||
appName: 'Notepad',
|
||||
updatedAt: 0,
|
||||
lastSkipReason: null,
|
||||
requestsToday: 0,
|
||||
dailyBudget: 100,
|
||||
lastLatencyMs: null,
|
||||
triggerDelayMs: 600,
|
||||
minPrefixChars: 8,
|
||||
requestTimeoutMs: 1000,
|
||||
coolingDown: false,
|
||||
learnTypedText: false,
|
||||
telemetryEnabled: true,
|
||||
overlayInteractive: true,
|
||||
provenance: null,
|
||||
...overrides,
|
||||
} as SuggestionState
|
||||
}
|
||||
|
||||
function fakeView(visible = false) {
|
||||
const view = {
|
||||
visible,
|
||||
show: vi.fn(() => {
|
||||
view.visible = true
|
||||
}),
|
||||
update: vi.fn(),
|
||||
hide: vi.fn(() => {
|
||||
view.visible = false
|
||||
}),
|
||||
isVisible: () => view.visible,
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
describe('SuggestionOverlayPresenter', () => {
|
||||
it('show → update → hide 로 전이하고, 텔레메트리 표시 플래그를 맞춘다', () => {
|
||||
const view = fakeView()
|
||||
const setActive = vi.fn()
|
||||
const presenter = new SuggestionOverlayPresenter({ view: view as SuggestionOverlayView, setPresentationActive: setActive, warn: vi.fn() })
|
||||
|
||||
presenter.onUpdated(state())
|
||||
presenter.onUpdated(state({ generating: true }))
|
||||
presenter.onUpdated(state({ candidates: [] }))
|
||||
|
||||
expect(view.show).toHaveBeenCalledTimes(1)
|
||||
expect(view.show).toHaveBeenCalledWith(expect.objectContaining({ anchorKind: 'caret', appName: 'Notepad' }))
|
||||
expect(view.update).toHaveBeenCalledTimes(1)
|
||||
expect(view.update.mock.calls[0][0]).not.toHaveProperty('appName')
|
||||
expect(view.hide).toHaveBeenCalledTimes(1)
|
||||
expect(setActive.mock.calls.map((c) => c[0])).toEqual([true, true, false])
|
||||
})
|
||||
|
||||
it('앵커가 없으면 보여 줄 것이 있어도 숨긴다', () => {
|
||||
const view = fakeView(true)
|
||||
const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: vi.fn(), warn: vi.fn() })
|
||||
presenter.onUpdated(state({ anchor: null }))
|
||||
expect(view.hide).toHaveBeenCalled()
|
||||
expect(view.show).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('창 호출이 던져도 삼키고 경고만 남긴다 — cleared 는 플래그를 항상 내린다', () => {
|
||||
const view = fakeView()
|
||||
view.show.mockImplementation(() => {
|
||||
throw new Error('window destroyed')
|
||||
})
|
||||
view.hide.mockImplementation(() => {
|
||||
throw new Error('window destroyed')
|
||||
})
|
||||
const warn = vi.fn()
|
||||
const setActive = vi.fn()
|
||||
const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: setActive, warn })
|
||||
|
||||
expect(() => presenter.onUpdated(state())).not.toThrow()
|
||||
expect(() => presenter.onCleared()).not.toThrow()
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
expect(setActive).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('show payload 는 update payload(단일 매핑)에 배치 정보만 더한다', () => {
|
||||
const s = state({ partialText: '부분', warmingUp: true })
|
||||
const { anchor, anchorKind, appName, ...content } = toSuggestionOverlayShowPayload(s)
|
||||
expect(content).toEqual(toSuggestionOverlayContent(s))
|
||||
expect({ anchor, anchorKind, appName }).toEqual({ anchor: s.anchor, anchorKind: 'caret', appName: 'Notepad' })
|
||||
})
|
||||
})
|
||||
52
apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts
Normal file
52
apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// tests/main/db-open-atomic-redteam-r2-1.test.ts
|
||||
// 계정 DB 가 손상돼 열리지 않아도 앱이 DB 없이 남지 않는다 — openForUser 는 새 DB 를 연 뒤에야
|
||||
// 이전 DB 를 닫는다(원자적 교체).
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import {
|
||||
closeCurrent,
|
||||
getCurrentUserId,
|
||||
getDatabase,
|
||||
isDatabaseOpen,
|
||||
openForUser,
|
||||
openLocal,
|
||||
} from '../../src/main/db'
|
||||
|
||||
const CORRUPT_USER = `corrupt-${process.pid}-${Date.now()}`
|
||||
const GOOD_USER = `good-${process.pid}-${Date.now()}`
|
||||
|
||||
function userDir(userId: string): string {
|
||||
return path.join(app.getPath('userData'), 'users', userId)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeCurrent()
|
||||
for (const id of [CORRUPT_USER, GOOD_USER]) fs.rmSync(userDir(id), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('openForUser 원자적 교체', () => {
|
||||
it('손상된 계정 DB 를 열지 못하면 이전(로컬) DB 가 그대로 열려 있다', () => {
|
||||
openLocal()
|
||||
fs.mkdirSync(userDir(CORRUPT_USER), { recursive: true })
|
||||
fs.writeFileSync(path.join(userDir(CORRUPT_USER), 'd3ro.db'), 'this is not a sqlite database'.repeat(64))
|
||||
|
||||
expect(() => openForUser(CORRUPT_USER)).toThrow(expect.objectContaining({ code: ErrorCode.DBOpenFailed }))
|
||||
|
||||
expect(isDatabaseOpen()).toBe(true)
|
||||
expect(getCurrentUserId()).toBe('_local')
|
||||
expect(() => getDatabase().run(sql`SELECT 1`)).not.toThrow()
|
||||
})
|
||||
|
||||
it('정상 계정 DB 는 열고 이전 DB 와 교체한다', () => {
|
||||
openLocal()
|
||||
const result = openForUser(GOOD_USER)
|
||||
expect(result.dbPath).toContain(GOOD_USER)
|
||||
expect(getCurrentUserId()).toBe(GOOD_USER)
|
||||
expect(() => getDatabase().run(sql`SELECT 1`)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
// tests/main/services/caption-stop-failure-redteam-r2-1.test.ts
|
||||
// 정지 중 마지막 문장 인식이 실패해도 CaptionService 가 'stopping' 에 굳지 않는다:
|
||||
// - stop() 은 성공하고 상태는 'inactive', 마이크 캡처는 정확히 한 번 멈춘다
|
||||
// - 다시 start() 할 수 있다
|
||||
// - 실패는 던지지 않는 'failure' 이벤트로 알린다
|
||||
// + CaptionAudioSources 의 멱등 해제
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CaptionAudioSources } from '../../../src/main/services/caption/CaptionAudioSources'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: string) => config.values[key]),
|
||||
configSet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => ({
|
||||
initialize: vi.fn(async () => undefined),
|
||||
ensureAuxModel: vi.fn(async () => undefined),
|
||||
transcribe: vi.fn(async () => {
|
||||
throw new Error('sidecar HTTP 500')
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const audio = vi.hoisted(() => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }))
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => ({ ...audio, on: vi.fn(), off: vi.fn() }),
|
||||
calculateRMS: () => 0.1,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/SoundEffectService', () => ({
|
||||
getSoundEffectService: () => ({ play: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: () => false, generate: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/HistoryService', () => ({
|
||||
getHistoryService: () => ({ create: vi.fn(() => ({ id: 'h' })) }),
|
||||
}))
|
||||
|
||||
// 트랙: flush 가 마지막 문장을 인식하려다 실패한다 — 실제 트랙처럼 deps.onError 로 보고한다
|
||||
const trackMode = vi.hoisted(() => ({ mode: 'report' as 'report' | 'reject' }))
|
||||
vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({
|
||||
StreamingCaptionTrack: class {
|
||||
constructor(private readonly deps: { onError: (err: unknown) => void }) {}
|
||||
push(): void {}
|
||||
async tick(): Promise<void> {}
|
||||
async flush(): Promise<void> {
|
||||
if (trackMode.mode === 'reject') throw new Error('flush exploded')
|
||||
this.deps.onError(new Error('sidecar HTTP 500'))
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
type CaptionModule = typeof import('../../../src/main/services/CaptionService')
|
||||
let mod: CaptionModule
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
config.values = {}
|
||||
trackMode.mode = 'report'
|
||||
mod = await import('../../../src/main/services/CaptionService')
|
||||
mod.resetCaptionServiceForTests()
|
||||
})
|
||||
|
||||
describe('CaptionService.stop — flush 실패', () => {
|
||||
it('인식 실패(onError)가 있어도 정지·해제·초기화를 끝내고 다시 시작할 수 있다', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
const failures: string[] = []
|
||||
caption.on('failure', (err) => failures.push(err.message))
|
||||
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||
|
||||
await expect(caption.stop('meeting')).resolves.toBeNull()
|
||||
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
expect(failures).toHaveLength(1)
|
||||
|
||||
await expect(caption.start({ audioSource: 'mic', showOverlay: false })).resolves.toBeUndefined()
|
||||
expect(caption.getState()).toBe('active')
|
||||
})
|
||||
|
||||
it('failure 리스너가 없어도 던지지 않는다(예전엔 리스너 없는 error 가 stop 을 튕겼다)', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
await caption.start({ audioSource: 'mic', showOverlay: false })
|
||||
await expect(caption.stop()).resolves.toBeNull()
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('flush 자체가 reject 해도 해제하고 inactive 가 된다', async () => {
|
||||
trackMode.mode = 'reject'
|
||||
const caption = mod.getCaptionService()
|
||||
await caption.start({ audioSource: 'mic', showOverlay: false })
|
||||
await expect(caption.stop()).resolves.toBeNull()
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
// 두 번째 정지는 아무것도 하지 않는다
|
||||
await expect(caption.stop()).resolves.toBeNull()
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CaptionAudioSources', () => {
|
||||
function sources(options: { stopFails?: boolean } = {}) {
|
||||
const mic = {
|
||||
onChunk: vi.fn(() => unsubscribe),
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => {
|
||||
if (options.stopFails) throw new Error('sox gone')
|
||||
}),
|
||||
}
|
||||
const unsubscribe = vi.fn()
|
||||
const system = { requestStart: vi.fn(), requestStop: vi.fn() }
|
||||
const onReleaseError = vi.fn()
|
||||
const audioSources = new CaptionAudioSources({ mic: () => mic, system, onReleaseError })
|
||||
return { audioSources, mic, unsubscribe, system, onReleaseError }
|
||||
}
|
||||
|
||||
it('release 는 멱등이다 — 마이크 stop 은 start 한 만큼만(한 번) 부른다', async () => {
|
||||
const h = sources()
|
||||
await h.audioSources.acquire('both', { onMicChunk: vi.fn() })
|
||||
expect(h.mic.start).toHaveBeenCalledTimes(1)
|
||||
expect(h.system.requestStart).toHaveBeenCalledTimes(1)
|
||||
|
||||
await h.audioSources.release()
|
||||
await h.audioSources.release()
|
||||
|
||||
expect(h.mic.stop).toHaveBeenCalledTimes(1)
|
||||
expect(h.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(h.audioSources.holdsMic).toBe(false)
|
||||
})
|
||||
|
||||
it('마이크 해제가 실패해도 시스템 소리 중지 요청은 보낸다', async () => {
|
||||
const h = sources({ stopFails: true })
|
||||
await h.audioSources.acquire('both', { onMicChunk: vi.fn() })
|
||||
await expect(h.audioSources.release()).resolves.toBeUndefined()
|
||||
expect(h.onReleaseError).toHaveBeenCalledWith('mic', expect.any(Error))
|
||||
expect(h.system.requestStop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('system 소스만 잡으면 마이크는 건드리지 않는다', async () => {
|
||||
const h = sources()
|
||||
await h.audioSources.acquire('system', { onMicChunk: vi.fn() })
|
||||
await h.audioSources.release()
|
||||
expect(h.mic.start).not.toHaveBeenCalled()
|
||||
expect(h.mic.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
// tests/main/services/llm-limits-routing-redteam-r2-1.test.ts
|
||||
// - Premium 단일 턴(generate/processText)은 프록시 한도를 넘으면 보내지 않고 LLMPromptTooLong
|
||||
// - 단일 턴도 호출 단위 signal 로 취소된다(전역 cancelGeneration 없이)
|
||||
// - 게이트웨이: 취소는 로컬로 폴백하지 않는다, 채팅 스트림에 signal 을 넘긴다
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
import {
|
||||
createRoutedLlmGateway,
|
||||
type LocalLlmAdapter,
|
||||
type PremiumLlmAdapter,
|
||||
} from '../../../src/main/services/llm/LlmGateway'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
const cloud = vi.hoisted(() => ({
|
||||
isEnabled: (): boolean => true,
|
||||
isAuthenticated: (): boolean => true,
|
||||
invokeFunctionStream: vi.fn(),
|
||||
invokeFunction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => cloud,
|
||||
}))
|
||||
|
||||
import { getPremiumLLMService, resetPremiumLLMServiceForTests } from '../../../src/main/services/PremiumLLMService'
|
||||
|
||||
const okResponse = {
|
||||
data: {
|
||||
id: 'm', model: 'claude', role: 'assistant',
|
||||
content: [{ type: 'text', text: '결과' }],
|
||||
stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
describe('PremiumLLMService 단일 턴 한도', () => {
|
||||
beforeEach(() => {
|
||||
resetPremiumLLMServiceForTests()
|
||||
cloud.invokeFunction.mockReset()
|
||||
cloud.invokeFunction.mockResolvedValue(okResponse)
|
||||
})
|
||||
|
||||
it('8,000자를 넘는 generate 입력은 보내지 않고 LLMPromptTooLong 이다', async () => {
|
||||
const text = 'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1)
|
||||
await expect(getPremiumLLMService().generate(text)).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
|
||||
expect(cloud.invokeFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('system 이 한도를 넘어도 보내지 않는다', async () => {
|
||||
await expect(
|
||||
getPremiumLLMService().generate('q', { systemPrompt: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) }),
|
||||
).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
|
||||
await expect(
|
||||
getPremiumLLMService().processText('y'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1), 'refine'),
|
||||
).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
|
||||
expect(cloud.invokeFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('경계값(정확히 8,000자, 앞뒤 공백 제외)은 보낸다', async () => {
|
||||
const text = ` ${'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars)} `
|
||||
await expect(getPremiumLLMService().generate(text)).resolves.toEqual({ text: '결과' })
|
||||
expect(cloud.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('단일 턴도 호출 단위 signal 로 취소된다(cancelGeneration 없이)', async () => {
|
||||
cloud.invokeFunction.mockImplementation(
|
||||
(_n: string, _b: unknown, options?: { signal?: AbortSignal }) =>
|
||||
new Promise((resolve) => {
|
||||
options?.signal?.addEventListener('abort', () =>
|
||||
resolve({ data: null, error: { message: 'FunctionsFetchError: aborted' } }), { once: true })
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const pending = getPremiumLLMService().generate('q', { signal: controller.signal })
|
||||
controller.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
|
||||
})
|
||||
})
|
||||
|
||||
async function* tokens(...values: string[]): AsyncGenerator<string, string> {
|
||||
for (const v of values) yield v
|
||||
return values.join('')
|
||||
}
|
||||
|
||||
function premium(overrides: Partial<PremiumLlmAdapter> = {}): PremiumLlmAdapter {
|
||||
return {
|
||||
isAvailable: vi.fn(() => true),
|
||||
processText: vi.fn(async () => 'premium'),
|
||||
generate: vi.fn(async () => ({ text: 'premium' })),
|
||||
chatStream: vi.fn(() => tokens('p')),
|
||||
cancelGeneration: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function local(overrides: Partial<LocalLlmAdapter> = {}): LocalLlmAdapter {
|
||||
return {
|
||||
isAvailable: vi.fn(() => true),
|
||||
processText: vi.fn(async () => 'local'),
|
||||
generate: vi.fn(async () => ({ text: 'local' })),
|
||||
chatStream: vi.fn(() => tokens('l')),
|
||||
cancelGeneration: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('LlmGateway 호출 단위 취소', () => {
|
||||
it('Premium 호출이 취소되면 로컬로 폴백하지 않고 취소를 전파한다', async () => {
|
||||
const controller = new AbortController()
|
||||
const l = local()
|
||||
const p = premium({
|
||||
generate: vi.fn(async () => {
|
||||
controller.abort()
|
||||
throw Object.assign(new Error('cancelled'), { code: ErrorCode.LLMProcessingCancelled })
|
||||
}),
|
||||
})
|
||||
const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => l })
|
||||
|
||||
await expect(gw.generate('q', undefined, { signal: controller.signal })).rejects.toBeTruthy()
|
||||
expect(l.generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('signal 을 Premium 단일 턴·채팅 스트림에 넘기고, 다른 호출은 건드리지 않는다', async () => {
|
||||
const p = premium()
|
||||
const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => local() })
|
||||
const a = new AbortController()
|
||||
const b = new AbortController()
|
||||
|
||||
await gw.processText('t', 'refine', undefined, undefined, { signal: a.signal })
|
||||
await gw.openChatStream([{ role: 'user', content: 'hi' }], { signal: b.signal })
|
||||
a.abort()
|
||||
|
||||
expect(p.processText).toHaveBeenCalledWith('t', 'refine', undefined, undefined, { signal: a.signal })
|
||||
expect(p.chatStream).toHaveBeenCalledWith([{ role: 'user', content: 'hi' }], { signal: b.signal })
|
||||
expect(b.signal.aborted).toBe(false)
|
||||
expect(p.cancelGeneration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('이미 취소된 signal 이면 어떤 백엔드도 부르지 않는다', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const l = local()
|
||||
const gw = createRoutedLlmGateway({ getBackend: () => 'local', loadPremium: async () => premium(), getLocal: () => l })
|
||||
await expect(gw.processText('t', 'refine', undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({
|
||||
code: ErrorCode.LLMProcessingCancelled,
|
||||
})
|
||||
expect(l.processText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// tests/main/services/meeting-session-redteam-r2-1.test.ts
|
||||
// MeetingRecordingSession(가짜 포트)·전사 줄이기(map-reduce) 단위 테스트
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CaptionSegment } from '@d3ro/core/types'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { MeetingRecordingSession } from '../../../src/main/services/meeting/MeetingRecordingSession'
|
||||
import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser'
|
||||
|
||||
function fakePorts() {
|
||||
let segmentListener: ((segment: CaptionSegment) => void) | null = null
|
||||
let chunkListener: ((buffer: Buffer) => void) | null = null
|
||||
let releaseStop: () => void = () => undefined
|
||||
const stop = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStop = resolve
|
||||
})
|
||||
const last: CaptionSegment = { id: 'last', text: '마지막', timestamp: 3_000, isFinal: true }
|
||||
segmentListener?.(last)
|
||||
return { segments: [last] }
|
||||
})
|
||||
const unsubscribeSegment = vi.fn(() => {
|
||||
segmentListener = null
|
||||
})
|
||||
const ports = {
|
||||
caption: {
|
||||
onSegment: (listener: (segment: CaptionSegment) => void) => {
|
||||
segmentListener = listener
|
||||
return unsubscribeSegment
|
||||
},
|
||||
stop,
|
||||
},
|
||||
audio: {
|
||||
onChunk: (listener: (buffer: Buffer) => void) => {
|
||||
chunkListener = listener
|
||||
return () => {
|
||||
chunkListener = null
|
||||
}
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
}
|
||||
return {
|
||||
ports,
|
||||
stop,
|
||||
unsubscribeSegment,
|
||||
emitSegment: (segment: CaptionSegment) => segmentListener?.(segment),
|
||||
emitChunk: (buffer: Buffer) => chunkListener?.(buffer),
|
||||
releaseStop: () => releaseStop(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('MeetingRecordingSession', () => {
|
||||
it('동시에 두 번 멈춰도 자막 정지와 스냅샷은 한 번 — 같은 불변 스냅샷을 받는다', async () => {
|
||||
const h = fakePorts()
|
||||
const session = new MeetingRecordingSession('s1', 1_000, h.ports)
|
||||
session.attachCaption()
|
||||
session.attachAudio()
|
||||
h.emitSegment({ id: 'a', text: '첫 문장', timestamp: 2_000, isFinal: true })
|
||||
h.emitChunk(Buffer.from([1, 2]))
|
||||
|
||||
const first = session.stop()
|
||||
const second = session.stop()
|
||||
expect(session.isStopping).toBe(true)
|
||||
// 정지가 시작되면 메모·오디오는 더 받지 않는다
|
||||
expect(session.addMemo('m', '늦은 메모')).toBeNull()
|
||||
h.emitChunk(Buffer.from([3]))
|
||||
h.releaseStop()
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
|
||||
expect(h.stop).toHaveBeenCalledTimes(1)
|
||||
expect(a).toBe(b)
|
||||
expect(a.segments.map((s) => s.text)).toEqual(['첫 문장', '마지막'])
|
||||
expect(a.durationMs).toBe(9_000)
|
||||
expect([...a.pcm]).toEqual([1, 2])
|
||||
expect(Object.isFrozen(a)).toBe(true)
|
||||
expect(h.unsubscribeSegment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('자막 정지가 실패해도 그때까지의 세그먼트로 스냅샷을 만든다', async () => {
|
||||
const h = fakePorts()
|
||||
const onStopError = vi.fn()
|
||||
h.ports.caption.stop = vi.fn(async () => {
|
||||
throw new Error('stt 500')
|
||||
})
|
||||
const session = new MeetingRecordingSession('s2', 1_000, { ...h.ports, onStopError })
|
||||
session.attachCaption()
|
||||
h.emitSegment({ id: 'a', text: '남은 문장', timestamp: 2_000, isFinal: true })
|
||||
|
||||
const snapshot = await session.stop()
|
||||
|
||||
expect(onStopError).toHaveBeenCalled()
|
||||
expect(snapshot.segments.map((s) => s.text)).toEqual(['남은 문장'])
|
||||
})
|
||||
|
||||
it('checkpoint 는 멈추지 않고 전사만(오디오 없이) 준다', () => {
|
||||
const h = fakePorts()
|
||||
const session = new MeetingRecordingSession('s3', 1_000, h.ports)
|
||||
session.attachCaption()
|
||||
session.attachAudio()
|
||||
h.emitChunk(Buffer.from([9]))
|
||||
h.emitSegment({ id: 'a', text: '중간', timestamp: 2_000, isFinal: true })
|
||||
|
||||
const snapshot = session.checkpoint({ includeAudio: false })
|
||||
|
||||
expect(snapshot.segments).toHaveLength(1)
|
||||
expect(snapshot.pcm.length).toBe(0)
|
||||
expect(session.isStopping).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('condenseTranscriptToBudget (map-reduce)', () => {
|
||||
const transcript = Array.from({ length: 300 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} `.padEnd(60, '가')).join('\n')
|
||||
|
||||
it('한도 안이면 LLM 을 부르지 않고 원문을 돌려준다', async () => {
|
||||
const llm = { generate: vi.fn() }
|
||||
const result = await condenseTranscriptToBudget(llm, '[00:01] 짧은 회의', 8_000)
|
||||
expect(result).toEqual({ text: '[00:01] 짧은 회의', condensed: false, rounds: 0 })
|
||||
expect(llm.generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('한도를 넘으면 조각마다 줄이고, 각 조각 입력과 결과 모두 한도 안이다', async () => {
|
||||
const inputs: string[] = []
|
||||
const llm = {
|
||||
generate: vi.fn(async (text: string) => {
|
||||
inputs.push(text)
|
||||
return { text: `요약 ${inputs.length}` }
|
||||
}),
|
||||
}
|
||||
const result = await condenseTranscriptToBudget(llm, transcript, 8_000)
|
||||
expect(transcript.length).toBeGreaterThan(8_000)
|
||||
expect(result.condensed).toBe(true)
|
||||
expect(result.text.length).toBeLessThanOrEqual(8_000)
|
||||
expect(inputs.length).toBeGreaterThan(1)
|
||||
for (const input of inputs) expect(input.length).toBeLessThanOrEqual(8_000)
|
||||
expect(result.text).toContain(`1/${inputs.length}`)
|
||||
})
|
||||
|
||||
it('여러 번 줄여도 넘으면 조용히 자르지 않고 LLMPromptTooLong', async () => {
|
||||
const llm = { generate: vi.fn(async (text: string) => ({ text })) }
|
||||
await expect(condenseTranscriptToBudget(llm, transcript, 8_000, { maxRounds: 1 })).rejects.toMatchObject({
|
||||
code: ErrorCode.LLMPromptTooLong,
|
||||
})
|
||||
})
|
||||
|
||||
it('남은 자리가 없으면(메모가 한도를 다 쓰면) LLMPromptTooLong', async () => {
|
||||
await expect(condenseTranscriptToBudget({ generate: vi.fn() }, 'x', 0)).rejects.toMatchObject({
|
||||
code: ErrorCode.LLMPromptTooLong,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
// tests/main/services/voice-conversation-redteam-r2-1.test.ts
|
||||
// VoiceConversationService 회귀 테스트:
|
||||
// - 오류 알림이 리스너 없는 'error' 로 던져져 세션이 'thinking' 에 마이크가 꺼진 채 굳던 버그
|
||||
// - 텍스트 턴이 마이크를 멈추지 않고 다시 start 해 캡처 참조 카운트가 쌓이고 세션을 끝내도 마이크가 열려 있던 버그
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import {
|
||||
getVoiceConversationService,
|
||||
resetVoiceConversationServiceForTests,
|
||||
} from '../../../src/main/services/VoiceConversationService'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audioHandlers: new Map<string, (payload: { buffer: Buffer }) => void>(),
|
||||
refCount: 0,
|
||||
audioStart: vi.fn(),
|
||||
audioStop: vi.fn(),
|
||||
transcribe: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
speakSentences: vi.fn(async () => undefined),
|
||||
send: vi.fn(),
|
||||
play: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
// AudioCaptureService 의 참조 카운트 의미를 흉내 낸다: start 는 +1, stop 은 -1
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => ({
|
||||
start: mocks.audioStart,
|
||||
stop: mocks.audioStop,
|
||||
on: (event: string, handler: (payload: { buffer: Buffer }) => void) => {
|
||||
mocks.audioHandlers.set(event, handler)
|
||||
},
|
||||
off: (event: string) => {
|
||||
mocks.audioHandlers.delete(event)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => ({
|
||||
initialize: vi.fn(async () => undefined),
|
||||
transcribe: mocks.transcribe,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
|
||||
getPremiumLLMService: () => ({ cancelGeneration: vi.fn(), isAvailable: () => false }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: () => true, chatStream: mocks.chatStream }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/TTSPlaybackService', () => ({
|
||||
getTTSPlaybackService: () => ({ speakSentences: mocks.speakSentences, stop: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/SoundEffectService', () => ({
|
||||
getSoundEffectService: () => ({ play: mocks.play }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({
|
||||
getMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }),
|
||||
}))
|
||||
|
||||
async function* answer(text: string): AsyncGenerator<string, string> {
|
||||
yield text
|
||||
return text
|
||||
}
|
||||
|
||||
async function* failing(): AsyncGenerator<string, string> {
|
||||
throw new Error('ollama down')
|
||||
}
|
||||
|
||||
function feedAudio(bytes: number): void {
|
||||
const handler = mocks.audioHandlers.get('audio-data')
|
||||
if (!handler) throw new Error('audio-data handler not bound')
|
||||
handler({ buffer: Buffer.alloc(bytes) })
|
||||
}
|
||||
|
||||
function conversationErrors(): unknown[] {
|
||||
return mocks.send.mock.calls
|
||||
.filter(([channel]) => channel === IPC_CHANNELS.VOICE_CONVERSATION.ERROR)
|
||||
.map(([, payload]) => payload)
|
||||
}
|
||||
|
||||
describe('VoiceConversationService — 오류 경로가 세션을 굳히지 않는다', () => {
|
||||
beforeEach(() => {
|
||||
resetVoiceConversationServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
mocks.audioHandlers.clear()
|
||||
mocks.refCount = 0
|
||||
mocks.audioStart.mockImplementation(async () => {
|
||||
mocks.refCount++
|
||||
})
|
||||
mocks.audioStop.mockImplementation(async () => {
|
||||
mocks.refCount = Math.max(0, mocks.refCount - 1)
|
||||
})
|
||||
mocks.chatStream.mockImplementation(() => answer('네.'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVoiceConversationServiceForTests()
|
||||
})
|
||||
|
||||
it('0.5초보다 짧게 말하고 멈추면 던지지 않고 listening 으로 돌아가 다시 듣는다', async () => {
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
feedAudio(100)
|
||||
|
||||
await expect(service.finishListening()).resolves.toBeUndefined()
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(mocks.refCount).toBe(1)
|
||||
expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'stt' })])
|
||||
expect(mocks.play).toHaveBeenCalledWith('error')
|
||||
})
|
||||
|
||||
it('음성이 감지되지 않으면(VAD 빈 결과) listening 으로 돌아가고 오류는 한 번만 알린다', async () => {
|
||||
mocks.transcribe.mockResolvedValue({ text: ' ' })
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
feedAudio(32_000)
|
||||
|
||||
await service.finishListening()
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(conversationErrors()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('LLM 이 실패하면 listening 으로 돌아가고 llm 오류 하나만 알린다(stt 로 다시 알리지 않는다)', async () => {
|
||||
mocks.transcribe.mockResolvedValue({ text: '안녕' })
|
||||
mocks.chatStream.mockImplementation(() => failing())
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
feedAudio(32_000)
|
||||
|
||||
await expect(service.finishListening()).resolves.toBeUndefined()
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'llm' })])
|
||||
expect(mocks.refCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VoiceConversationService — 마이크 참조 카운트 짝', () => {
|
||||
beforeEach(() => {
|
||||
resetVoiceConversationServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
mocks.audioHandlers.clear()
|
||||
mocks.refCount = 0
|
||||
mocks.audioStart.mockImplementation(async () => {
|
||||
mocks.refCount++
|
||||
})
|
||||
mocks.audioStop.mockImplementation(async () => {
|
||||
mocks.refCount = Math.max(0, mocks.refCount - 1)
|
||||
})
|
||||
mocks.chatStream.mockImplementation(() => answer('네.'))
|
||||
})
|
||||
|
||||
it('startSession → 텍스트 두 번 → stopSession 뒤 캡처 참조가 0 이다(마이크가 닫힌다)', async () => {
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
await service.sendTextMessage('첫 번째')
|
||||
await service.sendTextMessage('두 번째')
|
||||
expect(service.state).toBe('listening')
|
||||
expect(mocks.refCount).toBe(1)
|
||||
|
||||
service.stopSession()
|
||||
await Promise.resolve()
|
||||
expect(mocks.refCount).toBe(0)
|
||||
expect(mocks.audioStart.mock.calls.length).toBe(mocks.audioStop.mock.calls.length)
|
||||
})
|
||||
|
||||
it('텍스트 턴 도중 취소해도 start/stop 짝이 맞는다', async () => {
|
||||
let release: () => void = () => undefined
|
||||
mocks.chatStream.mockImplementation(async function* (_m: unknown, options?: { signal?: AbortSignal }) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
options?.signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
})
|
||||
return ''
|
||||
})
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
const turn = service.sendTextMessage('질문')
|
||||
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalled())
|
||||
service.cancelResponse()
|
||||
release()
|
||||
await turn
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(mocks.refCount).toBe(1)
|
||||
service.stopSession()
|
||||
await Promise.resolve()
|
||||
expect(mocks.refCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
// tests/main/sync/cloud-sync-restore-redteam-r2-1.test.ts
|
||||
// CloudSyncService 세션 수명주기 회귀 테스트 (Supabase·DB·엔진은 가짜):
|
||||
// - 오프라인·5xx 로 복원이 실패하면 토큰을 지우지 않고 저장된 계정 DB 에서 오프라인으로 동작하다 다시 시도한다
|
||||
// - 서버가 토큰을 확실히 거부(4xx AuthApiError)할 때만 토큰을 지우고 로컬 DB 로 되돌린다
|
||||
// - 엔진이 아직 없어도(로그인 직후 티어 조회 중·오프라인) 변경을 outbox 에 기록한다
|
||||
// - 로그아웃은 이 기기 세션만(scope 'local'), 외부 SIGNED_OUT 은 로그아웃 정리로 이어진다
|
||||
// + 순수 정책: 복원 실패 분류, 토큰 레코드, SIGNED_OUT 훅, 시작 DB 선택
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
EncryptedFileTokenStore,
|
||||
bindAuthEvents,
|
||||
decodeSessionRecord,
|
||||
type AuthSessionLike,
|
||||
type TokenCipher,
|
||||
type TokenFileSystem,
|
||||
} from '../../../src/main/services/sync/session-token-store'
|
||||
import {
|
||||
classifySessionRestoreFailure,
|
||||
sessionRestoreRetryDelayMs,
|
||||
} from '../../../src/main/services/sync/session-restore-policy'
|
||||
import { openStartupDatabase } from '../../../src/main/db/startup-database'
|
||||
|
||||
const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`)
|
||||
const TOKEN_FILE = path.join(USER_DATA, 'cloud-sync.token')
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
type AuthCallback = (event: string, session: unknown) => void
|
||||
const state = {
|
||||
currentUserId: '_local' as string | null,
|
||||
authCallback: null as AuthCallback | null,
|
||||
tierGate: null as Promise<void> | null,
|
||||
}
|
||||
return {
|
||||
state,
|
||||
db: {
|
||||
openForUser: (userId: string) => {
|
||||
state.currentUserId = userId
|
||||
return { created: false, dbPath: `/db/${userId}` }
|
||||
},
|
||||
openLocal: () => {
|
||||
state.currentUserId = '_local'
|
||||
return { created: false, dbPath: '/db/_local' }
|
||||
},
|
||||
},
|
||||
enqueueChange: (..._args: unknown[]) => undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
refreshSession: null as null | ((...args: unknown[]) => Promise<unknown>),
|
||||
signOut: null as null | ((...args: unknown[]) => Promise<unknown>),
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`),
|
||||
getVersion: () => '1.0.0',
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plain: string) => Buffer.from(plain, 'utf-8'),
|
||||
decryptString: (data: Buffer) => data.toString('utf-8'),
|
||||
},
|
||||
}))
|
||||
|
||||
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: () => undefined,
|
||||
configSet: vi.fn(),
|
||||
onConfigChanged: () => () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({
|
||||
LOCAL_USER_ID: '_local',
|
||||
openForUser: vi.fn((userId: string) => h.db.openForUser(userId)),
|
||||
openLocal: vi.fn(() => h.db.openLocal()),
|
||||
closeCurrent: vi.fn(() => {
|
||||
h.state.currentUserId = null
|
||||
}),
|
||||
getCurrentUserId: () => h.state.currentUserId,
|
||||
importLocalModeData: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/sync-outbox', () => ({
|
||||
enqueueChange: vi.fn((...args: unknown[]) => h.enqueueChange(...args)),
|
||||
getSyncState: () => null,
|
||||
setSyncState: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/SyncEngine', () => ({
|
||||
SyncEngine: class {
|
||||
on(): void {}
|
||||
dispose(): void {}
|
||||
async whenIdle(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
async runFullSync() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
async flush() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
async pull() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
getStatus() {
|
||||
return { pending: 0, parked: 0 }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/sync-adapters', () => ({
|
||||
SyncAbortedError: class SyncAbortedError extends Error {},
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/supabase-sync-remote', () => ({ SupabaseSyncRemote: class {} }))
|
||||
vi.mock('../../../src/main/services/sync/device-registration', () => ({
|
||||
checkInDesktopDevice: vi.fn(async () => ({ status: 'active', deviceId: 'dev-1' })),
|
||||
currentDeviceInfo: () => ({}),
|
||||
unregisterDesktopDevice: vi.fn(async () => undefined),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/sync-types', () => ({ realtimeTables: () => [] }))
|
||||
vi.mock('../../../src/main/services/sync/realtime-transport', () => ({ nodeRealtimeTransport: {} }))
|
||||
vi.mock('../../../src/main/services/sync/settings-sync', () => ({
|
||||
SETTINGS_ROW_ID: 'settings',
|
||||
SYNCED_CONFIG_KEYS: [],
|
||||
isApplyingRemoteSettings: () => false,
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/audio-sync', () => ({ AUDIO_BUCKET: 'audio', listLocalAudioOwners: () => [] }))
|
||||
vi.mock('../../../src/main/windows/web-contents-hardening', () => ({ openExternalSafe: vi.fn(async () => true) }))
|
||||
vi.mock('../../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: () => ({ syncFromCloud: vi.fn(), resetToFree: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/VoiceModeService', () => ({
|
||||
getVoiceModeService: () => ({ isActive: false, cancelSession: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/MeetingModeService', () => ({
|
||||
getMeetingModeService: () => ({ isMeetingModeActive: () => false, stopRecording: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||
getCaptionService: () => ({ stop: vi.fn(async () => null) }),
|
||||
}))
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => {
|
||||
const query = (): Record<string, unknown> => {
|
||||
const builder: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit']) builder[method] = () => builder
|
||||
builder.maybeSingle = async () => {
|
||||
if (h.state.tierGate) await h.state.tierGate
|
||||
return { data: null, error: null }
|
||||
}
|
||||
return builder
|
||||
}
|
||||
const channel = {
|
||||
on() {
|
||||
return channel
|
||||
},
|
||||
subscribe() {
|
||||
return channel
|
||||
},
|
||||
unsubscribe: async () => undefined,
|
||||
state: 'joined',
|
||||
}
|
||||
return {
|
||||
createClient: () => ({
|
||||
auth: {
|
||||
refreshSession: (...args: unknown[]) => spies.refreshSession!(...args),
|
||||
signOut: (...args: unknown[]) => spies.signOut!(...args),
|
||||
onAuthStateChange: (cb: (event: string, session: unknown) => void) => {
|
||||
h.state.authCallback = cb
|
||||
return { data: { subscription: { unsubscribe: () => undefined } } }
|
||||
},
|
||||
getSession: async () => ({ data: { session: null } }),
|
||||
stopAutoRefresh: async () => undefined,
|
||||
},
|
||||
from: () => query(),
|
||||
channel: () => channel,
|
||||
realtime: { setAuth: async () => undefined },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService'
|
||||
import * as db from '../../../src/main/db'
|
||||
import * as outbox from '../../../src/main/services/sync/sync-outbox'
|
||||
|
||||
function session(refreshToken = 'rt-new') {
|
||||
return { access_token: 'at', refresh_token: refreshToken, user: { id: 'user-1', email: 'u@example.test' } }
|
||||
}
|
||||
|
||||
function writeStoredToken(record: { rt: string; uid?: string }): void {
|
||||
fs.mkdirSync(USER_DATA, { recursive: true })
|
||||
fs.writeFileSync(TOKEN_FILE, record.uid ? JSON.stringify({ v: 1, rt: record.rt, uid: record.uid }) : record.rt)
|
||||
}
|
||||
|
||||
describe('CloudSyncService 세션 복원', () => {
|
||||
beforeEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
h.state.currentUserId = 'user-1' // bootstrap 이 저장된 계정 DB 를 먼저 열었다
|
||||
h.state.authCallback = null
|
||||
h.state.tierGate = null
|
||||
spies.signOut = vi.fn(async () => ({ error: null }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
vi.useRealTimers()
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('오프라인(AuthRetryableFetchError)이면 토큰을 지우지 않고 계정 DB 에서 오프라인으로 동작하며 변경을 기록한다', async () => {
|
||||
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
|
||||
spies.refreshSession = vi.fn(async () => ({
|
||||
data: { session: null },
|
||||
error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' },
|
||||
}))
|
||||
const sync = getCloudSyncService()
|
||||
const errors: string[] = []
|
||||
sync.on('sync-error', ({ error }) => errors.push(error))
|
||||
|
||||
await sync.init()
|
||||
|
||||
expect(fs.existsSync(TOKEN_FILE)).toBe(true)
|
||||
expect(sync.isOfflineAccount()).toBe(true)
|
||||
expect(h.state.currentUserId).toBe('user-1')
|
||||
expect(db.openLocal).not.toHaveBeenCalled()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).not.toMatch(/expired/i)
|
||||
|
||||
sync.pushOne('history', 'h-offline')
|
||||
expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-offline', 'upsert')
|
||||
})
|
||||
|
||||
it('나중에 다시 시도해 복원되면 로그인 상태가 된다', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
|
||||
spies.refreshSession = vi.fn(async () => ({
|
||||
data: { session: null },
|
||||
error: { name: 'AuthApiError', status: 503, message: 'upstream unavailable' },
|
||||
}))
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
expect(sync.isAuthenticated()).toBe(false)
|
||||
|
||||
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
|
||||
await vi.advanceTimersByTimeAsync(sessionRestoreRetryDelayMs(0))
|
||||
|
||||
expect(sync.isAuthenticated()).toBe(true)
|
||||
expect(sync.isOfflineAccount()).toBe(false)
|
||||
})
|
||||
|
||||
it('서버가 토큰을 거부(400 AuthApiError)하면 토큰을 지우고 로컬 DB 로 되돌린다', async () => {
|
||||
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
|
||||
spies.refreshSession = vi.fn(async () => ({
|
||||
data: { session: null },
|
||||
error: { name: 'AuthApiError', status: 400, code: 'refresh_token_not_found', message: 'Invalid Refresh Token' },
|
||||
}))
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
|
||||
expect(fs.existsSync(TOKEN_FILE)).toBe(false)
|
||||
expect(h.state.currentUserId).toBe('_local')
|
||||
expect(sync.isOfflineAccount()).toBe(false)
|
||||
sync.pushOne('history', 'h-local')
|
||||
expect(outbox.enqueueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('로그인 직후 티어 조회 중(엔진 생성 전)의 변경도 outbox 에 기록한다', async () => {
|
||||
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
|
||||
let releaseTier: () => void = () => undefined
|
||||
h.state.tierGate = new Promise<void>((resolve) => {
|
||||
releaseTier = resolve
|
||||
})
|
||||
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
|
||||
const sync = getCloudSyncService()
|
||||
const initDone = sync.init()
|
||||
await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(true))
|
||||
|
||||
sync.pushOne('history', 'h-gap')
|
||||
expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-gap', 'upsert')
|
||||
|
||||
releaseTier()
|
||||
await initDone
|
||||
})
|
||||
|
||||
it('복원·로그인 때 토큰과 함께 계정 id 를 저장한다', async () => {
|
||||
writeStoredToken({ rt: 'rt-legacy' }) // 구버전: 토큰만
|
||||
spies.refreshSession = vi.fn(async () => ({ data: { session: session('rt-rotated') }, error: null }))
|
||||
await getCloudSyncService().init()
|
||||
expect(decodeSessionRecord(fs.readFileSync(TOKEN_FILE, 'utf-8'))).toEqual({
|
||||
refreshToken: 'rt-rotated',
|
||||
userId: 'user-1',
|
||||
})
|
||||
expect(getCloudSyncService().peekStoredAccountUserId()).toBe('user-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloudSyncService 로그아웃 범위', () => {
|
||||
beforeEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
h.state.currentUserId = 'user-1'
|
||||
h.state.tierGate = null
|
||||
spies.signOut = vi.fn(async () => {
|
||||
// 실제 auth-js 처럼 자기 signOut 도 SIGNED_OUT 을 알린다 — 다시 로그아웃 정리를 돌지 않아야 한다
|
||||
h.state.authCallback?.('SIGNED_OUT', null)
|
||||
return { error: null }
|
||||
})
|
||||
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
|
||||
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("사용자 로그아웃은 이 기기 세션만 폐기한다(scope 'local')", async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
await sync.signOut()
|
||||
|
||||
expect(spies.signOut).toHaveBeenCalledTimes(1)
|
||||
expect(spies.signOut).toHaveBeenCalledWith({ scope: 'local' })
|
||||
expect(sync.isAuthenticated()).toBe(false)
|
||||
expect(h.state.currentUserId).toBe('_local')
|
||||
})
|
||||
|
||||
it('다른 곳에서 세션이 끝나면(SIGNED_OUT) 익명 키로 계속 돌지 않고 로그아웃 정리를 한다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
const authChanges: Array<unknown> = []
|
||||
const errors: string[] = []
|
||||
sync.on('auth-changed', ({ user }) => authChanges.push(user))
|
||||
sync.on('sync-error', ({ error }) => errors.push(error))
|
||||
|
||||
h.state.authCallback?.('SIGNED_OUT', null)
|
||||
|
||||
await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(false))
|
||||
await vi.waitFor(() => expect(errors).toHaveLength(1))
|
||||
expect(authChanges).toContain(null)
|
||||
expect(errors[0]).not.toMatch(/disconnected from your account on another device/)
|
||||
expect(fs.existsSync(TOKEN_FILE)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('순수 정책', () => {
|
||||
it('복원 실패 분류: 서버의 4xx 거부만 invalid', () => {
|
||||
expect(classifySessionRestoreFailure({ name: 'AuthRetryableFetchError', status: 0 })).toBe('transient')
|
||||
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 400 })).toBe('invalid')
|
||||
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 401 })).toBe('invalid')
|
||||
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 500 })).toBe('transient')
|
||||
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 429 })).toBe('transient')
|
||||
expect(classifySessionRestoreFailure(new TypeError('fetch failed'))).toBe('transient')
|
||||
expect(classifySessionRestoreFailure(null)).toBe('transient')
|
||||
expect(sessionRestoreRetryDelayMs(0)).toBe(15_000)
|
||||
expect(sessionRestoreRetryDelayMs(99)).toBe(300_000)
|
||||
})
|
||||
|
||||
it('토큰 레코드: 구버전 토큰 문자열과 새 레코드를 모두 읽는다', () => {
|
||||
const files = new Map<string, Buffer>()
|
||||
const memoryFs: TokenFileSystem = {
|
||||
existsSync: (p) => files.has(p),
|
||||
readFileSync: (p) => files.get(p) ?? Buffer.alloc(0),
|
||||
writeFileSync: (p, data) => {
|
||||
files.set(p, Buffer.from(data))
|
||||
},
|
||||
unlinkSync: (p) => {
|
||||
files.delete(p)
|
||||
},
|
||||
}
|
||||
const cipher: TokenCipher = {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (s) => Buffer.from(s),
|
||||
decryptString: (b) => b.toString(),
|
||||
}
|
||||
const store = new EncryptedFileTokenStore('/t', cipher, memoryFs, { warn: vi.fn() })
|
||||
store.save('rt-only')
|
||||
expect(store.loadRecord()).toEqual({ refreshToken: 'rt-only', userId: null })
|
||||
store.save('rt-2', 'user-9')
|
||||
expect(store.loadRecord()).toEqual({ refreshToken: 'rt-2', userId: 'user-9' })
|
||||
expect(store.load()).toBe('rt-2')
|
||||
})
|
||||
|
||||
it('bindAuthEvents: 로그인된 동안의 SIGNED_OUT 은 onSignedOut 으로, 로그아웃 뒤에는 무시', () => {
|
||||
type S = AuthSessionLike
|
||||
let emit: (event: string, s: S | null) => void = () => undefined
|
||||
const onSignedOut = vi.fn()
|
||||
let active: string | null = 'u1'
|
||||
const store = { load: () => null, loadRecord: () => null, save: vi.fn(), clear: vi.fn() }
|
||||
bindAuthEvents<S>(
|
||||
(cb) => {
|
||||
emit = cb
|
||||
return { unsubscribe: vi.fn() }
|
||||
},
|
||||
store,
|
||||
() => active,
|
||||
vi.fn(),
|
||||
onSignedOut,
|
||||
)
|
||||
emit('TOKEN_REFRESHED', { refresh_token: 'rt', user: { id: 'u1' } })
|
||||
expect(store.save).toHaveBeenCalledWith('rt', 'u1')
|
||||
emit('SIGNED_OUT', null)
|
||||
expect(onSignedOut).toHaveBeenCalledTimes(1)
|
||||
expect(store.clear).not.toHaveBeenCalled()
|
||||
active = null
|
||||
emit('SIGNED_OUT', null)
|
||||
expect(onSignedOut).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('시작 DB: 저장된 계정이 있으면 그 계정 DB, 열지 못하면 로컬, 없으면 로컬', () => {
|
||||
const openForUser = vi.fn((id: string) => ({ created: false, dbPath: `/u/${id}` }))
|
||||
const openLocal = vi.fn(() => ({ created: false, dbPath: '/u/_local' }))
|
||||
const warn = vi.fn()
|
||||
expect(openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser, openLocal, warn }).accountUserId).toBe('acc')
|
||||
expect(openLocal).not.toHaveBeenCalled()
|
||||
|
||||
const failing = vi.fn(() => {
|
||||
throw new Error('corrupt')
|
||||
})
|
||||
expect(
|
||||
openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser: failing, openLocal, warn }).accountUserId,
|
||||
).toBeNull()
|
||||
expect(openLocal).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
|
||||
expect(openStartupDatabase({ storedAccountUserId: () => null, openForUser, openLocal, warn }).dbPath).toBe('/u/_local')
|
||||
})
|
||||
})
|
||||
114
apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts
Normal file
114
apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// tests/red/data-policy-redteam-r2-1.test.ts
|
||||
// 로컬이 서버 쓰기 제약과 같은 규칙으로 입력을 거절한다(실제 서비스 + in-memory DB):
|
||||
// - 사전: 대소문자만 다른 단어는 같은 단어(서버 lower(btrim(word)) 유일성) — 추가·수정·가져오기
|
||||
// - 명령어: 편집으로 이름·프롬프트를 비울 수 없다(생성과 같은 규칙)
|
||||
// - 메모 태그: 80자를 넘으면 저장하지 않는다
|
||||
// - 검색: 기록은 다듬은 문장·제목·요약으로도 찾고, %·_ 는 글자 그대로 찾는다(사전 검색 포함)
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { useRedHarness, historyInput } from './harness'
|
||||
|
||||
useRedHarness()
|
||||
|
||||
import { getDictionaryService } from '../../src/main/services/DictionaryService'
|
||||
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
|
||||
import { getMemoService } from '../../src/main/services/MemoService'
|
||||
import { getHistoryService } from '../../src/main/services/HistoryService'
|
||||
|
||||
describe('사전 — 대소문자 무시 식별', () => {
|
||||
it("'API' 가 있으면 'api' 추가는 DictionaryDuplicate 다(발음이 동기화에서 조용히 사라지지 않게)", () => {
|
||||
const dict = getDictionaryService()
|
||||
dict.add({ word: 'API' })
|
||||
expect(() => dict.add({ word: ' api ', pronunciation: '에이피아이' })).toThrow(
|
||||
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
|
||||
)
|
||||
})
|
||||
|
||||
it('다른 카테고리는 같은 단어를 허용한다', () => {
|
||||
const dict = getDictionaryService()
|
||||
dict.add({ word: 'API', category: 'user' })
|
||||
expect(() => dict.add({ word: 'api', category: 'technical' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('수정으로 다른 항목과 대소문자만 다른 단어가 되면 거절하고, 자기 자신의 대소문자 변경은 허용한다', () => {
|
||||
const dict = getDictionaryService()
|
||||
dict.add({ word: 'Kubernetes' })
|
||||
const other = dict.add({ word: 'Docker' })
|
||||
expect(() => dict.update({ id: other.id, word: 'KUBERNETES' })).toThrow(
|
||||
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
|
||||
)
|
||||
expect(dict.update({ id: other.id, word: 'DOCKER' })?.word).toBe('DOCKER')
|
||||
})
|
||||
|
||||
it('가져오기는 대소문자만 다른 단어를 건너뛴다', () => {
|
||||
const dict = getDictionaryService()
|
||||
dict.add({ word: 'GraphQL' })
|
||||
const file = path.join(os.tmpdir(), `d3ro-dict-r2-1-${Date.now()}.json`)
|
||||
fs.writeFileSync(file, JSON.stringify([{ word: 'graphql' }, { word: 'gRPC' }, { word: 'GRPC' }]))
|
||||
try {
|
||||
const result = dict.importDictionary({ filePath: file, format: 'json' })
|
||||
expect(result).toEqual({ imported: 1, skipped: 2, errors: 0 })
|
||||
} finally {
|
||||
fs.rmSync(file, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('사전 검색의 %·_ 는 와일드카드가 아니다', () => {
|
||||
const dict = getDictionaryService()
|
||||
dict.add({ word: 'alpha' })
|
||||
dict.add({ word: 'snake_case' })
|
||||
expect(dict.search({ query: '_', page: 0, pageSize: 20 }).entries.map((e) => e.word)).toEqual(['snake_case'])
|
||||
expect(dict.search({ query: '%', page: 0, pageSize: 20 }).total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('명령어 — 빈 값 검증', () => {
|
||||
it('편집으로 프롬프트나 이름을 비울 수 없다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
const created = svc.create({ name: '요약해', description: '', prompt: '요약해 주세요' })
|
||||
expect(() => svc.update(created.id, { prompt: ' ' })).toThrow(/prompt is empty/)
|
||||
expect(() => svc.update(created.id, { name: '' })).toThrow(/name is empty/)
|
||||
expect(svc.getById(created.id)?.prompt).toBe('요약해 주세요')
|
||||
})
|
||||
})
|
||||
|
||||
describe('메모 태그 — 서버 길이 제한', () => {
|
||||
it('80자를 넘는 태그는 저장하지 않는다(80자는 허용)', () => {
|
||||
const entry = getHistoryService().create(historyInput())
|
||||
const memo = getMemoService()
|
||||
expect(() => memo.addTag(entry.id, 't'.repeat(81))).toThrow(/too long/)
|
||||
expect(memo.getTagsForEntry(entry.id)).toEqual([])
|
||||
expect(memo.addTag(entry.id, 't'.repeat(80)).tag).toHaveLength(80)
|
||||
})
|
||||
})
|
||||
|
||||
describe('기록 검색', () => {
|
||||
it('카드에 보이는 다듬은 문장·제목·요약으로도 찾는다', () => {
|
||||
const history = getHistoryService()
|
||||
history.create(historyInput({ originalText: '한국어 원문', polishedText: 'Quarterly roadmap review' }))
|
||||
history.create(historyInput({ originalText: '다른 원문', title: '출시 회의록' }))
|
||||
history.create(historyInput({ originalText: '세 번째', summaryText: '결정: 예산 증액' }))
|
||||
|
||||
const search = (query: string) => history.search({ query, page: 0, pageSize: 20 }).entries.length
|
||||
expect(search('roadmap')).toBe(1)
|
||||
expect(search('출시 회의')).toBe(1)
|
||||
expect(search('예산 증액')).toBe(1)
|
||||
})
|
||||
|
||||
it('%·_ 는 글자 그대로 찾는다', () => {
|
||||
const history = getHistoryService()
|
||||
history.create(historyInput({ originalText: '매출이 100% 늘었다' }))
|
||||
history.create(historyInput({ originalText: '매출이 1005 건이다' }))
|
||||
history.create(historyInput({ originalText: 'file_name 확인' }))
|
||||
|
||||
const texts = (query: string) =>
|
||||
history.search({ query, page: 0, pageSize: 20 }).entries.map((e) => e.originalText)
|
||||
expect(texts('100%')).toEqual(['매출이 100% 늘었다'])
|
||||
expect(texts('_')).toEqual(['file_name 확인'])
|
||||
})
|
||||
})
|
||||
132
apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts
Normal file
132
apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// tests/red/llm-privacy-routing-redteam-r2-1.test.ts
|
||||
// LLM 백엔드 선택이 게이트웨이 한 곳을 따른다(실제 서비스 + in-memory DB, LLM 어댑터만 가짜):
|
||||
// - llmBackend='local' 이면 자막 자동 요약·받아쓰기 자동 제목이 Premium(클라우드·쿼터)을 부르지 않는다
|
||||
// - llm:process 는 online + Supabase 로그인(레거시 authToken 없음)이면 Premium 을 탄다(.NET 경로 아님)
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { invokeIpc, useRedHarness, historyInput } from './harness'
|
||||
|
||||
const llm = vi.hoisted(() => ({
|
||||
localGenerate: vi.fn(),
|
||||
localProcess: vi.fn(),
|
||||
premiumGenerate: vi.fn(),
|
||||
premiumProcess: vi.fn(),
|
||||
premiumAvailable: true,
|
||||
onlineProcess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
|
||||
return {
|
||||
...actual,
|
||||
getLocalLLMService: () => ({
|
||||
isAvailable: () => true,
|
||||
generate: (...args: unknown[]) => llm.localGenerate(...args),
|
||||
processText: (...args: unknown[]) => llm.localProcess(...args),
|
||||
chatStream: async function* () {
|
||||
yield 'x'
|
||||
return 'x'
|
||||
},
|
||||
cancelGeneration: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getStatus: () => ({}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../src/main/services/PremiumLLMService', () => ({
|
||||
getPremiumLLMService: () => ({
|
||||
isAvailable: () => llm.premiumAvailable,
|
||||
generate: (...args: unknown[]) => llm.premiumGenerate(...args),
|
||||
processText: (...args: unknown[]) => llm.premiumProcess(...args),
|
||||
chatStream: async function* () {
|
||||
yield 'p'
|
||||
return 'p'
|
||||
},
|
||||
cancelGeneration: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getLastQuota: () => null,
|
||||
}),
|
||||
resetPremiumLLMServiceForTests: () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/OnlineLLMService', () => ({
|
||||
getOnlineLLMService: () => ({
|
||||
processText: (...args: unknown[]) => llm.onlineProcess(...args),
|
||||
cancelGeneration: vi.fn(),
|
||||
}),
|
||||
resetOnlineLLMServiceForTests: () => undefined,
|
||||
}))
|
||||
|
||||
useRedHarness()
|
||||
|
||||
import { configSet } from '../../src/main/services/ConfigService'
|
||||
import { getHistoryService } from '../../src/main/services/HistoryService'
|
||||
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
|
||||
import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers'
|
||||
import { setLlmGatewayForTests } from '../../src/main/services/llm/LlmGateway'
|
||||
|
||||
beforeEach(() => {
|
||||
setLlmGatewayForTests(null)
|
||||
llm.premiumAvailable = true
|
||||
llm.localGenerate.mockReset().mockResolvedValue({ text: '## 요약\n로컬 요약', model: 'local' })
|
||||
llm.localProcess.mockReset().mockResolvedValue('local-result')
|
||||
llm.premiumGenerate.mockReset().mockResolvedValue({ text: 'premium' })
|
||||
llm.premiumProcess.mockReset().mockResolvedValue('premium-result')
|
||||
llm.onlineProcess.mockReset().mockResolvedValue('dotnet-result')
|
||||
})
|
||||
|
||||
describe('llmBackend=local 은 텍스트를 클라우드로 보내지 않는다', () => {
|
||||
it('자막 요약은 로컬 LLM 을 쓰고 Premium 을 부르지 않는다', async () => {
|
||||
configSet('llmBackend', 'local')
|
||||
const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' }))
|
||||
await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled())
|
||||
llm.localGenerate.mockClear()
|
||||
|
||||
const result = await getMeetingSummaryService().summarize(entry.id)
|
||||
|
||||
expect(result.summary).toContain('로컬 요약')
|
||||
expect(llm.localGenerate).toHaveBeenCalled()
|
||||
expect(llm.premiumGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('받아쓰기 자동 제목도 Premium 을 부르지 않는다', async () => {
|
||||
configSet('llmBackend', 'local')
|
||||
const entry = getHistoryService().create(historyInput({ originalText: '다음 주 출시 일정을 정리해야 합니다' }))
|
||||
await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled())
|
||||
expect(llm.premiumGenerate).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(getHistoryService().getById(entry.id)?.title).toBeTruthy())
|
||||
})
|
||||
|
||||
it('online 을 고른 사용자는 요약이 Premium 을 탄다(명시적 선택)', async () => {
|
||||
configSet('llmBackend', 'online')
|
||||
const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' }))
|
||||
await getMeetingSummaryService().summarize(entry.id)
|
||||
expect(llm.premiumGenerate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('llm:process 는 실제 받아쓰기와 같은 경로를 탄다', () => {
|
||||
it('online + Supabase 로그인(레거시 authToken 없음) → Premium, .NET 경로 아님', async () => {
|
||||
configSet('llmBackend', 'online')
|
||||
configSet('authToken', null)
|
||||
registerLLMHandlers()
|
||||
const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' })
|
||||
expect(res.success).toBe(true)
|
||||
if (res.success) expect(res.data.processedText).toBe('premium-result')
|
||||
expect(llm.onlineProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('online 인데 Premium 을 쓸 수 없으면 받아쓰기처럼 로컬로 폴백한다', async () => {
|
||||
configSet('llmBackend', 'online')
|
||||
configSet('authToken', null)
|
||||
llm.premiumAvailable = false
|
||||
registerLLMHandlers()
|
||||
const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' })
|
||||
expect(res.success).toBe(true)
|
||||
if (res.success) expect(res.data.processedText).toBe('local-result')
|
||||
})
|
||||
})
|
||||
237
apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts
Normal file
237
apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// tests/red/meeting-recording-redteam-r2-1.test.ts
|
||||
// MeetingModeService 녹음 수명주기 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
|
||||
// - 정지를 두 번 눌러도(마지막 문장 flush 중) 정지·저장은 한 번 — 마지막 문장 유지, durationMs > 0, 완료 알림 1회
|
||||
// - 앱 종료(shutdown) 시 녹음 중이던 전사가 저장되고 행이 'recording' 으로 남지 않는다
|
||||
// - 시작 시 중단된 'recording'/'processing' 행을 닫는다
|
||||
// - 페이지를 다시 열 때 진행 중인 전사·메모를 돌려준다
|
||||
// - 긴 회의: 문서 생성은 조각별로 줄여 한 메시지 한도를 넘지 않고, 채팅은 관련 발췌만 넣는다
|
||||
// - 회의 채팅 지우기·취소는 그 회의만
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { CaptionSegment } from '@d3ro/core/types'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
import { useRedHarness } from './harness'
|
||||
|
||||
const caption = vi.hoisted(() => ({
|
||||
bus: null as EventEmitter | null,
|
||||
state: 'inactive' as string,
|
||||
stopCalls: 0,
|
||||
/** 정지(flush)를 붙잡아 두는 게이트 — null 이면 바로 끝난다 */
|
||||
gate: null as Promise<void> | null,
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/CaptionService', async () => {
|
||||
const { EventEmitter: Emitter } = await import('events')
|
||||
const bus = new Emitter()
|
||||
caption.bus = bus
|
||||
const service = {
|
||||
getState: () => caption.state,
|
||||
on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn),
|
||||
off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn),
|
||||
start: async () => {
|
||||
caption.state = 'active'
|
||||
},
|
||||
stop: async () => {
|
||||
caption.stopCalls++
|
||||
if (caption.state !== 'active') return null
|
||||
caption.state = 'stopping'
|
||||
if (caption.gate) await caption.gate
|
||||
const segment: CaptionSegment = { id: 'seg-last', text: '마지막 문장', timestamp: Date.now(), isFinal: true }
|
||||
bus.emit('segment', segment)
|
||||
caption.state = 'inactive'
|
||||
return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 }
|
||||
},
|
||||
}
|
||||
return { getCaptionService: () => service, resetCaptionServiceForTests: () => undefined }
|
||||
})
|
||||
|
||||
vi.mock('../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }),
|
||||
calculateRMS: () => 0,
|
||||
}))
|
||||
|
||||
const llm = vi.hoisted(() => ({
|
||||
generateInputs: [] as string[],
|
||||
chatCalls: [] as Array<Array<{ role: string; content: string }>>,
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
|
||||
return {
|
||||
...actual,
|
||||
getLocalLLMService: () => ({
|
||||
isAvailable: () => true,
|
||||
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
|
||||
llm.chatCalls.push(messages)
|
||||
yield '답변'
|
||||
return '답변'
|
||||
},
|
||||
generate: async (text: string) => {
|
||||
llm.generateInputs.push(text)
|
||||
return { text: `요약(${text.length})`, model: 'fx-local' }
|
||||
},
|
||||
cancelGeneration: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
useRedHarness()
|
||||
|
||||
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
|
||||
import { configSet } from '../../src/main/services/ConfigService'
|
||||
import { getDatabase } from '../../src/main/db'
|
||||
import { meetingSessions } from '../../src/main/db/schema'
|
||||
|
||||
function row(id: string) {
|
||||
return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get()
|
||||
}
|
||||
|
||||
function insertSession(id: string, fields: Partial<typeof meetingSessions.$inferInsert> = {}): void {
|
||||
const now = Date.now()
|
||||
getDatabase()
|
||||
.insert(meetingSessions)
|
||||
.values({ id, title: id, status: 'completed', startedAt: now - 60_000, createdAt: now, updatedAt: now, ...fields })
|
||||
.run()
|
||||
}
|
||||
|
||||
function longTranscript(lines: number): string {
|
||||
return Array.from({ length: lines }, (_, i) => {
|
||||
const mm = String(Math.floor(i / 60)).padStart(2, '0')
|
||||
const ss = String(i % 60).padStart(2, '0')
|
||||
return `[${mm}:${ss}] 이번 분기 예산과 일정에 대한 ${i}번째 발언이 이어집니다 항목 ${i}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
caption.state = 'inactive'
|
||||
caption.stopCalls = 0
|
||||
caption.gate = null
|
||||
llm.generateInputs = []
|
||||
llm.chatCalls = []
|
||||
configSet('llmBackend', 'local')
|
||||
})
|
||||
|
||||
describe('회의 정지 — 동시에 두 번', () => {
|
||||
it('두 번째 정지는 진행 중인 정지를 함께 기다린다 — 마지막 문장·durationMs 유지, 완료 1회', async () => {
|
||||
const meeting = getMeetingModeService()
|
||||
const { sessionId } = await meeting.startRecording()
|
||||
caption.bus?.emit('segment', { id: 'seg-1', text: '첫 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
let release: () => void = () => undefined
|
||||
caption.gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const completed = vi.fn()
|
||||
meeting.on('session-completed', completed)
|
||||
|
||||
const first = meeting.stopRecording()
|
||||
const second = meeting.stopRecording()
|
||||
// 정지 중에는 '처리 중' — 화면의 정지 버튼이 사라진다
|
||||
expect(meeting.getState()).toBe('processing')
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(caption.stopCalls).toBe(1)
|
||||
expect(completed).toHaveBeenCalledTimes(1)
|
||||
const saved = row(sessionId)
|
||||
expect(saved?.status).toBe('completed')
|
||||
expect(saved?.durationMs ?? 0).toBeGreaterThan(0)
|
||||
expect((saved?.rawTranscript ?? '').split('\n')).toHaveLength(2)
|
||||
expect(saved?.rawTranscript).toContain('마지막 문장')
|
||||
expect(meeting.getState()).toBe('idle')
|
||||
await expect(meeting.stopRecording()).rejects.toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('앱 종료·중단 복구', () => {
|
||||
it('종료(shutdown) 시 녹음 중이던 전사를 저장하고 행을 completed 로 닫는다', async () => {
|
||||
const meeting = getMeetingModeService()
|
||||
const { sessionId } = await meeting.startRecording()
|
||||
caption.bus?.emit('segment', { id: 'seg-a', text: '종료 전 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
|
||||
|
||||
await meeting.shutdown()
|
||||
|
||||
const saved = row(sessionId)
|
||||
expect(saved?.status).toBe('completed')
|
||||
expect(saved?.rawTranscript).toContain('종료 전 문장')
|
||||
expect(saved?.endedAt).not.toBeNull()
|
||||
expect(meeting.getState()).toBe('idle')
|
||||
})
|
||||
|
||||
it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => {
|
||||
insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' })
|
||||
insertSession('stuck-empty', { status: 'processing' })
|
||||
insertSession('done', { status: 'completed', rawTranscript: 'x' })
|
||||
|
||||
expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2)
|
||||
|
||||
expect(row('stuck-with-text')?.status).toBe('completed')
|
||||
expect(row('stuck-with-text')?.endedAt).not.toBeNull()
|
||||
expect(row('stuck-empty')?.status).toBe('error')
|
||||
expect(row('done')?.status).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('녹음 화면 복원', () => {
|
||||
it('진행 중인 녹음의 세그먼트와 메모를 돌려준다', async () => {
|
||||
const meeting = getMeetingModeService()
|
||||
const { sessionId } = await meeting.startRecording()
|
||||
caption.bus?.emit('segment', { id: 'seg-x', text: '앞부분 발언', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
|
||||
await meeting.addMemo('중요')
|
||||
|
||||
const live = meeting.getLiveSession()
|
||||
expect(live?.sessionId).toBe(sessionId)
|
||||
expect(live?.segments.map((s) => s.text)).toEqual(['앞부분 발언'])
|
||||
expect(live?.memos.map((m) => m.content)).toEqual(['중요'])
|
||||
|
||||
await meeting.stopRecording()
|
||||
expect(meeting.getLiveSession()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('긴 회의의 LLM 입력', () => {
|
||||
it('문서 생성은 전사를 조각별로 줄여, 어떤 호출도 메시지 한도를 넘지 않는다', async () => {
|
||||
const transcript = longTranscript(400)
|
||||
expect(transcript.length).toBeGreaterThan(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
|
||||
insertSession('long-doc', { rawTranscript: transcript })
|
||||
|
||||
const doc = await getMeetingModeService().generateDocument({ sessionId: 'long-doc', templateId: 'builtin-minutes' })
|
||||
|
||||
expect(doc.content).toBeTruthy()
|
||||
expect(llm.generateInputs.length).toBeGreaterThan(2)
|
||||
for (const input of llm.generateInputs) {
|
||||
expect(input.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
|
||||
}
|
||||
})
|
||||
|
||||
it('채팅은 system 한도 안에 질문과 관련된 발췌를 넣고, 발췌라는 사실을 모델에 알린다', async () => {
|
||||
const lines = longTranscript(300).split('\n')
|
||||
lines[280] = '[04:40] 마지막에 출시 담당자는 김민수로 결정했습니다'
|
||||
insertSession('long-chat', { rawTranscript: lines.join('\n') })
|
||||
|
||||
await getMeetingModeService().chatWithMeeting('long-chat', '출시 담당자는 누구로 결정했나요?')
|
||||
|
||||
const system = llm.chatCalls[0].find((m) => m.role === 'system')?.content ?? ''
|
||||
expect(system.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxSystemChars)
|
||||
expect(system).toContain('김민수')
|
||||
expect(system).toContain('발췌')
|
||||
})
|
||||
})
|
||||
|
||||
describe('회의 채팅 범위', () => {
|
||||
it('한 회의의 지우기는 다른 회의 대화를 남긴다', async () => {
|
||||
insertSession('chat-a', { rawTranscript: '[00:01] A' })
|
||||
insertSession('chat-b', { rawTranscript: '[00:01] B' })
|
||||
const meeting = getMeetingModeService()
|
||||
await meeting.chatWithMeeting('chat-a', 'A?')
|
||||
await meeting.chatWithMeeting('chat-b', 'B?')
|
||||
|
||||
meeting.clearMeetingChatHistory('chat-a')
|
||||
|
||||
expect(meeting.getMeetingChatHistory('chat-a')).toEqual([])
|
||||
expect(meeting.getMeetingChatHistory('chat-b')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
64
apps/desktop/tests/unit/renderer-models-redteam-r2-1.test.ts
Normal file
64
apps/desktop/tests/unit/renderer-models-redteam-r2-1.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// tests/unit/renderer-models-redteam-r2-1.test.ts
|
||||
// 렌더러 순수 모델:
|
||||
// - 녹음 화면 복원: main 사본과 먼저 도착한 이벤트를 id 로 합친다
|
||||
// - 회의 채팅 패널: main 이 기억하는 대화를 표시 메시지로 바꾼다(키 충돌 없음)
|
||||
// - 명령어 폼: 저장 거부 사유를 검증 메시지로 바꾼다(빈 프롬프트·길이 초과)
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { mergeRestoredItems } from '../../src/renderer/pages/meeting/live-restore'
|
||||
import { toPanelMessages, withRestoredHistory } from '../../src/renderer/components/meeting/meeting-chat-history'
|
||||
import { commandSaveError } from '../../src/renderer/pages/commands/command-form'
|
||||
|
||||
describe('mergeRestoredItems', () => {
|
||||
it('main 사본을 기준으로, 조회 중 먼저 온 항목은 중복 없이 합치고 시간 순으로 둔다', () => {
|
||||
const restored = [
|
||||
{ id: 'a', t: 1 },
|
||||
{ id: 'b', t: 2 },
|
||||
]
|
||||
const arrived = [
|
||||
{ id: 'b', t: 2 },
|
||||
{ id: 'c', t: 3 },
|
||||
]
|
||||
expect(mergeRestoredItems(restored, arrived, (x) => x.t).map((x) => x.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('meeting chat history', () => {
|
||||
it('보관된 턴을 순서 번호 timestamp 로 바꿔 목록 키가 겹치지 않는다', () => {
|
||||
const messages = toPanelMessages([
|
||||
{ role: 'user', content: 'q1' },
|
||||
{ role: 'assistant', content: 'a1' },
|
||||
{ role: 'user', content: 'q2' },
|
||||
])
|
||||
const keys = messages.map((m) => `${m.role}-${m.timestamp}`)
|
||||
expect(new Set(keys).size).toBe(3)
|
||||
expect(messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2'])
|
||||
})
|
||||
|
||||
it('조회가 끝나기 전에 보낸 새 메시지는 지우지 않고 뒤에 둔다', () => {
|
||||
const merged = withRestoredHistory(
|
||||
[{ role: 'user', content: '새 질문', timestamp: 1_700_000_000_000 }],
|
||||
[{ role: 'user', content: '이전 질문' }, { role: 'assistant', content: '이전 답' }],
|
||||
)
|
||||
expect(merged.map((m) => m.content)).toEqual(['이전 질문', '이전 답', '새 질문'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('commandSaveError', () => {
|
||||
it('빈 프롬프트·길이 초과는 저장 전에 검증 메시지다', () => {
|
||||
expect(commandSaveError({ kind: 'draft', draft: { name: 'n', description: '', prompt: ' ' } })).toEqual({
|
||||
key: 'mobile.commands.syncError.validation',
|
||||
})
|
||||
expect(commandSaveError({ kind: 'draft', draft: { name: 'n'.repeat(81), description: '', prompt: 'p' } })).not.toBeNull()
|
||||
expect(commandSaveError({ kind: 'draft', draft: { name: 'n', description: '', prompt: 'p' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('main 의 검증 거부는 검증 메시지, 그 밖의 실패는 호출자가 원문을 보여 준다(null)', () => {
|
||||
expect(
|
||||
commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigWriteFailed, message: 'Failed: Instruction prompt is too long (max 4000 characters)' } }),
|
||||
).not.toBeNull()
|
||||
expect(commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigInvalidValue, message: 'x' } })).not.toBeNull()
|
||||
expect(commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigWriteFailed, message: 'disk full' } })).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue