fix(text-insert): drop nut-js 300ms key delay and send paste as one chord
This commit is contained in:
parent
9e5b94ced2
commit
96b24e279c
2 changed files with 312 additions and 21 deletions
240
apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts
Normal file
240
apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue