Files
electerm--electerm/test/unit-ci/session-transports.spec.js
2026-07-13 12:46:20 +08:00

382 lines
9.7 KiB
JavaScript

process.env.NODE_ENV = 'development'
const { describe, test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const net = require('node:net')
const { once } = require('node:events')
const findFreePort = require('find-free-port')
const FtpSrv = require('@electerm/ftp-srv')
const serialportModulePath = require.resolve('serialport')
const serialportExports = require(serialportModulePath)
const { MockBinding } = require('@serialport/binding-mock')
const globalState = require('../../src/app/server/global-state')
const { Ftp } = require('../../src/app/server/session-ftp')
const sessionSerial = require('../../src/app/server/session-serial')
const sessionTelnet = require('../../src/app/server/session-telnet')
const FTP_USERNAME = 'test'
const FTP_PASSWORD = 'test123'
const SERIAL_PATH = '/dev/electerm-test'
const TELNET_USERNAME = 'tester'
const TELNET_PASSWORD = 'electerm-test'
function makeTmpDir (prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
}
async function getFreePort (start = 30000, end = 39999) {
const [port] = await findFreePort(start, end, '127.0.0.1')
return port
}
async function startFtpServer () {
const root = makeTmpDir('electerm-ftp-test-')
const port = await getFreePort(31000, 31999)
const server = new FtpSrv({
url: `ftp://127.0.0.1:${port}`,
anonymous: false,
root
})
server.on('login', ({ username, password }, resolve, reject) => {
if (username === FTP_USERNAME && password === FTP_PASSWORD) {
return resolve({ root })
}
return reject(new Error('Invalid username or password'))
})
await server.listen()
return {
port,
root,
async close () {
await server.close()
fs.rmSync(root, { recursive: true, force: true })
}
}
}
async function startTelnetServer () {
const port = await getFreePort(32000, 32999)
const sockets = new Set()
function waitForIdle (timeout = 5000) {
if (sockets.size === 0) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Timed out waiting for telnet sockets to close'))
}, timeout)
const interval = setInterval(() => {
if (sockets.size === 0) {
clearTimeout(timer)
clearInterval(interval)
resolve()
}
}, 25)
})
}
const server = net.createServer((socket) => {
sockets.add(socket)
socket.setEncoding('utf8')
let stage = 'username'
let buffer = ''
socket.write('login: ')
socket.on('data', (chunk) => {
buffer += chunk
while (buffer.includes('\n')) {
const endIndex = buffer.indexOf('\n')
const line = buffer.slice(0, endIndex).replace(/\r$/, '')
buffer = buffer.slice(endIndex + 1)
if (stage === 'username') {
if (line === TELNET_USERNAME) {
stage = 'password'
socket.write('Password: ')
} else {
socket.write('login incorrect\r\nlogin: ')
}
continue
}
if (stage === 'password') {
if (line === TELNET_PASSWORD) {
stage = 'shell'
socket.write('Welcome to electerm\r\n$ ')
} else {
socket.write('Login failed\r\n')
socket.end()
}
continue
}
socket.write(`echo:${line}\r\n$ `)
}
})
socket.on('close', () => {
sockets.delete(socket)
})
})
server.listen(port, '127.0.0.1')
await once(server, 'listening')
return {
port,
waitForIdle,
async close () {
await waitForIdle().catch(() => {})
for (const socket of sockets) {
socket.destroy()
}
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
}
}
function waitForText (emitter, matcher, timeout = 5000) {
return new Promise((resolve, reject) => {
let output = ''
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for text. Received: ${output}`))
}, timeout)
const onData = (chunk) => {
output += chunk.toString()
if (matcher(output, chunk)) {
cleanup()
resolve(output)
}
}
const onClose = () => {
cleanup()
reject(new Error(`Stream closed before matcher succeeded. Received: ${output}`))
}
const cleanup = () => {
clearTimeout(timer)
emitter.off('data', onData)
emitter.off('close', onClose)
emitter.off('end', onClose)
}
emitter.on('data', onData)
emitter.on('close', onClose)
emitter.on('end', onClose)
})
}
describe('session-ftp transport flows', () => {
let ftpServer
let ftp
beforeEach(async () => {
ftpServer = await startFtpServer()
ftp = new Ftp({
uid: 'ftp-session-ci',
host: '127.0.0.1',
port: ftpServer.port,
user: FTP_USERNAME,
password: FTP_PASSWORD,
readyTimeout: 5000
})
await ftp.connect(ftp.initOptions)
})
afterEach(async () => {
if (ftp) {
ftp.kill()
ftp = null
}
if (ftpServer) {
await ftpServer.close()
ftpServer = null
}
})
test('connects and performs core file operations against ftp-srv', async () => {
assert.equal(globalState.getSession(ftp.pid), ftp)
await ftp.mkdir('/docs')
await ftp.writeFile('/docs/hello.txt', 'hello ftp')
assert.equal(await ftp.readFile('/docs/hello.txt'), 'hello ftp')
const stats = await ftp.stat('/docs/hello.txt')
assert.equal(stats.isDirectory, false)
assert.equal(stats.size, 'hello ftp'.length)
await ftp.cp('/docs/hello.txt', '/docs-copy.txt')
assert.equal(await ftp.readFile('/docs-copy.txt'), 'hello ftp')
const list = await ftp.list('/docs')
assert.deepEqual(list.map(item => item.name), ['hello.txt'])
})
test('copies directories recursively and removes them recursively', async () => {
await ftp.mkdir('/source')
await ftp.mkdir('/source/nested')
await ftp.writeFile('/source/root.txt', 'root')
await ftp.writeFile('/source/nested/child.txt', 'child-data')
assert.equal(await ftp.cp('/source', '/copied'), 1)
assert.equal(await ftp.readFile('/copied/root.txt'), 'root')
assert.equal(await ftp.readFile('/copied/nested/child.txt'), 'child-data')
const size = await ftp.getFolderSize('/copied')
assert.deepEqual(size, {
size: 'root'.length + 'child-data'.length,
count: 2
})
assert.equal(await ftp.rmdir('/source'), 1)
assert.equal(await ftp.tryStat('/source'), null)
})
})
describe('session-serial transport flows', () => {
let term
beforeEach(() => {
require.cache[serialportModulePath].exports = {
...serialportExports,
SerialPort: serialportExports.SerialPortMock
}
MockBinding.reset()
MockBinding.createPort(SERIAL_PATH, {
echo: true,
record: true
})
})
afterEach(async () => {
if (term && term.port) {
const port = term.port
const closePromise = once(port, 'close').catch(() => {})
term.kill()
await closePromise
term = null
} else if (term) {
term.kill()
term = null
}
require.cache[serialportModulePath].exports = serialportExports
MockBinding.reset()
})
test('creates a serial session and echoes writes through the mock binding', async () => {
term = await sessionSerial.session({
uid: 'serial-session-ci',
path: SERIAL_PATH,
baudRate: 9600
})
assert.equal(globalState.getSession(term.pid), term)
const dataPromise = once(term.port, 'data')
term.write('ping')
const [data] = await dataPromise
assert.equal(data.toString(), 'ping')
const port = term.port
const closePromise = once(port, 'close')
term.kill()
await closePromise
term = null
assert.equal(globalState.getSession('serial-session-ci'), undefined)
})
test('reports serial connectivity through the exported test helper', async () => {
assert.equal(await sessionSerial.test({
path: SERIAL_PATH,
baudRate: 9600
}), true)
})
})
describe('session-telnet transport flows', () => {
let telnetServer
let term
beforeEach(async () => {
telnetServer = await startTelnetServer()
})
afterEach(async () => {
if (term) {
term.kill()
term = null
}
if (telnetServer) {
await telnetServer.close()
telnetServer = null
}
})
test('connects, authenticates with regex prompts, and exchanges shell data', async () => {
term = await sessionTelnet.session({
uid: 'telnet-session-ci',
host: '127.0.0.1',
port: telnetServer.port,
username: TELNET_USERNAME,
password: TELNET_PASSWORD,
loginPrompt: '/login[: ]*$/i',
passwordPrompt: '/password[: ]*$/i',
readyTimeout: 5000
})
term.channel.on('error', () => {})
term.channel.socket.on('error', () => {})
assert.equal(globalState.getSession(term.pid), term)
const banner = await waitForText(term.port, (output) => {
return output.includes('Welcome to electerm')
})
assert.match(banner, /welcome to electerm/i)
const responsePromise = waitForText(term.port, (output) => {
return output.includes('echo:status')
})
term.write('status\n')
const response = await responsePromise
assert.match(response, /echo:status/)
term.resize(132, 43)
assert.equal(term.channel.options.terminalWidth, 132)
assert.equal(term.channel.options.terminalHeight, 43)
term.kill()
await telnetServer.waitForIdle()
term = null
assert.equal(globalState.getSession('telnet-session-ci'), undefined)
})
})