228 lines
7.4 KiB
JavaScript
228 lines
7.4 KiB
JavaScript
// xmodem.spec.js
|
||
// Unit tests for XmodemSession – verifies that the session resets properly
|
||
// after a successful send so that normal serial-port I/O resumes.
|
||
process.env.NODE_ENV = 'development'
|
||
|
||
const { describe, it } = 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 { XmodemSession, XMODEM_STATE } = require('../../src/app/server/xmodem')
|
||
|
||
// XMODEM control bytes
|
||
const SOH = 0x01
|
||
const EOT = 0x04
|
||
const ACK = 0x06
|
||
const CRC = 0x43 // 'C'
|
||
|
||
/**
|
||
* Helper: create a minimal XmodemSession with mock terminal and websocket.
|
||
* The mock terminal records all writes; the mock websocket records all
|
||
* JSON messages sent to the client.
|
||
*/
|
||
function createMockSession () {
|
||
const written = []
|
||
const clientMessages = []
|
||
|
||
const term = {
|
||
writeRaw: (data) => written.push(Buffer.from(data)),
|
||
write: (data) => written.push(Buffer.from(data))
|
||
}
|
||
|
||
const ws = {
|
||
s: (msg) => clientMessages.push(msg)
|
||
}
|
||
|
||
const session = new XmodemSession(term, ws)
|
||
return { session, written, clientMessages, term, ws }
|
||
}
|
||
|
||
/**
|
||
* CRC-16/XMODEM calculation (mirrors the one in xmodem.js)
|
||
*/
|
||
function crc16Xmodem (data) {
|
||
let crc = 0
|
||
for (let i = 0; i < data.length; i++) {
|
||
crc = crc ^ (data[i] << 8)
|
||
for (let j = 0; j < 8; j++) {
|
||
if (crc & 0x8000) {
|
||
crc = (crc << 1) ^ 0x1021
|
||
} else {
|
||
crc = crc << 1
|
||
}
|
||
}
|
||
crc = crc & 0xFFFF
|
||
}
|
||
return crc
|
||
}
|
||
|
||
describe('XmodemSession send lifecycle', () => {
|
||
it('isActive returns false after send completes (session-end)', async () => {
|
||
const { session, clientMessages } = createMockSession()
|
||
|
||
// Start send
|
||
session.startSend()
|
||
assert.equal(session.isActive(), true)
|
||
|
||
// Set up a temp file to send (one 128-byte packet)
|
||
const tmpFile = path.join(os.tmpdir(), `xmodem-test-${Date.now()}.bin`)
|
||
const fileData = Buffer.alloc(128, 0xAA)
|
||
fs.writeFileSync(tmpFile, fileData)
|
||
|
||
session.setSendFiles([{
|
||
name: 'test.bin',
|
||
path: tmpFile,
|
||
size: 128
|
||
}])
|
||
|
||
// Simulate remote requesting CRC mode
|
||
session.handleData(Buffer.from([CRC]))
|
||
assert.equal(session.state, XMODEM_STATE.SENDING)
|
||
|
||
// Simulate remote ACKing the data packet
|
||
session.handleData(Buffer.from([ACK]))
|
||
|
||
// After ACK, sendNextPacket sees file is done → sendEot fires.
|
||
// sendEot sends EOT, fires session-end, and calls resetState.
|
||
// The state should now be IDLE.
|
||
assert.equal(session.state, XMODEM_STATE.IDLE)
|
||
assert.equal(session.isActive(), false)
|
||
|
||
// Verify session-end was sent to client
|
||
const sessionEndMsg = clientMessages.find(m => m.event === 'session-end')
|
||
assert.ok(sessionEndMsg, 'session-end message should be sent')
|
||
|
||
// Verify file-complete was sent to client
|
||
const fileCompleteMsg = clientMessages.find(m => m.event === 'file-complete')
|
||
assert.ok(fileCompleteMsg, 'file-complete message should be sent')
|
||
|
||
// Cleanup
|
||
try { fs.unlinkSync(tmpFile) } catch (e) {}
|
||
})
|
||
|
||
it('sendEot does not loop when called multiple times (stale ACK)', async () => {
|
||
const { session, written } = createMockSession()
|
||
|
||
session.startSend()
|
||
|
||
const tmpFile = path.join(os.tmpdir(), `xmodem-test-loop-${Date.now()}.bin`)
|
||
fs.writeFileSync(tmpFile, Buffer.alloc(128, 0xBB))
|
||
|
||
session.setSendFiles([{
|
||
name: 'test.bin',
|
||
path: tmpFile,
|
||
size: 128
|
||
}])
|
||
|
||
// Remote requests CRC mode
|
||
session.handleData(Buffer.from([CRC]))
|
||
|
||
// Remote ACKs the data packet → triggers sendEot internally
|
||
session.handleData(Buffer.from([ACK]))
|
||
assert.equal(session.state, XMODEM_STATE.IDLE)
|
||
|
||
// Now simulate a stale ACK arriving after the session is already IDLE.
|
||
// Before the fix this would cause sendEot → sendNextPacket → sendEot loop.
|
||
const eotCountBefore = written.filter(b => b.length === 1 && b[0] === EOT).length
|
||
session.handleData(Buffer.from([ACK]))
|
||
const eotCountAfter = written.filter(b => b.length === 1 && b[0] === EOT).length
|
||
|
||
// No additional EOT should have been sent
|
||
assert.equal(eotCountAfter, eotCountBefore, 'no extra EOT on stale ACK')
|
||
|
||
// State should still be IDLE
|
||
assert.equal(session.state, XMODEM_STATE.IDLE)
|
||
|
||
try { fs.unlinkSync(tmpFile) } catch (e) {}
|
||
})
|
||
|
||
it('resetState clears timeouts', async () => {
|
||
const { session } = createMockSession()
|
||
|
||
session.startSend()
|
||
// startSend sets a receive timeout
|
||
assert.ok(session.receiveTimeout, 'receiveTimeout should be set after startSend')
|
||
|
||
session.resetState()
|
||
|
||
// After reset, timeouts should be nulled and state should be IDLE
|
||
assert.equal(session.receiveTimeout, null)
|
||
assert.equal(session.sendTimeout, null)
|
||
assert.equal(session.state, XMODEM_STATE.IDLE)
|
||
})
|
||
|
||
it('handleData returns false when session is IDLE (data passes through)', async () => {
|
||
const { session } = createMockSession()
|
||
|
||
// Session starts as IDLE
|
||
assert.equal(session.state, XMODEM_STATE.IDLE)
|
||
|
||
// Regular terminal data should NOT be consumed by xmodem
|
||
const consumed = session.handleData(Buffer.from('hello'))
|
||
assert.equal(consumed, false)
|
||
})
|
||
|
||
it('handleData returns false for non-protocol data when session is IDLE after send', async () => {
|
||
const { session } = createMockSession()
|
||
|
||
// Full send cycle: start → send files → remote ACKs → EOT → reset
|
||
session.startSend()
|
||
const fs = require('fs')
|
||
const tmpFile = path.join(os.tmpdir(), `xmodem-test-idle-${Date.now()}.bin`)
|
||
fs.writeFileSync(tmpFile, Buffer.alloc(128, 0xDD))
|
||
|
||
session.setSendFiles([{ name: 'test.bin', path: tmpFile, size: 128 }])
|
||
session.handleData(Buffer.from([CRC])) // remote requests CRC
|
||
session.handleData(Buffer.from([ACK])) // remote ACKs packet → sendEot → resetState
|
||
|
||
// Session should be IDLE
|
||
assert.equal(session.state, XMODEM_STATE.IDLE)
|
||
|
||
// Normal terminal text should NOT be consumed (falls through to ws.send)
|
||
assert.equal(session.handleData(Buffer.from('hello\n')), false)
|
||
assert.equal(session.handleData(Buffer.from([0x0D])), false) // Enter key
|
||
|
||
try { fs.unlinkSync(tmpFile) } catch (e) {}
|
||
})
|
||
|
||
it('receive path resets state after EOT completion', async () => {
|
||
const { session, written, clientMessages } = createMockSession()
|
||
|
||
// Set up save path
|
||
session.setSavePath(os.tmpdir())
|
||
|
||
// Build a valid XMODEM-128 CRC packet: SOH + block 1 + ~block1 + 128 data + CRC16
|
||
const blockNum = 1
|
||
const data = Buffer.alloc(128, 0xCC)
|
||
const crc = crc16Xmodem(data)
|
||
|
||
const packet = Buffer.alloc(3 + 128 + 2)
|
||
packet[0] = SOH
|
||
packet[1] = blockNum
|
||
packet[2] = blockNum ^ 0xFF
|
||
data.copy(packet, 3)
|
||
packet[3 + 128] = (crc >> 8) & 0xFF
|
||
packet[3 + 128 + 1] = crc & 0xFF
|
||
|
||
// Send the data packet
|
||
session.handleData(packet)
|
||
assert.equal(session.state, XMODEM_STATE.RECEIVING)
|
||
|
||
// Send EOT (end of transmission)
|
||
session.handleData(Buffer.from([EOT]))
|
||
|
||
// After EOT, receive path should have reset state to IDLE
|
||
assert.equal(session.state, XMODEM_STATE.IDLE)
|
||
assert.equal(session.isActive(), false)
|
||
|
||
// Verify ACK was sent for EOT
|
||
const ackWritten = written.find(b => b.length === 1 && b[0] === ACK)
|
||
assert.ok(ackWritten, 'ACK should be sent for EOT')
|
||
|
||
// Verify session-end was sent to client
|
||
const sessionEndMsg = clientMessages.find(m => m.event === 'session-end')
|
||
assert.ok(sessionEndMsg, 'session-end should be sent after receive complete')
|
||
})
|
||
})
|