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:
commit
a9acdbe500
99 files changed
+23940
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user