mcpretentious
Version:
MCPretentious - Universal Terminal MCP. High-performance terminal automation for iTerm2 (WebSocket) and tmux (control mode). Cross-platform support with cursor position, colors, and layered screenshots.
818 lines (707 loc) • 26.7 kB
JavaScript
/**
* Multi-backend integration tests for MCPretentious
* These tests detect available terminal backends and run tests for each
*/
import { test, describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { exec, execSync } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { platform } from 'node:os';
const execPromise = promisify(exec);
const VERBOSE = process.env.VERBOSE === 'true';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Helper to extract terminal ID from response
function extractTerminalId(responseText) {
// Match terminal ID format in response text
// Format: "Backend terminal opened with ID: backend:sessionId"
const match = responseText.match(/ID:\s*([\w:-]+)/);
return match ? match[1] : null;
}
// Check which backends are available
async function getAvailableBackends() {
const backends = [];
// Check for iTerm2 (macOS only)
if (platform() === 'darwin') {
try {
execSync('osascript -e \'tell application "System Events" to name of application processes\' | grep -q iTerm', {
stdio: 'ignore'
});
// Make sure iTerm2 is running
execSync('open -a iTerm', { stdio: 'ignore' });
await new Promise(resolve => setTimeout(resolve, 1000));
backends.push('iterm');
if (VERBOSE) console.log('✓ iTerm2 backend available');
} catch {
if (VERBOSE) console.log('✗ iTerm2 backend not available');
}
}
// Check for tmux
try {
execSync('which tmux', { stdio: 'ignore' });
// Check if tmux server is running or can be started
try {
// First ensure no stale server is running
try {
execSync('tmux kill-server 2>/dev/null', { stdio: 'ignore' });
} catch {}
// Try to start a fresh server with a test session
execSync('tmux new-session -d -s test-check 2>/dev/null', {
stdio: 'ignore'
});
execSync('tmux kill-session -t test-check 2>/dev/null', {
stdio: 'ignore'
});
backends.push('tmux');
if (VERBOSE) console.log('✓ tmux backend available');
} catch (error) {
if (VERBOSE) console.log('✗ tmux backend not available:', error.message);
}
} catch {
if (VERBOSE) console.log('✗ tmux backend not available (not installed)');
}
return backends;
}
// Get list of available backends
const availableBackends = await getAvailableBackends();
if (availableBackends.length === 0) {
console.log('No terminal backends available. Skipping integration tests.');
console.log('Install iTerm2 (macOS) or tmux to run integration tests.');
process.exit(0);
}
console.log(`Running integration tests for backends: ${availableBackends.join(', ')}`);
// Disable focus reporting before tests start to prevent ^[[O and ^[[I when focus changes
// Write directly to /dev/tty to bypass test runner's output capture
import { openSync, writeSync, closeSync } from 'fs';
try {
const tty = openSync('/dev/tty', 'w');
writeSync(tty, '\x1b[?1004l');
closeSync(tty);
} catch (e) {
// If we can't open /dev/tty, try stdout as fallback
if (process.stdout.isTTY) {
process.stdout.write('\x1b[?1004l');
}
}
// Run tests for each available backend
for (const backend of availableBackends) {
describe(`Integration Tests - ${backend} backend`, () => {
let client;
let transport;
let openTerminals = [];
before(async () => {
// Set the backend environment variable
process.env.MCP_TERMINAL_BACKEND = backend;
// Create transport that spawns the server with the backend env var
transport = new StdioClientTransport({
command: 'node',
args: [join(__dirname, '..', 'mcpretentious.js')],
env: {
...process.env,
MCP_TERMINAL_BACKEND: backend
}
});
// Create and connect client
client = new Client({
name: `integration-test-${backend}`,
version: '1.0.0'
});
await client.connect(transport);
// Wait for connection to be ready
await new Promise(resolve => setTimeout(resolve, 200));
console.log(`\n--- Testing with ${backend} backend ---`);
});
after(async () => {
// Clean up any open terminals
if (openTerminals.length > 0) {
console.log(`[${backend}] Cleaning up ${openTerminals.length} terminals...`);
}
for (const terminalId of openTerminals) {
try {
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId }
});
if (VERBOSE) console.log(`[${backend}] Closed ${terminalId}`);
} catch (error) {
if (VERBOSE) console.error(`[${backend}] Failed to close ${terminalId}:`, error.message);
}
}
openTerminals = [];
// Close connections
await client?.close();
await transport?.close();
// Give backend time to clean up
await new Promise(resolve => setTimeout(resolve, 200));
// Clean up environment
delete process.env.MCP_TERMINAL_BACKEND;
});
describe('Basic Operations', () => {
let testTerminalId;
before(async () => {
// Open a terminal for these tests
const result = await client.callTool({
name: 'mcpretentious-open',
arguments: {}
});
// Debug: log the actual response
if (VERBOSE || backend === 'tmux') {
console.log(`[${backend}] Open response:`, result.content[0].text);
}
testTerminalId = extractTerminalId(result.content[0].text);
if (!testTerminalId) {
throw new Error(`Failed to extract terminal ID from response: ${result.content[0].text}`);
}
openTerminals.push(testTerminalId);
// Verify it's using the correct backend
assert.ok(testTerminalId.startsWith(`${backend}:`),
`Terminal ID should start with ${backend}:, got ${testTerminalId}`);
// Wait for terminal to be ready
await new Promise(resolve => setTimeout(resolve, 300));
});
after(async () => {
// Close this suite's terminal
if (testTerminalId) {
try {
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId: testTerminalId }
});
openTerminals = openTerminals.filter(id => id !== testTerminalId);
} catch (error) {
console.error(`Failed to close terminal ${testTerminalId}:`, error.message);
}
}
});
it('should open terminal and echo text', async () => {
// Send echo command
await client.callTool({
name: 'mcpretentious-type',
arguments: {
terminalId: testTerminalId,
input: [`echo "Testing ${backend} backend"`, { key: 'enter' }]
}
});
// Wait for command to execute
await new Promise(resolve => setTimeout(resolve, 200));
// Read the output
const readResult = await client.callTool({
name: 'mcpretentious-read',
arguments: {
terminalId: testTerminalId
}
});
const output = readResult.content[0].text;
assert.ok(output.includes(`Testing ${backend} backend`),
`Should see echo output for ${backend}. Actual output: ${output}`);
});
it('should handle special keys', async () => {
// Test Ctrl+C to clear any pending input
await client.callTool({
name: 'mcpretentious-type',
arguments: {
terminalId: testTerminalId,
input: [{ key: 'ctrl-c' }]
}
});
await new Promise(resolve => setTimeout(resolve, 100));
// Now send a command
await client.callTool({
name: 'mcpretentious-type',
arguments: {
terminalId: testTerminalId,
input: ['echo "After interrupt"', { key: 'enter' }]
}
});
await new Promise(resolve => setTimeout(resolve, 200));
const readResult = await client.callTool({
name: 'mcpretentious-read',
arguments: {
terminalId: testTerminalId,
lines: 10
}
});
const output = readResult.content[0].text;
assert.ok(output.includes('After interrupt'),
`Should execute command after Ctrl+C for ${backend}`);
});
it('should get terminal info', async () => {
const infoResult = await client.callTool({
name: 'mcpretentious-info',
arguments: { terminalId: testTerminalId }
});
const info = JSON.parse(infoResult.content[0].text);
// Verify info structure
assert.ok(info.terminalId === testTerminalId,
`Should have correct terminal ID for ${backend}`);
// Backend names are capitalized in the API response
const expectedBackendName = backend === 'iterm' ? 'iTerm2' : 'TMux';
assert.ok(info.backend === expectedBackendName,
`Should report correct backend: expected ${expectedBackendName}, got ${info.backend}`);
assert.ok(info.sessionId, 'Should have session ID');
assert.ok(info.dimensions, 'Should have dimensions');
assert.ok(typeof info.dimensions.columns === 'number', 'Should have columns');
assert.ok(typeof info.dimensions.rows === 'number', 'Should have rows');
});
it('should list terminals', async () => {
const listResult = await client.callTool({
name: 'mcpretentious-list',
arguments: {}
});
const sessions = JSON.parse(listResult.content[0].text);
// Should find our test terminal
const ourSession = sessions.find(s => s.terminalId === testTerminalId);
assert.ok(ourSession, `Should find our terminal in the list for ${backend}`);
// Backend names are capitalized in the API response
const expectedBackendName = backend === 'iterm' ? 'iTerm2' : 'TMux';
assert.ok(ourSession.backend === expectedBackendName,
`Listed terminal should have correct backend: expected ${expectedBackendName}, got ${ourSession.backend}`);
});
});
describe('Screenshot', () => {
let testTerminalId;
before(async () => {
const result = await client.callTool({
name: 'mcpretentious-open',
arguments: {}
});
testTerminalId = extractTerminalId(result.content[0].text);
openTerminals.push(testTerminalId);
await new Promise(resolve => setTimeout(resolve, 300));
});
after(async () => {
if (testTerminalId) {
try {
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId: testTerminalId }
});
openTerminals = openTerminals.filter(id => id !== testTerminalId);
} catch (error) {
console.error(`Failed to close terminal ${testTerminalId}:`, error.message);
}
}
});
it('should capture screenshot', async () => {
// Add some content
await client.callTool({
name: 'mcpretentious-type',
arguments: {
terminalId: testTerminalId,
input: [`echo "Screenshot test for ${backend}"`, { key: 'enter' }]
}
});
await new Promise(resolve => setTimeout(resolve, 200));
// Get screenshot
const result = await client.callTool({
name: 'mcpretentious-screenshot',
arguments: {
terminalId: testTerminalId,
layers: ['text', 'cursor']
}
});
const screenshot = JSON.parse(result.content[0].text);
// Verify screenshot structure
assert.ok(screenshot.terminal, `Should have terminal info for ${backend}`);
assert.ok(screenshot.viewport, `Should have viewport info for ${backend}`);
assert.ok(screenshot.cursor, `Should have cursor info for ${backend}`);
assert.ok(Array.isArray(screenshot.text), `Should have text array for ${backend}`);
assert.ok(screenshot.text.length > 0, `Should have screen content for ${backend}`);
// Check if our text is visible
const fullText = screenshot.text.join('\n');
assert.ok(fullText.includes('Screenshot test') || fullText.includes(backend),
`Should capture our test text for ${backend}`);
});
});
describe('Mouse Support', () => {
let testTerminalId;
before(async () => {
// Open a terminal for mouse tests
const result = await client.callTool({
name: 'mcpretentious-open',
arguments: {}
});
testTerminalId = extractTerminalId(result.content[0].text);
openTerminals.push(testTerminalId);
// Wait for terminal to be ready
await new Promise(resolve => setTimeout(resolve, 300));
// Clear terminal and prepare for mouse tests
await client.callTool({
name: 'mcpretentious-type',
arguments: {
terminalId: testTerminalId,
input: ['clear', { key: 'enter' }]
}
});
await new Promise(resolve => setTimeout(resolve, 200));
});
after(async () => {
if (testTerminalId) {
try {
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId: testTerminalId }
});
openTerminals = openTerminals.filter(id => id !== testTerminalId);
} catch (error) {
console.error(`Failed to close terminal ${testTerminalId}:`, error.message);
}
}
});
it('should send mouse click events', async () => {
// Send a left click at position (10, 5)
const clickResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 10,
y: 5,
button: 'left'
}
});
assert.ok(clickResult.content[0].text.includes('Mouse press: left at (10, 5)'),
`Should confirm left mouse press for ${backend}`);
// Send release
const releaseResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'release',
x: 10,
y: 5,
button: 'left'
}
});
assert.ok(releaseResult.content[0].text.includes('Mouse release: left at (10, 5)'),
`Should confirm left mouse release for ${backend}`);
});
it('should send mouse drag events', async () => {
// Start drag
await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 5,
y: 5,
button: 'left'
}
});
// Drag motion
const dragResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'drag',
x: 15,
y: 10,
button: 'left'
}
});
assert.ok(dragResult.content[0].text.includes('Mouse drag: left at (15, 10)'),
`Should confirm mouse drag for ${backend}`);
// End drag
await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'release',
x: 15,
y: 10,
button: 'left'
}
});
});
it('should send mouse scroll events', async () => {
// Scroll up
const scrollUpResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 20,
y: 10,
button: 'scrollUp'
}
});
assert.ok(scrollUpResult.content[0].text.includes('scrollUp at (20, 10)'),
`Should confirm scroll up for ${backend}`);
// Scroll down
const scrollDownResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 20,
y: 10,
button: 'scrollDown'
}
});
assert.ok(scrollDownResult.content[0].text.includes('scrollDown at (20, 10)'),
`Should confirm scroll down for ${backend}`);
});
it('should handle mouse events with modifiers', async () => {
// Click with shift modifier
const shiftClickResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 10,
y: 5,
button: 'left',
shift: true
}
});
assert.ok(shiftClickResult.content[0].text.includes('with Shift'),
`Should indicate shift modifier for ${backend}`);
// Release with shift
await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'release',
x: 10,
y: 5,
button: 'left',
shift: true
}
});
// Click with multiple modifiers
const multiModResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 10,
y: 5,
button: 'right',
ctrl: true,
alt: true
}
});
assert.ok(multiModResult.content[0].text.includes('Ctrl') &&
multiModResult.content[0].text.includes('Alt'),
`Should indicate multiple modifiers for ${backend}`);
// Release
await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'release',
x: 10,
y: 5,
button: 'right',
ctrl: true,
alt: true
}
});
});
it('should handle different mouse buttons', async () => {
// Middle button click
const middleResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 15,
y: 8,
button: 'middle'
}
});
assert.ok(middleResult.content[0].text.includes('middle'),
`Should handle middle button for ${backend}`);
await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'release',
x: 15,
y: 8,
button: 'middle'
}
});
// Right button click
const rightResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 25,
y: 12,
button: 'right'
}
});
assert.ok(rightResult.content[0].text.includes('right'),
`Should handle right button for ${backend}`);
await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'release',
x: 25,
y: 12,
button: 'right'
}
});
});
it('should handle direct button codes', async () => {
// Use direct button code (button-3 for example)
const buttonCodeResult = await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'press',
x: 30,
y: 15,
button: 'button-3'
}
});
assert.ok(buttonCodeResult.content[0].text.includes('button 3'),
`Should handle direct button codes for ${backend}`);
await client.callTool({
name: 'mcpretentious-mouse',
arguments: {
terminalId: testTerminalId,
event: 'release',
x: 30,
y: 15,
button: 'button-3'
}
});
});
});
describe('Multiple Terminals', () => {
it('should handle multiple terminals', async () => {
// Open first terminal
const result1 = await client.callTool({
name: 'mcpretentious-open',
arguments: {}
});
const terminal1 = extractTerminalId(result1.content[0].text);
openTerminals.push(terminal1);
// Open second terminal
const result2 = await client.callTool({
name: 'mcpretentious-open',
arguments: {}
});
const terminal2 = extractTerminalId(result2.content[0].text);
openTerminals.push(terminal2);
await new Promise(resolve => setTimeout(resolve, 300));
// Both should be using the same backend
assert.ok(terminal1.startsWith(`${backend}:`),
`First terminal should use ${backend}`);
assert.ok(terminal2.startsWith(`${backend}:`),
`Second terminal should use ${backend}`);
// Send different echo to each
await client.callTool({
name: 'mcpretentious-type',
arguments: {
terminalId: terminal1,
input: [`echo "Terminal ONE ${backend}"`, { key: 'enter' }]
}
});
await client.callTool({
name: 'mcpretentious-type',
arguments: {
terminalId: terminal2,
input: [`echo "Terminal TWO ${backend}"`, { key: 'enter' }]
}
});
await new Promise(resolve => setTimeout(resolve, 200));
// Read from each
const read1 = await client.callTool({
name: 'mcpretentious-read',
arguments: {
terminalId: terminal1,
lines: 5
}
});
const read2 = await client.callTool({
name: 'mcpretentious-read',
arguments: {
terminalId: terminal2,
lines: 5
}
});
assert.ok(read1.content[0].text.includes('Terminal ONE'),
`First terminal should show ONE for ${backend}`);
assert.ok(read2.content[0].text.includes('Terminal TWO'),
`Second terminal should show TWO for ${backend}`);
// Clean up
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId: terminal1 }
});
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId: terminal2 }
});
openTerminals = openTerminals.filter(id => id !== terminal1 && id !== terminal2);
});
});
if (backend === 'iterm') {
describe('iTerm2-specific features', () => {
it('should resize terminal', async () => {
// Open a terminal
const openResult = await client.callTool({
name: 'mcpretentious-open',
arguments: {}
});
const terminalId = extractTerminalId(openResult.content[0].text);
openTerminals.push(terminalId);
await new Promise(resolve => setTimeout(resolve, 300));
// Resize terminal
const resizeResult = await client.callTool({
name: 'mcpretentious-resize',
arguments: {
terminalId,
columns: 100,
rows: 30
}
});
assert.ok(resizeResult.content[0].text.includes('100×30'),
'Should confirm new dimensions for iTerm2');
// Clean up
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId }
});
openTerminals = openTerminals.filter(id => id !== terminalId);
});
});
}
if (backend === 'tmux') {
describe('tmux-specific features', () => {
it('should work with tmux session naming', async () => {
// Open a terminal - tmux uses session names
const openResult = await client.callTool({
name: 'mcpretentious-open',
arguments: {}
});
const terminalId = extractTerminalId(openResult.content[0].text);
openTerminals.push(terminalId);
// Terminal ID should include tmux session name format
assert.ok(terminalId.startsWith('tmux:'),
'Terminal ID should indicate tmux backend');
// The session part should follow tmux naming conventions
const sessionName = terminalId.split(':')[1];
assert.ok(/^mcp-[a-f0-9]{8}$/.test(sessionName),
`tmux session name should follow pattern: ${sessionName}`);
// Clean up
await client.callTool({
name: 'mcpretentious-close',
arguments: { terminalId }
});
openTerminals = openTerminals.filter(id => id !== terminalId);
});
});
}
});
}
console.log('\n✅ Multi-backend integration tests completed');