diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts index 91433c3ccc89..4f316d356c6f 100644 --- a/apps/desktop/e2e/boot-failure.spec.ts +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -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) }) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 14da89a3af1e..aa8dd94d2518 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -387,12 +387,24 @@ export interface DeadBackendFixture { cleanup: () => Promise } +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 { +export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise { 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 { 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 }, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 517121c2061d..fb60adbf1bf9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) { diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 87cc31648474..900d3f0c8739 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -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 (

@@ -365,10 +371,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {

@@ -376,7 +379,6 @@ function Preparing({ boot }: { boot: DesktopBootState }) { {boot.message} {progress}%
- {hasError ?

{boot.error}

: null}
) } diff --git a/apps/desktop/src/store/boot.ts b/apps/desktop/src/store/boot.ts index f25be5089db4..5300bdfc7d9a 100644 --- a/apps/desktop/src/store/boot.ts +++ b/apps/desktop/src/store/boot.ts @@ -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) }) }