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:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
|
|
@ -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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
317
apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts
Normal file
317
apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue