Files
2026-07-13 12:46:20 +08:00

564 lines
22 KiB
JavaScript

const { test, describe, before } = require('node:test')
const assert = require('assert/strict')
const axios = require('axios')
const serverUrl = 'http://127.0.0.1:30837/mcp'
// Helper function to make HTTP requests
async function makeHttpRequest (method, urlStr, data = null, headers = {}) {
try {
const response = await axios({
method: method.toUpperCase(),
url: urlStr,
data,
headers
})
return {
status: response.status,
headers: response.headers,
data: response.data
}
} catch (error) {
if (error.response) {
const err = new Error(`Request failed with status ${error.response.status}`)
err.response = {
status: error.response.status,
headers: error.response.headers,
data: error.response.data
}
throw err
} else {
throw error
}
}
}
// Helper to initialize MCP session
async function initSession () {
const initializeRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: {
name: 'electerm-test-client',
version: '1.0.0'
}
}
}
const response = await makeHttpRequest('post', serverUrl, initializeRequest, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
})
const sid = response.headers['mcp-session-id']
assert.ok(sid && sid !== 'null', `initSession: expected a real session ID but got: ${sid}`)
return sid
}
// Helper to call a tool via MCP
async function callTool (sessionId, id, toolName, args) {
const request = {
jsonrpc: '2.0',
id,
method: 'tools/call',
params: {
name: toolName,
arguments: args
}
}
const response = await makeHttpRequest('post', serverUrl, request, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
})
assert.equal(response.status, 200)
assert.equal(response.headers['content-type'], 'text/event-stream')
const sseData = response.data
const dataLine = sseData.split('\n').find(line => line.startsWith('data: '))
assert.ok(dataLine, 'SSE data line not found')
const jsonData = JSON.parse(dataLine.substring(6))
assert.equal(jsonData.jsonrpc, '2.0')
assert.equal(jsonData.id, id)
return jsonData
}
// Helper: check if renderer is available
async function checkRenderer (sid) {
const jsonData = await callTool(sid, 999, 'list_electerm_tabs', {})
return !jsonData.error
}
describe('mcp-sftp-transfer-trzsz', function () {
let sessionId = null
let hasRenderer = false
const uniqueId = Date.now()
before(async () => {
sessionId = await initSession()
hasRenderer = await checkRenderer(sessionId)
if (!hasRenderer) {
console.log('No renderer detected — renderer-dependent tests will be skipped')
}
})
// ==================== Tool availability check (no renderer needed) ====================
test('should list SFTP/transfer/trzsz tools in tools/list', { timeout: 100000 }, async function () {
const response = await makeHttpRequest('post', serverUrl, {
jsonrpc: '2.0',
id: 400,
method: 'tools/list',
params: {}
}, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
})
assert.equal(response.status, 200)
const sseData = response.data
const dataLine = sseData.split('\n').find(line => line.startsWith('data: '))
assert.ok(dataLine)
const jsonData = JSON.parse(dataLine.substring(6))
assert.equal(jsonData.jsonrpc, '2.0')
assert.equal(jsonData.id, 400)
assert.ok(jsonData.result)
assert.ok(Array.isArray(jsonData.result.tools))
const toolNames = jsonData.result.tools.map(t => t.name)
const expectedTools = [
'electerm_sftp_list',
'electerm_sftp_stat',
'electerm_sftp_read_file',
'electerm_sftp_del_file_or_folder',
'electerm_sftp_upload',
'electerm_sftp_download',
'electerm_zmodem_upload',
'electerm_zmodem_download',
'electerm_sftp_transfer_list',
'electerm_sftp_transfer_history',
'get_electerm_terminal_status',
'cancel_electerm_terminal_command',
'run_electerm_background_command',
'get_electerm_background_task_status',
'get_electerm_background_task_log',
'cancel_electerm_background_task'
]
for (const toolName of expectedTools) {
assert.ok(toolNames.includes(toolName), `Expected tool "${toolName}" to be registered`)
}
})
// ==================== Validation error tests (no renderer needed) ====================
test('sftp_list: should error if remotePath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 101, 'electerm_sftp_list', {})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remotePath is missing')
})
test('sftp_del: should error if remotePath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 131, 'electerm_sftp_del_file_or_folder', {})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remotePath is missing')
})
test('sftp_upload: should error if localPath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 201, 'electerm_sftp_upload', {
remotePath: '/tmp/test.txt'
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when localPath is missing')
})
test('sftp_download: should error if remotePath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 211, 'electerm_sftp_download', {
localPath: '/tmp/test.txt'
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remotePath is missing')
})
test('sftp_download: should error if saveFolder is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 312, 'electerm_zmodem_download', {
remoteFiles: ['/tmp/test.txt']
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when saveFolder is missing')
})
test('electerm_zmodem_upload: should error if files is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 301, 'electerm_zmodem_upload', {})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when files is missing')
})
test('electerm_zmodem_upload: should error if files array is empty', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 302, 'electerm_zmodem_upload', {
files: []
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when files array is empty')
})
test('electerm_zmodem_download: should error if remoteFiles is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 311, 'electerm_zmodem_download', {
saveFolder: '/tmp'
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remoteFiles is missing')
})
// ==================== Renderer-dependent SFTP operation tests ====================
test('sftp_list: should list remote directory', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 100, 'electerm_sftp_list', {
remotePath: '/tmp'
})
// Either no SSH tab open (error) or success with valid result
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(Array.isArray(result.list), 'result.list should be an array')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
assert.ok(result.host !== undefined, 'result.host should be present')
}
})
test('sftp_stat: should get stat of remote file or directory', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 110, 'electerm_sftp_stat', {
remotePath: '/tmp'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.stat !== undefined, 'result.stat should be present')
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
}
})
test('sftp_read_file: should read content of a remote file', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 120, 'electerm_sftp_read_file', {
remotePath: '/etc/hostname'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(result.content !== undefined, 'result.content should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
}
})
test('sftp_del: should delete a remote file', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 130, 'electerm_sftp_del_file_or_folder', {
remotePath: `/tmp/mcp2_test_delete_${uniqueId}.txt`
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'should succeed')
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
}
})
// ==================== Full SFTP workflow (requires SSH connection) ====================
test('sftp: full workflow - list, stat, read, del', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const {
TEST_HOST,
TEST_PASS,
TEST_USER,
TEST_PORT
} = require('../e2e/common/env')
const wfId = Date.now()
const bookmarkTitle = `MCP2_SFTP_Test_${wfId}`
// Step 1: Create SSH bookmark
const addData = await callTool(sessionId, 140, 'add_electerm_bookmark_ssh', {
title: bookmarkTitle,
host: TEST_HOST,
port: parseInt(TEST_PORT, 10),
username: TEST_USER,
password: TEST_PASS
})
assert.ok(addData.result, 'add_bookmark should succeed')
const addResult = JSON.parse(addData.result.content[0].text)
assert.equal(addResult.success, true)
const bookmarkId = addResult.id
try {
// Step 2: Open the bookmark
const openData = await callTool(sessionId, 141, 'open_electerm_bookmark', { id: bookmarkId })
assert.ok(openData.result, 'open_bookmark should succeed')
// Wait for tab to initialize and SFTP panel to be ready
await new Promise(resolve => setTimeout(resolve, 8000))
// Step 3: List /tmp directory
const listData = await callTool(sessionId, 142, 'electerm_sftp_list', { remotePath: '/tmp' })
if (listData.result?.isError) {
// SFTP panel may not be initialized in test environment
assert.ok(listData.result.content[0].text, 'error message should be present')
} else {
assert.ok(listData.result, 'sftp_list should succeed')
const listResult = JSON.parse(listData.result.content[0].text)
assert.ok(Array.isArray(listResult.list), 'list should be an array')
}
// Step 4: Stat /tmp
const statData = await callTool(sessionId, 143, 'electerm_sftp_stat', { remotePath: '/tmp' })
if (statData.result?.isError) {
assert.ok(statData.result.content[0].text, 'error message should be present')
} else {
assert.ok(statData.result, 'sftp_stat should succeed')
const statResult = JSON.parse(statData.result.content[0].text)
assert.ok(statResult.stat !== undefined)
}
// Step 5: Read /etc/hostname
const readData = await callTool(sessionId, 144, 'electerm_sftp_read_file', { remotePath: '/etc/hostname' })
if (readData.result?.isError) {
assert.ok(readData.result.content[0].text, 'error message should be present')
} else {
assert.ok(readData.result, 'sftp_read_file should succeed')
const readResult = JSON.parse(readData.result.content[0].text)
assert.ok(readResult.content !== undefined)
}
} finally {
// Step 6: Clean up - delete test bookmark
await callTool(sessionId, 145, 'delete_electerm_bookmark', { id: bookmarkId })
}
})
// ==================== File Transfer - Upload ====================
test('sftp_upload: should start upload transfer', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const fs = require('fs')
const uploadFile = `/tmp/mcp2-test-upload-${uniqueId}.txt`
if (!fs.existsSync(uploadFile)) {
fs.writeFileSync(uploadFile, 'test upload content for MCP test')
}
const jsonData = await callTool(sessionId, 200, 'electerm_sftp_upload', {
localPath: uploadFile,
remotePath: uploadFile
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'upload should succeed')
assert.ok(result.transferId !== undefined, 'should have transferId')
assert.ok(result.tabId !== undefined, 'should have tabId')
}
})
// ==================== File Transfer - Download ====================
test('sftp_download: should start download transfer', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 210, 'electerm_sftp_download', {
remotePath: '/etc/hostname',
localPath: `/tmp/mcp2-downloaded-hostname-${uniqueId}.txt`
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'download should succeed')
assert.ok(result.transferId !== undefined, 'should have transferId')
assert.ok(result.tabId !== undefined, 'should have tabId')
}
})
test('sftp_download: should support conflictPolicy parameter', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 212, 'electerm_sftp_download', {
remotePath: '/etc/hostname',
localPath: `/tmp/mcp2-downloaded-hostname-ow-${uniqueId}.txt`,
conflictPolicy: 'overwrite'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'download should succeed')
}
})
// ==================== Zmodem Upload (trzsz/rzsz) ====================
test('electerm_zmodem_upload: should initiate trz upload (trzsz, default)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 300, 'electerm_zmodem_upload', {
files: [`/tmp/mcp2-trzsz-upload-${uniqueId}.txt`]
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.ok(Array.isArray(result.files), 'result.files should be array')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
assert.equal(result.protocol, 'rzsz', 'default protocol should be rzsz')
assert.equal(result.command, 'rz', 'default command should be rz')
}
})
test('electerm_zmodem_upload: should initiate rz upload (rzsz protocol)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 303, 'electerm_zmodem_upload', {
files: [`/tmp/mcp2-rzsz-upload-${uniqueId}.txt`],
protocol: 'rzsz'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.equal(result.protocol, 'rzsz', 'protocol should be rzsz')
assert.equal(result.command, 'rz', 'command should be rz for rzsz')
}
})
// ==================== Zmodem Download (trzsz/rzsz) ====================
test('electerm_zmodem_download: should initiate tsz download (trzsz, default)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 310, 'electerm_zmodem_download', {
remoteFiles: [`/tmp/mcp2-trzsz-download-${uniqueId}.txt`],
saveFolder: '/tmp'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.ok(Array.isArray(result.remoteFiles), 'result.remoteFiles should be array')
assert.ok(result.saveFolder !== undefined, 'result.saveFolder should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
assert.equal(result.protocol, 'rzsz', 'default protocol should be rzsz')
assert.equal(result.command, 'sz', 'default command should be sz')
}
})
test('electerm_zmodem_download: should initiate sz download (rzsz protocol)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 313, 'electerm_zmodem_download', {
remoteFiles: [`/tmp/mcp2-rzsz-download-${uniqueId}.txt`],
saveFolder: '/tmp',
protocol: 'rzsz'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.equal(result.protocol, 'rzsz', 'protocol should be rzsz')
assert.equal(result.command, 'sz', 'command should be sz for rzsz')
}
})
// ==================== Transfer List & History ====================
test('sftp_transfer_list: should return current transfer list', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 400, 'electerm_sftp_transfer_list', {})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(Array.isArray(result), 'result should be an array')
if (result.length > 0) {
const t = result[0]
assert.ok(t.id !== undefined, 'transfer should have id')
assert.ok(t.fromPath !== undefined, 'transfer should have fromPath')
assert.ok(t.toPath !== undefined, 'transfer should have toPath')
assert.ok(t.typeFrom !== undefined, 'transfer should have typeFrom')
assert.ok(t.typeTo !== undefined, 'transfer should have typeTo')
}
}
})
test('sftp_transfer_history: should return transfer history', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 410, 'electerm_sftp_transfer_history', {})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(Array.isArray(result), 'result should be an array')
if (result.length > 0) {
const h = result[0]
assert.ok(h.id !== undefined, 'history item should have id')
assert.ok(h.fromPath !== undefined, 'history item should have fromPath')
assert.ok(h.toPath !== undefined, 'history item should have toPath')
assert.ok(h.typeFrom !== undefined, 'history item should have typeFrom')
}
}
})
})