338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
// 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' })
|
|
})
|
|
})
|