fix(text-insert): drop nut-js 300ms key delay and send paste as one chord

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 9e5b94ced2
commit 96b24e279c
2 changed files with 312 additions and 21 deletions

View file

@ -0,0 +1,240 @@
// 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<typeof KeyboardClass>[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<void> => {
this.events.push({ kind: 'type', char })
},
click: async (...keys: Key[]): Promise<void> => {
this.events.push({ kind: 'click', keys })
},
pressKey: async (...keys: Key[]): Promise<void> => {
this.events.push({ kind: 'press', keys })
},
releaseKey: async (...keys: Key[]): Promise<void> => {
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<unknown>): Promise<number> {
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<ClipboardImage> {
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)
})
})