feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -0,0 +1,285 @@
import { describe, it, expect } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { Feature } from '@d3ro/core/types'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { getDictionaryService } from '../../src/main/services/DictionaryService'
import { getMemoService } from '../../src/main/services/MemoService'
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
import { getChainService } from '../../src/main/services/ChainService'
import { getVoiceCommandService } from '../../src/main/services/VoiceCommandService'
import { getLicenseService } from '../../src/main/services/LicenseService'
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { getDictationTemplateService } from '../../src/main/services/DictationTemplateService'
import { configGet, configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
import { registerHistoryHandlers } from '../../src/main/ipc/history-handlers'
import { registerDictionaryHandlers } from '../../src/main/ipc/dictionary-handlers'
import { registerInstructionHandlers } from '../../src/main/ipc/instruction-handlers'
import { historyInput, invokeIpc, useRedHarness } from './harness'
import { FX, USER_TEXT } from './fixtures'
useRedHarness()
describe('유스케이스: 연계 플로우 (사전→힌트, 히스토리→태그, 명령→체인, 라이선스→설정)', () => {
it('사전 추가 후 prompt hint 가 받아쓰기 힌트에 쓰일 단어를 담는다', () => {
getDictionaryService().add({ word: 'D3ROVoice' })
const hints = getDictionaryService().getPromptHints()
expect(hints).toContain('D3ROVoice')
})
it('히스토리 저장 → 태그 → 태그 검색이 같은 엔트리를 찾는다', () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
getMemoService().addTag(entry.id, 'meeting')
const found = getMemoService().searchByTag({ tag: 'meeting', page: 0, pageSize: 5 })
expect(found.entries[0].originalText).toBe(USER_TEXT.KO)
})
it('히스토리 삭제 후 태그는 검색에서 빠진다', () => {
const entry = getHistoryService().create(historyInput())
getMemoService().addTag(entry.id, 'orphan')
getHistoryService().delete(entry.id)
const found = getMemoService().searchByTag({ tag: 'orphan', page: 0, pageSize: 5 })
expect(found.total).toBe(0)
})
it('명령어 생성 → 체인 스텝 → 실행 시 해당 명령을 찾는다 (LLM 실패는 표면화)', async () => {
const inst = getCustomInstructionService()
inst.initialize()
const created = inst.create({ name: '스텝용', description: '', prompt: 'do {{text}}', icon: 'Edit' })
const chain = getChainService()
chain.initialize()
const c = chain.create({
name: 'one',
steps: [{ instructionId: created.id, inputSource: 'original' }],
})
await expect(chain.execute(c.id, USER_TEXT.EN)).rejects.toMatchObject({
code: ErrorCode.ChainStepFailed,
})
})
it('음성 단축키 활성 + 키워드 후 전사 텍스트가 명령으로 갈린다', () => {
const vc = getVoiceCommandService()
vc.initialize()
vc.setEnabled(true)
vc.setKeywordsForInstruction('builtin-summarize', [
{ keyword: '짧게', matchMode: 'prefix' },
])
const match = vc.match('짧게 오늘 회의 내용')
expect(match.matched).toBe(true)
expect(match.cleanedText).toBe('오늘 회의 내용')
})
it('라이선스 pro 활성화 후 로컬 기능은 계속 허용된다', async () => {
const lic = getLicenseService()
lic.initialize()
await lic.activate(FX.LICENSE_PRO)
expect(lic.canUse(Feature.DICTATION).allowed).toBe(true)
expect(lic.canUse(Feature.LLM_PROCESS).allowed).toBe(true)
})
it('설정에서 LLM 액션을 none 으로 바꾸면 config 가 유지된다', () => {
configSet('defaultLLMAction', 'none')
expect(configGet('defaultLLMAction')).toBe('none')
configSet('defaultLLMAction', 'refine')
expect(configGet('defaultLLMAction')).toBe('refine')
})
it('회의 세션 생성 → 제목 수정 → 전사 수정 → 상세에 둘 다 남는다', () => {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({
id: 'chain-meet',
title: 'old',
status: 'completed',
startedAt: now,
createdAt: now,
updatedAt: now,
rawTranscript: USER_TEXT.KO,
})
.run()
getMeetingModeService().updateTitle('chain-meet', 'new')
getMeetingModeService().updateTranscript('chain-meet', USER_TEXT.EN)
const detail = getMeetingModeService().getSession('chain-meet')
expect(detail.title).toBe('new')
expect(detail.editedTranscript).toBe(USER_TEXT.EN)
})
it('이메일 템플릿 세션을 취소한 뒤 다시 시작할 수 있다', () => {
const svc = getDictationTemplateService()
svc.startSession('builtin-email')
svc.cancelSession()
svc.startSession('builtin-email')
expect(svc.getSessionState()?.templateId).toBe('builtin-email')
svc.cancelSession()
})
it('IPC 히스토리 추가 후 검색 → 삭제 연계', async () => {
registerHistoryHandlers()
const entry = getHistoryService().create(historyInput({ originalText: '연계검색어XYZ' }))
const found = await invokeIpc(IPC_CHANNELS.HISTORY.SEARCH, {
query: '연계검색어XYZ',
page: 0,
pageSize: 10,
})
expect(found.success).toBe(true)
const del = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE, { id: entry.id })
expect(del.success).toBe(true)
const again = getHistoryService().search({ query: '연계검색어XYZ', page: 0, pageSize: 10 })
expect(again.total).toBe(0)
})
it('IPC 사전 추가 → 검색 → 삭제 연계', async () => {
registerDictionaryHandlers()
const add = await invokeIpc(IPC_CHANNELS.DICTIONARY.ADD, { word: '연계단어Q' })
expect(add.success).toBe(true)
const search = await invokeIpc(IPC_CHANNELS.DICTIONARY.SEARCH, {
query: '연계단어',
page: 0,
pageSize: 10,
})
expect(search.success).toBe(true)
if (add.success) {
const del = await invokeIpc(IPC_CHANNELS.DICTIONARY.DELETE, { id: add.data.id })
expect(del.success).toBe(true)
}
})
it('사용자 명령 생성 후 IPC 로 조회한다', async () => {
getCustomInstructionService().initialize()
registerInstructionHandlers()
const created = await invokeIpc(IPC_CHANNELS.INSTRUCTION.CREATE, {
name: '연계명령',
description: 'd',
prompt: 'p',
})
expect(created.success).toBe(true)
if (created.success) {
const got = await invokeIpc(IPC_CHANNELS.INSTRUCTION.GET_BY_ID, { id: created.data.id })
expect(got.success).toBe(true)
if (got.success) expect(got.data.name).toBe('연계명령')
}
})
it('히스토리 여러 모드가 목록에 함께 보인다', () => {
getHistoryService().create(historyInput({ mode: 'dictation', originalText: 'd' }))
getHistoryService().create(historyInput({ mode: 'caption', originalText: 'c' }))
getHistoryService().create(historyInput({ mode: 'file-transcription', originalText: 'f' }))
expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(3)
})
it('같은 태그를 두 히스토리에 달면 카운트가 2 다', () => {
const a = getHistoryService().create(historyInput({ originalText: 'a-text' }))
const b = getHistoryService().create(historyInput({ originalText: 'b-text' }))
getMemoService().addTag(a.id, 'shared-tag')
getMemoService().addTag(b.id, 'shared-tag')
expect(getMemoService().getAllTags().find((t) => t.tag === 'shared-tag')?.count).toBe(2)
})
it('명령어 순서 변경 후 첫 항목이 바뀐다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const ids = svc.getAll().map((i) => i.id)
const reversed = [...ids].reverse()
svc.reorder(reversed)
expect(svc.getAll()[0].id).toBe(reversed[0])
})
it('온보딩 완료 플래그와 테마를 같이 저장한다', () => {
configSet('onboardingCompleted', true)
configSet('theme', 'dark')
expect(configGet('onboardingCompleted')).toBe(true)
expect(configGet('theme')).toBe('dark')
})
it('STT 언어와 LLM 액션을 함께 바꾼다', () => {
configSet('sttLanguage', 'en')
configSet('defaultLLMAction', 'translate')
expect(configGet('sttLanguage')).toBe('en')
expect(configGet('defaultLLMAction')).toBe('translate')
})
it('라이선스 활성화 실패 후 재시도 성공', async () => {
const lic = getLicenseService()
lic.initialize()
const fail = await lic.activate(FX.LICENSE_BAD)
expect(fail.success).toBe(false)
const ok = await lic.activate(FX.LICENSE_PRO)
expect(ok.success).toBe(true)
expect(lic.tier).toBe('pro')
})
it('회의 세션 두 건 중 하나만 삭제한다', () => {
const now = Date.now()
for (const id of ['m-keep', 'm-drop']) {
getDatabase()
.insert(meetingSessions)
.values({
id,
title: id,
status: 'completed',
startedAt: now,
createdAt: now,
updatedAt: now,
})
.run()
}
getMeetingModeService().deleteSession('m-drop')
expect(() => getMeetingModeService().getSession('m-drop')).toThrow()
expect(getMeetingModeService().getSession('m-keep').id).toBe('m-keep')
})
it('사전 사용횟수 증가 후 hint 순서가 바뀐다', () => {
const low = getDictionaryService().add({ word: 'lowuse' })
const high = getDictionaryService().add({ word: 'highuse' })
getDictionaryService().incrementUsage(high.id)
getDictionaryService().incrementUsage(high.id)
getDictionaryService().incrementUsage(low.id)
const hints = getDictionaryService().getPromptHints()
expect(hints.indexOf('highuse')).toBeLessThan(hints.indexOf('lowuse'))
})
it('히스토리 통계는 여러 건 누적된다', () => {
getHistoryService().create(historyInput({ duration: 1, wordCount: 2 }))
getHistoryService().create(historyInput({ duration: 2, wordCount: 3 }))
getHistoryService().create(historyInput({ duration: 3, wordCount: 5 }))
const st = getHistoryService().getStats()
expect(st.todaySessionCount).toBe(3)
expect(st.todayWordCount).toBe(10)
})
it('빌트인 명령 프롬프트 수정 후 reset 하면 원래 프롬프트로 돌아간다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const before = svc.getById('builtin-summarize')!.prompt
svc.update('builtin-summarize', { prompt: 'CHANGED-PROMPT' })
expect(svc.getById('builtin-summarize')!.prompt).toBe('CHANGED-PROMPT')
svc.resetBuiltins()
expect(svc.getById('builtin-summarize')!.prompt).toBe(before)
})
it('체인 수정 후 실행해도 없는 instruction 이면 실패한다', async () => {
const svc = getChainService()
svc.initialize()
const c = svc.create({ name: 'x', steps: [] })
svc.update({
id: c.id,
steps: [{ instructionId: 'still-missing', inputSource: 'original' }],
})
await expect(svc.execute(c.id, 'hi')).rejects.toMatchObject({ code: ErrorCode.ChainStepFailed })
})
it('유니코드 히스토리 + 유니코드 태그 검색', () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.UNICODE }))
getMemoService().addTag(entry.id, '아이디어🎉')
const found = getMemoService().searchByTag({ tag: '아이디어🎉', page: 0, pageSize: 5 })
expect(found.total).toBe(1)
})
it('긴 히스토리 원문이 검색된다', () => {
getHistoryService().create(historyInput({ originalText: `${USER_TEXT.LONG} UNIQUE_TAIL` }))
expect(getHistoryService().search({ query: 'UNIQUE_TAIL', page: 0, pageSize: 5 }).total).toBe(1)
})
})