fix: red-team round 3 hardening across desktop, mobile, core and server

Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
This commit is contained in:
Yun Chan 2026-09-28 20:45:52 +09:00
parent 2428ede03d
commit ba9ef9741e
161 changed files with 17056 additions and 2379 deletions

View file

@ -0,0 +1,46 @@
// redteam r3-4 회귀: macOS Dock 아이콘 클릭('activate')이 트레이로 숨긴 메인 창을 다시 띄운다.
// 예전엔 'activate' 리스너가 없어 closeToTray(기본 true)로 창을 닫은 뒤 Dock 으로 돌아올 수 없었다.
import { describe, expect, it, vi } from 'vitest'
type Listener = (...args: unknown[]) => void
const state = vi.hoisted(() => ({
appListeners: new Map<string, Listener>(),
showMainWindow: vi.fn(() => null),
}))
vi.mock('electron', () => ({
app: {
on: vi.fn((event: string, listener: Listener) => {
state.appListeners.set(event, listener)
}),
// 부트스트랩은 이 테스트의 관심사가 아니다 — ready 를 오지 않게 둔다
whenReady: vi.fn(() => new Promise<void>(() => undefined)),
setAsDefaultProtocolClient: vi.fn(),
requestSingleInstanceLock: vi.fn(() => true),
disableHardwareAcceleration: vi.fn(),
commandLine: { appendSwitch: vi.fn() },
exit: vi.fn(),
getPath: vi.fn(() => '.'),
},
}))
vi.mock('../../src/main/app-identity', () => ({ applyAppIdentity: vi.fn() }))
vi.mock('../../src/main/bootstrap', () => ({ bootstrap: vi.fn(async () => undefined) }))
vi.mock('../../src/main/lifecycle', () => ({ setupLifecycle: vi.fn() }))
vi.mock('../../src/main/windows/WindowManager', () => ({ showMainWindow: state.showMainWindow }))
vi.mock('../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
describe("app 'activate' (macOS Dock 클릭)", () => {
it('메인 창을 다시 띄운다', async () => {
await import('../../src/main/index')
const activate = state.appListeners.get('activate')
expect(activate).toBeTypeOf('function')
activate?.()
expect(state.showMainWindow).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,138 @@
// tests/main/ipc/ipc-redteam-r3-1.test.ts
// IPC 회귀 (r3-1):
// - caption:stop 은 사용자 자막만 멈추고 회의가 소유한 자막은 거부한다 (items 13·41)
// - stt:setModel 은 설치된 모델만 올린다 — 미설치 모델은 설정만 저장 (item 10)
// - 입력 수집 동의·일시정지 변경을 제안 서비스에 알린다 (item 7)
// - realtime-token 은 주입된 자격 증명 포트로 호출한다 (item 2)
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
const handlers = vi.hoisted(() => new Map<string, (event: unknown, params?: unknown) => Promise<unknown>>())
const caption = vi.hoisted(() => ({ stop: vi.fn(), start: vi.fn(), getState: vi.fn(() => 'active') }))
const stt = vi.hoisted(() => ({ initialize: vi.fn(async () => undefined), isModelInstalled: vi.fn(() => false), on: vi.fn() }))
const sttManager = vi.hoisted(() => ({ getActiveProvider: vi.fn(() => 'local'), on: vi.fn() }))
const config = vi.hoisted(() => ({ configGet: vi.fn(), configSet: vi.fn() }))
const telemetry = vi.hoisted(() => ({ setEnabled: vi.fn(), setPaused: vi.fn(), getState: vi.fn(() => ({})) }))
const suggestion = vi.hoisted(() => ({ handleInputConsentChanged: vi.fn(), applyConfig: vi.fn() }))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, params?: unknown) => Promise<unknown>) => {
handlers.set(channel, handler)
}),
on: vi.fn(),
},
session: { defaultSession: { setDisplayMediaRequestHandler: vi.fn() } },
desktopCapturer: { getSources: vi.fn(async () => []) },
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/CaptionService', () => ({ getCaptionService: () => caption }))
vi.mock('../../../src/main/windows/WindowManager', () => ({
endCaptionOverlayDrag: vi.fn(),
resetCaptionOverlayPosition: vi.fn(),
setCaptionOverlayInteractive: vi.fn(),
startCaptionOverlayDrag: vi.fn(),
getMainWindow: vi.fn(() => null),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => stt }))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ on: vi.fn() }) }))
vi.mock('../../../src/main/services/stt/STTManager', () => ({ getSTTManager: () => sttManager }))
vi.mock('../../../src/main/services/ConfigService', () => config)
vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => telemetry }))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: vi.fn() }))
vi.mock('../../../src/main/services/SuggestionService', () => ({ getSuggestionService: () => suggestion }))
vi.mock('../../../src/main/services/VoiceConversationService', () => ({ getVoiceConversationService: vi.fn() }))
vi.mock('../../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => {
throw new Error('handlers must use the injected credentials port')
},
}))
async function invoke(channel: string, params?: unknown): Promise<{ success: boolean; error?: { code: number } }> {
const handler = handlers.get(channel)
if (!handler) throw new Error(`handler not registered: ${channel}`)
return (await handler({}, params)) as { success: boolean; error?: { code: number } }
}
beforeEach(() => {
vi.clearAllMocks()
handlers.clear()
})
describe('caption:stop 소유자 확인', () => {
beforeEach(async () => {
const mod = await import('../../../src/main/ipc/caption-handlers')
mod.registerCaptionHandlers()
})
it("owner 'user' 로만 멈춘다", async () => {
caption.stop.mockResolvedValue(null)
expect((await invoke(IPC_CHANNELS.CAPTION.STOP)).success).toBe(true)
expect(caption.stop).toHaveBeenCalledWith('user')
})
it('회의가 소유한 자막이면 거부한다 (강제 정지하지 않음)', async () => {
caption.stop.mockResolvedValue(false)
const result = await invoke(IPC_CHANNELS.CAPTION.STOP)
expect(result.success).toBe(false)
expect(caption.stop).not.toHaveBeenCalledWith()
})
})
describe('stt:setModel 미설치 모델', () => {
beforeEach(async () => {
const mod = await import('../../../src/main/ipc/stt-handlers')
mod.registerSTTHandlers()
})
it('미설치 모델은 설정만 저장하고 사이드카에 올리지 않는다', async () => {
stt.isModelInstalled.mockReturnValue(false)
const result = await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'large-v3' })
expect(result.success).toBe(true)
expect(config.configSet).toHaveBeenCalledWith('sttModelId', 'large-v3')
expect(stt.initialize).not.toHaveBeenCalled()
})
it('설치된 모델은 바로 올린다', async () => {
stt.isModelInstalled.mockReturnValue(true)
await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'small' })
expect(stt.initialize).toHaveBeenCalledWith('small')
})
})
describe('입력 수집 동의 변경', () => {
beforeEach(async () => {
const mod = await import('../../../src/main/ipc/input-telemetry-handlers')
mod.registerInputTelemetryHandlers()
})
it('동의를 끄면 제안 서비스에 알린다', async () => {
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED, { enabled: false })
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
})
it('일시정지도 제안 서비스에 알린다', async () => {
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED, { paused: true })
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
})
})
describe('realtime-token 은 자격 증명 포트를 쓴다', () => {
it('로그아웃 상태의 포트가 error 를 주면 실패로 돌려준다', async () => {
const mod = await import('../../../src/main/ipc/voice-conversation-handlers')
const invokeFn = vi.fn(async () => ({ data: null, error: { message: 'No active session' } }))
mod.registerVoiceConversationHandlers({
hasCredentials: () => false,
accessToken: async () => null,
invoke: invokeFn,
invokeStream: async () => ({ stream: null, error: { message: 'No active session' } }),
})
const result = await invoke(IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, {})
expect(result.success).toBe(false)
expect(invokeFn).toHaveBeenCalledWith('realtime-token', {})
})
})

View file

@ -0,0 +1,89 @@
// tests/main/services/dictionary-file-codec.test.ts
// 사전 파일 형식(CSV·JSON) 순수 코덱 — DictionaryService 에서 분리한 로직의 단위 테스트
import { describe, it, expect } from 'vitest'
import {
DICTIONARY_CSV_HEADER,
parseDictionaryFile,
serializeDictionary,
toDictionaryImportRow
} from '../../../src/main/services/dictionary/dictionary-file-codec'
import type { DictionaryEntry } from '@d3ro/core/types'
const entry: DictionaryEntry = {
id: 'e1',
word: '=SUM, "quoted"',
pronunciation: null,
category: 'technical',
usageCount: 3,
lastUsedAt: null,
createdAt: 100,
updatedAt: 200
}
describe('serializeDictionary', () => {
it('CSV 는 BOM·헤더·CRLF 를 쓰고 수식·따옴표를 이스케이프한다', () => {
const csv = serializeDictionary([entry], 'csv')
expect(csv.startsWith(`\uFEFF${DICTIONARY_CSV_HEADER.join(',')}\r\n`)).toBe(true)
expect(csv).toContain('"=SUM, ""quoted""",,technical,3,100,200\r\n')
})
it('JSON 은 entries 객체로 쓴다', () => {
expect(JSON.parse(serializeDictionary([entry], 'json'))).toEqual({ entries: [entry] })
})
it('CSV 로 쓴 내용을 다시 읽으면 같은 값이 나온다', () => {
const [record] = parseDictionaryFile(serializeDictionary([entry], 'csv'), 'csv')
expect(record).toMatchObject({ word: entry.word, category: 'technical', usageCount: '3' })
})
})
describe('parseDictionaryFile', () => {
it('JSON 배열과 entries 객체를 모두 받는다', () => {
expect(parseDictionaryFile('[{"word":"a"}, 1, null]', 'json')).toEqual([{ word: 'a' }])
expect(parseDictionaryFile('{"entries":[{"word":"b"}]}', 'json')).toEqual([{ word: 'b' }])
})
it('형식이 맞지 않으면 Error 를 던진다', () => {
expect(() => parseDictionaryFile('{"x":1}', 'json')).toThrow(/entries/)
expect(() => parseDictionaryFile('', 'csv')).toThrow(/empty CSV/)
expect(() => parseDictionaryFile('term\r\nx\r\n', 'csv')).toThrow(/word/)
})
})
describe('toDictionaryImportRow', () => {
it('필드를 정규화하고 기본값을 채운다', () => {
expect(
toDictionaryImportRow(
{ word: ' AI ', category: 'bogus', usage_count: '2.7', created_at: 50 },
999
)
).toEqual({
ok: true,
row: {
word: 'AI',
pronunciation: null,
category: 'user',
usageCount: 2,
lastUsedAt: null,
createdAt: 50,
updatedAt: 50
}
})
})
it('빈 단어·너무 긴 값을 문제로 돌려준다', () => {
expect(toDictionaryImportRow({ pronunciation: 'x' }, 1)).toMatchObject({
ok: false,
problem: { reason: 'empty-word' }
})
expect(toDictionaryImportRow({ word: 'w'.repeat(121) }, 1)).toMatchObject({
ok: false,
problem: { reason: 'too-long', field: 'word', max: 120 }
})
expect(toDictionaryImportRow({ word: 'ok', pronunciation: 'p'.repeat(201) }, 1)).toMatchObject({
ok: false,
problem: { reason: 'too-long', field: 'pronunciation', max: 200 }
})
})
})

View file

@ -0,0 +1,180 @@
// tests/main/services/dictionary-redteam-r3-26.test.ts
// 서버 길이 제한(단어 120·발음 200)을 넘는 사전 항목은 로컬 저장·동기화 push 전에 거부돼야 한다.
// 예전엔 로컬에 저장한 뒤 pushOne 했고, 서버가 22023 으로 거부해 아웃박스에 영구 보류됐다.
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
const readFileSync = vi.fn()
const pushOne = vi.fn(async () => undefined)
vi.mock('fs', () => ({
default: { writeFileSync: vi.fn(), readFileSync },
writeFileSync: vi.fn(),
readFileSync
}))
vi.mock('electron', () => ({
app: { getPath: () => '/tmp/d3ro' },
dialog: { showSaveDialog: vi.fn() }
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null }))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
}))
vi.mock('../../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => ({ pushOne, pushDelete: vi.fn() })
}))
function chain(result: { all?: unknown[]; get?: unknown }): Record<string, unknown> {
const builder: Record<string, unknown> = {}
builder.from = vi.fn(() => builder)
builder.where = vi.fn(() => builder)
builder.all = vi.fn(() => result.all ?? [])
builder.get = vi.fn(() => result.get)
return builder
}
const existingRow = {
id: 'row-1',
word: 'AI',
pronunciation: null,
category: 'user',
usageCount: 0,
lastUsedAt: null,
createdAt: 1,
updatedAt: 1
}
const insertValues = vi.fn((_values: Record<string, unknown>) => ({
run: vi.fn(() => ({ changes: 1 }))
}))
const updateSet = vi.fn(() => ({ where: vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) })) }))
const txStub = {
select: vi.fn(() => chain({ get: undefined })),
insert: vi.fn(() => ({ values: insertValues }))
}
const mockDb = {
select: vi.fn(() => chain({ all: [], get: existingRow })),
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: updateSet })),
transaction: vi.fn((fn: (tx: typeof txStub) => void) => fn(txStub))
}
vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb }))
type ServiceModule = typeof import('../../../src/main/services/DictionaryService')
let mod: ServiceModule
beforeEach(async () => {
vi.clearAllMocks()
vi.resetModules()
mod = await import('../../../src/main/services/DictionaryService')
})
interface CapturedError {
name: string
message: string
code: unknown
details: unknown
}
// vi.resetModules() 로 서비스가 다른 D3ROError 클래스 인스턴스를 쓰므로 instanceof 대신 모양으로 확인한다
function captureError(fn: () => unknown): CapturedError {
try {
fn()
} catch (err) {
const captured = err as CapturedError
expect(captured.name).toBe('D3ROError')
return captured
}
throw new Error('expected a D3ROError')
}
describe('DictionaryService.add — 서버 길이 제한', () => {
it('121자 단어는 저장·push 없이 field/max 를 담아 거부한다', () => {
const err = captureError(() => mod.getDictionaryService().add({ word: 'w'.repeat(121) }))
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
expect(err.details).toMatchObject({ field: 'word', max: 120 })
expect(mockDb.insert).not.toHaveBeenCalled()
expect(pushOne).not.toHaveBeenCalled()
})
it('201자 발음은 저장·push 없이 거부한다', () => {
const err = captureError(() =>
mod.getDictionaryService().add({ word: 'ok', pronunciation: 'p'.repeat(201) })
)
expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 })
expect(mockDb.insert).not.toHaveBeenCalled()
expect(pushOne).not.toHaveBeenCalled()
})
it('제한 이내 항목은 그대로 저장하고 push 한다', () => {
const entry = mod.getDictionaryService().add({ word: 'w'.repeat(120), pronunciation: ' p ' })
expect(entry.word).toBe('w'.repeat(120))
expect(entry.pronunciation).toBe('p')
expect(mockDb.insert).toHaveBeenCalledTimes(1)
expect(pushOne).toHaveBeenCalledWith('dictionary', entry.id)
})
it('빈 단어는 기존과 같은 메시지로 거부한다', () => {
const err = captureError(() => mod.getDictionaryService().add({ word: ' ' }))
expect(err.message).toBe('Dictionary word is empty')
})
})
describe('DictionaryService.update — 서버 길이 제한', () => {
it('발음을 201자로 바꾸는 편집은 쓰기·push 없이 거부한다', () => {
const err = captureError(() =>
mod.getDictionaryService().update({ id: 'row-1', pronunciation: 'p'.repeat(201) })
)
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 })
expect(mockDb.update).not.toHaveBeenCalled()
expect(pushOne).not.toHaveBeenCalled()
})
it('단어를 121자로 바꾸는 편집을 거부한다', () => {
const err = captureError(() =>
mod.getDictionaryService().update({ id: 'row-1', word: 'w'.repeat(121) })
)
expect(err.details).toMatchObject({ field: 'word', max: 120 })
expect(mockDb.update).not.toHaveBeenCalled()
})
})
describe('DictionaryService.importDictionary — 서버 길이 제한', () => {
it('너무 긴 단어·발음 행은 오류로 세고 저장·push 하지 않는다', () => {
readFileSync.mockReturnValue(
JSON.stringify({
entries: [
{ word: 'w'.repeat(121) },
{ word: 'long-pron', pronunciation: 'p'.repeat(201) },
{ word: 'ok', pronunciation: '오케이' }
]
})
)
const result = mod
.getDictionaryService()
.importDictionary({ filePath: '/in/d.json', format: 'json' })
expect(result).toEqual({ imported: 1, skipped: 0, errors: 2 })
expect(insertValues).toHaveBeenCalledTimes(1)
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ word: 'ok', pronunciation: '오케이', category: 'user' })
)
expect(pushOne).toHaveBeenCalledTimes(1)
})
it('CSV 의 너무 긴 행도 거부한다', () => {
readFileSync.mockReturnValue(`word,pronunciation\r\n${'x'.repeat(121)},\r\nfine,\r\n`)
const result = mod
.getDictionaryService()
.importDictionary({ filePath: '/in/d.csv', format: 'csv' })
expect(result).toEqual({ imported: 1, skipped: 0, errors: 1 })
})
})

View file

@ -0,0 +1,104 @@
// tests/main/services/keybinding-altgr-redteam-r3-8.test.ts
// 레드팀 r3-8 회귀 테스트 (데스크톱 어댑터) — AltGr 배열.
//
// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 time)으로 보내고, libuiohook 은 그
// RAlt 눌림에 ctrlKey 를 싣는다. 어댑터가 그대로 옮기면 '@'(AltGr+Q) 마다 'command' 가 발동하고
// 기본 dictation(RightAlt 단독)은 발동하지 않는다.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { uIOhook, UiohookKey } from 'uiohook-napi'
import type { UiohookKeyboardEvent } from 'uiohook-napi'
import { ALTGR_CHORD_GRACE_MS } from '@d3ro/core/keybinding-runtime'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import { getKeyBindingService } from '../../../src/main/services/KeyBindingService'
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
function restorePlatform(): void {
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
}
type KeyHandler = (e: UiohookKeyboardEvent) => void
function handlerOf(event: 'keydown' | 'keyup'): KeyHandler {
const calls = vi.mocked(uIOhook.on).mock.calls.filter((c) => c[0] === event)
const last = calls[calls.length - 1]
if (!last) throw new Error(`no ${event} handler`)
return last[1] as KeyHandler
}
interface Mods {
ctrl?: boolean
alt?: boolean
}
function keyEvent(keycode: number, time: number, mods: Mods = {}): UiohookKeyboardEvent {
return {
keycode,
time,
ctrlKey: mods.ctrl ?? false,
altKey: mods.alt ?? false,
shiftKey: false,
metaKey: false
} as unknown as UiohookKeyboardEvent
}
describe('KeyBindingService — AltGr 배열 (Windows)', () => {
const events: string[] = []
const listener = (p: KeyBindingTriggerPayload): void => {
events.push(`${p.actionId}:${p.type}`)
}
beforeEach(() => {
vi.useFakeTimers()
setPlatform('win32')
initInMemoryConfig()
vi.mocked(uIOhook.on).mockClear()
events.length = 0
const service = getKeyBindingService()
service.loadFromConfig()
service.start()
service.on('triggered', listener)
})
afterEach(() => {
const service = getKeyBindingService()
service.off('triggered', listener)
service.stop()
resetInMemoryConfig()
restorePlatform()
vi.useRealTimers()
})
function altGrDown(time: number): void {
handlerOf('keydown')(keyEvent(UiohookKey.Ctrl, time, { ctrl: true }))
handlerOf('keydown')(keyEvent(UiohookKey.AltRight, time, { ctrl: true, alt: true }))
}
function altGrUp(): void {
handlerOf('keyup')(keyEvent(UiohookKey.Ctrl, 0, { alt: true }))
handlerOf('keyup')(keyEvent(UiohookKey.AltRight, 0))
}
it("AltGr+Q('@') 는 어떤 트리거도 내보내지 않는다", () => {
altGrDown(5_000)
handlerOf('keydown')(keyEvent(UiohookKey.Q, 5_040, { ctrl: true, alt: true }))
handlerOf('keyup')(keyEvent(UiohookKey.Q, 5_080, { ctrl: true, alt: true }))
altGrUp()
vi.advanceTimersByTime(1_000)
expect(events).toEqual([])
})
it('AltGr 를 누르고 있으면 기본 dictation 이 발동한다 (command 가 아니다)', () => {
altGrDown(6_000)
vi.advanceTimersByTime(ALTGR_CHORD_GRACE_MS)
expect(events).toEqual(['dictation:pressed'])
altGrUp()
expect(events).toEqual(['dictation:pressed', 'dictation:released'])
})
})

View file

@ -0,0 +1,28 @@
// tests/main/services/meeting-condense-redteam-r3-3.test.ts
// 줄바꿈 없는 긴 전사(자막·파일 전사 히스토리, 폰 회의)도 map 조각이 프록시 한도 안에 들어가야 한다.
import { describe, expect, it, vi } from 'vitest'
import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser'
const PROXY_MESSAGE_LIMIT = 8_000
/** CaptionService fullText 처럼 세그먼트를 공백으로 이어 붙인 한 줄 전사 */
const singleLine = Array.from({ length: 900 }, (_, i) => `${i}번째 발언에서 일정과 담당자를 정리했습니다.`).join(' ')
describe('condenseTranscriptToBudget — 한 줄짜리 긴 전사', () => {
it('한 줄 20,000자 전사를 여러 조각으로 나눠 줄이고, 조각 입력은 모두 한도 안이다', async () => {
expect(singleLine.includes('\n')).toBe(false)
expect(singleLine.length).toBeGreaterThan(20_000)
const inputs: string[] = []
const llm = {
generate: vi.fn(async (text: string) => {
inputs.push(text)
return { text: `요약 ${inputs.length}` }
}),
}
const result = await condenseTranscriptToBudget(llm, singleLine, PROXY_MESSAGE_LIMIT)
expect(result.condensed).toBe(true)
expect(result.text.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT)
expect(inputs.length).toBeGreaterThan(1)
for (const input of inputs) expect(input.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT)
})
})

View file

@ -0,0 +1,195 @@
// tests/main/services/runtime-provisioner-redteam-r3-5.test.ts
// 런타임 인덱스의 플랫폼 구분과 "feed 가 앱보다 늦은" 경우의 회귀 테스트.
//
// 회귀 대상:
// 1) runtime.json 에 platform/arch 가 없어 macOS 앱이 Windows 엔진(sidecar.exe)을 받아 풀고
// 검증에서 떨어진 뒤, ensure 할 때마다 ~160MB 를 다시 받던 문제
// 2) feed 버전이 앱의 RUNTIME_MIN_VERSION 보다 낮으면 설치된 엔진까지 버리고 로컬 STT 가
// 완전히 멈추던 문제
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
RuntimeIndexRejectedError,
isRuntimeIndexRejected,
parseRuntimeIndex,
} from '../../../src/main/services/runtime/runtime-index'
import type { RuntimeFetch, RuntimeFetchResponse } from '../../../src/main/services/runtime/download-part'
import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner'
const FEED = 'https://feed.test/runtime-latest'
const SHA = 'c'.repeat(64)
const PART = 'd3ro-runtime-sidecar.tar.gz.001'
function index(version: string, target?: { platform: string; arch: string }): Record<string, unknown> {
return {
schemaVersion: 1,
version,
...(target ?? {}),
components: {
sidecar: {
archive: 'd3ro-runtime-sidecar.tar.gz',
sha256: SHA,
totalSize: 10,
parts: [{ name: PART, size: 10, sha256: SHA, url: `${FEED}/${PART}` }],
},
},
}
}
function feed(body: Record<string, unknown>): { fetchImpl: RuntimeFetch; calls: string[] } {
const calls: string[] = []
const fetchImpl: RuntimeFetch = async (url) => {
calls.push(url)
if (url === `${FEED}/runtime.json`) {
return new Response(JSON.stringify(body), { status: 200 }) as RuntimeFetchResponse
}
return new Response(new Uint8Array(10), { status: 200 }) as RuntimeFetchResponse
}
return { fetchImpl, calls }
}
let workDir: string
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r3-5-'))
})
afterEach(() => {
rmSync(workDir, { recursive: true, force: true })
})
function provisioner(
fetchImpl: RuntimeFetch,
platform: NodeJS.Platform = 'win32',
arch = 'x64',
): RuntimeProvisioner {
return new RuntimeProvisioner({
userDataDir: () => workDir,
fetchImpl,
feedUrl: FEED,
platform,
arch,
stallTimeoutMs: 1000,
partAttempts: 1,
})
}
/** 예전에 받아 둔 엔진이 설치돼 있는 상태 (마커 버전 지정) */
function installOldSidecar(p: RuntimeProvisioner, markerVersion: string): void {
const dir = p.componentDir('sidecar')
mkdirSync(join(dir, '_internal'), { recursive: true })
writeFileSync(p.binaryPath('sidecar'), 'old engine')
writeFileSync(join(dir, '.runtime-version'), markerVersion)
}
describe('parseRuntimeIndex — 플랫폼 대상', () => {
const WIN = { platform: 'win32', arch: 'x64' }
it('인덱스가 밝힌 플랫폼과 앱 플랫폼이 다르면 platform-mismatch 로 거부한다', () => {
let caught: unknown
try {
parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' })
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(RuntimeIndexRejectedError)
expect(isRuntimeIndexRejected(caught, 'platform-mismatch')).toBe(true)
expect((caught as Error).message).toMatch(/darwin-arm64/)
})
it('아키텍처만 달라도 거부한다', () => {
expect(() =>
parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'win32', arch: 'arm64' }),
).toThrow(RuntimeIndexRejectedError)
})
it('같은 플랫폼이면 통과하고, 대상을 넘기지 않으면 검사하지 않는다', () => {
expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', WIN).version).toBe('1.9.0')
expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0').version).toBe('1.9.0')
})
it('platform/arch 가 없는 예전 인덱스는 그대로 읽는다 (하위 호환)', () => {
expect(parseRuntimeIndex(index('1.9.0'), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' }).version).toBe(
'1.9.0',
)
})
it('platform/arch 형식이 깨졌으면 형식 오류로 거부한다', () => {
const broken = { ...index('1.9.0'), platform: 'win32', arch: 7 }
let caught: unknown
try {
parseRuntimeIndex(broken, 'sidecar', '1.7.0', WIN)
} catch (err) {
caught = err
}
expect((caught as Error).message).toMatch(/platform\/arch/)
expect(isRuntimeIndexRejected(caught)).toBe(false)
})
it('최소 버전 미만 feed 는 feed-outdated 로 구분된다', () => {
let caught: unknown
try {
parseRuntimeIndex(index('1.6.0', WIN), 'sidecar', '1.7.0', WIN)
} catch (err) {
caught = err
}
expect(isRuntimeIndexRejected(caught, 'feed-outdated')).toBe(true)
expect((caught as Error).message).toMatch(/최소 요구 버전/)
})
})
describe('RuntimeProvisioner — 다른 플랫폼 feed', () => {
it('macOS 앱은 Windows 런타임 인덱스를 보면 부품을 받기 전에 실패한다', async () => {
const f = feed(index('1.9.0', { platform: 'win32', arch: 'x64' }))
const p = provisioner(f.fetchImpl, 'darwin', 'arm64')
await expect(p.ensure('sidecar')).rejects.toThrow(/이 플랫폼\(darwin-arm64\)용 런타임이 feed에 없습니다/)
expect(f.calls).toEqual([`${FEED}/runtime.json`])
expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false)
// 다시 불러도 인덱스만 읽는다 — 대용량 재다운로드 루프가 없다
f.calls.length = 0
await expect(p.ensure('sidecar')).rejects.toThrow(RuntimeIndexRejectedError)
expect(f.calls).toEqual([`${FEED}/runtime.json`])
})
})
describe('RuntimeProvisioner — feed 가 앱의 최소 버전보다 낮을 때', () => {
it('설치된 (낡은) 엔진이 있으면 그 경로를 돌려주고 부품을 받지 않는다', async () => {
const f = feed(index('1.6.0'))
const p = provisioner(f.fetchImpl)
installOldSidecar(p, '1.6.0')
await expect(p.ensure('sidecar')).resolves.toBe(p.binaryPath('sidecar'))
expect(f.calls).toEqual([`${FEED}/runtime.json`])
expect(p.isInstalled('sidecar')).toBe(true)
})
it('"다시 설치"(force)는 조용히 넘어가지 않고 실패를 알린다', async () => {
const f = feed(index('1.6.0'))
const p = provisioner(f.fetchImpl)
installOldSidecar(p, '1.6.0')
await expect(p.ensure('sidecar', { force: true })).rejects.toThrow(/최소 요구 버전/)
expect(p.isInstalled('sidecar')).toBe(true)
})
it('설치된 엔진이 없으면 실패한다', async () => {
const f = feed(index('1.6.0'))
await expect(provisioner(f.fetchImpl).ensure('sidecar')).rejects.toThrow(/최소 요구 버전/)
})
it('다른 이유의 실패(인덱스 404)는 설치된 엔진으로 덮지 않는다', async () => {
const calls: string[] = []
const fetchImpl: RuntimeFetch = async (url) => {
calls.push(url)
return new Response(null, { status: 404 }) as RuntimeFetchResponse
}
const p = provisioner(fetchImpl)
installOldSidecar(p, '1.6.0')
await expect(p.ensure('sidecar')).rejects.toThrow(/HTTP 404/)
})
})

