chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user