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.
129 lines
5 KiB
TypeScript
129 lines
5 KiB
TypeScript
// 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*\}, \[\]\)/)
|
|
})
|
|
})
|