fix(text-insert): restore every clipboard format after paste and never wipe non-text clipboards

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 190b6db284
commit 0adedf5e7b
5 changed files with 704 additions and 113 deletions

View file

@ -0,0 +1,338 @@
// 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<ClipboardImage> {
text = ''
html = ''
rtf = ''
image: ClipboardImage = new FakeImage(true)
buffers = new Map<string, Buffer>()
/** 표준 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<ClipboardImage>): 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 = '<b>bold</b>'
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('<b>bold</b>')
expect(clip.rtf).toBe('{\\rtf1 bold}')
expect(clip.image).toBe(img)
})
it('HTML 만 있는 클립보드도 HTML 로 되돌린다', async () => {
const clip = new MemoryClipboard()
clip.html = '<i>only html</i>'
const { service } = makeService(clip)
await service.insertText('x')
expect(clip.html).toBe('<i>only html</i>')
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 = '<p>keep me</p>'
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('<p>keep me</p>')
})
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' })
})
})