fix(voice): stop mic-test ref stealing, unblock action queue, keep tail audio, and move context/LLM routing behind ports and pure policies

This commit is contained in:
Yun Chan 2026-09-28 02:16:16 +09:00
parent 2cd462333f
commit 2f94d24c99
13 changed files with 1672 additions and 353 deletions

View file

@ -0,0 +1,147 @@
// tests/main/services/screen-context-redteam-r2-7.test.ts
// ScreenContextService + ForegroundWindowPort 회귀 테스트:
// - 활성 창 조회는 포트(koffi/osascript 어댑터)에 맡기고 PowerShell 을 띄우지 않는다
// - Windows 어댑터는 실제 앱 이름을 돌려준다 (예전 PowerShell 경로는 $PID 버그로 늘 'powershell')
// - 콘솔/터미널이 포그라운드면 복사 단축키를 보내지 않는다 (SIGINT·입력 줄 지우기 방지)
// - macOS 에선 ⌘+C 를 보낸다
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { clipboard } from 'electron'
import type { ForegroundWindowInfo } from '../../../src/main/utils/win32-foreground'
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(() => true),
configSet: vi.fn(),
}))
vi.mock('../../../src/main/services/modifier-state', () => ({
waitForModifiersReleased: vi.fn(async () => true),
}))
const nut = vi.hoisted(() => ({ pressKey: vi.fn(async () => undefined), releaseKey: vi.fn(async () => undefined) }))
vi.mock('@nut-tree-fork/nut-js', () => ({
keyboard: nut,
Key: { LeftControl: 'LeftControl', LeftSuper: 'LeftSuper', C: 'C' },
}))
const childProcess = vi.hoisted(() => ({ execFile: vi.fn(), exec: vi.fn(), spawn: vi.fn() }))
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>()
return { ...actual, ...childProcess }
})
type ScreenModule = typeof import('../../../src/main/services/ScreenContextService')
type PortModule = typeof import('../../../src/main/services/foreground-window-port')
let screenMod: ScreenModule
let portMod: PortModule
Object.assign(clipboard, { clear: vi.fn() })
function fakePort(appName: string | null, windowTitle: string | null = 'Title'): { current: ReturnType<typeof vi.fn> } {
return { current: vi.fn(async () => ({ appName, windowTitle })) }
}
function info(patch: Partial<ForegroundWindowInfo>): ForegroundWindowInfo {
return { hwnd: 1, title: '', processId: 1, appName: '', appPath: null, bounds: null, ...patch }
}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
screenMod = await import('../../../src/main/services/ScreenContextService')
portMod = await import('../../../src/main/services/foreground-window-port')
})
describe('captureContext — ForegroundWindowPort', () => {
it('포트가 준 앱·창 정보를 쓰고 자식 프로세스를 띄우지 않는다', async () => {
const port = fakePort('Code', 'main.ts - D3ROVoice')
const svc = new screenMod.ScreenContextService({ foreground: port, platform: 'win32' })
const { context, selectedTextAttempted } = await svc.captureContext(false)
expect(context.appName).toBe('Code')
expect(context.windowTitle).toBe('main.ts - D3ROVoice')
expect(selectedTextAttempted).toBe(false)
expect(childProcess.execFile).not.toHaveBeenCalled()
expect(childProcess.spawn).not.toHaveBeenCalled()
expect(svc.buildContextPrompt(context)).toContain('활성 앱: Code')
})
it('포트가 실패해도 컨텍스트 캡처는 계속된다', async () => {
const port = { current: vi.fn(async () => { throw new Error('ffi') }) }
const svc = new screenMod.ScreenContextService({ foreground: port, platform: 'win32' })
const { context } = await svc.captureContext(false)
expect(context.appName).toBeNull()
expect(svc.buildContextPrompt(context)).toBe('')
})
})
describe('Windows 포그라운드 어댑터 (koffi)', () => {
it('실행 파일 이름에서 .exe 를 떼어 실제 앱 이름을 돌려준다', async () => {
const port = portMod.createWin32ForegroundWindowPort(() =>
info({ appName: 'Code.exe', title: 'main.ts', appPath: 'C:\\Code\\Code.exe' }),
)
await expect(port.current()).resolves.toEqual({ appName: 'Code', windowTitle: 'main.ts' })
})
it('빈 값과 조회 실패는 null 이다', async () => {
await expect(portMod.createWin32ForegroundWindowPort(() => info({})).current()).resolves.toEqual({
appName: null,
windowTitle: null,
})
await expect(portMod.createWin32ForegroundWindowPort(() => null).current()).resolves.toEqual({
appName: null,
windowTitle: null,
})
})
})
describe('선택 텍스트 캡처 — 복사 단축키 안전성', () => {
it.each(['WindowsTerminal', 'conhost', 'pwsh', 'Terminal', 'iTerm2', 'WindowsTerminal.exe'])(
'포그라운드가 터미널(%s)이면 복사 단축키를 보내지 않는다',
async (appName) => {
const svc = new screenMod.ScreenContextService({ foreground: fakePort(appName), platform: 'win32' })
await expect(svc.captureSelectedText()).resolves.toBeNull()
expect(nut.pressKey).not.toHaveBeenCalled()
expect(clipboard.clear).not.toHaveBeenCalled()
},
)
it('Windows 에선 Ctrl+C 를 보낸다', async () => {
vi.mocked(clipboard.readText).mockReturnValueOnce('').mockReturnValueOnce('선택')
const svc = new screenMod.ScreenContextService({ foreground: fakePort('Code'), platform: 'win32' })
await expect(svc.captureSelectedText()).resolves.toBe('선택')
expect(nut.pressKey).toHaveBeenCalledWith('LeftControl', 'C')
})
it('macOS 에선 ⌘+C 를 보낸다', async () => {
vi.mocked(clipboard.readText).mockReturnValueOnce('').mockReturnValueOnce('선택')
const svc = new screenMod.ScreenContextService({ foreground: fakePort('Notes'), platform: 'darwin' })
await expect(svc.captureSelectedText()).resolves.toBe('선택')
expect(nut.pressKey).toHaveBeenCalledWith('LeftSuper', 'C')
expect(nut.pressKey).not.toHaveBeenCalledWith('LeftControl', 'C')
})
it('captureContext(true) 도 터미널이면 선택 텍스트를 건너뛴다', async () => {
const port = fakePort('WindowsTerminal')
const svc = new screenMod.ScreenContextService({ foreground: port, platform: 'win32' })
const { context, selectedTextAttempted } = await svc.captureContext(true)
expect(selectedTextAttempted).toBe(true)
expect(context.selectedText).toBeNull()
expect(nut.pressKey).not.toHaveBeenCalled()
// 포그라운드는 한 번만 조회한다
expect(port.current).toHaveBeenCalledTimes(1)
})
})
describe('isTerminalHost', () => {
it('대소문자·확장자와 무관하게 판별하고, 일반 앱은 아니다', () => {
expect(portMod.isTerminalHost('WINDOWSTERMINAL.EXE')).toBe(true)
expect(portMod.isTerminalHost('Code')).toBe(false)
expect(portMod.isTerminalHost(null)).toBe(false)
})
})