mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +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
|
* 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).
|
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
|
||||||
* The `hermes serve` backend starts, but when the renderer tries to connect
|
* resolution fails with a controlled error message. The app should show the
|
||||||
* or when a runtime check fails, the app should show a boot failure or
|
* BootFailureOverlay with retry/repair actions — no progress bar should be
|
||||||
* onboarding error overlay.
|
* visible.
|
||||||
*
|
*
|
||||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||||
*/
|
*/
|
||||||
|
|
@ -26,17 +26,13 @@ test.afterAll(async () => {
|
||||||
fixture = null
|
fixture = null
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('boot failure with dead provider endpoint', () => {
|
test.describe('boot failure with dead backend', () => {
|
||||||
test('app shows error state or onboarding', async () => {
|
test('app shows error state', async () => {
|
||||||
fixture = await setupDeadBackend()
|
// 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)
|
await waitForBootFailure(fixture.page, 90_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -387,12 +387,24 @@ export interface DeadBackendFixture {
|
||||||
cleanup: () => Promise<void>
|
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
|
* 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,
|
* nothing listens on). By default the backend still boots (`hermes serve`
|
||||||
* triggering the BootFailureOverlay.
|
* 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 sandbox = createSandbox('dead')
|
||||||
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|
@ -415,7 +427,7 @@ providers:
|
||||||
)
|
)
|
||||||
writeEnvFile(sandbox.hermesHome)
|
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)
|
const { app, page } = await launchDesktop(env)
|
||||||
|
|
||||||
return {
|
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> {
|
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = root.textContent ?? ''
|
const text = document.body.textContent ?? ''
|
||||||
|
|
||||||
// A dead provider either produces a boot failure or falls back to the
|
// BootFailureOverlay buttons.
|
||||||
// provider onboarding screen after the runtime check fails.
|
const hasFailureUI =
|
||||||
return (
|
|
||||||
text.includes('error') ||
|
|
||||||
text.includes('Error') ||
|
|
||||||
text.includes('failed') ||
|
|
||||||
text.includes('Failed') ||
|
|
||||||
text.includes('Retry') ||
|
text.includes('Retry') ||
|
||||||
text.includes('Repair') ||
|
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,
|
undefined,
|
||||||
{ timeout: timeoutMs },
|
{ timeout: timeoutMs },
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,7 @@ const DESKTOP_LOG_BACKUP_COUNT = 3
|
||||||
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
|
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
|
||||||
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
|
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
|
||||||
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
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 BOOT_FAKE_STEP_MS = (() => {
|
||||||
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
||||||
|
|
@ -6999,6 +7000,16 @@ async function startHermes() {
|
||||||
throw backendStartFailure
|
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()
|
const existingConnectionPromise = backendConnectionState.getPromise()
|
||||||
|
|
||||||
if (existingConnectionPromise) {
|
if (existingConnectionPromise) {
|
||||||
|
|
|
||||||
|
|
@ -355,9 +355,15 @@ function ReasonNotice({ reason }: { reason: string }) {
|
||||||
function Preparing({ boot }: { boot: DesktopBootState }) {
|
function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
||||||
const hasError = Boolean(boot.error)
|
|
||||||
const installing = boot.phase.startsWith('runtime.')
|
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 (
|
return (
|
||||||
<div className="grid gap-3" role="status">
|
<div className="grid gap-3" role="status">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -365,10 +371,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||||
</p>
|
</p>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="h-full rounded-full bg-primary transition-[width] duration-300 ease-out"
|
||||||
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
|
|
||||||
hasError && 'bg-destructive'
|
|
||||||
)}
|
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -376,7 +379,6 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||||
<span className="truncate">{boot.message}</span>
|
<span className="truncate">{boot.message}</span>
|
||||||
<span>{progress}%</span>
|
<span>{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
{hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,16 @@ export function applyDesktopBootProgress(progress: DesktopBootProgress) {
|
||||||
const nextProgress = clampProgress(progress.progress)
|
const nextProgress = clampProgress(progress.progress)
|
||||||
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
|
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({
|
$desktopBoot.set({
|
||||||
...current,
|
...current,
|
||||||
...progress,
|
...progress,
|
||||||
error: progress.error ?? null,
|
error,
|
||||||
progress: mergedProgress,
|
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