Files
hermes-agent/apps/desktop/electron/backend-ready.test.ts
hermes-seaeye[bot] 44fc5f629d fmt(js): npm run fix on merge (#129471)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-30 17:36:24 +00:00

381 lines
13 KiB
TypeScript

/**
* Tests for electron/backend-ready.ts.
*
* Run with: node --test electron/backend-ready.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* Covers the cold-start port-announcement deadline (issue #50209): the clock
* starts before the backend binds its port, so a tight 45s deadline killed a
* healthy-but-still-compiling backend on cold Windows installs. The default is
* now cold-start tolerant and overridable via
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile
} from './backend-ready'
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter
}
// A minimal stand-in for a spawned child process: an EventEmitter with a
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
function makeFakeChild(): FakeChildProcess {
const child = new EventEmitter() as FakeChildProcess
child.stdout = new EventEmitter()
return child
}
// ---------------------------------------------------------------------------
// resolvePortAnnounceTimeoutMs
// ---------------------------------------------------------------------------
test('default is cold-start tolerant (> the historical 45s floor)', () => {
assert.equal(resolvePortAnnounceTimeoutMs({}), DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS)
assert.ok(
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS > MIN_PORT_ANNOUNCE_TIMEOUT_MS,
'cold-start default must exceed the warm-start floor'
)
})
test('honors a valid HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS override', () => {
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '120000' }
assert.equal(resolvePortAnnounceTimeoutMs(env), 120_000)
})
test('clamps an override below the floor up to the 45s minimum', () => {
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '1000' }
assert.equal(resolvePortAnnounceTimeoutMs(env), MIN_PORT_ANNOUNCE_TIMEOUT_MS)
})
test('rounds a fractional override', () => {
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '60000.7' }
assert.equal(resolvePortAnnounceTimeoutMs(env), 60_001)
})
test('falls back to the default for malformed / non-positive overrides', () => {
for (const bad of ['', 'abc', '0', '-5', 'NaN', undefined]) {
const env = bad === undefined ? {} : { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: bad }
assert.equal(
resolvePortAnnounceTimeoutMs(env),
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
`override ${JSON.stringify(bad)} should fall through to the default`
)
}
})
// ---------------------------------------------------------------------------
// waitForDashboardPort
// ---------------------------------------------------------------------------
test('resolves with the announced port', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'noise before\nHERMES_DASHBOARD_READY port=54321\n')
assert.equal(await p, 54321)
})
test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n')
assert.equal(await p, 43210)
})
test('parses the port even when the line arrives split across chunks', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'HERMES_DASHBOARD_READY po')
child.stdout.emit('data', 'rt=8080\n')
assert.equal(await p, 8080)
})
test('rejects when the child exits before announcing', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.emit('exit', 1, null)
await assert.rejects(p, /exited before port announcement/)
})
test('rejects on a child error event', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.emit('error', new Error('spawn ENOENT'))
await assert.rejects(p, /spawn ENOENT/)
})
test('a late announcement after timeout does not throw (listeners torn down)', async () => {
const child = makeFakeChild()
await assert.rejects(waitForDashboardPort(child, 20), /Timed out/)
// The orphaned backend may still print its READY line later; the watcher
// must have detached so this emit is a no-op rather than a double-settle.
assert.doesNotThrow(() => {
child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n')
})
})
// ---------------------------------------------------------------------------
// ready-file port announcement
// ---------------------------------------------------------------------------
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
cleanup: () => fs.rmSync(dir, { recursive: true, force: true })
}
}
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
} finally {
tmp.cleanup()
}
})
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 }))
assert.equal(readDashboardReadyFile(tmp.file), null)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
assert.equal(await p, 8765)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
assert.equal(await p, 9876)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)
await assert.rejects(p, /exited before port announcement/)
} finally {
tmp.cleanup()
}
})
// ---------------------------------------------------------------------------
// describeOutputTail (#93608): the child's real stderr reaches the exit error
// ---------------------------------------------------------------------------
test('exit-before-announcement error carries the buffered output tail (stdout path)', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPortAnnouncement(child, {
describeOutputTail: () => '\nRecent backend output:\nModuleNotFoundError: hermes_cli'
})
child.emit('exit', 1, null)
await assert.rejects(wait, /exited before port announcement \(1\)[\s\S]*ModuleNotFoundError: hermes_cli/)
})
test('exit-before-announcement error carries the buffered output tail (ready-file path)', async () => {
const child = makeFakeChild()
const readyFile = path.join(os.tmpdir(), `hermes-ready-${process.pid}-${Date.now()}.json`)
const wait = waitForDashboardPortAnnouncement(child, {
describeOutputTail: () => '\nRecent backend output:\nTraceback (most recent call last)',
readyFile
})
child.emit('exit', null, 'SIGSEGV')
await assert.rejects(wait, /exited before port announcement \(SIGSEGV\)[\s\S]*Traceback/)
})
// ---------------------------------------------------------------------------
// bufferedOutput (#60323): a sentinel consumed BEFORE the wait attaches must
// still resolve. main.ts attaches an output tail at spawn, then awaits
// claimBackendChild/advanceBootProgress before calling this wait; flowing-mode
// stdout never replays consumed chunks to late listeners.
// ---------------------------------------------------------------------------
test('resolves from bufferedOutput when the sentinel was consumed before the wait attached (#60323)', async () => {
const child = makeFakeChild()
// Simulate the spawn-time output tail: it consumed the READY line already,
// and no further stdout data will ever arrive.
const alreadyConsumed = 'boot noise\nHERMES_BACKEND_READY port=43211\n'
const port = await waitForDashboardPortAnnouncement(child, {
bufferedOutput: () => alreadyConsumed,
timeoutMs: 500
})
assert.equal(port, 43211)
})
test('bufferedOutput accepts the legacy HERMES_DASHBOARD_READY sentinel too', async () => {
const child = makeFakeChild()
const port = await waitForDashboardPortAnnouncement(child, {
bufferedOutput: () => 'HERMES_DASHBOARD_READY port=43212\n',
timeoutMs: 500
})
assert.equal(port, 43212)
})
test('bufferedOutput without a sentinel still resolves from later live stdout', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPortAnnouncement(child, {
bufferedOutput: () => 'uvicorn still importing...\n',
timeoutMs: 1000
})
child.stdout.emit('data', Buffer.from('HERMES_BACKEND_READY port=43213\n'))
assert.equal(await wait, 43213)
})
test('bufferedOutput without a sentinel still times out (no false positive)', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPort(
child,
50,
() => '',
() => 'no sentinel here\n'
)
await assert.rejects(wait, /Timed out waiting/)
})
test('the merged-tail seed recovers a sentinel spliced onto a partial stderr line (#103792)', async () => {
const child = makeFakeChild()
// uvicorn's stderr chunk has no trailing newline, so the tail is not line-accurate.
const port = await waitForDashboardPortAnnouncement(child, {
bufferedOutput: () => 'INFO Started server process [4711]HERMES_BACKEND_READY port=65238',
timeoutMs: 500
})
assert.equal(port, 65238)
})
test('the merged-tail seed does not match prose that merely names the sentinel', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPort(
child,
50,
() => '',
() => 'still waiting for HERMES_BACKEND_READY from the backend\n'
)
await assert.rejects(wait, /Timed out waiting/)
})
// ---------------------------------------------------------------------------
// source-update completion banners extend the deadline (#122206)
// ---------------------------------------------------------------------------
test('a completion banner re-arms the deadline past the original timeout', async () => {
// The backend prints venv_sync's banner (finishing an owed update tail)
// after the normal budget would have expired. The wait must keep waiting
// while the repair is in progress, then resolve on the late announcement.
const child = makeFakeChild()
const p = waitForDashboardPort(child, 40)
child.stdout.emit('data', 'hermes: finishing an interrupted source update...\n')
// Past the original 40ms deadline: without the banner grace this wait has
// already rejected. Give the "repair" a moment, then announce.
await new Promise(resolve => setTimeout(resolve, 80))
child.stdout.emit('data', 'HERMES_BACKEND_READY port=4455\n')
assert.equal(await p, 4455)
})
test(
'a stalled completion banner is capped, not waited out forever',
async () => {
// The banner says a repair is in progress but nothing ever resolves: the
// wait must still fail (bounded), and the message must carry that the
// completion was in progress — truthful and actionable instead of a bare
// 90s timeout that hides a multi-minute repair. The 5-minute grace is
// wall-clock, so this holds the wait past it with a long test budget.
const child = makeFakeChild()
const wait = waitForDashboardPort(child, 20)
wait.catch(() => {}) // mark handled; the assertion below re-awaits
child.stdout.emit('data', 'hermes: finishing an interrupted source update...\n')
await assert.rejects(
wait,
/Timed out waiting for Hermes backend port announcement .* while an update completion was in progress/
)
},
6 * 60_000
)
test('a banner already in the spawn-time tail also re-arms', async () => {
// The dormant bufferedOutput gap (#60323): the banner was flushed before the
// listener attached. Seeding from the tail must extend the deadline too.
const child = makeFakeChild()
const p = waitForDashboardPort(
child,
40,
() => '',
() => 'hermes: completing source-update dependencies...\n'
)
await new Promise(resolve => setTimeout(resolve, 80))
child.stdout.emit('data', 'HERMES_BACKEND_READY port=4471\n')
assert.equal(await p, 4471)
}, 10_000)