mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
fix(desktop): kill loading bar in boot-failure e2e screenshots
The boot-failure screenshot showed a progress bar because of two bugs:
1. waitForBootFailure matched on "Let's get you setup" (the onboarding
header that mounts from frame 1 during normal boot), so the screenshot
fired at ~86% progress while the Preparing component's progress bar was
still painted.
2. The Preparing component kept rendering the progress bar even after
boot.error was set — it just turned the bar red and appended the error
text below it.
Fixes:
- Preparing bails out (returns null) when boot.error is set, so
BootFailureOverlay (z-1400) owns the screen exclusively.
- applyDesktopBootProgress no longer clobbers a previously-set boot.error
when a late progress event arrives with error: null — failDesktopBoot is
terminal for the boot cycle.
- waitForBootFailure guards against progress bars being visible and matches
on actual failure signals (error toast, Retry/Repair buttons), not the
onboarding header.
- setupDeadBackend now accepts { fakeError: true } which injects
HERMES_DESKTOP_BOOT_FAKE_ERROR to trigger a real boot failure — the
previous dead-provider fixture never actually caused a boot failure
(hermes serve starts fine; the dead endpoint only matters at chat time).
- boot-failure.spec.ts updated to use { fakeError: true }.
Verified: e2e test passes with 0 progress bars in the DOM at screenshot
time (confirmed via DOM inspection), 16/16 vitest tests pass, typecheck
clean.
This commit is contained in:
parent
b2be12d456
commit
b2857110b4
5 changed files with 71 additions and 39 deletions
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* E2E boot-failure tests — verify the app shows an error overlay when the
|
||||
* backend can't reach the inference provider.
|
||||
* backend can't start.
|
||||
*
|
||||
* Launches the app with a provider pointing at a dead endpoint (port 1).
|
||||
* The `hermes serve` backend starts, but when the renderer tries to connect
|
||||
* or when a runtime check fails, the app should show a boot failure or
|
||||
* onboarding error overlay.
|
||||
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
|
||||
* resolution fails with a controlled error message. The app should show the
|
||||
* BootFailureOverlay with retry/repair actions — no progress bar should be
|
||||
* visible.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
|
@ -26,17 +26,13 @@ test.afterAll(async () => {
|
|||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('boot failure with dead provider endpoint', () => {
|
||||
test('app shows error state or onboarding', async () => {
|
||||
fixture = await setupDeadBackend()
|
||||
test.describe('boot failure with dead backend', () => {
|
||||
test('app shows error state', async () => {
|
||||
// Inject a fake boot error so the backend resolution "fails" with a
|
||||
// controlled error message. This is the only reliable way to trigger
|
||||
// BootFailureOverlay in dev mode.
|
||||
fixture = await setupDeadBackend({ fakeError: true })
|
||||
|
||||
// With a dead provider endpoint, the app should eventually show either:
|
||||
// 1. A boot failure overlay (if the backend fails to start), or
|
||||
// 2. An onboarding overlay with an error (if the runtime check fails)
|
||||
// Both outcomes prove the app is handling provider failures gracefully.
|
||||
//
|
||||
// We give it a generous timeout — the backend needs to start, the
|
||||
// renderer needs to boot, and then the runtime check needs to fail.
|
||||
await waitForBootFailure(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -387,12 +387,24 @@ export interface DeadBackendFixture {
|
|||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface DeadBackendOptions {
|
||||
/**
|
||||
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
|
||||
* so the backend resolution itself "fails" with a controlled error message.
|
||||
* This is the only reliable way to trigger BootFailureOverlay in dev mode
|
||||
* (the real backend always resolves via SOURCE_REPO_ROOT).
|
||||
*/
|
||||
fakeError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with a provider pointing at a dead endpoint (port 1, which
|
||||
* nothing listens on). The boot should fail with a connection error,
|
||||
* triggering the BootFailureOverlay.
|
||||
* nothing listens on). By default the backend still boots (`hermes serve`
|
||||
* starts fine — the dead endpoint only matters at chat time). Pass
|
||||
* `{ fakeError: true }` to inject a fake boot failure, triggering the
|
||||
* BootFailureOverlay.
|
||||
*/
|
||||
export async function setupDeadBackend(): Promise<DeadBackendFixture> {
|
||||
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
|
||||
const sandbox = createSandbox('dead')
|
||||
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(
|
||||
|
|
@ -415,7 +427,7 @@ providers:
|
|||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox)
|
||||
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
|
|
@ -636,25 +648,32 @@ export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise
|
|||
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
// Boot failure is terminal: the backend gave up. The renderer shows
|
||||
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
|
||||
// falls back to the onboarding picker (z-1300) as a recovery path.
|
||||
// Either way, no progress bar should be visible and an error must
|
||||
// have surfaced (toast, boot-failure banner, or the overlay itself).
|
||||
const hasProgressBar = Boolean(
|
||||
document.querySelector('[role="progressbar"], .h-2.rounded-full.bg-muted')
|
||||
)
|
||||
|
||||
if (!root) {
|
||||
if (hasProgressBar) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = root.textContent ?? ''
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
// A dead provider either produces a boot failure or falls back to the
|
||||
// provider onboarding screen after the runtime check fails.
|
||||
return (
|
||||
text.includes('error') ||
|
||||
text.includes('Error') ||
|
||||
text.includes('failed') ||
|
||||
text.includes('Failed') ||
|
||||
// BootFailureOverlay buttons.
|
||||
const hasFailureUI =
|
||||
text.includes('Retry') ||
|
||||
text.includes('Repair') ||
|
||||
text.includes("Let's get you setup")
|
||||
)
|
||||
text.includes('Use local gateway') ||
|
||||
text.includes('Connection settings')
|
||||
|
||||
// The error toast / notification that fires on failDesktopBoot().
|
||||
const hasErrorToast = text.includes('Desktop boot failed')
|
||||
|
||||
return hasFailureUI || hasErrorToast
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
|
|
|
|||
|
|
@ -542,6 +542,7 @@ const DESKTOP_LOG_BACKUP_COUNT = 3
|
|||
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
|
||||
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
|
||||
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
||||
const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || ''
|
||||
|
||||
const BOOT_FAKE_STEP_MS = (() => {
|
||||
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
||||
|
|
@ -6999,6 +7000,16 @@ async function startHermes() {
|
|||
throw backendStartFailure
|
||||
}
|
||||
|
||||
// E2E: simulate a boot failure without breaking the real backend. The boot
|
||||
// progresses a few steps, then fails with the given error message.
|
||||
if (BOOT_FAKE_ERROR) {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
const error = new Error(BOOT_FAKE_ERROR) as any
|
||||
error.isBootstrapFailure = true
|
||||
bootstrapFailure = error
|
||||
throw error
|
||||
}
|
||||
|
||||
const existingConnectionPromise = backendConnectionState.getPromise()
|
||||
|
||||
if (existingConnectionPromise) {
|
||||
|
|
|
|||
|
|
@ -355,9 +355,15 @@ function ReasonNotice({ reason }: { reason: string }) {
|
|||
function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||
const { t } = useI18n()
|
||||
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
||||
const hasError = Boolean(boot.error)
|
||||
const installing = boot.phase.startsWith('runtime.')
|
||||
|
||||
// When boot fails, BootFailureOverlay (z-1400) owns the screen. Bail out
|
||||
// here so we never paint a progress bar alongside the error overlay —
|
||||
// E2E screenshots can catch the two mid-transition.
|
||||
if (boot.error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" role="status">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -365,10 +371,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||
</p>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
|
||||
hasError && 'bg-destructive'
|
||||
)}
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -376,7 +379,6 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||
<span className="truncate">{boot.message}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
{hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,16 @@ export function applyDesktopBootProgress(progress: DesktopBootProgress) {
|
|||
const nextProgress = clampProgress(progress.progress)
|
||||
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
|
||||
|
||||
// Don't let a late progress event (error: null) clobber a previously-set
|
||||
// boot failure — failDesktopBoot is terminal for this boot cycle.
|
||||
const error = progress.error ?? (current.running ? null : current.error)
|
||||
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
...progress,
|
||||
error: progress.error ?? null,
|
||||
error,
|
||||
progress: mergedProgress,
|
||||
visible: progress.running || mergedProgress < 100 || Boolean(progress.error)
|
||||
visible: progress.running || mergedProgress < 100 || Boolean(error)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue