Initial commit: OpenTerminal v0.1.0

Open-source terminal app (local + SSH): split panes, themes, SFTP,
server monitoring, broadcast input, ZMODEM, system tray, single instance.
Electron + React + TypeScript. MIT License.
This commit is contained in:
Bill committed 2026-09-07 16:15:50 +08:00
commit a9acdbe500
99 files changed
+23940

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
// Decisive test: exact sftp.ts upload code, inline (no esbuild bundle).
import { createRequire } from 'module'
import { promises as fsp } from 'fs'
const require_ = createRequire(import.meta.url)
const ssh2 = require_('ssh2')
const sessionLayer = require_('D:/Coding/Terminal/tests/.session-e2e.cjs')
let clientProvider
function p(fn) {
return new Promise((resolve, reject) => {
fn((err, res) => (err ? reject(err) : resolve(res)))
})
}
sessionLayer.configureSessionRuntime({
broadcast: () => {},
getConnection: () => ({
id: 'jd', name: 't', host: process.env.JD_HOST, port: 22, username: process.env.JD_USER,
auth: 'password', askPasswordAtConnect: false, askPassphraseAtConnect: false,
keepaliveIntervalSec: 30, createdAt: Date.now(),
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
}),
getSecret: () => process.env.JD_PASS,
touch: () => {},
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
promptHostKey: () => { throw new Error('x') }
})
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
const client = sessionLayer.getSshClient(id)
// --- exact sftp.ts upload pattern ---
const transfer = { kind: 'upload', cancelled: false }
const emit = (e) => console.log('[evt]', e.state, e.file, e.bytes + '/' + e.totalBytes, e.error ?? '')
const local = './.exact-test.bin'
const content = Buffer.alloc(256 * 1024, 2)
await fsp.writeFile(local, content)
const remote = '/root/ot_exact.bin'
await new Promise((resolve, reject) => {
client.sftp((err, sftp) => (err != null ? reject(err) : resolve(sftp)))
})
const sftp = await new Promise((resolve, reject) => {
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
})
console.log('[exact] sftp channel open, calling fastPut')
await new Promise((resolve, reject) => {
sftp.fastPut(local, remote, {
concurrency: 4,
step: (totalTransferred, _chunk, total) => {
console.log('[exact] step', totalTransferred, '/', total)
if (transfer.cancelled) {
reject(new Error('已取消'))
return
}
}
}, (err) => (err ? reject(err) : resolve()))
})
const st = await new Promise((resolve, reject) => {
sftp.stat(remote, (err, s) => (err ? reject(err) : resolve(s)))
})
console.log('[exact] OK, remote size:', st.size, 'expected:', content.length)
await new Promise((resolve, reject) => sftp.unlink(remote, (e) => (e ? reject(e) : resolve())))
await fsp.rm(local, { force: true })
sessionLayer.killPty(id)
process.exit(0)
+139
View File
@@ -0,0 +1,139 @@
/**
* M5 command-store + session-log self-test (commands-store.mjs).
*
* Bundles src/main/commands.ts for plain Node with an in-memory electron stub,
* then verifies: history record/dedupe/cap/clear, library CRUD + ordering, and
* session log write/read/index behavior against a temp userData dir.
*
* Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs
* Run: node tests/commands-store.mjs (must exit 0)
*/
import { mkdtempSync, readFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'module'
const require_ = createRequire(import.meta.url)
const fail = (msg) => {
console.error(`FAIL: ${msg}`)
process.exit(1)
}
const ok = (cond, msg) => {
if (!cond) fail(msg)
console.log(` ok: ${msg}`)
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) -------
let commandsMod
try {
commandsMod = require_('./.commands-store.cjs')
} catch {
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs')
}
// ---- 2. Temp userData; capture openDir target ----------------------------------
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
let openedPath = null
const store = new commandsMod.CommandsStore(userData, async (p) => {
openedPath = p
})
// ---- 3. History record / dedupe / cap ------------------------------------------
store.recordCommand(' echo hello ') // trims to 'echo hello'
store.recordCommand('ls -la')
store.recordCommand('ps aux')
let hist = store.listHistory()
ok(hist.length === 3, 'history records 3 distinct commands')
ok(hist[0].command === 'ps aux', 'newest first by lastUsedAt')
// Re-run the newest command -> no new entry, recency refreshed, still length 3.
store.recordCommand('ps aux')
hist = store.listHistory()
ok(hist.length === 3, 're-run of newest command is deduped (no new entry)')
// Re-run an OLDER command -> it is NOT the newest, so it becomes a fresh front
// entry (spec dedupes only against the newest entry) with blank-recency guard.
store.recordCommand('echo hello')
hist = store.listHistory()
ok(hist.length === 4, 'older command re-run is a new front entry (dedupe is newest-only)')
ok(hist[0].command === 'echo hello', 'older re-run lands at front')
// Cap: fill until well above 500 keeps only the newest 500.
for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`)
hist = store.listHistory()
ok(hist.length === 500, `history capped at 500 (got ${hist.length})`)
ok(hist[0].command === 'cmd-519', 'newest filler at front')
// Clear.
store.clearHistory()
ok(store.listHistory().length === 0, 'clearHistory empties history')
// ---- 4. Library CRUD + ordering ------------------------------------------------
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
const b = store.saveLibraryItem({ command: 'df -h', name: 'disk', group: 'ops' })
ok(typeof a.id === 'string' && a.id.length > 0, 'saveLibraryItem assigns an id + createdAt')
let lib = store.listLibrary()
ok(lib.length === 2, 'library has 2 items')
ok(lib[0].id === b.id, 'library newest first by createdAt')
// Update existing by id.
const updated = store.saveLibraryItem({ ...b, command: 'df -hT', note: 'human + type' })
lib = store.listLibrary()
ok(lib.length === 2, 'update keeps count at 2')
ok(lib.find((x) => x.id === b.id).command === 'df -hT', 'update applied fields')
ok(lib.find((x) => x.id === b.id).createdAt === b.createdAt, 'update preserves createdAt')
// Delete.
store.deleteLibraryItem(a.id)
lib = store.listLibrary()
ok(lib.length === 1 && lib[0].id === b.id, 'deleteLibraryItem removes the item')
// ---- 5. Session logs ------------------------------------------------------------
const sid = '11111111-2222-3333-4444-555555555555'
const start = store.logStart(sid)
ok(!!start.fileName && start.fileName.startsWith('20') && start.file.endsWith('11111111.log'), 'logStart names file <stamp>-<id8>.log')
ok(start.endedAt === undefined, 'logStart has no endedAt yet')
ok(store.listSessionLogs().length === 1, 'listSessionLogs sees the active log')
// Writes land asynchronously in the file.
store.logWrite(sid, 'hello line one\n')
store.logWrite(sid, 'hello line two\n')
await wait(100)
const content = readFileSync(start.file, 'utf8')
ok(content === 'hello line one\nhello line two\n', 'async appends are written in order')
// Stop -> endedAt stamped + stays in list.
store.logStop(sid)
let logs = store.listSessionLogs()
ok(logs[0].endedAt !== undefined && logs[0].endedAt >= start.startedAt, 'logStop stamps endedAt')
ok(logs.length === 1, 'stopped log still listed')
// Writes after stop are ignored (no reopen, no throw).
store.logWrite(sid, 'after stop\n')
await wait(100)
ok(readFileSync(start.file, 'utf8') === 'hello line one\nhello line two\n', 'writes after stop are ignored')
// A fresh store (same userData) restores the log from the index file.
const store2 = new commandsMod.CommandsStore(userData)
logs = store2.listSessionLogs()
ok(logs.length === 1, 'new store hydrates log list from index.json')
ok(logs[0].sessionId === sid && logs[0].endedAt !== undefined, 'hydrated meta intact')
// Re-logStart same id stops the previous and creates a new file (delay so the
// <HHmmss> stamp differs).
await wait(1100)
const start2 = store.logStart(sid)
ok(start2.file !== start.file, 're-logStart creates a new file')
logs = store.listSessionLogs()
ok(logs.length === 2, 're-logStart yields 2 (old is stopped, new is active)')
ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log finalized on re-start')
// ---- 6. openLogsDir ------------------------------------------------------------
store.openLogsDir()
ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir')
console.log('\n[commands] ALL CHECKS PASSED')
process.exit(0)
+12
View File
@@ -0,0 +1,12 @@
// Minimal electron stub for bundling src/main/pty.ts under plain Node
// (tests/ssh-session-e2e.mjs). Only what broadcast.ts touches.
module.exports = {
BrowserWindow: {
getAllWindows: () => []
},
app: {},
ipcMain: {},
safeStorage: {
isEncryptionAvailable: () => false
}
}
+64
View File
@@ -0,0 +1,64 @@
// Split-chunk regression test for HighlightStream.
// Builds the engine + TerminalView-free stream, feeds a kimi-like banner in
// deliberately nasty chunk splits, and requires byte-identical output vs the
// single-chunk reference.
import { compileRules, applyHighlights, HighlightStream } from '../src/renderer/src/terminal/highlightEngine.ts'
const rules = compileRules([
{ id: 'okstate', pattern: '\\b(SUCCESS|PASS|OK|DONE)\\b', enabled: true, priority: 5, color: { fg: '#3fb950' } },
{ id: 'badstate', pattern: '\\b(ERROR|FAILED)\\b', enabled: true, priority: 6, color: { fg: '#f85149' } },
{ id: 'numbers', pattern: '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', enabled: true, priority: 25, color: { fg: '#f2cc60' } },
{ id: 'url', pattern: 'https?://[^\\s]+', enabled: true, priority: 30, color: { fg: '#58a6ff' } }
])
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints
const banner =
'\x1b]0;kimi — session\x07' +
'\x1b[38;2;79;168;255m╭───────╮\x1b[0m\r\n' +
'\x1b[38;2;136;136;136mWelcome to Kimi Code!\x1b[0m \x1b[38;2;79;168;255m│\x1b[0m send /help\r\n' +
'\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n'
const reference = applyHighlights(banner, rules)
// every possible single split point + a few double splits
let failed = 0
const splitAt = (s, i) => [s.slice(0, i), s.slice(i)]
for (let i = 1; i < banner.length - 1; i++) {
const [a, b] = splitAt(banner, i)
const stream = new HighlightStream(rules)
const out = stream.push(a) + stream.push(b) + stream.flush()
if (out !== reference) {
failed++
if (failed <= 3) {
console.log(`MISMATCH at split ${i}:`)
console.log(' got: ' + JSON.stringify(out.slice(0, 120)))
console.log(' expected: ' + JSON.stringify(reference.slice(0, 120)))
}
}
}
// double splits at 1/3 and 2/3, plus a pathological 1-byte-per-push feed
{
const i = Math.floor(banner.length / 3)
const j = Math.floor((banner.length * 2) / 3)
const stream = new HighlightStream(rules)
const out = stream.push(banner.slice(0, i)) + stream.push(banner.slice(i, j)) + stream.push(banner.slice(j)) + stream.flush()
if (out !== reference) failed++
}
{
const stream = new HighlightStream(rules)
let out = ''
for (const ch of banner) out += stream.push(ch)
out += stream.flush()
if (out !== reference) failed++
}
if (failed === 0) {
console.log('SPLIT-SMOKE PASS: all split points byte-identical to single-chunk reference')
process.exit(0)
} else {
console.log(`SPLIT-SMOKE FAIL: ${failed} mismatches`)
process.exit(1)
}
+78
View File
@@ -0,0 +1,78 @@
// Real-server chmod/chown + mode-string verification (M4). Credentials from env:
// JD_HOST / JD_USER / JD_PASS
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
import { createRequire } from 'module'
const require_ = createRequire(import.meta.url)
const sessionLayer = require_('./.session-e2e.cjs')
const sftpMod = await import('./.sftp-svc.mjs')
sftpMod.registerSftpClientProvider((id) => sessionLayer.getSshClient(id))
const events = []
sessionLayer.configureSessionRuntime({
broadcast: (channel, payload) => events.push({ channel, payload }),
getConnection: () => ({
id: 'jd',
name: 'test',
host: process.env.JD_HOST,
port: 22,
username: process.env.JD_USER,
auth: 'password',
askPasswordAtConnect: false,
askPassphraseAtConnect: false,
keepaliveIntervalSec: 30,
createdAt: Date.now(),
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
}),
getSecret: () => process.env.JD_PASS,
touch: () => {},
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
promptHostKey: () => { throw new Error('unexpected prompt') }
})
const fail = (m) => { console.error('FAIL:', m); process.exit(1) }
if (!process.env.JD_HOST) { console.error('set JD_HOST/JD_USER/JD_PASS'); process.exit(2) }
console.log('[chmod] connecting ...')
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
console.log('[chmod] session ready')
// 1. mode-string format from a live listing
const root = await sftpMod.listRemote(id, '/')
const sample = root.find((e) => e.mode && e.mode.length === 10)
if (!sample) fail('no entry carries a mode string in /')
const expectedType = sample.isDir ? 'd' : '-'
if (sample.mode[0] !== expectedType) fail(`mode type bit wrong: ${sample.mode} isDir=${sample.isDir}`)
const permPart = sample.mode.slice(1)
if (!/^[rwx-]{9}$/.test(permPart)) fail(`perm digits malformed: ${sample.mode}`)
console.log('[chmod] mode string ok:', sample.mode,
'uid=' + sample.uid, 'gid=' + sample.gid)
// 2. chmod a scratch dir, verify, restore
const dir = '/root/ot_chmod_test'
await sftpMod.deleteRemote(id, [dir]).catch(() => undefined)
await sftpMod.mkdirRemote(id, '/root', 'ot_chmod_test')
try {
const before = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir)
if (!before || !before.mode) fail('scratch dir missing or lacks mode')
console.log('[chmod] before:', before.mode)
await sftpMod.chmodRemote(id, dir, '700')
const after700 = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir)
if (after700.mode !== 'drwx------') fail(`expected drwx------ got ${after700.mode}`)
console.log('[chmod] chmod 700 ->', after700.mode)
await sftpMod.chmodRemote(id, dir, '755')
const after755 = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir)
if (after755.mode !== 'drwxr-xr-x') fail(`expected drwxr-xr-x got ${after755.mode}`)
console.log('[chmod] chmod 755 ->', after755.mode)
} finally {
await sftpMod.deleteRemote(id, [dir]).catch(() => undefined)
}
console.log('[chmod] cleanup ok')
const exits = events.filter((e) => e.channel === 'pty:exit')
console.log('[chmod] unexpected exits:', exits.length)
sessionLayer.killPty(id)
console.log('[chmod] ALL CHECKS PASSED')
setTimeout(() => process.exit(0), 300)
+109
View File
@@ -0,0 +1,109 @@
// Real-server SFTP verification (M4). Credentials from env (never hardcoded):
// JD_HOST / JD_USER / JD_PASS
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.cjs (sftp service)
import { createRequire } from 'module'
import { randomUUID } from 'crypto'
import { promises as fsp } from 'fs'
const require_ = createRequire(import.meta.url)
const sessionLayer = require_('./.session-e2e.cjs')
const sftpMod = await import('./.sftp-svc.mjs')
sftpMod.registerSftpClientProvider((id) => sessionLayer.getSshClient(id))
const events = []
sessionLayer.configureSessionRuntime({
broadcast: (channel, payload) => events.push({ channel, payload }),
getConnection: () => ({
id: 'jd',
name: 'test',
host: process.env.JD_HOST,
port: 22,
username: process.env.JD_USER,
auth: 'password',
askPasswordAtConnect: false,
askPassphraseAtConnect: false,
keepaliveIntervalSec: 30,
createdAt: Date.now(),
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
}),
getSecret: () => process.env.JD_PASS,
touch: () => {},
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
promptHostKey: () => { throw new Error('unexpected prompt') }
})
const fail = (m) => { console.error('FAIL:', m); process.exit(1) }
if (!process.env.JD_HOST) { console.error('set JD_HOST/JD_USER/JD_PASS'); process.exit(2) }
console.log('[sftp] connecting ...')
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
console.log('[sftp] session ready')
const root = await sftpMod.listRemote(id, '/')
if (!root.some((e) => e.isDir)) fail('no directories listed in /')
console.log('[sftp] listRemote(/) ok,', root.length, 'entries')
const dir = '/root/ot_sftp_test'
// tolerate leftovers from earlier aborted runs
await sftpMod.deleteRemote(id, [dir]).catch(() => undefined)
await sftpMod.mkdirRemote(id, '/root', 'ot_sftp_test')
console.log('[sftp] mkdir ok')
const content = randomUUID() + '\n' + 'x'.repeat(256 * 1024)
const localFile = `./.sftp-test-${Date.now()}.bin`
await fsp.writeFile(localFile, content, 'utf8')
console.log('[debug] alive before upload:', !!sessionLayer.getSshClient(id))
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('upload timeout 60s')), 60000)
sftpMod.uploadRemote(id, [localFile], dir, (e) => {
if (e.state === 'done') { clearTimeout(timer); resolve() }
if (e.state === 'error') { clearTimeout(timer); reject(new Error(e.error ?? 'upload error')) }
})
})
console.log('[debug] alive after upload:', !!sessionLayer.getSshClient(id))
} catch (err) {
console.error('[debug] upload error:', err.message)
console.error('[debug] alive:', !!sessionLayer.getSshClient(id))
console.error('[debug] alive:', !!sessionLayer.getSshClient(id))
console.error('[debug] exits:', JSON.stringify(events.filter((e) => e.channel === 'pty:exit')))
console.error('[debug] all events:', JSON.stringify(events.map((e) => e.channel)))
throw err
}
await new Promise((r) => setTimeout(r, 500))
console.log('[debug] alive after 500ms sleep:', !!sessionLayer.getSshClient(id))
console.log('[debug] events so far:', JSON.stringify(events.map((e) => e.channel)))
const uploaded = await sftpMod.listRemote(id, dir)
const entry = uploaded.find((e) => e.name.endsWith('.bin'))
if (!entry || entry.size !== Buffer.byteLength(content)) fail(`upload size mismatch: ${entry?.size}`)
console.log('[sftp] upload ok,', entry.size, 'bytes')
const dlDir = `./.sftp-dl-${Date.now()}`
await fsp.mkdir(dlDir, { recursive: true })
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('download timeout 60s')), 60000)
sftpMod.downloadRemote(id, [entry.path], dlDir, (e) => {
if (e.state === 'done') { clearTimeout(timer); resolve() }
if (e.state === 'error') { clearTimeout(timer); reject(new Error(e.error ?? 'download error')) }
})
})
const back = await fsp.readFile(`${dlDir}/${entry.name}`, 'utf8')
if (back !== content) fail('download content mismatch')
console.log('[sftp] download round-trip ok')
await sftpMod.deleteRemote(id, [dir])
try {
await sftpMod.listRemote(dir)
fail('dir still exists after delete')
} catch {
// expected — dir gone
}
console.log('[sftp] recursive delete ok')
const exits = events.filter((e) => e.channel === 'pty:exit')
console.log('[sftp] unexpected exits:', exits.length)
await fsp.rm(localFile, { force: true })
await fsp.rm(dlDir, { recursive: true, force: true })
sessionLayer.killPty(id)
console.log('[sftp] ALL CHECKS PASSED')
setTimeout(() => process.exit(0), 300)
+163
View File
@@ -0,0 +1,163 @@
/**
* SSH loopback verification (M2). Pure Node ESM, no Electron.
*
* Spins up an in-process ssh2.Server (127.0.0.1, random port), then connects
* with a plain ssh2.Client using the exact same connect parameters the main
* process ssh service uses (host/port/username/password/keepalive/hostVerifier),
* and confirms the full data plane round-trip:
*
* ready -> shell -> banner "LOOPBACK-OK" -> write data -> echo -> close
*
* Run: `node tests/ssh-loopback.mjs` (must exit 0)
*/
import pkg from 'ssh2'
const { Server, Client, utils } = pkg
import { createHash } from 'crypto'
function fingerprintOf(key) {
return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
}
const fail = (msg) => {
console.error(`FAIL: ${msg}`)
process.exit(1)
}
// ---- 1. Boot the loopback server -------------------------------------------
const serverKey = utils.generateKeyPairSync('ed25519')
let serverPort = 0
let seenWindowChange = { cols: 0, rows: 0 }
const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
client.on('authentication', (ctx) => {
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') {
ctx.accept()
} else {
ctx.reject(['password'])
}
})
client.on('ready', () => {
client.on('session', (accept, reject) => {
const session = accept()
session.on('pty', (accept) => accept())
session.on('window-change', (accept, reject, info) => {
seenWindowChange = { cols: info.cols, rows: info.rows }
accept && accept()
})
session.on('shell', (accept, reject) => {
const stream = accept()
stream.write('LOOPBACK-OK\n')
stream.on('data', (d) => {
const data = d.toString('utf8')
stream.write(data) // echo back
if (data.includes('exit')) {
stream.end()
client.end()
}
})
})
})
})
client.on('error', (err) => console.error('[server] client error', err.message))
})
await new Promise((resolve, reject) => {
srv.on('error', reject)
srv.listen(0, '127.0.0.1', () => {
serverPort = srv.address().port
resolve()
})
})
console.log(`[loopback] ssh server listening on 127.0.0.1:${serverPort}`)
// ---- 2. Connect with the same params the main ssh service uses --------------
const client = new Client()
let bannerSeen = false
let echoed = false
let output = ''
let verifyCalls = 0
let resolveDone
const done = new Promise((r) => (resolveDone = r))
const timer = setTimeout(() => {
fail(`timed out (client connected: ${client._stream ? 'yes' : 'no'})`)
}, 10000)
client.on('ready', () => {
console.log('[loopback] client ready')
client.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, stream) => {
if (err) return fail(`shell error: ${err.message}`)
console.log('[loopback] shell open')
let sentInput = false
let sentExit = false
stream.on('data', (d) => {
const chunk = d.toString('utf8')
output += chunk
if (output.includes('LOOPBACK-OK') && !sentInput) {
bannerSeen = true
console.log('[loopback] banner received')
// exercise resize while the session is live
stream.setWindow(40, 120, 0, 0)
sentInput = true
stream.write('hello loopback\n')
}
if (output.includes('hello loopback') && echoed === false) {
echoed = true
console.log('[loopback] echo received')
}
if (bannerSeen && echoed && !sentExit) {
sentExit = true
stream.write('exit\n')
}
})
stream.on('close', () => {
clearTimeout(timer)
console.log('[loopback] stream closed')
client.end()
resolveDone()
})
})
})
client.on('error', (err) => fail(`client error: ${err.message}`))
// hostVerifier mirrors ssh.ts (SHA256 fingerprint; loopback accepts the key)
client.connect({
host: '127.0.0.1',
port: serverPort,
username: 'test',
password: 'test',
keepaliveInterval: 0,
hostVerifier: (hostKey, verify) => {
verifyCalls++
const fp = fingerprintOf(hostKey)
console.log(`[loopback] hostVerifier called (${verifyCalls}), fp=${fp}`)
verify(true)
}
})
// ---- 3. Assertions ----------------------------------------------------------
await done
srv.close()
const checks = [
['client reached ready', bannerSeen],
['banner LOOPBACK-OK received', bannerSeen],
['typed data echoed back', echoed],
['host key verified exactly once', verifyCalls === 1],
['resize propagated to server (40x120)', seenWindowChange.cols === 120 && seenWindowChange.rows === 40]
]
let ok = true
for (const [label, pass] of checks) {
console.log(`[loopback] ${pass ? 'PASS' : 'FAIL'}: ${label}`)
if (!pass) ok = false
}
if (!ok) fail('one or more checks failed')
console.log('[loopback] ALL CHECKS PASSED')
process.exit(0)
+151
View File
@@ -0,0 +1,151 @@
/**
* Session-layer e2e (M2). Drives the REAL session routing (src/main/pty.ts +
* src/main/ssh.ts, bundled by esbuild with an electron stub) against an
* in-process ssh2.Server — the same openSession/write/resize/kill pipeline the
* app uses, minus the IPC + renderer shells.
*
* Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
* Run: node tests/ssh-session-e2e.mjs (must exit 0)
*/
import pkg from 'ssh2'
import { createHash } from 'crypto'
import { createRequire } from 'module'
const require_ = createRequire(import.meta.url)
const { Server, utils } = pkg
const fail = (msg) => {
console.error(`FAIL: ${msg}`)
process.exit(1)
}
// ---- 1. Loopback ssh server --------------------------------------------------
const serverKey = utils.generateKeyPairSync('ed25519')
let serverPort = 0
let seenWindowChange = { cols: 0, rows: 0 }
const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
client.on('authentication', (ctx) => {
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') {
ctx.accept()
} else {
ctx.reject(['password'])
}
})
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept()
session.on('pty', (accept) => accept())
session.on('window-change', (accept, reject, info) => {
seenWindowChange = { cols: info.cols, rows: info.rows }
accept && accept()
})
session.on('shell', (accept) => {
const stream = accept()
stream.write('SESSION-E2E-BANNER\n')
stream.on('data', (d) => {
const data = d.toString('utf8')
stream.write(data)
if (data.includes('exit')) {
stream.end()
client.end()
}
})
})
})
})
client.on('error', () => {})
})
await new Promise((resolve, reject) => {
srv.on('error', reject)
srv.listen(0, '127.0.0.1', () => {
serverPort = srv.address().port
resolve()
})
})
console.log(`[e2e] ssh server listening on 127.0.0.1:${serverPort}`)
// ---- 2. Load the real session layer ------------------------------------------
// esbuild replaces 'electron' with tests/electron-stub.cjs via --alias.
const sessionLayer = require_('./.session-e2e.cjs')
const events = []
sessionLayer.configureSessionRuntime({
broadcast: (channel, payload) => events.push({ channel, payload }),
getConnection: (id) => {
if (id !== 'conn-1') fail(`unexpected connectionId ${id}`)
return {
id: 'conn-1',
name: 'loopback',
host: '127.0.0.1',
port: serverPort,
username: 'test',
auth: 'password',
askPasswordAtConnect: false,
askPassphraseAtConnect: false,
keepaliveIntervalSec: 0,
createdAt: Date.now(),
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
}
},
getSecret: (conn, field) => (field === 'password' ? 'test' : undefined),
touch: (id) => events.push({ channel: 'touched', payload: id }),
knownHosts: {
check: (host, port, key) => {
const fp = 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
return { status: 'match', fingerprint: fp }
},
accept: () => {}
},
promptHostKey: () => fail('promptHostKey must not fire for matching keys')
})
// ---- 3. Drive the pipeline ----------------------------------------------------
const timer = setTimeout(() => fail('e2e timed out'), 15000)
const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' })
if (!openResult?.id) fail('openSession did not resolve with an id')
console.log(`[e2e] session open: ${openResult.id}`)
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
const seenData = (needle) =>
events.some((e) => e.channel === 'pty:data' && typeof e.payload?.data === 'string' && e.payload.data.includes(needle))
// banner should already have streamed once the shell opened
for (let i = 0; i < 50 && !seenData('SESSION-E2E-BANNER'); i++) await wait(100)
if (!seenData('SESSION-E2E-BANNER')) fail('banner never arrived via PTY_DATA broadcast')
console.log('[e2e] banner received through session data plane')
// replay buffer: a late subscriber must still get the banner
const replay = await sessionLayer.getSessionReplay(openResult.id)
if (!replay.includes('SESSION-E2E-BANNER')) fail('replay buffer missing the banner')
console.log('[e2e] replay buffer serves the banner to late subscribers')
sessionLayer.writePty(openResult.id, 'ping-session-e2e\n')
for (let i = 0; i < 50 && !seenData('ping-session-e2e'); i++) await wait(100)
if (!seenData('ping-session-e2e')) fail('written data never echoed back')
console.log('[e2e] write -> echo round-trip ok')
sessionLayer.resizePty(openResult.id, 111, 33)
for (let i = 0; i < 50 && seenWindowChange.cols !== 111; i++) await wait(100)
if (seenWindowChange.cols !== 111 || seenWindowChange.rows !== 33) fail(`resize not propagated (${JSON.stringify(seenWindowChange)})`)
console.log('[e2e] resize propagated (111x33)')
sessionLayer.killPty(openResult.id)
const exited = events.find((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id)
if (!exited) {
// 'close' on the client stream may lag slightly behind kill
for (let i = 0; i < 50 && !events.some((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id); i++) await wait(100)
}
if (!events.some((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id)) fail('PTY_EXIT never broadcast after killPty')
console.log('[e2e] kill -> PTY_EXIT ok')
if (!events.some((e) => e.channel === 'touched' && e.payload === 'conn-1')) fail('lastConnectedAt touch not recorded')
console.log('[e2e] connection touch recorded')
clearTimeout(timer)
srv.close()
console.log('[e2e] ALL CHECKS PASSED')
process.exit(0)
+200
View File
@@ -0,0 +1,200 @@
/**
* Sysinfo polling e2e (M3). Drives the REAL M3 engine (src/main/sysinfo.ts,
* reached through pty.ts's re-export) against an in-process ssh2.Server whose
* `exec(accept)` responds with canned /proc-style text. Verifies the META
* broadcast, growing samples (usage + net rates), and that stopPolling halts
* the stream.
*
* Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
* Run: node tests/sysinfo-e2e.mjs (must exit 0)
*/
import pkg from 'ssh2'
import { createHash } from 'crypto'
import { createRequire } from 'module'
const require_ = createRequire(import.meta.url)
const { Server, utils } = pkg
const fail = (msg) => {
console.error(`FAIL: ${msg}`)
process.exit(1)
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
// ---- 1. Loopback ssh server with canned /proc exec -----------------------------
const serverKey = utils.generateKeyPairSync('ed25519')
let serverPort = 0
// Two successive outputs with rising cpu ticks and rx/tx bytes so rates compute.
let execCount = 0
const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
client.on('authentication', (ctx) => {
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') {
ctx.accept()
} else {
ctx.reject(['password'])
}
})
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept()
session.on('pty', (accept) => accept())
session.on('shell', (accept) => {
const stream = accept()
stream.write('SYSINFO-E2E-BANNER\n')
stream.on('data', () => {})
})
session.on('exec', (accept, reject, info) => {
if (!info || typeof info.command !== 'string') {
reject && reject()
return
}
const stream = accept()
execCount += 1
const rising = execCount > 1
const rxBytes = (rising ? 80 * 1024 * 1024 : 40 * 1024 * 1024) // 64KB/s basis
const txBytes = (rising ? 32 * 1024 * 1024 : 16 * 1024 * 1024)
const user = rising ? 4000 : 2000
const idle = rising ? 8000 : 6000
const idle2 = rising ? 8950 : 6000
const idle3 = rising ? 9600 : 7500
const out =
`cpu ${user} 100 500 ${idle} 120 30 20 0 0 0\n` +
`cpu0 ${user} 100 500 ${idle} 120 30 20 0 0 0\n` +
`cpu1 ${user} 100 500 ${idle2} 120 30 20 0 0 0\n` +
`cpu2 ${user} 100 500 ${idle3} 120 30 20 0 0 0\n` +
`irq 0 0 0 0 0 0 0 0 0 0\n` +
`MemTotal: ${4000000} kB\n` +
`MemFree: 1000000 kB\n` +
`MemAvailable: ${3100000} kB\n` +
`Buffers: 100000 kB\n` +
'0.15 0.12 0.10 1/234 5678\n' +
'12345.67 9876.54\n' +
`__DF__\n` +
'Filesystem 1K-blocks Used Available Use% Mounted on\n' +
'/dev/sda1 10240000 2048000 8192000 20% /\n' +
'tmpfs 2048000 128 2047872 1% /dev/shm\n' +
'overlay 10240000 2048000 8192000 20% /overlay\n' +
'/dev/nvme0n1p3 51200000 12800000 38400000 25% /home\n' +
`__NET__\n` +
`Inter-| Receive | Transmit\n` +
` face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n` +
` lo: ${rxBytes} 1000 0 0 0 0 0 0 ${txBytes} 1000 0 0 0 0 0 0\n` +
` eth0: ${rxBytes} 5000 0 0 0 0 0 0 ${txBytes} 2000 0 0 0 0 0 0\n` +
`__HOST__\n` +
'loopback-host\n' +
'Linux 6.8.0-45-generic x86_64\n'
stream.write(out)
stream.exit(0)
stream.end()
})
})
})
client.on('error', () => {})
})
await new Promise((resolve, reject) => {
srv.on('error', reject)
srv.listen(0, '127.0.0.1', () => {
serverPort = srv.address().port
resolve()
})
})
console.log(`[sysinfo] ssh server listening on 127.0.0.1:${serverPort}`)
// ---- 2. Load the real session layer + sysinfo engine ---------------------------
const sessionLayer = require_('./.session-e2e.cjs')
const events = []
sessionLayer.configureSessionRuntime({
broadcast: (channel, payload) => events.push({ channel, payload }),
getConnection: (id) => {
if (id !== 'conn-1') fail(`unexpected connectionId ${id}`)
return {
id: 'conn-1',
name: 'loopback',
host: '127.0.0.1',
port: serverPort,
username: 'test',
auth: 'password',
askPasswordAtConnect: false,
askPassphraseAtConnect: false,
keepaliveIntervalSec: 0,
createdAt: Date.now(),
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
}
},
getSecret: (conn, field) => (field === 'password' ? 'test' : undefined),
touch: () => {},
knownHosts: {
check: (host, port, key) => {
const fp = 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
return { status: 'match', fingerprint: fp }
},
accept: () => {}
},
promptHostKey: () => fail('promptHostKey must not fire for matching keys')
})
const timer = setTimeout(() => fail('sysinfo e2e timed out'), 20000)
const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' })
if (!openResult?.id) fail('openSession did not resolve with an id')
console.log(`[sysinfo] session open: ${openResult.id}`)
const seenMeta = (id) =>
events.some((e) => e.channel === 'sysinfo:meta' && e.payload?.id === id)
const samples = (id) =>
events.filter((e) => e.channel === 'sysinfo:sample' && e.payload?.id === id).map((e) => e.payload.sample)
// ---- 3. Start polling -----------------------------------------------------------
sessionLayer.startPolling(openResult.id, 200)
// META once the first sample has fields to build it from.
for (let i = 0; i < 100 && !seenMeta(openResult.id); i++) await wait(50)
if (!seenMeta(openResult.id)) fail('SYSINFO_META never broadcast')
const meta = events.find((e) => e.channel === 'sysinfo:meta' && e.payload?.id === openResult.id).payload.meta
if (meta.hostname !== 'loopback-host') fail(`meta.hostname wrong: ${meta.hostname}`)
if (!meta.os.includes('Linux') || !meta.os.includes('6.8.0')) fail(`meta.os wrong: ${meta.os}`)
console.log(`[sysinfo] SYSINFO_META ok: hostname=${meta.hostname} os="${meta.os}"`)
// At least two SYSINFO_SAMPLE.
for (let i = 0; i < 100 && samples(openResult.id).length < 2; i++) await wait(50)
const got = samples(openResult.id)
if (got.length < 2) fail(`expected >=2 samples, got ${got.length}`)
const first = got[0]
const second = got[1]
if (first.cpu.usage !== 0) fail(`first sample cpu.usage must be 0, got ${first.cpu.usage}`)
if (first.net.rxKbs !== 0 || first.net.txKbs !== 0) fail('first sample net rates must be 0')
if (!(second.cpu.usage > 0 && second.cpu.usage <= 100)) fail(`second cpu.usage out of range: ${second.cpu.usage}`)
if (!(second.net.rxKbs > 0 && second.net.rxKbs < 1_000_000)) fail(`second net.rxKbs unreasonable: ${second.net.rxKbs}`)
if (!(second.net.txKbs > 0 && second.net.txKbs < 1_000_000)) fail(`second net.txKbs unreasonable: ${second.net.txKbs}`)
if (second.cpu.cores !== 3) fail(`cpu.cores should be 3, got ${second.cpu.cores}`)
if (second.mem.totalMb !== Math.round(4000000 / 1024)) fail(`mem.totalMb wrong: ${second.mem.totalMb}`)
if (second.uptimeSec !== 12346 && second.uptimeSec !== 12345) fail(`uptimeSec wrong: ${second.uptimeSec}`)
// df must skip tmpfs + overlay pseudofs; only the two real mounts remain.
const mounts = second.disks.map((d) => d.mount).sort()
if (mounts.length !== 2 || mounts[0] !== '/' || mounts[1] !== '/home') {
fail(`df mounts wrong (pseudofs not filtered?): ${JSON.stringify(mounts)}`)
}
console.log(
`[sysinfo] samples ok: usage=${second.cpu.usage.toFixed(1)} cores=${second.cpu.cores} ` +
`rx=${second.net.rxKbs.toFixed(1)}kB/s tx=${second.net.txKbs.toFixed(1)}kB/s disks=${mounts.join(',')}`
)
// ---- 4. Stop polling: no further samples -----------------------------------------
const before = samples(openResult.id).length
sessionLayer.stopPolling(openResult.id)
await wait(400)
const after = samples(openResult.id).length
if (after !== before) fail(`stopPolling did not halt: got ${after - before} new samples`)
console.log('[sysinfo] stopPolling halts the sample stream')
clearTimeout(timer)
srv.close()
console.log('[sysinfo] ALL CHECKS PASSED')
process.exit(0)
+257
View File
@@ -0,0 +1,257 @@
/**
* ZMODEM engine e2e (M6). In-process, no SSH server and no real credentials:
* our engine (src/main/zmodem.ts) is attached to one end of an in-process wire,
* and a SEPARATE zmodem.js Sentry plays the remote rz/sz on the other end. Two
* full duplex directions are exercised over the real ZMODEM framing:
*
* 场景 1 (download): remote `sz` sends a file -> our engine receives it into
* a temp dir; assert content matches + done(ok) + progress.
* 场景 2 (upload): our engine uploads a local temp file -> remote `rz`
* receives it; assert remote content matches + done(ok).
*
* Pre-req (the bundle is built automatically on first run):
* npx esbuild src/main/zmodem.ts --bundle --platform=node --format=cjs \
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs \
* --outfile=tests/.zmodem-e2e.cjs
* zmodem.js stays bundled (NOT external).
*
* Run: node tests/zmodem-e2e.mjs (must exit 0)
*/
import { createRequire } from 'module'
import {
mkdtempSync,
writeFileSync,
readFileSync,
mkdirSync,
rmSync,
existsSync
} from 'fs'
import { join, dirname, resolve, basename } from 'path'
import { fileURLToPath } from 'url'
import { execFileSync } from 'child_process'
import { randomBytes } from 'crypto'
const __dirname = dirname(fileURLToPath(import.meta.url))
const require_ = createRequire(import.meta.url)
const fail = (msg) => {
console.error(`FAIL: ${msg}`)
process.exit(1)
}
// ---- 0. ensure bundle ---------------------------------------------------------
const bundlePath = join(__dirname, '.zmodem-e2e.cjs')
if (!existsSync(bundlePath)) {
console.log('[zmodem-e2e] building bundle ...')
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'
execFileSync(
cmd,
[
'esbuild', resolve(__dirname, '../src/main/zmodem.ts'),
'--bundle', '--platform=node', '--format=cjs',
'--external:ssh2',
'--alias:electron=./tests/electron-stub.cjs',
`--outfile=${bundlePath}`
],
{ stdio: 'inherit', shell: process.platform === 'win32' }
)
}
const zmodem = require_('zmodem.js')
const engine = require_(bundlePath)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
/**
* Drive a zmodem.js SEND session with a real file (the fs-port of the browser
* send_files helper, used by the remote half of the download scenario).
*/
async function driveRemoteSend(session, filePath) {
const data = readFileSync(filePath)
const name = basename(filePath)
const xfer = await session.send_offer({ name, size: data.length, mtime: new Date() })
if (!xfer) throw new Error('offer was skipped')
const CHUNK = 8192
let off = 0
if (data.length === 0) {
await xfer.end(new Uint8Array(0))
} else {
while (off < data.length) {
const end = Math.min(off + CHUNK, data.length)
if (end < data.length) {
xfer.send(new Uint8Array(data.buffer, data.byteOffset + off, end - off))
} else {
await xfer.end(new Uint8Array(data.buffer, data.byteOffset + off, end - off))
}
off = end
}
}
await session.close()
}
/** Read all bytes a remote receive side has drained so far. */
function collectRemote(self) {
return Buffer.concat(self._incoming)
}
/**
* Build a remote (peer) Sentry. `remoteOnDetect(confirm)` wires the remote's
* on_detect: it should confirm + drive its side. `routeOut` is called with the
* octets the remote sent so we can feed them into our engine.
*/
function makeRemoteSentry(remoteOnDetect, routeOut) {
const self = { _incoming: [] }
const remoteSentry = new zmodem.Sentry({
to_terminal: () => {},
on_retract: () => {},
on_detect: (detection) => remoteOnDetect(detection, self),
sender: (octets) => routeOut(Buffer.from(octets))
})
self.sentry = remoteSentry
self.consume = (buf) => {
self._incoming.push(Buffer.from(buf))
}
return self
}
// ---- SCENARIO 1: download (remote `sz` -> our engine receives) ----------------
async function scenarioDownload() {
console.log('\n=== 场景 1: 远端 sz + 本地接收 (download) ===')
const sessionId = `sess-${randomBytes(3).toString('hex')}`
const srcDir = mkdtempSync(join(__dirname, '.zm-src-dl-'))
const srcFile = join(srcDir, 'transferred.bin')
const payload = randomBytes(200_000)
writeFileSync(srcFile, payload)
const outDir = mkdtempSync(join(__dirname, '.zm-out-dl-'))
const events = []
const remote = makeRemoteSentry((detection) => {
const s = detection.confirm()
if (s.type === 'send') {
// remote plays sz: send the source file, then finish
driveRemoteSend(s, srcFile).catch((e) => fail(`remote send error: ${e}`))
}
}, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0))
engine.attachZmodem(sessionId, {
broadcast: (channel, payload) => events.push({ channel, payload }),
toTerminal: () => {},
writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0)
})
// remote sz initiates with ZRQINIT
engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
// wait for the receive offer
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10)
const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload
if (!offer) fail('download: no ZMODEM_OFFER received')
if (offer.mode !== 'receive') fail(`download: expected receive offer, got ${offer.mode}`)
console.log(`[download] offer received (mode=receive) id=${offer.id}`)
engine.respondZmodem({ id: offer.id, cancelled: false, dir: outDir })
// wait for done
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10)
const done = events.find((e) => e.channel === 'zmodem:done')?.payload
if (!done) fail('download: no ZMODEM_DONE received')
if (!done.ok) fail(`download: expected ok, got ${JSON.stringify(done)}`)
// assert progress events flowed
const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload)
if (prog.length === 0) fail('download: no TRANSFER_PROGRESS emitted')
if (prog[0].kind !== 'zmodem-download') fail(`download: expected zmodem-download kind, got ${prog[0].kind}`)
const finalProg = prog[prog.length - 1]
if (finalProg.state !== 'done') fail(`download: last progress not done (${finalProg.state})`)
// assert content
const got = readFileSync(join(outDir, 'transferred.bin'))
if (got.length !== payload.length || !got.equals(payload)) {
fail(`download: content mismatch (len ${got.length} vs ${payload.length})`)
}
console.log(`[download] file received: ${got.length} bytes, content matches`)
console.log(`[download] progress events: ${prog.length} (state->done), done(ok)=true`)
rmSync(srcDir, { recursive: true, force: true })
rmSync(outDir, { recursive: true, force: true })
}
// ---- SCENARIO 2: upload (our engine -> remote `rz`) ----------------------------
async function scenarioUpload() {
console.log('\n=== 场景 2: 本地上传 + 远端 rz (upload) ===')
const sessionId = `sess-${randomBytes(3).toString('hex')}`
const srcDir = mkdtempSync(join(__dirname, '.zm-src-up-'))
const srcFile = join(srcDir, 'sendme.bin')
const payload = randomBytes(150_000)
writeFileSync(srcFile, payload)
const outDir = mkdtempSync(join(__dirname, '.zm-out-up-'))
const events = []
let remoteGot = null
const remote = makeRemoteSentry((detection, self) => {
const s = detection.confirm()
if (s.type === 'receive') {
// remote plays rz: accept the offered file, spool to disk.
s.on('offer', (offer) => {
const name = basename(offer.get_details().name)
const chunks = []
offer.on('input', (p) => chunks.push(Buffer.from(p)))
offer
.accept()
.then(async () => {
const data = Buffer.concat(chunks)
writeFileSync(join(outDir, name), data)
remoteGot = data
})
.catch((e) => fail(`remote receive error: ${e}`))
})
// start() once; zmodem.js re-sends ZRINIT itself after each accepted file.
s.start().catch((e) => fail(`remote receive start error: ${e}`))
}
}, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0))
engine.attachZmodem(sessionId, {
broadcast: (channel, payload) => events.push({ channel, payload }),
toTerminal: () => {},
writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0)
})
// remote rz receiver initiates: we feed it ZRQINIT so it detects "receive",
// confirms and start()s, which sends ZRINIT toward our engine.
remote.sentry.consume(Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
// engine should detect send and offer
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10)
const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload
if (!offer) fail('upload: no ZMODEM_OFFER received')
if (offer.mode !== 'send') fail(`upload: expected send offer, got ${offer.mode}`)
console.log(`[upload] offer received (mode=send) id=${offer.id}`)
engine.respondZmodem({ id: offer.id, cancelled: false, paths: [srcFile] })
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10)
const done = events.find((e) => e.channel === 'zmodem:done')?.payload
if (!done) fail('upload: no ZMODEM_DONE received')
if (!done.ok) fail(`upload: expected ok, got ${JSON.stringify(done)}`)
const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload)
if (prog.length === 0) fail('upload: no TRANSFER_PROGRESS emitted')
if (prog[0].kind !== 'zmodem-upload') fail(`upload: expected zmodem-upload kind, got ${prog[0].kind}`)
if (!remoteGot) fail('upload: remote never received the file data')
if (remoteGot.length !== payload.length || !payload.equals(remoteGot)) {
fail(`upload: content mismatch (remote ${remoteGot.length} vs src ${payload.length})`)
}
console.log(`[upload] remote received ${remoteGot.length} bytes, content matches`)
console.log(`[upload] progress events: ${prog.length}, done(ok)=true`)
rmSync(srcDir, { recursive: true, force: true })
rmSync(outDir, { recursive: true, force: true })
}
// ---- run ----------------------------------------------------------------------
const timer = setTimeout(() => fail('zmodem-e2e timed out'), 30000)
await scenarioDownload()
await scenarioUpload()
clearTimeout(timer)
console.log('\n[zmodem-e2e] ALL CHECKS PASSED')
process.exit(0)