View file

@ -0,0 +1,21 @@
// tests/main/services/stt-timeout-redteam-r3-1.test.ts
// 최종 전사 응답 기한은 오디오 길이에 비례한다 (item 9) — 고정 120초가 몇 분짜리 핸즈프리 녹음을 버렸다.
import { describe, expect, it } from 'vitest'
import { transcriptionTimeoutMs, PCM16_MONO_16K_BYTES_PER_SECOND } from '../../../src/main/services/stt/audio-utils'
describe('transcriptionTimeoutMs', () => {
const options = { floorMs: 120_000, perAudioSecondMs: 3000 }
it('짧은 녹음은 하한(120초)을 쓴다', () => {
expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 5, options)).toBe(120_000)
})
it('10분 녹음은 오디오 길이에 비례해 기다린다', () => {
expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 600, options)).toBe(1_800_000)
})
it('음수·0 바이트는 하한', () => {
expect(transcriptionTimeoutMs(0, options)).toBe(120_000)
expect(transcriptionTimeoutMs(-5, options)).toBe(120_000)
})
})

View file

@ -0,0 +1,317 @@
// tests/main/services/suggestion-redteam-r3-1.test.ts
// 제안 서비스 회귀 (r3-1):
// - 케어렛을 모르는 문서 중간 편집 뒤에는 수락하지 않는다
// - 다른 기능의 짧은 keep_alive 로 모델이 내려갔으면 warm 이 아니다 / 시간 초과면 콜드로 본다
// - 입력 수집 동의가 없으면 모델을 데우지 않고, 철회하면 세션·보관 문맥을 버린다
// - 한 세션은 한 이력 행이다 (채우기 후보는 행을 늘리지 않고, 수락은 세션 행에 기록)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersonalPhrase } from '@d3ro/core/input-intelligence'
const config = vi.hoisted(() => ({
suggestionEnabled: true as boolean,
suggestionModelId: 'model-a' as string | null,
llmModelId: 'model-a' as string | null,
inputExcludedApps: [] as string[],
suggestionTriggerDelayMs: 600,
suggestionMinPrefixChars: 8,
suggestionMaxRequestsPerMinute: 12,
suggestionDailyBudget: 500,
suggestionRequestTimeoutMs: 8000,
inputLearnTypedText: false as boolean,
inputTelemetryEnabled: false as boolean,
inputTelemetryPaused: false as boolean,
suggestionOverlayInteractive: true,
insertMethod: 'clipboard'
}))
const harness = vi.hoisted(() => ({
isAvailable: vi.fn(() => true),
streamGenerate: vi.fn(),
insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })),
phrases: [] as PersonalPhrase[]
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: keyof typeof config) => config[key]),
configSet: vi.fn((key: keyof typeof config, value: never) => {
config[key] = value
})
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate })
}))
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
getInputTelemetryService: () => ({ listPhrases: () => harness.phrases })
}))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) })
}))
vi.mock('../../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => ({ insertText: harness.insertText })
}))
vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true }))
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService'
import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget'
import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker'
import type { TypingContext } from '../../../src/main/services/InputTelemetryService'
const PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
const SUGGESTION = '다음 단계도 확인하겠습니다.'
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
const prefix = overrides.prefix ?? PREFIX
return {
prefix,
fullText: prefix,
caretOffset: prefix.length,
anchor: { x: 100, y: 100, width: 2, height: 20 },
anchorKind: 'caret',
isPassword: false,
isEditable: true,
isComposing: false,
hasSelection: false,
available: true,
appName: 'Slack.exe',
windowTitle: 'general',
windowHandle: 101,
idleMs: 2000,
capturedAt: Date.now(),
editedSinceFocus: true,
typedRecently: true,
...overrides
}
}
interface Fakes {
deps: SuggestionServiceDeps
record: ReturnType<typeof vi.fn>
markAccepted: ReturnType<typeof vi.fn>
expectInsert: ReturnType<typeof vi.fn>
recordAccepted: ReturnType<typeof vi.fn>
}
function fakes(): Fakes {
const record = vi.fn()
const markAccepted = vi.fn()
const expectInsert = vi.fn(() => vi.fn())
const recordAccepted = vi.fn()
return {
record,
markAccepted,
expectInsert,
recordAccepted,
deps: {
repository: { record, markAccepted, list: vi.fn(() => []) },
budget: new SuggestionBudget(),
warmth: new ModelWarmTracker(),
foreground: { currentWindowHandle: () => 101 },
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
}
}
}
function streamOf(text: string): AsyncGenerator<string> {
return (async function* () {
yield text
})()
}
/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */
function gatedStream(signal: AbortSignal | undefined, gate: Promise<string>): AsyncGenerator<string> {
return (async function* () {
const text = await new Promise<string>((resolve, reject) => {
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
void gate.then(resolve)
})
yield text
})()
}
interface Internal {
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
_lastContext: TypingContext | null
_inFlight: boolean
_warmUpPromise: Promise<void> | null
}
let services: SuggestionService[] = []
function create(deps: SuggestionServiceDeps, warm = true): SuggestionService {
if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000)
const service = new SuggestionService(deps)
services.push(service)
return service
}
import { ModelResidencyLedger, parseKeepAliveMs } from '../../../src/main/services/llm/model-residency'
beforeEach(() => {
config.suggestionEnabled = true
config.suggestionModelId = 'model-a'
config.llmModelId = 'model-a'
config.suggestionRequestTimeoutMs = 8000
config.inputLearnTypedText = false
config.inputTelemetryEnabled = false
config.inputTelemetryPaused = false
harness.isAvailable.mockReset()
harness.isAvailable.mockReturnValue(true)
harness.streamGenerate.mockReset()
harness.insertText.mockClear()
harness.phrases = []
})
afterEach(() => {
for (const service of services) service.dispose()
services = []
})
async function flush(): Promise<void> {
for (let i = 0; i < 8; i += 1) await new Promise((resolve) => setTimeout(resolve, 0))
}
describe('케어렛을 모르는 필드 (item 3)', () => {
it('문서 중간 편집 뒤(caretReliable=false)에는 표시 중인 후보를 수락하지 않는다', async () => {
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const fake = fakes()
const service = create(fake.deps)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
expect(service.isVisible).toBe(true)
const internal = service as unknown as Internal
internal._lastContext = typingContext({ caretOffset: null, caretReliable: false })
const result = await service.accept()
expect(result).toEqual({ ok: false, reason: 'caret-unknown' })
expect(harness.insertText).not.toHaveBeenCalled()
})
it('케어렛을 모르는 문맥이 오면 세션을 닫는다', async () => {
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const fake = fakes()
const service = create(fake.deps)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
service.handleTypingContext(typingContext({ caretOffset: null, caretReliable: false }))
expect(service.isVisible).toBe(false)
expect(service.getState().lastSkipReason).toBe('caret-unknown')
})
})
describe('모델 상주 추정 (item 4)', () => {
it('keep_alive 문자열을 해석한다', () => {
expect(parseKeepAliveMs('10m')).toBe(600_000)
expect(parseKeepAliveMs('2m')).toBe(120_000)
expect(parseKeepAliveMs(undefined)).toBe(300_000)
expect(parseKeepAliveMs('1h30m')).toBe(5_400_000)
})
it('같은 모델을 더 짧은 keep_alive 로 쓴 요청이 있으면 그 만료 뒤에는 warm 이 아니다', () => {
const ledger = new ModelResidencyLedger()
const tracker = new ModelWarmTracker(ledger)
const t0 = 1_000_000
ledger.noteRequest('model-a', '10m', t0)
tracker.noteWarm('model-a', t0 + 570_000)
// 1분 뒤 음성 대화가 같은 모델을 keep_alive 2m 로 사용
ledger.noteRequest('model-a', '2m', t0 + 60_000)
expect(tracker.isWarm('model-a', t0 + 120_000)).toBe(true)
expect(tracker.isWarm('model-a', t0 + 6 * 60_000)).toBe(false)
})
it('warm 이라 믿은 요청이 시간 초과하면 콜드로 본다 (다음 멈춤은 워밍업)', async () => {
config.suggestionRequestTimeoutMs = 20
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
gatedStream(options.signal, new Promise<string>(() => undefined))
)
const fake = fakes()
const service = create(fake.deps)
expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(true)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(false)
})
})
describe('입력 수집 동의 (item 7)', () => {
it('동의 없이 제안을 켜면 모델을 데우지 않는다', async () => {
const fake = fakes()
const service = create(fake.deps, false)
service.setEnabled(true)
await flush()
expect(harness.streamGenerate).not.toHaveBeenCalled()
})
it('동의를 철회하면 떠 있는 세션을 닫고 보관 문맥을 버린다 (수동 요청이 옛 접두로 생성하지 않음)', async () => {
config.inputTelemetryEnabled = true
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const fake = fakes()
const service = create(fake.deps)
service.handleTypingContext(typingContext())
await flush()
expect(service.isVisible).toBe(true)
config.inputTelemetryEnabled = false
harness.streamGenerate.mockClear()
service.handleInputConsentChanged()
expect(service.isVisible).toBe(false)
expect(await service.requestNow()).toEqual({ ok: false, reason: 'not-editable' })
expect(harness.streamGenerate).not.toHaveBeenCalled()
})
})
describe('제안 이력은 세션당 한 행 (item 8)', () => {
function sessionRepository() {
return {
record: vi.fn(() => 'row-1'),
updateSessionCandidates: vi.fn(),
markSessionAccepted: vi.fn(),
markAccepted: vi.fn(),
list: vi.fn(() => [])
}
}
it('채우기 후보는 새 행(지연 0)을 만들지 않고 세션 행의 후보 수만 갱신한다', async () => {
const outputs = ['첫 번째 후보 문장입니다.', '두 번째 후보 문장입니다.', '세 번째 후보 문장입니다.']
let call = 0
harness.streamGenerate.mockImplementation(() => streamOf(outputs[Math.min(call++, outputs.length - 1)]))
const fake = fakes()
const repository = sessionRepository()
fake.deps.repository = repository
const service = create(fake.deps)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
await flush()
expect(service.getState().candidates.length).toBeGreaterThanOrEqual(2)
expect(repository.record).toHaveBeenCalledTimes(1)
expect(repository.updateSessionCandidates).toHaveBeenCalledWith('row-1', expect.any(Number))
})
it('로컬 기억 세션에서 2번째 후보를 수락하면 그 세션 행에 기록한다', async () => {
config.suggestionModelId = null
config.llmModelId = null
harness.phrases = [
{ id: 'p1', phrase: '다음 단계도 확인하겠습니다.', count: 3, score: 3, lastUsedAt: Date.now(), appName: null, source: 'typed' },
{ id: 'p2', phrase: '회의록은 내일 공유드리겠습니다.', count: 2, score: 2, lastUsedAt: Date.now(), appName: null, source: 'typed' }
] as unknown as typeof harness.phrases
const fake = fakes()
const repository = sessionRepository()
fake.deps.repository = repository
const service = create(fake.deps, false)
const context = typingContext({ prefix: '오늘 회의 정리했습니다.' })
const internal = service as unknown as Internal
internal._lastContext = context
await internal._generate(context.prefix, context, 3, 160)
const state = service.getState()
expect(state.candidates.length).toBeGreaterThanOrEqual(2)
expect(repository.record).toHaveBeenCalledTimes(1)
const second = state.candidates[1].text
expect(await service.accept(1)).toEqual({ ok: true })
expect(repository.markSessionAccepted).toHaveBeenCalledWith('row-1', second)
expect(repository.markAccepted).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest'
import type { VoiceCommandRule } from '@d3ro/core/types'
import {
DEFAULT_VOICE_COMMAND_KEYWORDS,
buildDefaultRules,
findRuleMatch,
matchKeyword,
migrateLegacyDefaultRules
} from '../../../src/main/services/voice-command-policy'
import {
createVoiceCommandService,
type VoiceCommandStorePort
} from '../../../src/main/services/VoiceCommandService'
function memoryStore(
initial: { rules?: VoiceCommandRule[]; enabled?: boolean } = {}
): VoiceCommandStorePort & { saved: VoiceCommandRule[] | undefined } {
const state = {
rules: initial.rules,
enabled: initial.enabled,
saved: undefined as VoiceCommandRule[] | undefined
}
return {
get saved() {
return state.saved
},
loadRules: () => state.rules,
saveRules: (rules) => {
state.rules = rules
state.saved = rules
},
loadEnabled: () => state.enabled,
saveEnabled: (enabled) => {
state.enabled = enabled
}
}
}
let seq = 0
const defaultRules = (): VoiceCommandRule[] => buildDefaultRules(() => `r${seq++}`)
describe('기본 키워드: 일반 받아쓰기 문장을 명령으로 삼키지 않는다', () => {
const dictation = [
'요약 보고서 첨부해서 보내드립니다.',
'요약.',
'설명 드리겠습니다, 이번 배포는 늦어집니다.',
'번역 작업은 다음 주에 끝납니다.',
'영어로 된 문서를 보내 주세요.',
'다듬기 작업이 남았습니다.',
'Explain to the team that the deploy is delayed',
'Polish the slides before the meeting',
'Translate team is out today',
'Summarize: nothing yet'
]
for (const text of dictation) {
it(`"${text}" 는 매칭되지 않는다`, () => {
expect(findRuleMatch(text, defaultRules())).toBeNull()
})
}
})
describe('기본 키워드: 명령형은 매칭하고 키워드를 떼어낸다', () => {
const cases: Array<[string, string, string]> = [
['번역해줘 이 문장', 'builtin-translate', '이 문장'],
['번역해 줘, 오늘 회의 끝', 'builtin-translate', '오늘 회의 끝'],
['영어로 번역해줘. 안녕하세요', 'builtin-translate', '안녕하세요'],
['Translate this: good morning', 'builtin-translate', 'good morning'],
['요약해줘 긴 글', 'builtin-summarize', '긴 글'],
['요약해 주세요. 오늘 회의 내용', 'builtin-summarize', '오늘 회의 내용'],
['다듬어줘 메일 초안', 'builtin-formal', '메일 초안'],
['설명해줘 const x = 1', 'builtin-explain-code', 'const x = 1'],
['explain this "for (;;)"', 'builtin-explain-code', '"for (;;)"']
]
for (const [text, instructionId, cleaned] of cases) {
it(`"${text}" → ${instructionId}`, () => {
const found = findRuleMatch(text, defaultRules())
expect(found?.rule.instructionId).toBe(instructionId)
expect(found?.cleanedText).toBe(cleaned)
})
}
it('명령어만 말하고 내용이 없으면 명령으로 보지 않는다', () => {
expect(findRuleMatch('요약해줘.', defaultRules())).toBeNull()
expect(findRuleMatch('번역해줘', defaultRules())).toBeNull()
})
it('기본 키워드에 맨 명사가 없다', () => {
const all = DEFAULT_VOICE_COMMAND_KEYWORDS.flatMap((e) => e.keywords.map((k) => k.keyword))
for (const bare of ['요약', '설명', '번역', '영어로', '다듬기', 'explain', 'polish', 'translate', 'summarize']) {
expect(all).not.toContain(bare)
}
})
})
describe('matchKeyword: 잘라낸 자리의 구두점과 빈 내용', () => {
const table: Array<[string, string, 'prefix' | 'suffix' | 'contains', boolean, string]> = [
['짧게, 오늘 회의 내용', '짧게', 'prefix', true, '오늘 회의 내용'],
['짧게.', '짧게', 'prefix', false, '짧게.'],
['짧게 ...', '짧게', 'prefix', false, '짧게 ...'],
['짧게요 오늘', '짧게', 'prefix', false, '짧게요 오늘'],
['오늘 회의 내용, 번역해줘', '번역해줘', 'suffix', true, '오늘 회의 내용'],
['번역해줘', '번역해줘', 'suffix', false, '번역해줘'],
['이 메일 정중하게 써줘', '정중하게', 'contains', true, '이 메일 써줘'],
['정중하게', '정중하게', 'contains', false, '정중하게'],
['anything', '', 'prefix', false, 'anything']
]
for (const [text, keyword, mode, matched, cleaned] of table) {
it(`${mode} "${keyword}" in "${text}"`, () => {
expect(matchKeyword(text, keyword, mode)).toEqual({ matched, cleanedText: cleaned })
})
}
})
describe('레거시 기본값 이관', () => {
const legacy = (): VoiceCommandRule[] => [
{
id: 'a',
instructionId: 'builtin-summarize',
keywords: [
{ keyword: '요약해줘', matchMode: 'prefix' },
{ keyword: '요약', matchMode: 'prefix' },
{ keyword: 'summarize', matchMode: 'prefix' }
],
enabled: true,
priority: 1
},
{
id: 'b',
instructionId: 'builtin-translate',
keywords: [{ keyword: '영작', matchMode: 'prefix' }],
enabled: true,
priority: 0
}
]
it('손대지 않은 레거시 기본값만 교체하고 사용자 키워드는 둔다', () => {
const { rules, migrated } = migrateLegacyDefaultRules(legacy())
expect(migrated).toBe(1)
expect(rules[0].id).toBe('a')
expect(rules[0].keywords.map((k) => k.keyword)).not.toContain('요약')
expect(rules[1].keywords).toEqual([{ keyword: '영작', matchMode: 'prefix' }])
expect(migrateLegacyDefaultRules(rules).migrated).toBe(0)
})
it('서비스 초기화 때 저장된 레거시 기본값을 이관하고 저장한다', () => {
const store = memoryStore({ rules: legacy(), enabled: true })
const svc = createVoiceCommandService(store)
svc.initialize()
svc.initDefaultKeywords()
expect(svc.match('요약 보고서 첨부해서 보내드립니다.').matched).toBe(false)
const cmd = svc.match('요약해줘 긴 글')
expect(cmd.instructionId).toBe('builtin-summarize')
expect(cmd.cleanedText).toBe('긴 글')
expect(store.saved?.find((r) => r.id === 'a')?.keywords.map((k) => k.keyword)).not.toContain(
'요약'
)
})
it('이관할 것이 없으면 저장하지 않는다', () => {
const store = memoryStore({ rules: defaultRules(), enabled: true })
createVoiceCommandService(store).initialize()
expect(store.saved).toBeUndefined()
})
})

View file

@ -0,0 +1,282 @@
// tests/main/services/voice-recording-store-redteam-r3-1.test.ts
// 받아쓰기 녹음 저장 순서 회귀 (r3-1, items 11·12):
// - 녹음 WAV 는 session-completed(히스토리 생성·history_audio 동기화 예약) 전에 저장된다
// - 완료 이벤트는 실제 저장 경로를 싣고, 저장 실패·오디오 없음이면 null 이다
// - 히스토리는 이벤트의 경로만 audioLocalPath 로 쓴다
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { EventEmitter } from 'events'
import os from 'os'
import path from 'path'
import fs from 'fs'
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
import { createFsRecordingStore, type RecordingStore } from '../../../src/main/services/recording/recording-store'
import { pcmToWav } from '../../../src/main/services/stt/audio-utils'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
type SttResult = { text: string; segments: never[]; language: string; duration: number; processingTime: number }
const result = (text: string): SttResult => ({ text, segments: [], language: 'ko', duration: 1, processingTime: 1 })
const mockSTT = vi.hoisted(() => ({
initialize: vi.fn(),
transcribe: vi.fn(),
transcribePartial: vi.fn(async () => ''),
getModels: vi.fn(),
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => mockSTT,
resetLocalSTTServiceForTests: () => undefined,
}))
const audioBus = new EventEmitter()
const mockAudio = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn(), on: vi.fn(), off: vi.fn() }))
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => mockAudio,
}))
const keyBinding = vi.hoisted(() => ({
handler: null as ((payload: KeyBindingTriggerPayload) => void) | null,
}))
vi.mock('../../../src/main/services/KeyBindingService', () => ({
getKeyBindingService: () => ({
on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => {
keyBinding.handler = fn
},
off: 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]),
}))
const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() }))
vi.mock('../../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => mockInsert,
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({ isAvailable: () => true }),
}))
const gateway = vi.hoisted(() => ({ processText: vi.fn() }))
vi.mock('../../../src/main/services/llm/LlmGateway', () => ({
getLlmGateway: () => gateway,
}))
vi.mock('../../../src/main/services/CaptionService', () => ({
getCaptionService: () => ({ getState: () => 'inactive', stop: vi.fn(), start: vi.fn() }),
}))
const chain = vi.hoisted(() => ({ execute: vi.fn() }))
vi.mock('../../../src/main/services/ChainService', () => ({
getChainService: () => chain,
}))
const instructions = vi.hoisted(() => ({
byId: {} as Record<string, { id: string; name: string; prompt: string }>,
}))
vi.mock('../../../src/main/services/CustomInstructionService', () => ({
getCustomInstructionService: () => ({ getById: (id: string) => instructions.byId[id] ?? null }),
}))
const voiceCommand = vi.hoisted(() => ({ instructionId: null as string | null }))
vi.mock('../../../src/main/services/VoiceCommandService', () => ({
getVoiceCommandService: () => ({
isEnabled: () => voiceCommand.instructionId !== null,
match: (text: string) =>
voiceCommand.instructionId
? { matched: true, ruleId: 'r', instructionId: voiceCommand.instructionId, cleanedText: text, matchedKeyword: 'k' }
: { matched: false, ruleId: null, instructionId: null, cleanedText: text, matchedKeyword: null },
}),
}))
const screen = vi.hoisted(() => ({
enabled: false,
captureContext: vi.fn(),
captureSelectedText: vi.fn(),
}))
vi.mock('../../../src/main/services/ScreenContextService', () => ({
getScreenContextService: () => ({
isEnabled: () => screen.enabled,
captureContext: screen.captureContext,
captureSelectedText: screen.captureSelectedText,
buildContextPrompt: (ctx: { appName: string | null; selectedText: string | null }) =>
`[ctx app=${ctx.appName ?? '-'} sel=${ctx.selectedText ?? '-'}]\n`,
}),
}))
type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService')
let getVoiceModeService: VoiceModeModule['getVoiceModeService']
let createWithStore: VoiceModeModule['createVoiceModeServiceForTests']
const SPEECH = Buffer.alloc(16000 * 2) // 1초
async function flush(times = 8): Promise<void> {
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r))
}
function advanceClock(ms: number): void {
const base = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(base + ms)
}
function key(
actionId: 'dictation' | 'hands-free',
type: 'pressed' | 'released',
isDoublePress: boolean,
): KeyBindingTriggerPayload {
return {
actionId,
type,
isDoublePress,
holdMode: actionId === 'dictation',
timestamp: Date.now(),
durationMs: 0,
} as unknown as KeyBindingTriggerPayload
}
function screenContext(appName: string): { context: { appName: string; windowTitle: string; selectedText: null; capturedAt: number }; selectedTextAttempted: boolean } {
return { context: { appName, windowTitle: 'w', selectedText: null, capturedAt: 0 }, selectedTextAttempted: false }
}
beforeEach(async () => {
vi.restoreAllMocks()
vi.resetModules()
audioBus.removeAllListeners()
config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' }
keyBinding.handler = null
screen.enabled = false
voiceCommand.instructionId = null
instructions.byId = {}
for (const fn of [
mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels,
mockAudio.start, mockAudio.stop, mockAudio.on, mockAudio.off, mockInsert.insertText,
gateway.processText, chain.execute, screen.captureContext, screen.captureSelectedText,
]) {
fn.mockReset()
}
mockSTT.initialize.mockResolvedValue(undefined)
mockSTT.transcribe.mockResolvedValue(result('기본 전사'))
mockSTT.transcribePartial.mockResolvedValue('')
mockSTT.getModels.mockReturnValue([
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
])
mockAudio.start.mockResolvedValue(undefined)
mockAudio.stop.mockResolvedValue(undefined)
mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
audioBus.on(ev, fn)
})
mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
audioBus.off(ev, fn)
})
mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })
gateway.processText.mockImplementation(async (text: string) => `LLM(${text})`)
chain.execute.mockResolvedValue({ finalText: '체인 결과' })
screen.captureContext.mockResolvedValue(screenContext('Code'))
screen.captureSelectedText.mockResolvedValue('선택 문장')
const mod = await import('../../../src/main/services/VoiceModeService')
mod.resetVoiceModeServiceForTests()
getVoiceModeService = mod.getVoiceModeService
createWithStore = mod.createVoiceModeServiceForTests
})
async function recordAndStop(store: RecordingStore): Promise<{ order: string[]; completed: Array<{ audioPath: string | null }> }> {
const order: string[] = []
const completed: Array<{ audioPath: string | null }> = []
const svc = createWithStore({
save: async (sessionId, pcm) => {
const saved = await store.save(sessionId, pcm)
order.push('saved')
return saved
},
})
svc.on('session-completed', ({ audioPath }) => {
order.push('completed')
completed.push({ audioPath })
})
await svc.startSession('dictation')
await vi.waitFor(() => expect(svc.isActive).toBe(true))
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(3000)
await svc.stopSession()
await flush()
return { order, completed }
}
describe('녹음 저장 → 완료 순서', () => {
it('WAV 저장이 끝난 뒤에 session-completed 가 나가고 실제 경로를 싣는다', async () => {
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => {
release = resolve
})
const store: RecordingStore = {
save: async (sessionId) => {
await gate
return `/rec/${sessionId}.wav`
},
}
const pending = recordAndStop(store)
await flush()
release()
const { order, completed } = await pending
expect(order).toEqual(['saved', 'completed'])
expect(completed[0].audioPath).toMatch(/^\/rec\/.+\.wav$/)
})
it('저장에 실패하면 audioPath 는 null 이다 (존재하지 않는 경로를 남기지 않음)', async () => {
const { completed } = await recordAndStop({ save: async () => null })
expect(completed).toEqual([{ audioPath: null }])
})
})
describe('fs 녹음 저장소', () => {
it('pcmToWav 와 같은 바이트로 recordings/<id>.wav 에 쓴다', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-rec-r3-1-'))
try {
const store = createFsRecordingStore({
resolveDir: async () => dir,
mkdir: (target) => fs.promises.mkdir(target, { recursive: true }),
writeFile: (target, data) => fs.promises.writeFile(target, data),
})
const pcm = Buffer.alloc(3200, 1)
const saved = await store.save('abc', pcm)
expect(saved).toBe(path.join(dir, 'abc.wav'))
expect(fs.readFileSync(saved as string).equals(pcmToWav(pcm, 16000, 1, 16))).toBe(true)
expect(await store.save('empty', Buffer.alloc(0))).toBeNull()
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
it('쓰기 실패는 예외 대신 null', async () => {
const store = createFsRecordingStore({
resolveDir: async () => '/nowhere',
mkdir: async () => undefined,
writeFile: async () => {
throw new Error('EACCES')
},
})
expect(await store.save('x', Buffer.alloc(10))).toBeNull()
})
})

View file

@ -0,0 +1,81 @@
// tests/main/sync/audio-sync-redteam-r3-1.test.ts
// 동기화 회귀 (r3-1):
// - 파일 전사 기록(audioLocalPath = 사용자가 고른 원본 미디어)은 '녹음 동기화'로 올리지 않는다
// - 대기 중인 원격 삭제를 뒤늦은 upsert(예: 자동 제목)가 덮어쓰지 않는다
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import { listLocalAudioOwners, localAudioPath } from '../../../src/main/services/sync/audio-sync'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
const USER = '11111111-1111-4111-8111-111111111111'
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
let tmpDir: string
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
remote = new FakeSyncRemote(USER)
engine = new SyncEngine({ remote, userId: USER })
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-audio-r3-1-'))
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
fs.rmSync(tmpDir, { recursive: true, force: true })
})
function insertHistory(mode: 'dictation' | 'file-transcription', fileName: string): string {
const id = crypto.randomUUID()
const file = path.join(tmpDir, fileName)
fs.writeFileSync(file, Buffer.from('RIFF....WAVEfmt fake media'))
const now = Date.now()
testDb.db
.insert(history)
.values({ id, originalText: 'spoken', duration: 2, mode, audioLocalPath: file, createdAt: now, updatedAt: now })
.run()
return id
}
describe('파일 전사 원본은 녹음으로 올리지 않는다 (item 19)', () => {
it('파일 전사 기록의 원본 미디어는 업로드 대상이 아니다', async () => {
const fileId = insertHistory('file-transcription', 'meeting-video.mp4')
const dictationId = insertHistory('dictation', 'dictation.wav')
expect(localAudioPath('history', fileId)).toBeNull()
expect(localAudioPath('history', dictationId)).not.toBeNull()
expect(listLocalAudioOwners('history')).toEqual([dictationId])
const result = await engine.runFullSync()
expect(result.errors).toEqual([])
expect(remote.objects.has(`audio/${USER}/desktop/history/${fileId}.mp4`)).toBe(false)
expect(remote.objects.has(`audio/${USER}/desktop/history/${dictationId}.wav`)).toBe(true)
})
})
describe('대기 중인 삭제는 뒤늦은 upsert 에 덮이지 않는다 (item 37)', () => {
it('delete 뒤에 같은 행의 upsert 가 와도 op 는 delete 로 남는다', () => {
const id = crypto.randomUUID()
enqueueChange('history', id, 'upsert')
enqueueChange('history', id, 'delete')
enqueueChange('history', id, 'upsert')
expect(pendingOps('history').get(id)).toBe('delete')
})
})

