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

228 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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')
})
})