// tests/main/services/textinsert-redteam-r1-2.test.ts // 회귀: 붙여넣기 삽입 뒤 클립보드 복원이 포맷 하나만 되돌리거나(서식 손실), // 표준 포맷이 없는 클립보드(탐색기에서 복사한 파일)를 clear() 로 지우던 버그. // 그리고 동시 삽입이 서로의 save/restore 를 엇갈리게 하던 문제. import { describe, it, expect, vi } from 'vitest' import { TextInsertService } from '../../../src/main/services/TextInsertService' import { captureClipboard, planClipboardRestore, isStandardClipboardFormat, UNKNOWN_FORMATS, type ClipboardImage, type ClipboardPort, type ClipboardWriteData } from '../../../src/main/services/text-insert/clipboard-snapshot' import type { KeyboardPort } from '../../../src/main/services/text-insert/nut-keyboard-port' class FakeImage implements ClipboardImage { constructor(private readonly _empty: boolean, readonly tag = 'img') {} isEmpty(): boolean { return this._empty } } /** * Electron 처럼 write()/writeText()/writeBuffer() 가 매번 클립보드 전체를 교체하는 * 메모리 클립보드. */ class MemoryClipboard implements ClipboardPort { text = '' html = '' rtf = '' image: ClipboardImage = new FakeImage(true) buffers = new Map() /** 표준 API 로 읽을 수 없는 네이티브 포맷 (예: CF_HDROP → 'text/uri-list') */ opaque: string[] = [] clearCalls = 0 availableFormats(): string[] { const f: string[] = [] if (this.text) f.push('text/plain') if (this.html) f.push('text/html') if (this.rtf) f.push('text/rtf') if (!this.image.isEmpty()) f.push('image/png') f.push(...this.buffers.keys(), ...this.opaque) return f } readText(): string { return this.text } readHTML(): string { return this.html } readRTF(): string { return this.rtf } readImage(): ClipboardImage { return this.image } readBuffer(format: string): Buffer { return this.buffers.get(format) ?? Buffer.alloc(0) } private _reset(): void { this.text = '' this.html = '' this.rtf = '' this.image = new FakeImage(true) this.buffers = new Map() this.opaque = [] } writeText(text: string): void { this._reset() this.text = text } write(data: ClipboardWriteData): void { this._reset() this.text = data.text ?? '' this.html = data.html ?? '' this.rtf = data.rtf ?? '' if (data.image) this.image = data.image } writeBuffer(format: string, buffer: Buffer): void { this._reset() this.buffers.set(format, buffer) } clear(): void { this.clearCalls++ this._reset() } } function makeKeyboard(clip: MemoryClipboard, pasted: string[], typed: string[]): KeyboardPort { return { paste: vi.fn(async () => { pasted.push(clip.readText()) }), type: vi.fn(async (text: string) => { typed.push(text) }) } } function makeService(clip: MemoryClipboard, pasted: string[] = [], typed: string[] = []) { const keyboard = makeKeyboard(clip, pasted, typed) const service = new TextInsertService({ clipboard: clip, keyboard, sleep: () => new Promise((resolve) => setImmediate(resolve)), platform: 'win32' }) return { service, keyboard, pasted, typed } } describe('TextInsertService 클립보드 복원 (redteam r1-2)', () => { it('탐색기에서 복사한 파일(표준 포맷 없음)을 지우지 않고 키보드로 넣는다', async () => { const clip = new MemoryClipboard() clip.opaque = ['text/uri-list'] // CF_HDROP — readBuffer 로 읽을 수 없음 const { service, pasted, typed } = makeService(clip) const result = await service.insertText('안녕하세요', 'clipboard') expect(clip.clearCalls).toBe(0) expect(clip.availableFormats()).toEqual(['text/uri-list']) expect(pasted).toEqual([]) expect(typed).toEqual(['안녕하세요']) expect(result.method).toBe('keyboard') expect(result.success).toBe(true) }) it('Word/브라우저 서식(텍스트+HTML+RTF+이미지)을 모두 되돌린다', async () => { const clip = new MemoryClipboard() const img = new FakeImage(false, 'table-picture') clip.text = 'plain' clip.html = 'bold' clip.rtf = '{\\rtf1 bold}' clip.image = img const { service, pasted } = makeService(clip) const result = await service.insertText('dictated', 'clipboard') expect(pasted).toEqual(['dictated']) expect(result.method).toBe('clipboard') expect(clip.text).toBe('plain') expect(clip.html).toBe('bold') expect(clip.rtf).toBe('{\\rtf1 bold}') expect(clip.image).toBe(img) }) it('HTML 만 있는 클립보드도 HTML 로 되돌린다', async () => { const clip = new MemoryClipboard() clip.html = 'only html' const { service } = makeService(clip) await service.insertText('x') expect(clip.html).toBe('only html') expect(clip.text).toBe('') }) it('사용자 정의 포맷 하나뿐이면 writeBuffer 로 되돌린다', async () => { const clip = new MemoryClipboard() clip.buffers.set('FileNameW', Buffer.from('C:\\a.txt', 'utf16le')) const { service, pasted } = makeService(clip) await service.insertText('hi') expect(pasted).toEqual(['hi']) expect(clip.clearCalls).toBe(0) expect(clip.readBuffer('FileNameW').toString('utf16le')).toBe('C:\\a.txt') }) it('원래 비어 있던 클립보드는 비운 상태로 되돌린다', async () => { const clip = new MemoryClipboard() const { service, pasted } = makeService(clip) await service.insertText('hi') expect(pasted).toEqual(['hi']) expect(clip.availableFormats()).toEqual([]) expect(clip.clearCalls).toBe(1) }) it('붙여넣기 도중 사용자가 새로 복사한 내용은 덮어쓰지 않는다', async () => { const clip = new MemoryClipboard() clip.text = 'old' const keyboard: KeyboardPort = { paste: vi.fn(async () => { clip.writeText('user copied meanwhile') }), type: vi.fn(async () => undefined) } const service = new TextInsertService({ clipboard: clip, keyboard, sleep: async () => undefined, platform: 'win32' }) await service.insertText('hi') expect(clip.text).toBe('user copied meanwhile') }) it('붙여넣기 실패 시에도 원래 클립보드를 되돌리고 에러를 던진다', async () => { const clip = new MemoryClipboard() clip.text = 'keep me' clip.html = '

