d3ro-voice/apps/desktop/tests/main/services/windows-child-process-hide.test.ts
Yun Chan 0d92a4a853
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Successful in 51s
ci / 모바일 린트·타입·Jest (push) Successful in 37s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 26s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Failing after 15s
ci / 워크스페이스 빌드 검증 (push) Failing after 11m7s
fix(rag): do not mark a document indexed when no chunk could be embedded
With the embedding server unavailable every chunk failed, yet the document
was stored as indexed=true with 0 chunks, so the knowledge base listed it as
searchable while queries could never match it. The red use-case test caught
this; it had been written off as an environment failure.

Now a run with zero embedded chunks leaves indexed=false and throws
RAGEmbeddingFailed (surfaced by reindex, logged by addDocument).

Tests that only hold on the Windows developer machine now declare it: the
bundled SoX binary and PowerShell device discovery run on win32 only, and
the sidecar venv test runs only when sidecar/.venv exists. The Linux Forgejo
runner skips them instead of failing.
2026-09-26 21:10:40 +09:00

165 lines
5.6 KiB
TypeScript

import { EventEmitter } from 'events'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { promisify } from 'util'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const childProcess = vi.hoisted(() => ({
exec: vi.fn(),
execFile: vi.fn(),
execFileAsync: vi.fn(),
spawn: vi.fn(),
}))
vi.mock('child_process', () => childProcess)
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn(),
configSet: vi.fn(),
}))
vi.mock('../../../src/main/services/stt/STTManager', () => ({
getSTTManager: vi.fn(),
}))
vi.mock('../../../src/main/services/HistoryService', () => ({
getHistoryService: vi.fn(),
}))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({
getRuntimeProvisioner: vi.fn(),
}))
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: vi.fn(),
}))
function createProcess(args: unknown[]): EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
kill: ReturnType<typeof vi.fn>
} {
const process = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
kill: vi.fn(),
})
queueMicrotask(() => {
if (args.includes('null')) {
process.stderr.emit('data', Buffer.from('Duration: 00:00:01.00'))
}
if (args.includes('pipe:1')) {
process.stdout.emit('data', Buffer.from('pcm'))
}
process.emit('close', 0)
})
return process
}
function mockSuccessfulExec(): void {
childProcess.exec.mockImplementation((...args: unknown[]) => {
const callback = args.find((arg): arg is (error: Error | null, stdout: string) => void => typeof arg === 'function')
callback?.(null, '[{"InstanceId":"device-1","FriendlyName":"Mic"}]')
})
}
function expectWindowsHideOnAllCalls(mock: { mock: { calls: unknown[][] } }): void {
for (const call of mock.mock.calls) {
const options = call.find(
(arg): arg is { windowsHide?: boolean } => typeof arg === 'object' && arg !== null && !Array.isArray(arg),
)
expect(options?.windowsHide).toBe(true)
}
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
mockSuccessfulExec()
childProcess.spawn.mockImplementation((_command: unknown, args: unknown[]) => createProcess(args))
Reflect.set(
childProcess.execFile,
promisify.custom,
childProcess.execFileAsync,
)
childProcess.execFileAsync.mockResolvedValue({ stdout: 'TestApp\nTest window', stderr: '' })
})
describe('Windows child process visibility', () => {
it('hides the PowerShell SAPI process used for TTS playback', async () => {
const serviceModule = await import('../../../src/main/services/TTSPlaybackService')
serviceModule.resetTTSPlaybackServiceForTests()
const service = serviceModule.getTTSPlaybackService() as unknown as {
_speakOneWindows(text: string): Promise<void>
}
await service._speakOneWindows('테스트')
expect(childProcess.spawn).toHaveBeenCalledTimes(1)
expect(childProcess.spawn.mock.calls[0]?.[0]).toBe('powershell')
expect(childProcess.spawn.mock.calls[0]?.[2]).toMatchObject({ stdio: 'pipe', windowsHide: true })
})
it('hides cmd and PowerShell commands invoked by voice actions', async () => {
const serviceModule = await import('../../../src/main/services/VoiceActionService')
serviceModule.resetVoiceActionServiceForTests()
const service = serviceModule.getVoiceActionService() as unknown as {
_openApp(appName: string): Promise<void>
_simulateKeyboard(combo: string): Promise<void>
_runCommand(command: string): Promise<void>
}
await service._openApp('notepad')
await service._simulateKeyboard('volumeup')
await service._simulateKeyboard('volumedown')
await service._simulateKeyboard('volumemute')
await service._simulateKeyboard('ctrl+c')
await service._runCommand('echo test')
expect(childProcess.exec).toHaveBeenCalledTimes(6)
expect(childProcess.exec.mock.calls[0]?.[1]).toMatchObject({ shell: 'cmd.exe', windowsHide: true })
expect(childProcess.exec.mock.calls[5]?.[1]).toMatchObject({ timeout: 10000, windowsHide: true })
expectWindowsHideOnAllCalls(childProcess.exec)
})
// 장치 목록·활성 창 조회는 win32 분기에서만 PowerShell 을 부른다.
it.skipIf(process.platform !== 'win32')('hides Windows device discovery and active-window PowerShell calls', async () => {
const audioModule = await import('../../../src/main/services/AudioCaptureService')
const audioService = audioModule.getAudioCaptureService() as unknown as {
_getDevicesWindows(): Promise<unknown>
}
await audioService._getDevicesWindows()
const contextModule = await import('../../../src/main/services/ScreenContextService')
const contextService = contextModule.getScreenContextService()
await contextService.captureContext(false)
expect(childProcess.exec.mock.calls[0]?.[1]).toMatchObject({
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
})
expect(childProcess.execFileAsync.mock.calls[0]?.[2]).toMatchObject({
timeout: 3000,
windowsHide: true,
})
})
it('declares hidden ffmpeg processes for conversion, duration, and chunk extraction', () => {
const source = readFileSync(
resolve(process.cwd(), 'src/main/services/FileTranscriptionService.ts'),
'utf8',
)
const ffmpegSpawns = source.match(
/spawn\(ffmpegPath, args, \{ stdio: \['pipe', 'pipe', 'pipe'\], windowsHide: true \}\)/g,
)
expect(ffmpegSpawns).toHaveLength(3)
})
})