d3ro-voice/apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts

240 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
})
})