NexusTerm: an SSH session manager for Linux
Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// Drives the running app's renderer over the Chrome DevTools Protocol, calling
|
||||
// the same `window.nexusterm` API the UI calls. This exercises the real path —
|
||||
// preload bridge, ipcRenderer.invoke, main-process handler, zod validation,
|
||||
// repository, SQLite, and the reply — rather than a stand-in for it.
|
||||
//
|
||||
// Usage:
|
||||
// npm run build
|
||||
// ./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
|
||||
// npm run smoke
|
||||
const PORT = 9222
|
||||
|
||||
async function findPage() {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${PORT}/json`)
|
||||
const targets = await res.json()
|
||||
const page = targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl)
|
||||
if (page) return page
|
||||
} catch {
|
||||
// DevTools endpoint not up yet.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error('no debuggable page found on port ' + PORT)
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function evaluate(ws, expression) {
|
||||
const id = nextId++
|
||||
return new Promise((resolve) => {
|
||||
const onMessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.id !== id) return
|
||||
ws.removeEventListener('message', onMessage)
|
||||
|
||||
if (msg.error) return resolve({ ok: false, error: JSON.stringify(msg.error) })
|
||||
|
||||
const result = msg.result
|
||||
if (result.exceptionDetails) {
|
||||
const d = result.exceptionDetails
|
||||
return resolve({ ok: false, error: d.exception?.description ?? d.text ?? 'exception' })
|
||||
}
|
||||
return resolve({ ok: true, value: result.result.value })
|
||||
}
|
||||
|
||||
ws.addEventListener('message', onMessage)
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression, awaitPromise: true, returnByValue: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const page = await findPage()
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.addEventListener('open', resolve, { once: true })
|
||||
ws.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
|
||||
const script = `(async () => {
|
||||
const api = window.nexusterm.tree
|
||||
const report = {}
|
||||
|
||||
const findByLabel = (t, label) => {
|
||||
for (const n of t) {
|
||||
if (n.kind === 'host' && n.label === label) return n
|
||||
if (n.kind === 'folder') { const hit = findByLabel(n.children, label); if (hit) return hit }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const findFolder = (t, name) => {
|
||||
for (const n of t) {
|
||||
if (n.kind === 'folder' && n.name === name) return n
|
||||
if (n.kind === 'folder') { const hit = findFolder(n.children, name); if (hit) return hit }
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Names of a layer in tree order, for asserting interleaving.
|
||||
const layerNames = (nodes) => nodes.map(n => n.kind === 'folder' ? n.name : n.label)
|
||||
|
||||
const before = await api.list()
|
||||
report.initialRootCount = before.length
|
||||
|
||||
// --- create and nest -------------------------------------------------
|
||||
let tree = await api.createFolder({ parentId: null, name: 'SMOKE-alpha' })
|
||||
const alpha = findFolder(tree, 'SMOKE-alpha')
|
||||
|
||||
tree = await api.createFolder({ parentId: alpha.id, name: 'SMOKE-beta' })
|
||||
const beta = findFolder(tree, 'SMOKE-beta')
|
||||
report.nestedFolder = beta && beta.parentId === alpha.id
|
||||
|
||||
tree = await api.createHost({
|
||||
parentId: beta.id, label: 'SMOKE-host', hostname: '10.9.9.9',
|
||||
port: 2222, username: 'tester', authType: 'password', keyPath: null, color: null,
|
||||
})
|
||||
report.createdHost = !!findByLabel(tree, 'SMOKE-host')
|
||||
|
||||
tree = await api.renameFolder({ id: alpha.id, name: 'SMOKE-renamed' })
|
||||
report.renamed = !!findFolder(tree, 'SMOKE-renamed')
|
||||
|
||||
// --- interleaving ----------------------------------------------------
|
||||
// Two folders with a host dropped between them. Not expressible before the
|
||||
// tree was interleaved: the two sort_order sequences had no common scale.
|
||||
tree = await api.createFolder({ parentId: null, name: 'SMOKE-first' })
|
||||
const first = findFolder(tree, 'SMOKE-first')
|
||||
tree = await api.createFolder({ parentId: null, name: 'SMOKE-third' })
|
||||
const third = findFolder(tree, 'SMOKE-third')
|
||||
tree = await api.createHost({
|
||||
parentId: null, label: 'SMOKE-middle', hostname: '10.9.9.10',
|
||||
port: 22, username: 'tester', authType: 'password', keyPath: null, color: null,
|
||||
})
|
||||
const middle = findByLabel(tree, 'SMOKE-middle')
|
||||
|
||||
tree = await api.moveNode({ id: middle.id, parentId: null, prevSiblingId: first.id, nextSiblingId: third.id })
|
||||
const order = layerNames(tree)
|
||||
report.interleaved = order.indexOf('SMOKE-first') < order.indexOf('SMOKE-middle')
|
||||
&& order.indexOf('SMOKE-middle') < order.indexOf('SMOKE-third')
|
||||
report.interleavedOrder = order.filter(n => n.startsWith('SMOKE-'))
|
||||
|
||||
// --- move back out to the root layer ---------------------------------
|
||||
const hostInBeta = findByLabel(tree, 'SMOKE-host')
|
||||
tree = await api.moveNode({ id: hostInBeta.id, parentId: null, prevSiblingId: null, nextSiblingId: null })
|
||||
report.movedToRoot = tree.some(n => n.kind === 'host' && n.label === 'SMOKE-host')
|
||||
|
||||
// --- a move into its own descendant must be refused ------------------
|
||||
const renamed = findFolder(tree, 'SMOKE-renamed')
|
||||
const betaAgain = findFolder(tree, 'SMOKE-beta')
|
||||
try {
|
||||
await api.moveNode({ id: renamed.id, parentId: betaAgain.id, prevSiblingId: null, nextSiblingId: null })
|
||||
report.cycleRefused = false
|
||||
} catch (e) {
|
||||
report.cycleRefused = /descendant/.test(String(e && e.message))
|
||||
report.cycleMessage = String(e && e.message).slice(0, 120)
|
||||
}
|
||||
|
||||
// --- validation ------------------------------------------------------
|
||||
try {
|
||||
await api.createHost({ parentId: null, label: '', hostname: '', port: 99999, username: '', authType: 'nope', keyPath: null, color: null })
|
||||
report.validationRejected = false
|
||||
} catch {
|
||||
report.validationRejected = true
|
||||
}
|
||||
|
||||
// --- clean up --------------------------------------------------------
|
||||
tree = await api.list()
|
||||
for (const node of tree) {
|
||||
if (node.kind === 'folder' && node.name.startsWith('SMOKE-')) await api.deleteNode(node.id)
|
||||
if (node.kind === 'host' && node.label.startsWith('SMOKE-')) await api.deleteNode(node.id)
|
||||
}
|
||||
const final = await api.list()
|
||||
report.cleanedUp = !final.some(n =>
|
||||
(n.kind === 'folder' && n.name.startsWith('SMOKE-')) ||
|
||||
(n.kind === 'host' && n.label.startsWith('SMOKE-')))
|
||||
report.finalRootCount = final.length
|
||||
|
||||
return report
|
||||
})()`
|
||||
|
||||
const result = await evaluate(ws, script)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
ws.close()
|
||||
|
||||
if (!result.ok) process.exit(1)
|
||||
Reference in New Issue
Block a user