keep me

' const keyboard: KeyboardPort = { paste: vi.fn(async () => { throw new Error('SendInput failed') }), type: vi.fn(async () => undefined) } const service = new TextInsertService({ clipboard: clip, keyboard, sleep: async () => undefined, platform: 'win32' }) await expect(service.insertText('hi')).rejects.toThrow(/SendInput failed/) expect(clip.text).toBe('keep me') expect(clip.html).toBe('

keep me

') }) it('동시 삽입은 직렬화되어 서로의 텍스트를 원래 클립보드로 되돌리지 않는다', async () => { const clip = new MemoryClipboard() clip.text = 'original' const pasted: string[] = [] const { service } = makeService(clip, pasted) await Promise.all([ service.insertText('first'), service.insertText('second'), service.insertText('third') ]) expect(pasted).toEqual(['first', 'second', 'third']) expect(clip.text).toBe('original') }) it('앞선 삽입이 실패해도 다음 삽입은 진행된다', async () => { const clip = new MemoryClipboard() clip.text = 'original' let calls = 0 const keyboard: KeyboardPort = { paste: vi.fn(async () => { calls++ if (calls === 1) throw new Error('boom') }), type: vi.fn(async () => undefined) } const service = new TextInsertService({ clipboard: clip, keyboard, sleep: async () => undefined, platform: 'win32' }) const first = service.insertText('a') const second = service.insertText('b') await expect(first).rejects.toThrow() await expect(second).resolves.toMatchObject({ success: true, method: 'clipboard' }) expect(clip.text).toBe('original') }) it('keyboard 요청은 클립보드를 건드리지 않는다', async () => { const clip = new MemoryClipboard() clip.text = 'orig' const writeSpy = vi.spyOn(clip, 'writeText') const { service, typed } = makeService(clip) const result = await service.insertText('typed', 'keyboard') expect(result.method).toBe('keyboard') expect(typed).toEqual(['typed']) expect(writeSpy).not.toHaveBeenCalled() }) }) describe('clipboard-snapshot 정책', () => { it('표준 포맷 판정', () => { expect(isStandardClipboardFormat('text/plain')).toBe(true) expect(isStandardClipboardFormat('text/plain;charset=utf-8')).toBe(true) expect(isStandardClipboardFormat('TEXT/HTML')).toBe(true) expect(isStandardClipboardFormat('image/png')).toBe(true) expect(isStandardClipboardFormat('text/uri-list')).toBe(false) expect(isStandardClipboardFormat('FileNameW')).toBe(false) }) it('표준 내용이 있으면 함께 쓰고, 같이 쓸 수 없는 포맷은 dropped 로 보고한다', () => { const clip = new MemoryClipboard() clip.text = 't' clip.html = 'h' clip.buffers.set('vscode-editor-data', Buffer.from('{}')) const plan = planClipboardRestore(captureClipboard(clip)) expect(plan).toEqual({ kind: 'write', data: { text: 't', html: 'h' }, dropped: ['vscode-editor-data'] }) }) it('사용자 정의 포맷이 여럿이면 되돌릴 수 없다고 판정한다 (clear 금지)', () => { const clip = new MemoryClipboard() clip.buffers.set('A', Buffer.from('a')) clip.buffers.set('B', Buffer.from('b')) expect(planClipboardRestore(captureClipboard(clip)).kind).toBe('unrestorable') }) it('readBuffer 가 예외를 던지는 포맷은 읽지 못한 포맷으로 분류한다', () => { const clip = new MemoryClipboard() clip.opaque = ['weird'] vi.spyOn(clip, 'readBuffer').mockImplementation(() => { throw new Error('nope') }) const snap = captureClipboard(clip) expect(snap.unreadable).toEqual(['weird']) expect(planClipboardRestore(snap).kind).toBe('unrestorable') }) it('availableFormats 가 실패하면 비어 있다고 가정하지 않는다', () => { const clip = new MemoryClipboard() vi.spyOn(clip, 'availableFormats').mockImplementation(() => { throw new Error('locked') }) const snap = captureClipboard(clip) expect(snap.unreadable).toEqual([UNKNOWN_FORMATS]) expect(planClipboardRestore(snap).kind).toBe('unrestorable') }) it('표준 포맷만 광고되고 내용이 비었으면 clear 로 되돌린다', () => { const clip = new MemoryClipboard() vi.spyOn(clip, 'availableFormats').mockReturnValue(['text/plain']) expect(planClipboardRestore(captureClipboard(clip))).toEqual({ kind: 'clear' }) }) })