// tests/main/services/textinsert-redteam-r2-4.test.ts // 회귀: nut-js 기본 autoDelay(300ms)가 그대로 남아 붙여넣기마다 Ctrl 이 ~300ms 눌린 채 // 유지되고(사용자의 다음 키가 Ctrl 단축키가 됨), 키보드 삽입이 글자당 300ms 걸리던 버그. // 실제 nut-js KeyboardClass 를 가짜 네이티브 provider 위에 올려 검증한다 (키는 실제로 눌리지 않는다). import { describe, it, expect, vi } from 'vitest' import { KeyboardClass } from '@nut-tree-fork/nut-js/dist/lib/keyboard.class' import { Key } from '@nut-tree-fork/shared' import { NutKeyboardPort, NUT_KEY_DELAY_MS, configureNutKeyDelay, type NutKeyboardModule } from '../../../src/main/services/text-insert/nut-keyboard-port' import { TextInsertService } from '../../../src/main/services/TextInsertService' import type { ClipboardImage, ClipboardPort } from '../../../src/main/services/text-insert/clipboard-snapshot' import { ErrorCode } from '@d3ro/core/errors' type ProviderRegistryArg = ConstructorParameters[0] type NativeEvent = | { kind: 'type'; char: string } | { kind: 'click'; keys: Key[] } | { kind: 'press'; keys: Key[] } | { kind: 'release'; keys: Key[] } /** libnut 대신 이벤트만 기록하는 가짜 provider registry. */ class FakeRegistry { events: NativeEvent[] = [] nativeDelays: number[] = [] private readonly _log = { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn(), trace: vi.fn() } private readonly _keyboard = { setKeyboardDelay: (delay: number): void => { this.nativeDelays.push(delay) }, type: async (char: string): Promise => { this.events.push({ kind: 'type', char }) }, click: async (...keys: Key[]): Promise => { this.events.push({ kind: 'click', keys }) }, pressKey: async (...keys: Key[]): Promise => { this.events.push({ kind: 'press', keys }) }, releaseKey: async (...keys: Key[]): Promise => { this.events.push({ kind: 'release', keys }) } } hasKeyboard(): boolean { return true } getKeyboard(): typeof this._keyboard { return this._keyboard } getLogProvider(): typeof this._log { return this._log } } function makeNut(): { nut: NutKeyboardModule; registry: FakeRegistry; keyboard: KeyboardClass } { const registry = new FakeRegistry() const keyboard = new KeyboardClass(registry as unknown as ProviderRegistryArg) return { nut: { keyboard, providerRegistry: registry, Key }, registry, keyboard } } async function timed(fn: () => Promise): Promise { const start = performance.now() await fn() return performance.now() - start } describe('NutKeyboardPort key delay (redteam r2-4)', () => { it('real nut-js KeyboardClass starts with a 300ms autoDelay (the hazard being fixed)', () => { const { keyboard, registry } = makeNut() expect(keyboard.config.autoDelayMs).toBe(300) expect(registry.nativeDelays).toEqual([300]) }) it('zeroes both the JS autoDelay and the native keyboard delay on load', async () => { const { nut, registry, keyboard } = makeNut() const port = new NutKeyboardPort('win32', async () => nut) await port.paste() expect(NUT_KEY_DELAY_MS).toBe(0) expect(keyboard.config.autoDelayMs).toBe(0) expect(registry.nativeDelays.at(-1)).toBe(0) }) it('sends Ctrl+V as a single native chord without holding the modifier across awaits', async () => { const { nut, registry } = makeNut() const port = new NutKeyboardPort('win32', async () => nut) const elapsed = await timed(() => port.paste()) expect(registry.events).toEqual([{ kind: 'click', keys: [Key.LeftControl, Key.V] }]) expect(registry.events.some((e) => e.kind === 'press' || e.kind === 'release')).toBe(false) // 수정 전: pressKey·releaseKey 앞에서 각각 300ms → 600ms 이상 expect(elapsed).toBeLessThan(150) }) it('uses the Super (⌘) modifier on macOS', async () => { const { nut, registry } = makeNut() const port = new NutKeyboardPort('darwin', async () => nut) await port.paste() expect(registry.events).toEqual([{ kind: 'click', keys: [Key.LeftSuper, Key.V] }]) }) it('types a 60-character text without per-character 300ms sleeps', async () => { const { nut, registry } = makeNut() const port = new NutKeyboardPort('win32', async () => nut) const text = '가나다라마바사아자차카타파하 hello world, this is a suggestion!!'.padEnd(60, '.') const elapsed = await timed(() => port.type(text)) // 글자마다 JS sleep 을 끼우는 KeyboardClass.type 대신 네이티브 호출 한 번 expect(registry.events).toEqual([{ kind: 'type', char: text }]) // 수정 전: 60 × 300ms = 18s expect(elapsed).toBeLessThan(150) }) it('loads nut-js once and reuses it', async () => { const { nut } = makeNut() const load = vi.fn(async () => nut) const port = new NutKeyboardPort('win32', load) await Promise.all([port.paste(), port.type('a'), port.paste()]) expect(load).toHaveBeenCalledTimes(1) }) it('does not touch the keyboard for empty text', async () => { const { nut, registry } = makeNut() const load = vi.fn(async () => nut) const port = new NutKeyboardPort('win32', load) await port.type('') expect(load).not.toHaveBeenCalled() expect(registry.events).toEqual([]) }) it('fails with TextInsertKeySimulationFailed when no native keyboard provider is registered', async () => { const { nut, registry } = makeNut() const port = new NutKeyboardPort('win32', async () => ({ ...nut, providerRegistry: { hasKeyboard: () => false, getKeyboard: () => registry.getKeyboard() } })) await expect(port.paste()).rejects.toMatchObject({ code: ErrorCode.TextInsertKeySimulationFailed }) }) it('maps a load failure to TextInsertKeySimulationFailed', async () => { const port = new NutKeyboardPort('win32', async () => { throw new Error('native module missing') }) await expect(port.paste()).rejects.toMatchObject({ code: ErrorCode.TextInsertKeySimulationFailed }) }) it('configureNutKeyDelay skips the native delay when no keyboard provider is registered', () => { const { nut, keyboard } = makeNut() const setKeyboardDelay = vi.fn() const noProvider: NutKeyboardModule = { ...nut, providerRegistry: { hasKeyboard: () => false, getKeyboard: () => ({ setKeyboardDelay, type: vi.fn(), click: vi.fn() }) } } configureNutKeyDelay(noProvider, 5) expect(keyboard.config.autoDelayMs).toBe(5) expect(setKeyboardDelay).not.toHaveBeenCalled() }) }) describe('TextInsertService with the nut-js adapter (redteam r2-4)', () => { function memoryClipboard(): ClipboardPort { let text = 'original' const empty: ClipboardImage = { isEmpty: () => true } return { availableFormats: () => (text ? ['text/plain'] : []), readText: () => text, readHTML: () => '', readRTF: () => '', readImage: () => empty, readBuffer: () => Buffer.alloc(0), writeText: (value: string) => { text = value }, write: (data) => { text = data.text ?? '' }, writeBuffer: () => undefined, clear: () => { text = '' } } } it('clipboard insert sends one paste chord and restores the clipboard', async () => { const { nut, registry } = makeNut() const clipboard = memoryClipboard() const service = new TextInsertService({ clipboard, keyboard: new NutKeyboardPort('win32', async () => nut), sleep: async () => undefined, platform: 'win32' }) const result = await service.insertText('dictated text') expect(result.method).toBe('clipboard') expect(registry.events).toEqual([{ kind: 'click', keys: [Key.LeftControl, Key.V] }]) expect(clipboard.readText()).toBe('original') expect(result.durationMs).toBeLessThan(150) }) it('keyboard insert finishes promptly instead of 300ms per character', async () => { const { nut, registry } = makeNut() const service = new TextInsertService({ clipboard: memoryClipboard(), keyboard: new NutKeyboardPort('win32', async () => nut), sleep: async () => undefined, platform: 'win32' }) const text = 'x'.repeat(60) const result = await service.insertText(text, 'keyboard') expect(result.method).toBe('keyboard') expect(registry.events).toEqual([{ kind: 'type', char: text }]) expect(result.durationMs).toBeLessThan(150) }) })