Files
nexusterm/tests/sidebar.test.ts
T
ylhan 430d78b9f0 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.
2026-09-17 03:12:24 +08:00

441 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { FolderNode, HostNode, TreeNode } from '../src/shared/ipc-schemas'
import {
allFolderKeys,
ancestorKeysOf,
buildSidebarRows,
computeMove,
computeTabMove,
dropPositionFor,
folderKey,
hostKey,
LOCAL_KEY,
orderAllSessions,
nextAutoIndex,
orderSessions,
seqAfterSession,
seqForNewSession,
sessionKey,
sessionLabel,
type SidebarRow,
type SidebarSession,
} from '../src/renderer/src/lib/sidebar'
// ---------------------------------------------------------------- fixtures
function folder(id: number, name: string, children: TreeNode[] = []): FolderNode {
return { kind: 'folder', id, parentId: null, name, sortOrder: id * 1024, children }
}
function host(id: number, label: string): HostNode {
return {
kind: 'host',
id,
parentId: null,
label,
hostname: '10.0.1.5',
port: 22,
username: 'root',
authType: 'password',
color: null,
sortOrder: id * 1024,
}
}
function session(
id: string,
hostId: number | null,
seq: number,
pinned = false,
autoIndex = 1,
): SidebarSession {
return {
id,
hostId,
label: 'session',
autoIndex,
customLabel: null,
status: 'connected',
pinned,
seq,
host: null,
}
}
function rowOf(rows: SidebarRow[], key: string): SidebarRow {
const row = rows.find((r) => r.key === key)
if (!row) throw new Error(`no row for ${key}`)
return row
}
// ---------------------------------------------------------------- ordering
describe('orderSessions', () => {
it('puts pinned sessions first, then open order', () => {
const ordered = orderSessions([
session('c', 1, 3),
session('a', 1, 1),
session('b', 1, 2, true),
])
expect(ordered.map((s) => s.id)).toEqual(['b', 'a', 'c'])
})
it('does not mutate the input', () => {
const input = [session('b', 1, 2), session('a', 1, 1)]
orderSessions(input)
expect(input.map((s) => s.id)).toEqual(['b', 'a'])
})
})
describe('orderAllSessions', () => {
it('puts local shells first, then one contiguous block per host', () => {
// A session opened for host 2 after host 1 must not land between host 1's
// sessions — same-host tabs stay together, which is the whole point of the
// grouping.
const ordered = orderAllSessions([
session('a', 1, 1),
session('b', 2, 2),
session('c', 1, 3),
session('local', null, 4),
])
expect(ordered.map((s) => s.id)).toEqual(['local', 'a', 'c', 'b'])
})
it('keeps hosts in the order they were first opened', () => {
const ordered = orderAllSessions([session('a', 7, 1), session('b', 3, 2), session('c', 7, 3)])
expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b'])
})
it('applies pinning within each host group, not across groups', () => {
const ordered = orderAllSessions([
session('a', 1, 1),
session('b', 2, 2),
session('c', 2, 3, true),
])
// c moves to the front of host 2's block, but does not jump above host 1.
expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b'])
})
})
// ---------------------------------------------------------------- numbering
describe('nextAutoIndex', () => {
it('starts at 1 for a host with no sessions', () => {
expect(nextAutoIndex([], 7)).toBe(1)
})
it('counts per host, not globally', () => {
expect(nextAutoIndex([session('a', 7, 1, false, 5)], 8)).toBe(1)
})
it('treats local shells as their own group', () => {
expect(nextAutoIndex([session('a', 7, 1, false, 3)], null)).toBe(1)
expect(nextAutoIndex([session('a', null, 1, false, 2)], null)).toBe(3)
})
it('increments past the highest index in use, not past the count', () => {
// Three opened, the middle one closed: the next must not collide with the
// surviving "3". Counting open sessions would hand out 3 a second time.
const sessions = [session('a', 7, 1, false, 1), session('c', 7, 3, false, 3)]
expect(nextAutoIndex(sessions, 7)).toBe(4)
})
it('restarts at 1 once a host has no sessions left', () => {
expect(nextAutoIndex([session('a', 9, 1, false, 4)], 7)).toBe(1)
})
})
describe('sessionLabel', () => {
it('leaves the first session for a host unnumbered', () => {
expect(sessionLabel(session('a', 7, 1, false, 1), 'local')).toBe('session')
})
it('appends the index from the second onward', () => {
expect(sessionLabel(session('a', 7, 1, false, 2), 'local')).toBe('session 2')
expect(sessionLabel(session('a', 7, 1, false, 11), 'local')).toBe('session 11')
})
it('uses the translated base for a local shell', () => {
// The base has to come from the caller: this module has no business
// knowing a language.
expect(sessionLabel(session('a', null, 1, false, 1), '本地终端')).toBe('本地终端')
expect(sessionLabel(session('a', null, 1, false, 3), '本地终端')).toBe('本地终端 3')
})
it('lets a custom name override the numbering entirely', () => {
const renamed = { ...session('a', 7, 1, false, 4), customLabel: 'prod db' }
expect(sessionLabel(renamed, 'local')).toBe('prod db')
})
})
// ---------------------------------------------------------------- tab placement
describe('seqForNewSession', () => {
it('starts a host group at 1', () => {
expect(seqForNewSession([], 7)).toBe(1)
})
it('appends past the maximum, not past the array length', () => {
// `seq` is fractional once anything has been dragged, so counting entries
// would collide with an existing position.
const sessions = [session('a', 7, 1), session('b', 7, 1.5), session('c', 7, 9)]
expect(seqForNewSession(sessions, 7)).toBe(10)
})
it('ignores other hosts', () => {
expect(seqForNewSession([session('a', 7, 100)], 8)).toBe(1)
})
})
describe('seqAfterSession', () => {
it('lands between the original and the next one in its bucket', () => {
const sessions = [session('a', 7, 1), session('b', 7, 3)]
expect(seqAfterSession(sessions, 'a')).toBe(2)
})
it('lands just past the original when it is last', () => {
const sessions = [session('a', 7, 1), session('b', 7, 3)]
expect(seqAfterSession(sessions, 'b')).toBe(4)
})
it('does not see past its own pin bucket', () => {
// A pinned neighbour is not "next" for an unpinned original.
const sessions = [session('a', 7, 1), session('b', 7, 2, true), session('c', 7, 3)]
expect(seqAfterSession(sessions, 'a')).toBe(2)
})
})
describe('computeTabMove', () => {
const sessions = [session('a', 7, 1), session('b', 7, 2), session('c', 7, 3)]
it('reorders within a host group', () => {
expect(computeTabMove(sessions, 'c', 'a', 'before')).toEqual({ seq: 0 })
expect(computeTabMove(sessions, 'a', 'c', 'after')).toEqual({ seq: 4 })
expect(computeTabMove(sessions, 'a', 'b', 'after')).toEqual({ seq: 2.5 })
})
it('refuses to move a tab into another host group', () => {
// A session that left its host's block would have no place in a sidebar
// that groups by host.
const mixed = [...sessions, session('d', 9, 1)]
expect(computeTabMove(mixed, 'a', 'd', 'before')).toBeNull()
})
it('refuses to move across pin states', () => {
// The sort would put it straight back where it started, so the drop is
// better refused than silently undone.
const mixed = [...sessions, session('p', 7, 4, true)]
expect(computeTabMove(mixed, 'a', 'p', 'before')).toBeNull()
})
it('refuses a drop onto itself', () => {
expect(computeTabMove(sessions, 'a', 'a', 'before')).toBeNull()
})
it('refuses landing back in the slot it came from', () => {
expect(computeTabMove(sessions, 'a', 'b', 'before')).toBeNull()
expect(computeTabMove(sessions, 'b', 'a', 'after')).toBeNull()
})
})
// ---------------------------------------------------------------- rows
describe('buildSidebarRows', () => {
const tree = [folder(1, 'prod', [host(10, 'web'), host(11, 'db')]), host(12, 'rooted')]
it('always shows the local entry, even with nothing open', () => {
const rows = buildSidebarRows(tree, [], new Set())
expect(rows[0].key).toBe(LOCAL_KEY)
expect(rows[0].kind).toBe('local')
expect(rows[0].sessionCount).toBe(0)
// Nothing under it to expand.
expect(rows[0].expandable).toBe(false)
})
it('omits children of collapsed folders', () => {
const rows = buildSidebarRows(tree, [], new Set())
expect(rows.map((r) => r.key)).toEqual([LOCAL_KEY, folderKey(1), hostKey(12)])
})
it('includes children of expanded folders', () => {
const rows = buildSidebarRows(tree, [], new Set([folderKey(1)]))
expect(rows.map((r) => r.key)).toEqual([
LOCAL_KEY,
folderKey(1),
hostKey(10),
hostKey(11),
hostKey(12),
])
})
it('nests sessions under their host, once that host is expanded', () => {
const sessions = [session('s1', 10, 1), session('s2', 10, 2)]
const collapsed = buildSidebarRows(tree, sessions, new Set([folderKey(1)]))
expect(collapsed.map((r) => r.key)).not.toContain(sessionKey('s1'))
expect(rowOf(collapsed, hostKey(10)).sessionCount).toBe(2)
expect(rowOf(collapsed, hostKey(10)).expandable).toBe(true)
const expanded = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)]))
expect(expanded.map((r) => r.key)).toEqual([
LOCAL_KEY,
folderKey(1),
hostKey(10),
sessionKey('s1'),
sessionKey('s2'),
hostKey(11),
hostKey(12),
])
})
it('nests local shells under the local entry', () => {
const sessions = [session('l1', null, 1), session('l2', null, 2)]
const rows = buildSidebarRows(tree, sessions, new Set([LOCAL_KEY]))
expect(rows.map((r) => r.key)).toEqual([
LOCAL_KEY,
sessionKey('l1'),
sessionKey('l2'),
folderKey(1),
hostKey(12),
])
expect(rows[0].sessionCount).toBe(2)
})
it('orders sessions within a host by pin state, not by array position', () => {
const sessions = [session('a', 10, 1), session('b', 10, 2), session('c', 10, 3, true)]
const rows = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)]))
const sessionRows = rows.filter((r) => r.kind === 'session').map((r) => r.session?.id)
expect(sessionRows).toEqual(['c', 'a', 'b'])
})
it('indents sessions one level below their host', () => {
const rows = buildSidebarRows(
tree,
[session('s1', 10, 1), session('l1', null, 2)],
new Set([folderKey(1), hostKey(10), LOCAL_KEY]),
)
expect(rowOf(rows, LOCAL_KEY).depth).toBe(0)
expect(rowOf(rows, sessionKey('l1')).depth).toBe(1)
expect(rowOf(rows, folderKey(1)).depth).toBe(0)
expect(rowOf(rows, hostKey(10)).depth).toBe(1)
expect(rowOf(rows, sessionKey('s1')).depth).toBe(2)
})
it('makes a host a leaf again once its last session closes', () => {
const withSession = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1)]))
expect(rowOf(withSession, hostKey(10)).expandable).toBe(true)
const without = buildSidebarRows(tree, [], new Set([folderKey(1)]))
expect(rowOf(without, hostKey(10)).expandable).toBe(false)
})
it('carries the sibling layer a drop needs', () => {
const rows = buildSidebarRows(tree, [], new Set([folderKey(1)]))
expect(rowOf(rows, hostKey(10)).siblings?.map((n) => n.id)).toEqual([10, 11])
expect(rowOf(rows, hostKey(10)).parentId).toBe(1)
expect(rowOf(rows, hostKey(12)).parentId).toBeNull()
})
})
// ---------------------------------------------------------------- drop position
describe('dropPositionFor', () => {
const rect = { top: 100, height: 24 }
it('offers three zones on a folder, the middle meaning "nest inside"', () => {
expect(dropPositionFor(rect, 102, folder(1, 'f'))).toBe('before')
expect(dropPositionFor(rect, 112, folder(1, 'f'))).toBe('inside')
expect(dropPositionFor(rect, 122, folder(1, 'f'))).toBe('after')
})
it('never offers "inside" on a host, which holds sessions but not nodes', () => {
expect(dropPositionFor(rect, 102, host(1, 'h'))).toBe('before')
expect(dropPositionFor(rect, 112, host(1, 'h'))).toBe('after')
})
})
// ---------------------------------------------------------------- computeMove
describe('computeMove', () => {
const tree = [
folder(1, 'prod', [host(10, 'web'), host(11, 'db'), folder(3, 'inner', [host(13, 'deep')])]),
folder(2, 'staging'),
host(12, 'rooted'),
]
const rows = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1), hostKey(10)]))
it('expresses dropping between two nodes', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(11)), position: 'before' }))
.toEqual({ id: 12, parentId: 1, prevSiblingId: 10, nextSiblingId: 11 })
})
it('expresses dropping between two folders', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'before' }))
.toEqual({ id: 12, parentId: null, prevSiblingId: 1, nextSiblingId: 2 })
})
it('expresses dropping into a folder, appended after its children', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'inside' }))
.toEqual({ id: 12, parentId: 2, prevSiblingId: null, nextSiblingId: null })
})
it('refuses to drop a node on itself', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(12)), position: 'before' }))
.toBeNull()
})
it('refuses to drop a folder into its own descendant', () => {
// Without this the subtree detaches from every root and disappears from the
// UI while its rows remain. The main process guards it too; catching it
// here means the drag simply refuses rather than erroring after the fact.
//
// The dragged node has to be the one from the tree — the check walks its
// children, and a freshly built FolderNode with none has no subtree to
// protect.
const prod = tree[0]
expect(computeMove(prod, { row: rowOf(rows, folderKey(3)), position: 'inside' })).toBeNull()
})
it('allows dropping a folder into an unrelated folder', () => {
expect(computeMove(folder(3, 'inner'), { row: rowOf(rows, folderKey(2)), position: 'inside' }))
.toMatchObject({ id: 3, parentId: 2 })
})
it('refuses a session row as a drop target', () => {
// Sessions are ordered by their own rules and are not in the database at
// all, so there is no move to express.
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, sessionKey('s1')), position: 'before' }))
.toBeNull()
})
it('treats landing back in the current slot as a no-op', () => {
expect(computeMove(host(10, 'web'), { row: rowOf(rows, hostKey(11)), position: 'before' }))
.toBeNull()
})
})
// ---------------------------------------------------------------- helpers
describe('ancestorKeysOf', () => {
const tree = [folder(1, 'a', [folder(2, 'b', [host(10, 'deep')])])]
it('lists the folders above a node, outermost first', () => {
expect(ancestorKeysOf(tree, hostKey(10))).toEqual([folderKey(1), folderKey(2)])
})
it('returns nothing for a root-level node', () => {
expect(ancestorKeysOf(tree, folderKey(1))).toEqual([])
})
})
describe('allFolderKeys', () => {
it('collects folder keys only, so expand-all does not open every session list', () => {
const tree = [folder(1, 'a', [folder(2, 'b'), host(10, 'x')]), host(11, 'y')]
expect(allFolderKeys(tree).sort()).toEqual([folderKey(1), folderKey(2)])
})
})