View file

@ -0,0 +1,264 @@
// tests/main/sync/cloud-credentials-redteam-r3-1.test.ts
// 로그아웃이 오프라인·5xx 로 서버 폐기에 실패해도(auth-js 가 { error } 를 돌려주고 _removeSession 을 건너뜀)
// 자격 증명 포트(Cloud STT·Premium·realtime-token)가 옛 계정 토큰을 더는 내주지 않는지 검증한다.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudcred-r3-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>),
clients: [] as Array<{ session: null | { access_token: string; user: { id: string } } }>,
}))
vi.mock('electron', () => ({
app: {
getPath: () => path.join(os.tmpdir(), `d3ro-cloudcred-r3-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: () => {
const clientState = { session: null as null | { access_token: string; user: { id: string } } }
spies.clients.push(clientState)
return {
auth: {
refreshSession: async (...args: unknown[]) => {
const result = (await spies.refreshSession!(...args)) as { data: { session: unknown } }
if (result.data.session) clientState.session = result.data.session as typeof clientState.session
return result
},
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: clientState.session } }),
stopAutoRefresh: vi.fn(async () => undefined),
},
from: () => query(),
channel: () => channel,
realtime: { setAuth: async () => undefined },
functions: {
invoke: vi.fn(async () => ({ data: { ok: true }, error: null })),
},
}
},
}
})
import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService'
import { cloudSyncCredentials, createCloudSttGateway } from '../../../src/main/services/cloud/cloud-credentials'
import { D3ROCloudDriver } from '../../../src/main/services/stt/drivers/D3ROCloudDriver'
function session() {
return { access_token: 'at-user-1', refresh_token: 'rt-new', user: { id: 'user-1', email: 'u@example.test' } }
}
describe('로그아웃 실패(retryable) 뒤 자격 증명 폐기', () => {
beforeEach(() => {
resetCloudSyncServiceForTests()
vi.clearAllMocks()
spies.clients.length = 0
fs.rmSync(USER_DATA, { recursive: true, force: true })
fs.mkdirSync(USER_DATA, { recursive: true })
fs.writeFileSync(TOKEN_FILE, JSON.stringify({ v: 1, rt: 'rt-1', uid: 'user-1' }))
h.state.currentUserId = 'user-1'
h.state.tierGate = null
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
// auth-js: 네트워크 실패면 throw 하지 않고 { error } 를 돌려주며 클라이언트 세션을 지우지 않는다
spies.signOut = vi.fn(async () => ({
error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' },
}))
})
afterEach(() => {
resetCloudSyncServiceForTests()
fs.rmSync(USER_DATA, { recursive: true, force: true })
})
it('로그인 중에는 토큰과 Edge Function 호출이 가능하다', async () => {
const sync = getCloudSyncService()
await sync.init()
expect(cloudSyncCredentials.hasCredentials()).toBe(true)
await expect(cloudSyncCredentials.accessToken()).resolves.toBe('at-user-1')
const result = await cloudSyncCredentials.invoke('realtime-token', {})
expect(result.error).toBeNull()
})
it('signOut 이 { error } 를 돌려줘도 접근자는 모두 null / no session 이다', async () => {
const sync = getCloudSyncService()
await sync.init()
// 옛 클라이언트는 여전히 세션을 들고 있다(auth-js 동작 재현)
expect(spies.clients[0].session).not.toBeNull()
await sync.signOut()
expect(sync.isAuthenticated()).toBe(false)
expect(cloudSyncCredentials.hasCredentials()).toBe(false)
await expect(sync.getAccessToken()).resolves.toBeNull()
await expect(cloudSyncCredentials.accessToken()).resolves.toBeNull()
const invoked = await cloudSyncCredentials.invoke('realtime-token', {})
expect(invoked.error?.message).toMatch(/session/i)
const streamed = await cloudSyncCredentials.invokeStream('llm-proxy', {})
expect(streamed.stream).toBeNull()
expect(streamed.error).not.toBeNull()
})
it('실패한 signOut 뒤 옛 클라이언트는 자동 갱신을 멈추고 새 클라이언트로 바뀐다', async () => {
const sync = getCloudSyncService()
await sync.init()
await sync.signOut()
expect(spies.clients).toHaveLength(2)
expect(spies.clients[1].session).toBeNull()
})
it('Cloud STT 드라이버는 로그아웃 뒤 업로드하지 않는다', async () => {
const sync = getCloudSyncService()
await sync.init()
await sync.signOut()
const fetchSpy = vi.spyOn(globalThis, 'fetch')
const driver = new D3ROCloudDriver({
...createCloudSttGateway(),
getSupabaseUrl: () => 'https://example.supabase.co',
getAnonKey: () => 'anon',
})
await expect(driver.transcribe(Buffer.alloc(3200))).rejects.toThrow()
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})
})

View file

@ -0,0 +1,159 @@
// 레드팀 r3-2: 다른 기기에서 재색인한 지식 문서가 이미 문서를 가진 데스크톱에도 반영된다.
// - 원문이 바뀌었으면 청크를 교체하고(옛 임베딩 제거) 다시 색인한다
// - 원문이 같으면(자기 push의 에코 등) 로컬 청크·임베딩을 건드리지 않는다
// - 재업로드가 중간이면 잘린 청크로 덮지 않고 미뤘다가 완성본을 반영한다
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { ragChunks, ragDocuments } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { planRemoteDocument, sameChunks, usableChunks } from '../../../src/main/services/rag/remote-document'
const USER = '11111111-1111-4111-8111-111111111111'
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetRAGServiceForTests()
getCustomInstructionService().initialize()
// 임베딩 서버(Ollama)는 없다 — 원문만 저장되는 경로를 탄다.
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
remote = new FakeSyncRemote(USER)
engine = new SyncEngine({ remote, userId: USER })
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
unbindTestDatabase()
resetInMemoryConfig()
resetRAGServiceForTests()
testDb.close()
})
function localChunks(id: string): Array<{ content: string; embedding: string }> {
return testDb.db
.select()
.from(ragChunks)
.where(eq(ragChunks.documentId, id))
.all()
.sort((a, b) => a.chunkIndex - b.chunkIndex)
.map((c) => ({ content: c.content, embedding: c.embedding }))
}
function markEmbedded(id: string): void {
testDb.db.update(ragChunks).set({ embedding: '[1,2]' }).where(eq(ragChunks.documentId, id)).run()
testDb.db.update(ragDocuments).set({ indexed: true, indexedAt: 1 }).where(eq(ragDocuments.id, id)).run()
}
/** 다른 데스크톱(A)의 재색인 push를 흉내: 행 upsert(updated_at 이동) → 청크 전부 교체 */
function remoteReindex(id: string, chunks: string[], uploaded = chunks.length): void {
remote.mobileUpdate('knowledge_documents', id, { chunk_count: chunks.length })
const rows = remote.rows('knowledge_chunks')
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].document_id === id) rows.splice(i, 1)
chunks.slice(0, uploaded).forEach((content, chunk_index) =>
rows.push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
)
}
function uploadRest(id: string, chunks: string[], from: number): void {
chunks.slice(from).forEach((content, offset) =>
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: from + offset, content })
)
}
async function seedSyncedDocument(chunks: string[]): Promise<string> {
const id = crypto.randomUUID()
remote.mobileInsert('knowledge_documents', { id, title: 'Doc', file_name: 'doc.md', file_type: 'md', chunk_count: chunks.length })
chunks.forEach((content, chunk_index) =>
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
)
await engine.runFullSync()
expect(localChunks(id).map((c) => c.content)).toEqual(chunks)
// 받은 직후 시작된 색인 시도(임베딩 서버 없음)가 끝난 뒤에 "이미 색인됨" 상태를 만든다
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0))
markEmbedded(id)
return id
}
describe('재색인된 지식 문서 동기화', () => {
it('다른 기기에서 원문이 바뀌면 이미 있는 문서의 청크를 교체하고 옛 임베딩을 버린다', async () => {
const id = await seedSyncedDocument(['old intro', 'old body'])
remoteReindex(id, ['new intro', 'new body', 'new appendix'])
await engine.pull()
const chunks = localChunks(id)
expect(chunks.map((c) => c.content)).toEqual(['new intro', 'new body', 'new appendix'])
// 옛 벡터가 새 원문에 붙어 검색되지 않는다(임베딩 서버가 없어 아직 비어 있다)
expect(chunks.every((c) => c.embedding === '')).toBe(true)
const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
expect(doc?.chunkCount).toBe(3)
expect(doc?.indexed).toBe(false)
})
it('원문이 같으면(에코·메타데이터만 변경) 로컬 청크와 임베딩을 그대로 둔다', async () => {
const id = await seedSyncedDocument(['same a', 'same b'])
remote.mobileUpdate('knowledge_documents', id, { indexed: true })
await engine.pull()
expect(localChunks(id)).toEqual([
{ content: 'same a', embedding: '[1,2]' },
{ content: 'same b', embedding: '[1,2]' },
])
expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()?.indexed).toBe(true)
})
it('재업로드가 중간이면 잘린 청크로 덮지 않고, 완성되면 반영한다', async () => {
const id = await seedSyncedDocument(['v1 a', 'v1 b'])
const next = ['v2 a', 'v2 b', 'v2 c']
remoteReindex(id, next, 1)
await engine.pull()
expect(localChunks(id).map((c) => c.content)).toEqual(['v1 a', 'v1 b'])
uploadRest(id, next, 1)
await engine.pull()
expect(localChunks(id).map((c) => c.content)).toEqual(next)
})
})
describe('planRemoteDocument', () => {
it('로컬에 없으면 insert, 같으면 skip, 다르면 replace', () => {
expect(planRemoteDocument(null, ['a', ' ', 'b'])).toEqual({ kind: 'insert', chunks: ['a', 'b'] })
expect(planRemoteDocument(['a', 'b'], ['a', 'b'])).toEqual({ kind: 'skip' })
expect(planRemoteDocument(['a', 'b'], ['a', 'c'])).toEqual({ kind: 'replace', chunks: ['a', 'c'] })
expect(planRemoteDocument(['a'], ['a', 'b'])).toEqual({ kind: 'replace', chunks: ['a', 'b'] })
})
it('받은 청크가 전부 비어 있으면 로컬을 비우지 않는다', () => {
expect(planRemoteDocument(['a'], ['', ' '])).toEqual({ kind: 'skip' })
expect(planRemoteDocument(null, [])).toEqual({ kind: 'skip' })
})
it('빈 청크는 비교에서 빠진다', () => {
expect(usableChunks(['a', '', ' b '])).toEqual(['a', ' b '])
expect(planRemoteDocument(['a', ''], ['a'])).toEqual({ kind: 'skip' })
expect(sameChunks(['a'], ['a', 'b'])).toBe(false)
})
})

View file

@ -0,0 +1,269 @@
// 레드팀 r3-0: push 전 tombstone 읽기 실패 시 upsert 보류(fail closed),
// 서버에 없는 활성 명령이 설정 항목을 끝없는 재시도에 묶어 설정 pull을 막던 문제.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history } from '../../../src/main/db/schema'
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
import {
SyncRemoteError,
type RemotePageRequest,
type RemoteRow,
} from '../../../src/main/services/sync/sync-types'
import { pushableEntries } from '../../../src/main/services/sync/push-gate-policy'
import {
isActiveInstructionMissing,
planActiveInstructionStep,
shouldResyncSettingsAfterInstructionPush,
} from '../../../src/main/services/sync/active-instruction-push-policy'
import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port'
const USER = '11111111-1111-4111-8111-111111111111'
const U = '22222222-2222-4222-8222-222222222222'
/**
* 운영 서버처럼 INSERT 때 updated_at을 서버 시각으로 새로 찍고(0037 stamp_sync_timestamp_v1),
* sync_tombstones 읽기를 지정한 횟수만큼 일시 오류로 실패시키는 원격.
*/
class ServerStampingRemote extends FakeSyncRemote {
failTombstoneReads = 0
override async fetchPage(request: RemotePageRequest): Promise<RemoteRow[]> {
if (request.table === 'sync_tombstones' && this.failTombstoneReads > 0) {
this.failTombstoneReads--
throw new SyncRemoteError('canceling statement due to statement timeout', '57014', true)
}
return super.fetchPage(request)
}
override async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise<void> {
const existing = new Set(this.rows(table).map((r) => String(r.id)))
await super.upsert(table, rows, onConflict)
for (const row of this.rows(table)) {
if (!existing.has(String(row.id))) row.updated_at = this.now()
}
}
}
const offlineEmbedder: EmbeddingPort = {
model: 'test-embed',
ensureModel: () => Promise.reject(new Error('no embedding server in tests')),
embed: () => Promise.reject(new Error('no embedding server in tests')),
}
let testDb: ReturnType<typeof createTestDb>
let remote: ServerStampingRemote
let engine: SyncEngine
let clock: number
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 })
getCustomInstructionService().initialize()
remote = new ServerStampingRemote(USER)
clock = Date.parse('2026-09-28T00:00:00.000Z')
engine = new SyncEngine({ remote, userId: USER, now: () => clock })
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
resetRAGServiceForTests()
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
})
function historyRow(id: string, text: string): Record<string, unknown> {
return { id, original_text: text, duration: 1, mode: 'dictation', status: 'completed' }
}
function localHistoryIds(): string[] {
return testDb.db.select({ id: history.id }).from(history).all().map((r) => r.id).sort()
}
describe('push 전 tombstone 읽기 실패 → upsert 보류(fail closed)', () => {
it('폰에서 지운 행의 대기 upsert가 서버에 행을 되살리지 않고, 다음 pull에서 삭제가 반영된다', async () => {
const x = crypto.randomUUID()
remote.mobileInsert('history', historyRow(x, 'will be deleted on phone'))
await engine.runFullSync()
expect(localHistoryIds()).toEqual([x])
// 데스크톱의 대기 편집(즐겨찾기 등) + 폰의 삭제(T1)
enqueueChange('history', x, 'upsert', clock)
remote.mobileDelete('history', x)
remote.failTombstoneReads = 1
const flushed = await engine.flush()
expect(flushed.errors.some((e) => e.startsWith('tombstones:'))).toBe(true)
expect(flushed.pushed).toBe(0)
expect(remote.find('history', x)).toBeUndefined()
expect(pendingOps('history').get(x)).toBe('upsert')
await engine.pull()
expect(localHistoryIds()).toEqual([])
expect(pendingOps('history').has(x)).toBe(false)
expect(remote.find('history', x)).toBeUndefined()
})
it('tombstone 읽기가 실패해도 삭제는 보낸다', async () => {
const x = crypto.randomUUID()
const y = crypto.randomUUID()
remote.mobileInsert('history', historyRow(x, 'edit me'))
remote.mobileInsert('history', historyRow(y, 'delete me'))
await engine.runFullSync()
enqueueChange('history', x, 'upsert', clock)
testDb.db.delete(history).where(eq(history.id, y)).run()
enqueueChange('history', y, 'delete', clock)
remote.failTombstoneReads = 1
const flushed = await engine.flush()
expect(remote.find('history', y)).toBeUndefined()
expect(pendingOps('history').has(y)).toBe(false)
expect(pendingOps('history').get(x)).toBe('upsert')
expect(flushed.pushed).toBe(1)
})
it('runFullSync에서 tombstone 읽기가 실패하면 최초 대조·upsert를 미루고 다음 flush에서 이어 간다', async () => {
const local = crypto.randomUUID()
const at = clock
testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run()
remote.failTombstoneReads = 1
await engine.runFullSync()
expect(remote.find('history', local)).toBeUndefined()
await engine.flush()
expect(remote.find('history', local)?.original_text).toBe('offline note')
})
})
describe('서버에 없는 활성 명령과 설정 동기화', () => {
function rejectInstructionUploads(error: SyncRemoteError | null): void {
remote.rejectRow = (table) => (table === 'custom_instructions' ? error : null)
}
function seedRemoteSettings(): void {
remote.mobileInsert('user_settings', {
locale: 'ko',
theme_mode: 'system',
auto_polish_enabled: true,
revision: 1,
active_instruction_id: null,
})
}
it('올라가지 못한(보관된) 명령이 활성이어도 설정 항목은 완료되고, 폰의 설정 변경을 계속 받는다', async () => {
seedRemoteSettings()
const command = getCustomInstructionService().create({ name: 'Local only', description: '', prompt: 'Keep it local' })
rejectInstructionUploads(new SyncRemoteError('value too long for type character varying(4000)', '22001', false))
await engine.runFullSync()
expect(pendingOps('custom_instructions').get(command.id)).toBe('upsert')
// 서버에 없는 명령을 활성으로 고르고 테마를 바꾼다(CloudSyncService가 설정 항목을 넣는다)
configSet('activeInstructionId', command.id)
configSet('theme', 'dark')
enqueueChange('user_settings', 'self', 'upsert', clock)
const flushed = await engine.flush()
expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([])
expect(pendingOps('user_settings').has('self')).toBe(false)
expect(remote.rows('user_settings')[0].theme_mode).toBe('dark')
expect(configGet('activeInstructionId')).toBe(command.id)
expect(remote.calls).not.toContain('rpc:set_active_custom_instruction')
const settings = remote.rows('user_settings')[0]
Object.assign(settings, { theme_mode: 'light', revision: Number(settings.revision) + 1, updated_at: remote.now() })
await engine.pull()
expect(configGet('theme')).toBe('light')
})
it('outbox에 없는데 서버가 모르는 명령(P0002)은 설정 항목을 재시도에 묶지 않는다', async () => {
seedRemoteSettings()
const command = getCustomInstructionService().create({ name: 'Gone', description: '', prompt: 'Vanished on server' })
await engine.runFullSync()
// 서버에서 tombstone 없이 사라진 경우(보존 기간 정리 등)
const rows = remote.rows('custom_instructions')
rows.splice(rows.findIndex((r) => r.id === command.id), 1)
configSet('activeInstructionId', command.id)
enqueueChange('user_settings', 'self', 'upsert', clock)
const flushed = await engine.flush()
expect(remote.calls).toContain('rpc:set_active_custom_instruction')
expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([])
expect(pendingOps('user_settings').has('self')).toBe(false)
})
it('미룬 활성 명령이 나중에 서버에 올라가면 설정을 다시 올려 서버 활성 명령을 맞춘다', async () => {
seedRemoteSettings()
const command = getCustomInstructionService().create({ name: 'Late', description: '', prompt: 'Arrives later' })
rejectInstructionUploads(new SyncRemoteError('canceling statement due to statement timeout', '57014', true))
await engine.runFullSync()
configSet('activeInstructionId', command.id)
enqueueChange('user_settings', 'self', 'upsert', clock)
await engine.flush()
expect(pendingOps('user_settings').has('self')).toBe(false)
expect(remote.rows('user_settings')[0].active_instruction_id).toBeNull()
expect(remote.calls).not.toContain('rpc:set_active_custom_instruction')
rejectInstructionUploads(null)
clock += 60 * 60_000
await engine.flush()
expect(remote.find('custom_instructions', command.id)).toBeDefined()
expect(pendingOps('user_settings').has('self')).toBe(true)
await engine.flush()
expect(remote.rows('user_settings')[0].active_instruction_id).toBe(command.id)
expect(pendingOps('user_settings').has('self')).toBe(false)
})
})
describe('push-gate-policy', () => {
const entries = [
{ op: 'upsert' as const, rowId: 'a' },
{ op: 'delete' as const, rowId: 'b' },
]
it('tombstone을 반영했으면 전부, 실패했으면 삭제만 push한다', () => {
expect(pushableEntries(entries, { tombstonesSynced: true })).toEqual(entries)
expect(pushableEntries(entries, { tombstonesSynced: false })).toEqual([{ op: 'delete', rowId: 'b' }])
})
})
describe('active-instruction-push-policy', () => {
it('서버에 아직 없는 명령은 미루고, 나머지는 RPC로 보낸다', () => {
expect(planActiveInstructionStep(null, new Set([U]))).toEqual({ kind: 'none' })
expect(planActiveInstructionStep({ instructionId: U }, new Set([U]))).toEqual({ kind: 'defer', instructionId: U })
expect(planActiveInstructionStep({ instructionId: U }, new Set())).toEqual({ kind: 'rpc', instructionId: U })
expect(planActiveInstructionStep({ instructionId: null }, new Set([U]))).toEqual({ kind: 'rpc', instructionId: null })
})
it('P0002만 "서버에 명령 없음"으로 본다', () => {
expect(isActiveInstructionMissing(new SyncRemoteError('instruction_not_found', 'P0002', true))).toBe(true)
expect(isActiveInstructionMissing(new SyncRemoteError('fetch failed', 'network', true))).toBe(false)
})
it('미룬 명령이 올라갔을 때만 설정을 다시 올린다', () => {
expect(shouldResyncSettingsAfterInstructionPush(U, [U])).toBe(true)
expect(shouldResyncSettingsAfterInstructionPush(U, ['other'])).toBe(false)
expect(shouldResyncSettingsAfterInstructionPush('', [U])).toBe(false)
expect(shouldResyncSettingsAfterInstructionPush(null, [U])).toBe(false)
})
})

View file

@ -0,0 +1,83 @@
// tests/main/update-policy-redteam-r3-5.test.ts
// 원격 정책의 안전 필드(killSwitch, stagingPercentage)가 형식이 틀리면 가장 허용적인 값이
// 아니라 가장 보수적인 값으로 읽히는지 확인한다.
//
// 회귀: `"killSwitch": "true"` 가 false 로, `"stagingPercentage": "5"` / 5.5 가 100% 로
// 조용히 바뀌어 운영자 의도와 반대로 전체 배포되던 문제.
import { describe, it, expect } from 'vitest'
import {
DEFAULT_UPDATE_POLICY,
evaluateUpdateOffer,
parseUpdatePolicy,
} from '../../src/main/update-policy'
const BASE = {
schemaVersion: 1,
defaultChannel: 'latest',
channels: {
latest: { allowPrerelease: false },
beta: { allowPrerelease: true },
alpha: { allowPrerelease: true },
},
minimumSupportedVersion: '1.0.0',
forceInstallBelow: null,
fullInstallOnMajorChange: true,
fullInstallVersionGap: 3,
stagingPercentage: 100,
killSwitch: false,
}
describe('parseUpdatePolicy — 안전 필드 fail-closed', () => {
it.each(['true', 'false', 1, 0, null, {}])('killSwitch=%j 는 켜진 것으로 본다', (value) => {
expect(parseUpdatePolicy({ ...BASE, killSwitch: value }).killSwitch).toBe(true)
})
it.each([5.5, '5', null, Number.NaN, '100'])('stagingPercentage=%j 는 0% 로 본다', (value) => {
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: value }).stagingPercentage).toBe(0)
})
it('형식이 틀린 킬 스위치는 업데이트 제안을 막는다', () => {
const policy = parseUpdatePolicy({ ...BASE, killSwitch: 'true' })
const offer = evaluateUpdateOffer({
policy,
channel: 'latest',
currentVersion: '1.8.0',
targetVersion: '1.9.0',
skippedVersion: null,
deviceId: 'device-a',
})
expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' })
})
it('형식이 틀린 staging 은 필수 업데이트가 아니면 아무에게도 노출하지 않는다', () => {
const policy = parseUpdatePolicy({ ...BASE, stagingPercentage: '5' })
for (const deviceId of ['a', 'b', 'c', 'd', 'e', 'f']) {
const offer = evaluateUpdateOffer({
policy,
channel: 'latest',
currentVersion: '1.8.0',
targetVersion: '1.9.0',
skippedVersion: null,
deviceId,
})
expect(offer).toMatchObject({ action: 'ignore', reason: 'rollout' })
}
})
it('필드가 아예 없으면 기존처럼 기본값을 쓴다', () => {
const { killSwitch: _k, stagingPercentage: _s, ...rest } = BASE
const parsed = parseUpdatePolicy(rest)
expect(parsed.killSwitch).toBe(DEFAULT_UPDATE_POLICY.killSwitch)
expect(parsed.stagingPercentage).toBe(DEFAULT_UPDATE_POLICY.stagingPercentage)
})
it('올바른 값은 그대로(범위 밖 정수는 기존처럼 잘라서) 읽는다', () => {
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 5, killSwitch: false })).toMatchObject({
stagingPercentage: 5,
killSwitch: false,
})
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 250 }).stagingPercentage).toBe(100)
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: -3 }).stagingPercentage).toBe(0)
})
})

View file

@ -83,6 +83,7 @@ import { getMeetingModeService } from '../../src/main/services/MeetingModeServic
import { configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
import { syncStateRecordingLedger } from '../../src/main/services/meeting/local-recording-ledger'
function row(id: string) {
return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get()
@ -163,7 +164,10 @@ describe('앱 종료·중단 복구', () => {
it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => {
insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' })
// 전사가 없는 행은 이 기기에서 시작한 녹음(원장 표식)일 때만 닫는다 — redteam r3: 폰의 진행 중 회의 보호
insertSession('stuck-empty', { status: 'processing' })
syncStateRecordingLedger.mark('stuck-empty')
insertSession('phone-live', { status: 'recording' })
insertSession('done', { status: 'completed', rawTranscript: 'x' })
expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2)
@ -171,6 +175,8 @@ describe('앱 종료·중단 복구', () => {
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('phone-live')?.status).toBe('recording')
expect(row('done')?.status).toBe('completed')
})
})

View file

@ -0,0 +1,90 @@
// 받아쓰기 템플릿 필드 id 회귀 테스트 (redteam r3-7)
// 버그: 편집기가 필드 개수로 id를 만들고(중간 삭제 후 재사용) 이름을 그대로 id로 써서
// 빈 id·중복 id 템플릿이 저장됐다. 세션에서 같은 id 필드의 받아쓴 값이 앞 값을 덮어써
// 출력({{id}})에서 앞 값이 조용히 사라졌다. 이제 서비스가 저장 전에 TemplateInvalidFormat 으로 거부한다.
import { describe, expect, it } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { TemplateField } from '@d3ro/core/types'
import { getDictationTemplateService } from '../../src/main/services/DictationTemplateService'
import { useRedHarness } from './harness'
useRedHarness()
function field(id: string): TemplateField {
return { id, name: id, label: id, promptText: '', required: true, maxDurationSec: 30 }
}
function expectInvalidFormat(run: () => unknown): void {
let caught: unknown
try {
run()
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(D3ROError)
expect((caught as D3ROError).code).toBe(ErrorCode.TemplateInvalidFormat)
}
describe('받아쓰기 템플릿 필드 id 검증', () => {
it('중복 필드 id 템플릿 생성을 거부하고 저장하지 않는다', () => {
const svc = getDictationTemplateService()
const before = svc.getAll().length
// 편집기 시나리오: [field1, field2, field3] → field1 삭제 → 추가 → [field2, field3, field3]
expectInvalidFormat(() =>
svc.create({
name: 'dup',
description: '',
fields: [field('field2'), field('field3'), field('field3')],
outputFormat: '{{field2}} {{field3}}',
}),
)
expect(svc.getAll()).toHaveLength(before)
})
it('빈/공백 필드 id 템플릿 생성을 거부한다', () => {
const svc = getDictationTemplateService()
expectInvalidFormat(() =>
svc.create({ name: 'empty', description: '', fields: [field('a'), field('')], outputFormat: '{{a}}' }),
)
expectInvalidFormat(() =>
svc.create({ name: 'blank', description: '', fields: [field(' ')], outputFormat: '' }),
)
})
it('수정으로 중복 필드 id를 넣으면 거부하고 기존 필드를 유지한다', () => {
const svc = getDictationTemplateService()
const created = svc.create({
name: 'ok',
description: '',
fields: [field('a'), field('b')],
outputFormat: '{{a}} {{b}}',
})
expectInvalidFormat(() => svc.update({ id: created.id, fields: [field('a'), field('a')] }))
expect(svc.getById(created.id)?.fields.map((f) => f.id)).toEqual(['a', 'b'])
})
it('필드를 바꾸지 않는 수정과 유효한 필드 수정은 그대로 된다', () => {
const svc = getDictationTemplateService()
const created = svc.create({ name: 'ok', description: '', fields: [field('a')], outputFormat: '{{a}}' })
expect(svc.update({ id: created.id, name: 'renamed' }).name).toBe('renamed')
expect(svc.update({ id: created.id, fields: [field('a'), field('b')] }).fields).toHaveLength(2)
})
it('유효한 템플릿은 필드마다 받아쓴 값이 모두 출력에 남는다', () => {
const svc = getDictationTemplateService()
const created = svc.create({
name: 'flow',
description: '',
fields: [field('field2'), field('field3'), field('field4')],
outputFormat: '{{field2}}|{{field3}}|{{field4}}',
})
const outputs: string[] = []
svc.on('session-completed', (e: { outputText: string }) => outputs.push(e.outputText))
svc.startSession(created.id)
svc.consumeDictatedText('one')
svc.consumeDictatedText('two')
svc.consumeDictatedText('three')
expect(outputs).toEqual(['one|two|three'])
})
})

View file

@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import {
normalizeErrorCode,
offersForceRestart,
presentError,
settingsTabOf,
} from '../../src/renderer/navigation/error-recovery'
import { buildMeetingErrorInfo, forceRestartLabel } from '../../src/renderer/pages/meeting/meeting-error'
import { canDismissOnboarding } from '../../src/renderer/components/onboarding-dismiss'
// 회귀:
// 1) voice:error 101(STT 모델 없음)이 닫을 수 없는 온보딩 모달을 열어 메인 창을 가뒀다.
// 2) 회의 후처리 실패(882)가 '회의를 시작할 수 없습니다'로 보이고, 주 버튼이 새 녹음을 강제로 시작했다.
// 3) 회의 오류 분류가 ErrorCode 와 맞지 않았다(1400=충돌, 200-220=마이크).
describe('presentError — voice', () => {
it('101 은 온보딩이 아니라 설정 → STT 탭 딥링크로 복구한다', () => {
const p = presentError({ context: 'voice', code: ErrorCode.STTModelNotFound })
expect(p.category).toBe('sttModelMissing')
expect(p.messageKey).toBe('voice.error.modelMissing')
expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }])
expect(settingsTabOf(p)).toBe('stt')
})
it.each([103, 130, 131, 132])('%i 은 엔진 오류 문구를 유지하고 복구 동작은 없다', (code) => {
const p = presentError({ context: 'voice', code })
expect(p.category).toBe('sttEngine')
expect(p.messageKey).toBe('voice.error.engine')
expect(p.recoveries).toEqual([])
})
it('300 경고는 llmSkipped 문구, 300 오류는 원문 그대로', () => {
expect(presentError({ context: 'voice', code: 300, severity: 'warning' }).messageKey).toBe('voice.warning.llmSkipped')
const asError = presentError({ context: 'voice', code: 300, severity: 'error' })
expect(asError.category).toBe('general')
expect(asError.messageKey).toBeNull()
})
it('모르는 코드는 원문을 쓰고 설정 딥링크가 없다', () => {
const p = presentError({ context: 'voice', code: 999 })
expect(p.messageKey).toBeNull()
expect(settingsTabOf(p)).toBeNull()
})
})
describe('presentError — meetingProcessing', () => {
it('882 는 후처리 실패이고 강제 재시작(새 녹음)을 절대 제시하지 않는다', () => {
const p = presentError({ context: 'meetingProcessing', code: ErrorCode.MeetingProcessingFailed, message: '후처리 실패: SQLITE_FULL' })
expect(p.category).toBe('processing')
expect(offersForceRestart(p)).toBe(false)
})
it('후처리 문구에 model/already 같은 키워드가 있어도 processing 이다', () => {
for (const message of ['후처리 실패: model busy', '후처리 실패: already locked', '후처리 실패: audio']) {
const p = presentError({ context: 'meetingProcessing', code: 882, message })
expect(p.category).toBe('processing')
expect(offersForceRestart(p)).toBe(false)
}
})
})
describe('presentError — meetingStart', () => {
it.each([ErrorCode.MeetingAlreadyRecording, ErrorCode.CaptionAlreadyActive])('%i 는 충돌 → 강제 재시작', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('collision')
expect(p.recoveries).toEqual([{ kind: 'forceRestart' }])
})
it('1400 은 ErrorCode 가 아니므로 충돌로 보지 않는다', () => {
expect(presentError({ context: 'meetingStart', code: 1400, message: 'x' }).category).toBe('general')
})
it.each([101, 103, 110, 130, 132, 140])('STT 코드 %i 는 STT 설정 + 재시도', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('stt')
expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }, { kind: 'forceRestart' }])
})
it.each([400, 401, 410, 420, 431])('오디오 코드 %i 는 오디오 설정 + 재시도', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('audio')
expect(settingsTabOf(p)).toBe('audio')
})
it.each([200, 210, 220])('TTS 코드 %i 는 마이크 오류가 아니다', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('general')
expect(settingsTabOf(p)).toBeNull()
})
it('시작 경로에 882 가 와도 강제 재시작을 주지 않는다', () => {
expect(offersForceRestart(presentError({ context: 'meetingStart', code: 882 }))).toBe(false)
})
it('코드가 없는 catch 경로는 문구 키워드로 폴백한다', () => {
expect(presentError({ context: 'meetingStart', message: 'Caption already active' }).category).toBe('collision')
expect(presentError({ context: 'meetingStart', message: 'whisper sidecar died' }).category).toBe('stt')
expect(presentError({ context: 'meetingStart', message: 'mic not found' }).category).toBe('audio')
expect(presentError({ context: 'meetingStart', message: 'ipc timeout' }).category).toBe('general')
})
it('숫자 문자열 코드도 정규화한다', () => {
expect(normalizeErrorCode('880')).toBe(880)
expect(normalizeErrorCode('STT_UNAVAILABLE')).toBeNull()
expect(normalizeErrorCode(undefined)).toBeNull()
expect(presentError({ context: 'meetingStart', code: '880' }).category).toBe('collision')
})
})
describe('buildMeetingErrorInfo', () => {
it('후처리 실패는 시작 실패 제목이 아니고 복구 버튼이 없다', () => {
const info = buildMeetingErrorInfo('meetingProcessing', '후처리 실패: disk full', 882)
expect(info.title).not.toBe('회의를 시작할 수 없습니다')
expect(info.category).toBe('processing')
expect(info.recoveries).toEqual([])
expect(info.code).toBe(882)
expect(info.message).toBe('후처리 실패: disk full')
})
it('충돌 라벨과 일반 라벨이 다르다', () => {
expect(forceRestartLabel('collision')).not.toBe(forceRestartLabel('general'))
})
it('코드·문구가 없으면 카테고리 기본값을 쓴다', () => {
const info = buildMeetingErrorInfo('meetingStart')
expect(info.category).toBe('general')
expect(info.code).toBe('MEETING_START_ERROR')
expect(info.message.length).toBeGreaterThan(0)
})
})
describe('canDismissOnboarding', () => {
it('첫 실행(미완료)은 success 전까지 닫을 수 없다', () => {
expect(canDismissOnboarding('select_mode', false)).toBe(false)
expect(canDismissOnboarding('local_ollama_setup', false)).toBe(false)
expect(canDismissOnboarding('success', false)).toBe(true)
})
it('이미 온보딩을 마친 사용자는 어느 단계에서든 닫을 수 있다', () => {
for (const phase of ['select_mode', 'local_ollama_setup', 'online_auth', 'failed'] as const) {
expect(canDismissOnboarding(phase, true)).toBe(true)
}
})
})

View file

@ -0,0 +1,147 @@
import fs from 'fs'
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MouseButton, VK } from '@d3ro/core/keybinding'
type EffectFn = () => void | (() => void)
const effects: EffectFn[] = []
vi.mock('react', () => ({
useEffect: (fn: EffectFn) => {
effects.push(fn)
},
}))
const {
KeyBindingCaptureSession,
resolveCapturePort,
useKeyBindingCaptureSuspension,
} = await import('../../src/renderer/components/keybinding/keybinding-capture')
const { BROWSER_BUTTON_TO_MOUSE, NO_MODIFIERS, buildBinding, resolveKeyCode } = await import(
'../../src/renderer/components/keybinding/keybinding-recording'
)
const PICKER = path.resolve(
__dirname,
'../../src/renderer/components/keybinding/KeyBindingPicker.tsx'
)
function fakePort() {
const calls: string[] = []
return {
calls,
port: {
beginCapture: vi.fn(async () => {
calls.push('begin')
}),
endCapture: vi.fn(async () => {
calls.push('end')
}),
},
}
}
describe('KeyBindingPicker — 녹화 중 전역 트리거 중단 (r3-23)', () => {
beforeEach(() => {
effects.length = 0
})
it('피커가 열려 있는 동안 캡처 억제 훅을 open 으로 건다', () => {
const source = fs.readFileSync(PICKER, 'utf8')
expect(source).toContain('useKeyBindingCaptureSuspension(open)')
// 전역 on/off 는 hotkeyEnabled 를 저장하므로 캡처에 쓰면 안 된다.
expect(source).not.toMatch(/keybinding\s*\.\s*setEnabled/)
})
it('열리면 beginCapture, 닫히거나 언마운트되면 endCapture 를 한 번씩 보낸다', () => {
const { port, calls } = fakePort()
useKeyBindingCaptureSuspension(true, () => port)
expect(effects).toHaveLength(1)
const cleanup = effects[0]()
expect(calls).toEqual(['begin'])
expect(typeof cleanup).toBe('function')
if (typeof cleanup === 'function') cleanup()
expect(calls).toEqual(['begin', 'end'])
})
it('닫혀 있으면 아무것도 보내지 않는다', () => {
const { port, calls } = fakePort()
useKeyBindingCaptureSuspension(false, () => port)
const cleanup = effects[0]()
expect(cleanup).toBeUndefined()
expect(calls).toEqual([])
})
it('세션은 begin/end 를 중복 전송하지 않고 begin 없이 end 를 보내지 않는다', () => {
const { port, calls } = fakePort()
const session = new KeyBindingCaptureSession(port)
session.end()
session.begin()
session.begin()
expect(session.isActive).toBe(true)
session.end()
session.end()
expect(session.isActive).toBe(false)
expect(calls).toEqual(['begin', 'end'])
})
it('IPC 실패는 녹화를 막지 않는다', async () => {
const session = new KeyBindingCaptureSession({
beginCapture: () => Promise.reject(new Error('no handler')),
endCapture: () => Promise.reject(new Error('no handler')),
})
expect(() => {
session.begin()
session.end()
}).not.toThrow()
await Promise.resolve()
})
it('포트가 없으면(null) 조용히 아무것도 하지 않는다', () => {
const session = new KeyBindingCaptureSession(null)
session.begin()
expect(session.isActive).toBe(false)
})
it('preload API 구조로 포트를 판별한다', () => {
expect(resolveCapturePort(undefined)).toBeNull()
expect(resolveCapturePort({ setEnabled: () => undefined })).toBeNull()
expect(resolveCapturePort({ beginCapture: () => undefined })).toBeNull()
const begin = vi.fn(() => Promise.resolve({ success: true }))
const end = vi.fn(() => Promise.resolve({ success: true }))
const port = resolveCapturePort({ beginCapture: begin, endCapture: end })
expect(port).not.toBeNull()
void port?.beginCapture()
void port?.endCapture()
expect(begin).toHaveBeenCalledTimes(1)
expect(end).toHaveBeenCalledTimes(1)
})
})
describe('keybinding-recording — 추출된 녹화 규칙 (동작 보존)', () => {
it('좌/우 수정자를 location 으로 구분한다', () => {
expect(resolveKeyCode({ keyCode: 17, which: 17, location: 1 })).toBe(VK.CtrlLeft)
expect(resolveKeyCode({ keyCode: 17, which: 17, location: 2 })).toBe(VK.CtrlRight)
expect(resolveKeyCode({ keyCode: 16, which: 16, location: 2 })).toBe(VK.ShiftRight)
expect(resolveKeyCode({ keyCode: 18, which: 18, location: 1 })).toBe(VK.AltLeft)
expect(resolveKeyCode({ keyCode: 93, which: 93, location: 2 })).toBe(VK.MetaRight)
expect(resolveKeyCode({ keyCode: 93, which: 93, location: 0 })).toBe(93)
expect(resolveKeyCode({ keyCode: 0, which: 65, location: 0 })).toBe(65)
})
it('주 키는 비수정자 키, 없으면 마지막 수정자', () => {
expect(buildBinding([], NO_MODIFIERS)).toBeNull()
const combo = buildBinding([VK.CtrlLeft, 65], { ...NO_MODIFIERS, ctrl: true })
expect(combo).toMatchObject({ device: 'keyboard', code: 65, ctrl: true })
const mods = buildBinding([VK.CtrlLeft, VK.AltRight], { ...NO_MODIFIERS, ctrl: true })
expect(mods?.code).toBe(VK.AltRight)
})
it('브라우저 마우스 버튼을 uiohook 코드로 옮긴다', () => {
expect(BROWSER_BUTTON_TO_MOUSE[1]).toBe(MouseButton.Middle)
expect(BROWSER_BUTTON_TO_MOUSE[2]).toBe(MouseButton.Right)
expect(BROWSER_BUTTON_TO_MOUSE[3]).toBe(MouseButton.Back)
expect(BROWSER_BUTTON_TO_MOUSE[4]).toBe(MouseButton.Forward)
})
})

View file

@ -0,0 +1,129 @@
// tests/unit/stt-model-download-redteam-r3-24.test.ts
// STT 탭 모델 다운로드가 실패/취소로 끝나도 '다운로드 중 (x%)' 에 멈추지 않는지 회귀 검증.
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { ErrorCode, ipcError, ipcSuccess, type IPCResult } from '@d3ro/core/errors'
import {
classifyModelDownloadResult,
releaseIfCurrent,
runModelDownload,
type ModelDownloadView,
} from '../../src/renderer/components/stt/modelDownload'
/** STTTab 의 downloadingModelId / downloadError 상태를 흉내 내는 가짜 view */
function fakeView(): ModelDownloadView & {
downloadingModelId: string | null
error: { modelId: string; message: string } | null
events: string[]
} {
const state = {
downloadingModelId: null as string | null,
error: null as { modelId: string; message: string } | null,
events: [] as string[],
}
return Object.assign(state, {
started: (id: string) => {
state.events.push(`started:${id}`)
state.error = null
state.downloadingModelId = id
},
succeeded: (id: string) => {
state.events.push(`succeeded:${id}`)
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
},
cancelled: (id: string) => {
state.events.push(`cancelled:${id}`)
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
},
failed: (id: string, message: string) => {
state.events.push(`failed:${id}`)
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
state.error = { modelId: id, message }
},
})
}
describe('runModelDownload', () => {
it('ipcError(사이드카 기동 실패 등)면 진행 표시를 풀고 오류 메시지를 노출한다', async () => {
const view = fakeView()
const outcome = await runModelDownload(
'large-v3-turbo',
{ download: async () => ipcError<void>(ErrorCode.STTModelDownloadFailed, '다운로드 시작 실패 (HTTP 500): boom') },
view,
)
expect(outcome).toEqual({ kind: 'failed', message: '다운로드 시작 실패 (HTTP 500): boom' })
expect(view.downloadingModelId).toBeNull()
expect(view.error).toEqual({ modelId: 'large-v3-turbo', message: '다운로드 시작 실패 (HTTP 500): boom' })
})
it('취소 결과는 오류 없이 진행 표시만 푼다', async () => {
const view = fakeView()
const outcome = await runModelDownload(
'small',
{ download: async () => ipcError<void>(ErrorCode.STTModelDownloadCancelled, '모델 다운로드 취소: small') },
view,
)
expect(outcome.kind).toBe('cancelled')
expect(view.downloadingModelId).toBeNull()
expect(view.error).toBeNull()
})
it('invoke 자체가 reject 되어도 진행 표시를 푼다', async () => {
const view = fakeView()
const outcome = await runModelDownload(
'small',
{ download: async () => { throw new Error('ipc channel closed') } },
view,
)
expect(outcome).toEqual({ kind: 'failed', message: 'ipc channel closed' })
expect(view.downloadingModelId).toBeNull()
})
it('성공하면 succeeded 로 끝나고 오류가 없다', async () => {
const view = fakeView()
const outcome = await runModelDownload('small', { download: async () => ipcSuccess(undefined) }, view)
expect(outcome.kind).toBe('succeeded')
expect(view.events).toEqual(['started:small', 'succeeded:small'])
expect(view.downloadingModelId).toBeNull()
expect(view.error).toBeNull()
})
it('늦게 도착한 A 실패가 그 사이 시작한 B 의 진행 표시를 지우지 않는다', async () => {
const view = fakeView()
let failA: (r: IPCResult<void>) => void = () => undefined
const pendingA = runModelDownload(
'A',
{ download: () => new Promise<IPCResult<void>>((resolve) => { failA = resolve }) },
view,
)
view.started('B')
failA(ipcError<void>(ErrorCode.STTModelDownloadFailed, 'A failed'))
await pendingA
expect(view.downloadingModelId).toBe('B')
})
})
describe('classifyModelDownloadResult', () => {
it('실패 코드별 결말', () => {
expect(classifyModelDownloadResult(ipcSuccess(undefined))).toEqual({ kind: 'succeeded' })
expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadCancelled, 'x'))).toEqual({ kind: 'cancelled' })
expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadFailed, 'y'))).toEqual({
kind: 'failed',
message: 'y',
})
expect(classifyModelDownloadResult({ thrown: 'raw' })).toEqual({ kind: 'failed', message: 'raw' })
})
})
describe('STTTab wiring', () => {
it('handleDownloadModel 이 IPC 결과를 버리지 않고 runModelDownload 로 처리한다', () => {
const src = readFileSync(
join(__dirname, '../../src/renderer/components/STTTab.tsx'),
'utf8',
)
expect(src).toContain('await runModelDownload(')
expect(src).not.toMatch(/await window\.electronAPI\.stt\.downloadModel\(\{ modelId \}\)\s*\n\s*\}, \[\]\)/)
})
})

View file

@ -0,0 +1,319 @@
// tests/unit/system-audio-capture-redteam-r3-22.test.ts
// SystemAudioCaptureController: stop-during-start 취소, off/on 중복 획득 방지,
// getDisplayMedia 실패 시 loopback 해제를 fake port로 검증한다.
import { describe, it, expect, vi } from 'vitest'
import {
SystemAudioCaptureController,
type PcmPipeline,
type SystemAudioPort,
type SystemAudioStream,
} from '../../src/renderer/utils/systemAudioCaptureController'
import { float32ToPcm16 } from '../../src/renderer/utils/systemAudioCapture'
interface Deferred<T> {
promise: Promise<T>
resolve: (v: T) => void
reject: (e: unknown) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
class FakeStream implements SystemAudioStream {
stopped = false
videoDropped = false
constructor(readonly id: number, private readonly audio = true) {}
dropVideoTracks(): void {
this.videoDropped = true
}
hasAudioTrack(): boolean {
return this.audio
}
stop(): void {
this.stopped = true
}
}
class FakePipeline implements PcmPipeline {
closed = false
constructor(readonly stream: FakeStream, readonly onPcm: (b: ArrayBuffer) => void) {}
close(): void {
this.closed = true
}
}
class FakePort implements SystemAudioPort<FakeStream> {
loopbackEnabled = false
enableCalls = 0
disableCalls = 0
displayRequests: Deferred<FakeStream>[] = []
pipelines: FakePipeline[] = []
/** 루프백 핸들러가 꺼진 상태에서 getDisplayMedia 요청 → 실제 Electron처럼 실패 */
requestsWithoutLoopback = 0
async enableLoopback(): Promise<void> {
this.enableCalls++
this.loopbackEnabled = true
}
async disableLoopback(): Promise<void> {
this.disableCalls++
this.loopbackEnabled = false
}
getDisplayMedia(): Promise<FakeStream> {
if (!this.loopbackEnabled) this.requestsWithoutLoopback++
const d = deferred<FakeStream>()
this.displayRequests.push(d)
return d.promise
}
createPcmPipeline(stream: FakeStream, onPcm: (b: ArrayBuffer) => void): PcmPipeline {
const p = new FakePipeline(stream, onPcm)
this.pipelines.push(p)
return p
}
openPipelines(): FakePipeline[] {
return this.pipelines.filter((p) => !p.closed)
}
}
/** 마이크로태스크 큐 비우기 */
async function flush(): Promise<void> {
for (let i = 0; i < 10; i++) await Promise.resolve()
}
function setup(): { port: FakePort; ctrl: SystemAudioCaptureController<FakeStream>; onPcm: ReturnType<typeof vi.fn> } {
const port = new FakePort()
const onPcm = vi.fn()
const ctrl = new SystemAudioCaptureController(port, { onPcm })
return { port, ctrl, onPcm }
}
describe('SystemAudioCaptureController', () => {
it('starts: enable loopback → getDisplayMedia → disable loopback → pipeline', async () => {
const { port, ctrl, onPcm } = setup()
const started = ctrl.start()
expect(ctrl.getState()).toBe('starting')
await flush()
const stream = new FakeStream(1)
port.displayRequests[0].resolve(stream)
await started
expect(ctrl.getState()).toBe('active')
expect(ctrl.isActive()).toBe(true)
expect(port.enableCalls).toBe(1)
expect(port.disableCalls).toBe(1)
expect(port.loopbackEnabled).toBe(false)
expect(stream.videoDropped).toBe(true)
expect(port.openPipelines()).toHaveLength(1)
expect(port.pipelines[0].onPcm).toBe(onPcm)
ctrl.stop()
expect(ctrl.getState()).toBe('idle')
expect(port.pipelines[0].closed).toBe(true)
expect(stream.stopped).toBe(true)
})
it('stop while getDisplayMedia is pending cancels the start and releases the stream', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
expect(port.displayRequests).toHaveLength(1)
ctrl.stop() // 대기 중 STOP 도착
expect(ctrl.getState()).toBe('idle')
const stream = new FakeStream(1)
port.displayRequests[0].resolve(stream)
await expect(started).resolves.toBeUndefined()
expect(ctrl.getState()).toBe('idle')
expect(stream.stopped).toBe(true)
expect(port.pipelines).toHaveLength(0) // 캡처 파이프라인이 만들어지지 않음
expect(port.loopbackEnabled).toBe(false)
})
it('stop issued synchronously after start acquires nothing', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
ctrl.stop()
await started
expect(port.displayRequests).toHaveLength(0)
expect(port.loopbackEnabled).toBe(false)
expect(ctrl.getState()).toBe('idle')
})
it('stop while enableLoopback is pending disables loopback without requesting media', async () => {
const { port, ctrl } = setup()
const enable = deferred<void>()
port.enableLoopback = async () => {
port.enableCalls++
await enable.promise
port.loopbackEnabled = true
}
const started = ctrl.start()
await flush()
expect(port.enableCalls).toBe(1)
ctrl.stop()
enable.resolve()
await started
expect(port.displayRequests).toHaveLength(0)
expect(port.loopbackEnabled).toBe(false)
expect(port.disableCalls).toBe(1)
})
it('start while starting returns the same pending promise (no second acquisition)', async () => {
const { port, ctrl } = setup()
const a = ctrl.start()
const b = ctrl.start()
expect(b).toBe(a)
await flush()
expect(port.enableCalls).toBe(1)
expect(port.displayRequests).toHaveLength(1)
port.displayRequests[0].resolve(new FakeStream(1))
await a
expect(port.openPipelines()).toHaveLength(1)
})
it('start while active is a no-op', async () => {
const { port, ctrl } = setup()
const a = ctrl.start()
await flush()
port.displayRequests[0].resolve(new FakeStream(1))
await a
await ctrl.start()
expect(port.enableCalls).toBe(1)
expect(port.openPipelines()).toHaveLength(1)
})
it('on → off → on inside the pending window leaves exactly one stoppable capture', async () => {
const { port, ctrl } = setup()
const first = ctrl.start()
await flush()
ctrl.stop()
const second = ctrl.start()
await flush()
// 두 번째 start는 첫 starter 정리 전까지 loopback/getDisplayMedia를 건드리지 않는다
expect(port.displayRequests).toHaveLength(1)
const s1 = new FakeStream(1)
port.displayRequests[0].resolve(s1)
await first
await flush()
expect(s1.stopped).toBe(true)
// 이제 두 번째 starter가 자기 loopback 핸들러로 요청
expect(port.displayRequests).toHaveLength(2)
expect(port.requestsWithoutLoopback).toBe(0)
const s2 = new FakeStream(2)
port.displayRequests[1].resolve(s2)
await second
expect(ctrl.getState()).toBe('active')
expect(port.openPipelines()).toHaveLength(1)
expect(port.openPipelines()[0].stream).toBe(s2)
ctrl.stop()
expect(port.openPipelines()).toHaveLength(0)
expect(s2.stopped).toBe(true)
})
it('on → off → on → off leaves nothing running', async () => {
const { port, ctrl } = setup()
const first = ctrl.start()
await flush()
ctrl.stop()
const second = ctrl.start()
ctrl.stop()
port.displayRequests[0].resolve(new FakeStream(1))
await first
await second
await flush()
expect(ctrl.getState()).toBe('idle')
expect(port.displayRequests).toHaveLength(1) // 두 번째 starter는 시작 전에 취소됨
expect(port.pipelines).toHaveLength(0)
expect(port.loopbackEnabled).toBe(false)
})
it('failed getDisplayMedia always disables loopback and rejects to the caller', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
port.displayRequests[0].reject(new Error('denied'))
await expect(started).rejects.toThrow('denied')
expect(port.loopbackEnabled).toBe(false)
expect(port.disableCalls).toBe(1)
expect(ctrl.getState()).toBe('idle')
// 실패 후 재시작 가능
const again = ctrl.start()
await flush()
port.displayRequests[1].resolve(new FakeStream(2))
await again
expect(ctrl.getState()).toBe('active')
})
it('failed getDisplayMedia after stop still disables loopback but does not reject', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
ctrl.stop()
port.displayRequests[0].reject(new Error('denied'))
await expect(started).resolves.toBeUndefined()
expect(port.loopbackEnabled).toBe(false)
})
it('stream without an audio track is stopped and the start rejects', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
const stream = new FakeStream(1, false)
port.displayRequests[0].resolve(stream)
await expect(started).rejects.toThrow('No audio track')
expect(stream.stopped).toBe(true)
expect(port.pipelines).toHaveLength(0)
expect(ctrl.getState()).toBe('idle')
})
it('stop is idempotent when nothing was started', () => {
const { port, ctrl } = setup()
expect(() => {
ctrl.stop()
ctrl.stop()
}).not.toThrow()
expect(ctrl.getState()).toBe('idle')
expect(port.enableCalls).toBe(0)
})
it('cleanup errors are reported, not thrown', async () => {
const port = new FakePort()
const onCleanupError = vi.fn()
const ctrl = new SystemAudioCaptureController(port, { onPcm: vi.fn(), onCleanupError })
const started = ctrl.start()
await flush()
const stream = new FakeStream(1)
stream.stop = () => {
throw new Error('stop failed')
}
port.displayRequests[0].resolve(stream)
await started
expect(() => ctrl.stop()).not.toThrow()
expect(onCleanupError).toHaveBeenCalledTimes(1)
})
})
describe('float32ToPcm16', () => {
it('clamps and scales samples to PCM16', () => {
const out = new Int16Array(float32ToPcm16(new Float32Array([0, 1, -1, 2, -2, 0.5])))
expect(Array.from(out)).toEqual([0, 32767, -32768, 32767, -32768, 16383])
})
})