// 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() 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 } { return { current: vi.fn(async () => ({ appName, windowTitle })) } } function info(patch: Partial): 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) }) })