* feat(cli): add --safe-mode troubleshooting flag Inspired by Claude Code v2.1.169 (June 2026): run Hermes with all customizations disabled to isolate setup problems from product bugs. --safe-mode implies --ignore-user-config and --ignore-rules, and additionally skips plugin discovery (hermes_cli/plugins.py) and MCP server loading (tools/mcp_tool.py) via the internal HERMES_SAFE_MODE env bridge. * fix(desktop): keep composer usable during reconnect
145 lines
5.1 KiB
TypeScript
145 lines
5.1 KiB
TypeScript
import { cleanup, render, screen } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import { $desktopBoot } from '@/store/boot'
|
|
import { $desktopOnboarding } from '@/store/onboarding'
|
|
import { setGatewayState } from '@/store/session'
|
|
|
|
import { BootFailureOverlay } from './boot-failure-overlay'
|
|
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
|
|
|
|
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
|
|
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
|
|
// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
|
|
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
|
|
// "Retry" — only renders when `boot.error` is set.
|
|
//
|
|
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
|
|
// INITIAL boot() throws. After the first successful connect (bootCompleted),
|
|
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
|
|
// against the dead remote. So gatewayState sits at 'closed'/'error' with
|
|
// boot.error null. The fix keeps the initial-boot overlay out of post-boot
|
|
// reconnects, leaving chat/settings usable while the reconnect loop runs.
|
|
|
|
function resetStores() {
|
|
setGatewayState('idle')
|
|
$desktopBoot.set({
|
|
error: null,
|
|
fakeMode: false,
|
|
message: 'ready',
|
|
phase: 'renderer.ready',
|
|
progress: 100,
|
|
running: false,
|
|
timestamp: Date.now(),
|
|
visible: false
|
|
})
|
|
$desktopOnboarding.set({
|
|
configured: true,
|
|
flow: { status: 'idle' },
|
|
mode: 'oauth',
|
|
providers: null,
|
|
reason: null,
|
|
requested: false,
|
|
firstRunSkipped: false,
|
|
manual: false,
|
|
localEndpoint: false
|
|
})
|
|
}
|
|
|
|
beforeEach(resetStores)
|
|
afterEach(cleanup)
|
|
|
|
// The connecting overlay renders "CONN" + a scrambled tail inside one
|
|
// uppercase span; match that node specifically so the recovery overlay's
|
|
// "Lost connection…" copy doesn't read as a false positive.
|
|
const isConnectingShown = () =>
|
|
screen.queryAllByText((_, el) => /^CONN[/\\|\-_=+<>~:*A-Z]*$/.test(el?.textContent?.trim() ?? '')).length > 0
|
|
const isRecoveryShown = () =>
|
|
Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i))
|
|
|
|
describe('connecting overlay vs recovery surface', () => {
|
|
it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => {
|
|
// failDesktopBoot() ran: error set, gateway never opened.
|
|
$desktopBoot.set({ ...$desktopBoot.get(), error: 'Hermes backend did not become ready', running: false, visible: true })
|
|
setGatewayState('error')
|
|
|
|
render(
|
|
<>
|
|
<GatewayConnectingOverlay />
|
|
<BootFailureOverlay />
|
|
</>
|
|
)
|
|
|
|
expect(isRecoveryShown()).toBe(true)
|
|
// Connecting overlay bows out when boot.error is set.
|
|
expect(isConnectingShown()).toBe(false)
|
|
})
|
|
|
|
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
|
|
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
|
|
setGatewayState('open')
|
|
const { rerender } = render(
|
|
<>
|
|
<GatewayConnectingOverlay />
|
|
<BootFailureOverlay />
|
|
</>
|
|
)
|
|
expect(isConnectingShown()).toBe(false)
|
|
|
|
// 2. The remote VPS socket drops (sleep/wake, remote restart, network).
|
|
// bootCompleted is true, so useGatewayBoot routes this through
|
|
// scheduleReconnect() — boot.error stays NULL.
|
|
setGatewayState('closed')
|
|
rerender(
|
|
<>
|
|
<GatewayConnectingOverlay />
|
|
<BootFailureOverlay />
|
|
</>
|
|
)
|
|
|
|
// The initial-boot connecting overlay stays out of the way, so settings and
|
|
// the composer remain reachable during the reconnect loop.
|
|
expect(isConnectingShown()).toBe(false)
|
|
expect(isRecoveryShown()).toBe(false)
|
|
|
|
// 3. Reconnect loops against the dead remote: gatewayState bounces closed
|
|
// → error → closed. Until the escalation path sets boot.error, the app
|
|
// remains usable instead of modal-blocked.
|
|
setGatewayState('error')
|
|
rerender(
|
|
<>
|
|
<GatewayConnectingOverlay />
|
|
<BootFailureOverlay />
|
|
</>
|
|
)
|
|
expect($desktopBoot.get().error).toBeNull()
|
|
expect(isConnectingShown()).toBe(false)
|
|
expect(isRecoveryShown()).toBe(false)
|
|
})
|
|
|
|
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => {
|
|
// Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of
|
|
// failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI
|
|
// from the dead-end CONNECTING overlay to the recovery surface.
|
|
setGatewayState('error')
|
|
$desktopBoot.set({
|
|
...$desktopBoot.get(),
|
|
error: 'Lost connection to the Hermes gateway and could not reconnect.',
|
|
running: false,
|
|
visible: true
|
|
})
|
|
|
|
render(
|
|
<>
|
|
<GatewayConnectingOverlay />
|
|
<BootFailureOverlay />
|
|
</>
|
|
)
|
|
|
|
// Escape hatch is now reachable; the connecting overlay bows out.
|
|
expect(isRecoveryShown()).toBe(true)
|
|
expect(screen.getByText(/use local gateway/i)).toBeTruthy()
|
|
expect(isConnectingShown()).toBe(false)
|
|
})
|
|
})
|