clipboard-tools.test.tsā¢22.3 kB
import { ClipboardTools } from '../clipboard-tools.js';
import { FileHandler } from '../../lib/file-handler.js';
import { ClipboardManager } from '../../lib/clipboard-manager.js';
import { OperationLogger } from '../../lib/operation-logger.js';
import { SessionManager } from '../../lib/session-manager.js';
import { DatabaseManager } from '../../lib/database.js';
import { PathAccessControl } from '../../lib/path-access-control.js';
import { existsSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
describe('ClipboardTools', () => {
let dbPath: string;
let testDir: string;
let dbManager: DatabaseManager;
let fileHandler: FileHandler;
let clipboardManager: ClipboardManager;
let operationLogger: OperationLogger;
let sessionManager: SessionManager;
let clipboardTools: ClipboardTools;
let testSessionId: string;
beforeEach(() => {
const uniqueId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 9)}`;
dbPath = join(tmpdir(), `test-clipboard-${uniqueId}.db`);
testDir = join(tmpdir(), `test-files-${uniqueId}`);
mkdirSync(testDir, { recursive: true });
dbManager = new DatabaseManager(dbPath);
fileHandler = new FileHandler();
clipboardManager = new ClipboardManager(dbManager);
operationLogger = new OperationLogger(dbManager);
sessionManager = new SessionManager(dbManager);
clipboardTools = new ClipboardTools(
fileHandler,
clipboardManager,
operationLogger,
sessionManager
);
// Create test session
testSessionId = sessionManager.createSession();
});
afterEach(() => {
if (dbManager) {
dbManager.close();
const keyPath = dbManager.getEncryptionKeyPath();
if (keyPath && existsSync(keyPath)) {
rmSync(keyPath);
}
}
if (existsSync(dbPath)) {
rmSync(dbPath);
}
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
describe('copyLines', () => {
it('should copy lines from file to clipboard', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\nline 4\nline 5\n');
const result = await clipboardTools.copyLines(testSessionId, filePath, 2, 4);
expect(result.success).toBe(true);
expect(result.lines).toEqual(['line 2', 'line 3', 'line 4']);
expect(result.content).toBe('line 2\nline 3\nline 4');
});
it('should store content in clipboard', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
await clipboardTools.copyLines(testSessionId, filePath, 1, 2);
const clipboard = clipboardManager.getClipboard(testSessionId);
expect(clipboard?.content).toBe('line 1\nline 2');
expect(clipboard?.operationType).toBe('copy');
});
it('should log copy operation', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
await clipboardTools.copyLines(testSessionId, filePath, 1, 1);
const history = operationLogger.getHistory(testSessionId);
expect(history.length).toBe(1);
expect(history[0].operationType).toBe('copy');
});
it('should validate integer line numbers', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
await expect(clipboardTools.copyLines(testSessionId, filePath, 1.2, 2)).rejects.toThrow(
'Copy failed: start_line must be a positive integer'
);
});
it('should throw error for invalid session', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\n');
await expect(clipboardTools.copyLines('invalid-session', filePath, 1, 1)).rejects.toThrow(
'Invalid session'
);
});
it('should not modify source file', async () => {
const filePath = join(testDir, 'test.txt');
const originalContent = 'line 1\nline 2\nline 3\n';
writeFileSync(filePath, originalContent);
await clipboardTools.copyLines(testSessionId, filePath, 1, 2);
const snapshot = fileHandler.getFileSnapshot(filePath);
expect(snapshot.content).toBe(originalContent);
});
});
describe('cutLines', () => {
it('should cut lines from file to clipboard', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\nline 4\n');
const result = await clipboardTools.cutLines(testSessionId, filePath, 2, 3);
expect(result.success).toBe(true);
expect(result.lines).toEqual(['line 2', 'line 3']);
});
it('should remove lines from source file', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
await clipboardTools.cutLines(testSessionId, filePath, 2, 2);
const snapshot = fileHandler.getFileSnapshot(filePath);
expect(snapshot.content).toBe('line 1\nline 3\n');
});
it('should store content in clipboard with cut type', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
await clipboardTools.cutLines(testSessionId, filePath, 1, 1);
const clipboard = clipboardManager.getClipboard(testSessionId);
expect(clipboard?.operationType).toBe('cut');
});
it('should log cut operation', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\n');
await clipboardTools.cutLines(testSessionId, filePath, 1, 1);
const history = operationLogger.getHistory(testSessionId);
expect(history[0].operationType).toBe('cut');
});
it('should validate integer line numbers for cuts', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
await expect(clipboardTools.cutLines(testSessionId, filePath, 1, 2.7)).rejects.toThrow(
'Cut failed: end_line must be a positive integer'
);
});
});
describe('pasteLines', () => {
it('should paste clipboard content to single target', async () => {
const sourceFile = join(testDir, 'source.txt');
const targetFile = join(testDir, 'target.txt');
writeFileSync(sourceFile, 'copied line\n');
writeFileSync(targetFile, 'line 1\nline 2\n');
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 1);
const result = await clipboardTools.pasteLines(testSessionId, [
{ file_path: targetFile, target_line: 2 },
]);
expect(result.success).toBe(true);
expect(result.pastedTo.length).toBe(1);
const snapshot = fileHandler.getFileSnapshot(targetFile);
expect(snapshot.content).toContain('copied line');
});
it('should paste to multiple targets', async () => {
const sourceFile = join(testDir, 'source.txt');
const target1 = join(testDir, 'target1.txt');
const target2 = join(testDir, 'target2.txt');
writeFileSync(sourceFile, 'pasted content\n');
writeFileSync(target1, 'file 1\n');
writeFileSync(target2, 'file 2\n');
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 1);
const result = await clipboardTools.pasteLines(testSessionId, [
{ file_path: target1, target_line: 1 },
{ file_path: target2, target_line: 1 },
]);
expect(result.pastedTo.length).toBe(2);
});
it('should throw error if clipboard is empty', async () => {
const targetFile = join(testDir, 'target.txt');
writeFileSync(targetFile, 'content\n');
await expect(
clipboardTools.pasteLines(testSessionId, [{ file_path: targetFile, target_line: 1 }])
).rejects.toThrow('Clipboard is empty');
});
it('should log paste operation', async () => {
const sourceFile = join(testDir, 'source.txt');
const targetFile = join(testDir, 'target.txt');
writeFileSync(sourceFile, 'content\n');
writeFileSync(targetFile, 'existing\n');
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 1);
await clipboardTools.pasteLines(testSessionId, [{ file_path: targetFile, target_line: 1 }]);
const history = operationLogger.getHistory(testSessionId);
expect(history.some((op) => op.operationType === 'paste')).toBe(true);
});
it('should validate paste targets', async () => {
const sourceFile = join(testDir, 'source.txt');
const targetFile = join(testDir, 'target.txt');
writeFileSync(sourceFile, 'content\n');
writeFileSync(targetFile, 'existing\n');
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 1);
await expect(
clipboardTools.pasteLines(testSessionId, [{ file_path: targetFile, target_line: 2.5 }])
).rejects.toThrow('Paste failed: targets[0].target_line must be an integer >= 0');
});
it('should require at least one target', async () => {
await expect(clipboardTools.pasteLines(testSessionId, [])).rejects.toThrow(
'Paste failed: targets must be a non-empty array'
);
});
});
describe('showClipboard', () => {
it('should return clipboard content', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
await clipboardTools.copyLines(testSessionId, filePath, 1, 2);
const result = await clipboardTools.showClipboard(testSessionId);
expect(result.hasContent).toBe(true);
expect(result.content).toBe('line 1\nline 2');
expect(result.operationType).toBe('copy');
});
it('should indicate empty clipboard', async () => {
const result = await clipboardTools.showClipboard(testSessionId);
expect(result.hasContent).toBe(false);
expect(result.content).toBeUndefined();
});
it('should include source metadata', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
await clipboardTools.copyLines(testSessionId, filePath, 2, 3);
const result = await clipboardTools.showClipboard(testSessionId);
expect(result.sourceFile).toBe(filePath);
expect(result.startLine).toBe(2);
expect(result.endLine).toBe(3);
});
});
describe('undoLastPaste', () => {
it('should undo last paste operation', async () => {
const sourceFile = join(testDir, 'source.txt');
const targetFile = join(testDir, 'target.txt');
const originalContent = 'original line 1\noriginal line 2\n';
writeFileSync(sourceFile, 'new content\n');
writeFileSync(targetFile, originalContent);
// Copy and paste
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 1);
await clipboardTools.pasteLines(testSessionId, [{ file_path: targetFile, target_line: 1 }]);
// Undo
const result = await clipboardTools.undoLastPaste(testSessionId);
expect(result.success).toBe(true);
expect(result.restoredFiles.length).toBe(1);
const snapshot = fileHandler.getFileSnapshot(targetFile);
expect(snapshot.content).toBe(originalContent);
});
it('should throw error if no paste to undo', async () => {
await expect(clipboardTools.undoLastPaste(testSessionId)).rejects.toThrow(
'No paste operation to undo'
);
});
it('should log undo operation', async () => {
const sourceFile = join(testDir, 'source.txt');
const targetFile = join(testDir, 'target.txt');
writeFileSync(sourceFile, 'content\n');
writeFileSync(targetFile, 'original\n');
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 1);
await clipboardTools.pasteLines(testSessionId, [{ file_path: targetFile, target_line: 1 }]);
await clipboardTools.undoLastPaste(testSessionId);
const history = operationLogger.getHistory(testSessionId);
expect(history.some((op) => op.operationType === 'undo')).toBe(true);
});
it('should not undo same paste twice', async () => {
const sourceFile = join(testDir, 'source.txt');
const targetFile = join(testDir, 'target.txt');
writeFileSync(sourceFile, 'content\n');
writeFileSync(targetFile, 'original\n');
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 1);
await clipboardTools.pasteLines(testSessionId, [{ file_path: targetFile, target_line: 1 }]);
await clipboardTools.undoLastPaste(testSessionId);
// Try to undo again
await expect(clipboardTools.undoLastPaste(testSessionId)).rejects.toThrow(
'No paste operation to undo'
);
});
});
describe('getOperationHistory', () => {
it('should return operation history', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
await clipboardTools.copyLines(testSessionId, filePath, 1, 1);
await clipboardTools.copyLines(testSessionId, filePath, 2, 2);
const result = await clipboardTools.getOperationHistory(testSessionId);
expect(result.operations.length).toBe(2);
expect(result.operations[0].operationType).toBe('copy');
});
it('should respect limit parameter', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
for (let i = 0; i < 10; i++) {
await clipboardTools.copyLines(testSessionId, filePath, 1, 1);
}
const result = await clipboardTools.getOperationHistory(testSessionId, 5);
expect(result.operations.length).toBe(5);
});
it('should validate limit parameter', async () => {
await expect(clipboardTools.getOperationHistory(testSessionId, 1.5)).rejects.toThrow(
'Failed to get history: limit must be a positive integer'
);
});
it('should order by most recent first', async () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
await clipboardTools.copyLines(testSessionId, filePath, 1, 1);
await wait(10);
await clipboardTools.cutLines(testSessionId, filePath, 1, 1);
const result = await clipboardTools.getOperationHistory(testSessionId);
expect(result.operations[0].operationType).toBe('cut');
expect(result.operations[1].operationType).toBe('copy');
});
});
describe('Path Access Control', () => {
let allowFilePath: string;
let pathControl: PathAccessControl;
let restrictedTools: ClipboardTools;
let restrictedFileHandler: FileHandler;
beforeEach(() => {
allowFilePath = join(testDir, 'paths.allow');
});
it('should block copy operations for paths not in allowlist', async () => {
writeFileSync(allowFilePath, '/allowed/path/**\n');
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
const filePath = join(testDir, 'blocked.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
await expect(restrictedTools.copyLines(testSessionId, filePath, 1, 2)).rejects.toThrow(
'Access denied: path not in allowlist'
);
});
it('should allow copy operations for paths in allowlist', async () => {
writeFileSync(allowFilePath, `${testDir}/**\n`);
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
const filePath = join(testDir, 'allowed.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
const result = await restrictedTools.copyLines(testSessionId, filePath, 1, 2);
expect(result.success).toBe(true);
expect(result.lines).toEqual(['line 1', 'line 2']);
});
it('should block cut operations for paths not in allowlist', async () => {
writeFileSync(allowFilePath, '/allowed/path/**\n');
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
const filePath = join(testDir, 'blocked.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
await expect(restrictedTools.cutLines(testSessionId, filePath, 1, 2)).rejects.toThrow(
'Access denied: path not in allowlist'
);
});
it('should allow cut operations for paths in allowlist', async () => {
writeFileSync(allowFilePath, `${testDir}/**\n`);
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
const filePath = join(testDir, 'allowed.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
const result = await restrictedTools.cutLines(testSessionId, filePath, 1, 2);
expect(result.success).toBe(true);
expect(result.lines).toEqual(['line 1', 'line 2']);
});
it('should block paste operations for paths not in allowlist', async () => {
writeFileSync(allowFilePath, '/allowed/path/**\n');
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
// First copy something with unrestricted tools
const sourceFile = join(testDir, 'source.txt');
writeFileSync(sourceFile, 'line 1\nline 2\n');
await clipboardTools.copyLines(testSessionId, sourceFile, 1, 2);
// Try to paste to blocked location
const targetFile = join(testDir, 'blocked.txt');
writeFileSync(targetFile, 'existing\n');
await expect(
restrictedTools.pasteLines(testSessionId, [{ file_path: targetFile, target_line: 1 }])
).rejects.toThrow('Access denied: path not in allowlist');
});
it('should allow paste operations for paths in allowlist', async () => {
writeFileSync(allowFilePath, `${testDir}/**\n`);
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
// Copy something
const sourceFile = join(testDir, 'source.txt');
writeFileSync(sourceFile, 'line 1\nline 2\n');
await restrictedTools.copyLines(testSessionId, sourceFile, 1, 2);
// Paste to allowed location
const targetFile = join(testDir, 'target.txt');
writeFileSync(targetFile, 'existing\n');
const result = await restrictedTools.pasteLines(testSessionId, [
{ file_path: targetFile, target_line: 1 },
]);
expect(result.success).toBe(true);
});
it('should respect negation patterns for clipboard operations', async () => {
writeFileSync(allowFilePath, `${testDir}/**\n!${testDir}/blocked/**\n`);
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
// Create blocked subdirectory
const blockedDir = join(testDir, 'blocked');
mkdirSync(blockedDir, { recursive: true });
const allowedFile = join(testDir, 'allowed.txt');
const blockedFile = join(blockedDir, 'blocked.txt');
writeFileSync(allowedFile, 'content');
writeFileSync(blockedFile, 'content');
// Should work for allowed file
await expect(
restrictedTools.copyLines(testSessionId, allowedFile, 1, 1)
).resolves.toHaveProperty('success', true);
// Should fail for blocked file
await expect(restrictedTools.copyLines(testSessionId, blockedFile, 1, 1)).rejects.toThrow(
'Access denied: path not in allowlist'
);
});
it('should validate all paste targets before attempting any writes', async () => {
writeFileSync(allowFilePath, `${testDir}/**\n!${testDir}/blocked/**\n`);
pathControl = new PathAccessControl(allowFilePath);
restrictedFileHandler = new FileHandler(pathControl);
restrictedTools = new ClipboardTools(
restrictedFileHandler,
clipboardManager,
operationLogger,
sessionManager,
pathControl
);
// Create blocked subdirectory
const blockedDir = join(testDir, 'blocked');
mkdirSync(blockedDir, { recursive: true });
// Copy something first
const sourceFile = join(testDir, 'source.txt');
writeFileSync(sourceFile, 'line 1\nline 2\n');
await restrictedTools.copyLines(testSessionId, sourceFile, 1, 2);
// Try to paste to mix of allowed and blocked locations
const allowedTarget = join(testDir, 'allowed.txt');
const blockedTarget = join(blockedDir, 'blocked.txt');
writeFileSync(allowedTarget, 'existing\n');
writeFileSync(blockedTarget, 'existing\n');
await expect(
restrictedTools.pasteLines(testSessionId, [
{ file_path: allowedTarget, target_line: 1 },
{ file_path: blockedTarget, target_line: 1 },
])
).rejects.toThrow('Access denied: path not in allowlist');
});
});
});