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:
ethernet 2026-07-20 10:56:23 -04:00
parent b2be12d456
commit b2857110b4
5 changed files with 71 additions and 39 deletions

View file

@ -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)
})

View file

@ -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 },

View file

@ -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) {

View file

@ -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>
)
}

View file

@ -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)
})
}