Files
hermes-ui/tests/server/cli-port-detection.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

227 lines
8.0 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { scryptSync, timingSafeEqual } from 'crypto'
import { DatabaseSync } from 'node:sqlite'
type ChildProcessMocks = {
execFileSync: ReturnType<typeof vi.fn>
execSync: ReturnType<typeof vi.fn>
spawn: ReturnType<typeof vi.fn>
}
async function loadCli(overrides: Partial<ChildProcessMocks> = {}) {
const execFileSync = overrides.execFileSync ?? vi.fn()
const execSync = overrides.execSync ?? vi.fn()
const spawn = overrides.spawn ?? vi.fn()
vi.resetModules()
vi.doMock('child_process', () => ({ execFileSync, execSync, spawn }))
const mod = await import('../../bin/hermes-web-ui.mjs')
return {
...mod,
mocks: { execFileSync, execSync, spawn },
}
}
function verifyPassword(password: string, passwordHash: string): boolean {
const [scheme, salt, expectedHex] = passwordHash.split(':')
if (scheme !== 'scrypt' || !salt || !expectedHex) return false
const expected = Buffer.from(expectedHex, 'hex')
const actual = scryptSync(password, salt, expected.length)
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
describe('CLI port detection', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalEnv = { ...process.env }
afterEach(() => {
process.env = { ...originalEnv }
vi.doUnmock('child_process')
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('falls back to lsof without executing ss when ss is unavailable', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const execFileSync = vi.fn((command: string, args: string[]) => {
if (command === 'sh' && args.at(-1) === 'ss') {
throw new Error('not found')
}
if (command === 'sh' && args.at(-1) === 'lsof') {
return ''
}
if (command === 'lsof') {
return '1234\n1234\n'
}
throw new Error(`unexpected command: ${command}`)
})
const { getListeningPids, mocks } = await loadCli({ execFileSync })
expect(getListeningPids(8648)).toEqual([1234])
expect(mocks.execFileSync).not.toHaveBeenCalledWith(
'ss',
expect.any(Array),
expect.any(Object),
)
expect(mocks.execFileSync).toHaveBeenCalledWith(
'lsof',
['-tiTCP:8648', '-sTCP:LISTEN'],
expect.objectContaining({ encoding: 'utf-8' }),
)
})
it('uses ss first when available', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' })
const execFileSync = vi.fn((command: string, args: string[]) => {
if (command === 'sh' && args.at(-1) === 'ss') {
return ''
}
if (command === 'ss') {
return 'LISTEN 0 511 0.0.0.0:8648 0.0.0.0:* users:(("node",pid=4321,fd=20))\n'
}
throw new Error(`unexpected command: ${command}`)
})
const { getListeningPids } = await loadCli({ execFileSync })
expect(getListeningPids(8648)).toEqual([4321])
})
it('parses Linux netstat listener output as a final fallback', async () => {
const { parseUnixNetstatListeningPids } = await loadCli()
expect(parseUnixNetstatListeningPids(
[
'tcp 0 0 0.0.0.0:8648 0.0.0.0:* LISTEN 2468/node',
'tcp 0 0 0.0.0.0:5173 0.0.0.0:* LISTEN 1357/node',
].join('\n'),
8648,
)).toEqual([2468])
})
it('clears the login lock file from the configured Web UI home', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-locks-'))
process.env.HERMES_WEB_UI_HOME = home
const lockFile = join(home, '.login-lock.json')
writeFileSync(lockFile, '{"passwordIpMap":{}}\n')
try {
const { clearLoginLocks } = await loadCli()
const result = clearLoginLocks({ silent: true, checkRunning: false })
expect(result).toEqual({ path: lockFile, removed: true, serverRunning: false })
expect(existsSync(lockFile)).toBe(false)
const second = clearLoginLocks({ silent: true, checkRunning: false })
expect(second).toEqual({ path: lockFile, removed: false, serverRunning: false })
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('cleans a stale server PID file during stop', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-stale-pid-'))
process.env.HERMES_WEB_UI_HOME = home
const pidFile = join(home, 'server.pid')
writeFileSync(pidFile, '999999999\n')
try {
const { stopDaemon } = await loadCli()
stopDaemon()
expect(existsSync(pidFile)).toBe(false)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('waits for bridge cleanup on restart unless broker preservation is requested', async () => {
const { getDaemonStopGraceMs } = await loadCli()
expect(getDaemonStopGraceMs({ restart: true })).toBe(15_000)
expect(getDaemonStopGraceMs()).toBe(15_000)
process.env.HERMES_AGENT_BRIDGE_STOP_ON_SHUTDOWN = '0'
expect(getDaemonStopGraceMs({ restart: true })).toBe(5_000)
})
it('allows CLI stop and restart grace periods to be overridden separately', async () => {
process.env.HERMES_WEB_UI_RESTART_GRACE_MS = '2500'
process.env.HERMES_WEB_UI_STOP_GRACE_MS = '9000'
const { getDaemonStopGraceMs } = await loadCli()
expect(getDaemonStopGraceMs({ restart: true })).toBe(2_500)
expect(getDaemonStopGraceMs()).toBe(9_000)
delete process.env.HERMES_WEB_UI_RESTART_GRACE_MS
expect(getDaemonStopGraceMs({ restart: true })).toBe(9_000)
})
it('skips browser launch when --no-open is provided', async () => {
const { shouldOpenBrowser } = await loadCli()
expect(shouldOpenBrowser(['node', 'bin/hermes-web-ui.mjs', 'start'])).toBe(true)
expect(shouldOpenBrowser(['node', 'bin/hermes-web-ui.mjs', 'start', '--no-open'])).toBe(false)
expect(shouldOpenBrowser(['node', 'bin/hermes-web-ui.mjs', 'restart', '--port', '9000', '--no-open'])).toBe(false)
})
it('preserves --no-open when update respawns restart', async () => {
const { getRestartArgs } = await loadCli()
expect(getRestartArgs(9000, ['node', 'bin/hermes-web-ui.mjs', 'update', '--port', '9000'])).toEqual([
'restart',
'--port',
'9000',
])
expect(getRestartArgs(9000, ['node', 'bin/hermes-web-ui.mjs', 'update', '--port', '9000', '--no-open'])).toEqual([
'restart',
'--port',
'9000',
'--no-open',
])
})
it('resets an existing admin user to the default password', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-default-login-'))
process.env.HERMES_WEB_UI_HOME = home
const dbPath = join(home, 'hermes-web-ui.db')
try {
const { resetDefaultLogin } = await loadCli()
const created = await resetDefaultLogin({ silent: true })
expect(created.action).toBe('created')
const db = new DatabaseSync(dbPath)
try {
const initial = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get('admin') as any
expect(verifyPassword('123456', initial.password_hash)).toBe(true)
db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run('scrypt:bad:bad', 'admin')
} finally {
db.close()
}
const updated = await resetDefaultLogin({ silent: true })
expect(updated.action).toBe('updated')
const verifyDb = new DatabaseSync(dbPath)
try {
const rows = verifyDb.prepare('SELECT id, username, password_hash, role, status FROM users WHERE username = ?').all('admin') as any[]
expect(rows).toHaveLength(1)
expect(verifyPassword('123456', rows[0].password_hash)).toBe(true)
expect(rows[0].role).toBe('super_admin')
expect(rows[0].status).toBe('active')
} finally {
verifyDb.close()
}
} finally {
rmSync(home, { recursive: true, force: true })
}
})
})