Files
hapi/web/src/components/RunnerVersionSkewBanner.test.tsx
T
1cd4d1137a feat(hub,cli,web): fleet runner version governance (skew, self-upgrade, soft-fail reopen) (#1108)
* fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew

Hub↔runner protocol drift was reported as missing Cursor chat data when
cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors,
advertise required machine capabilities, surface an unmissable upgrade banner,
and stop-runner when a newer CLI binary is already on disk.

Fixes #1084

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web,hub): make runner skew banner dismissible; gate auto-upgrade

Compact the out-of-date banner (minimize + 1h snooze + per-host Restart)
so it no longer blocks the session list. Auto stop-runner on skew stays
opt-in via HAPI_AUTO_UPGRADE_RUNNERS / autoUpgradeRunners (default off).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): tolerate full sessionStorage on skew banner minimize

QuotaExceededError from setItem aborted minimize before React state
updated, leaving the banner stuck over the session list. Persist to
memory when storage fails; only enable Restart when a newer CLI is
already on disk; clarify opt-in is stop-runner only, not package push.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): drop redundant autoUpgradeRunners; runners already self-restart

CLI version handoff already reloads the runner when the on-disk binary
mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the
skew banner and manual Restart only as a stuck/disabled-handoff escape.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli,hub,web): runner-only caps ads; gate Restart on supervisor

Address #1108 bot Majors on the thin tip: terminal/lazy bootstraps no
longer merge CURRENT_MACHINE_CAPABILITIES into the machine row (only
asRunner registration does). Banner Restart refuses unsupervised hosts
so stop-runner cannot leave a detached laptop offline; supervised
runners advertise supervisedRestart via HAPI_RUNNER_SUPERVISED=1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,cli,web): clear sticky runner ads; docs SUPERVISED; i18n skew label

Omit-means-clear on runner registration so rollback cannot leave
supervisedRestart/capabilities sticky; always advertise boolean
supervisedRestart from asRunner. Document HAPI_RUNNER_SUPERVISED=1
and localize MachineSelector UPDATE REQUIRED.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:24:39 +08:00

304 lines
9.7 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities'
import type { Machine } from '@/types/api'
import {
RunnerVersionSkewBanner,
listSkewedMachines,
machineDisplayHost,
} from './RunnerVersionSkewBanner'
import { I18nProvider } from '@/lib/i18n-context'
import {
clearRunnerSkewTempDismiss,
resetRunnerSkewBannerMemoryForTests,
setRunnerSkewMinimized,
} from '@/lib/runnerSkewBannerState'
const useMachinesMock = vi.fn()
const restartMachineRunnerMock = vi.fn(async () => ({ message: 'ok' }))
const useAppContextMock = vi.fn(() => ({
api: { restartMachineRunner: restartMachineRunnerMock } as never,
token: 't',
baseUrl: 'http://localhost',
}))
vi.mock('@/hooks/queries/useMachines', () => ({
useMachines: (...args: unknown[]) => useMachinesMock(...args),
}))
vi.mock('@/lib/app-context', () => ({
useAppContext: () => useAppContextMock(),
}))
vi.mock('@/hooks/useOnlineStatus', () => ({
useOnlineStatus: () => true,
}))
vi.mock('@/hooks/usePlatform', () => ({
usePlatform: () => ({
haptic: { impact: vi.fn(), notification: vi.fn() },
}),
}))
function makeMachine(overrides: Partial<Machine> & { id: string }): Machine {
const { id, ...rest } = overrides
return {
id,
namespace: 'default',
seq: 1,
createdAt: 0,
updatedAt: 0,
active: rest.active ?? true,
activeAt: Date.now(),
metadata: rest.metadata ?? {
host: 'proxmox',
platform: 'linux',
happyCliVersion: '0.20.0',
},
metadataVersion: 1,
runnerState: null,
runnerStateVersion: 0,
...rest,
} as Machine
}
describe('listSkewedMachines', () => {
it('flags online machines without required capabilities', () => {
const skewed = listSkewedMachines([
makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }),
makeMachine({
id: 'new',
metadata: {
host: 'oos',
platform: 'linux',
happyCliVersion: '0.23.0',
capabilities: [...CURRENT_MACHINE_CAPABILITIES],
},
}),
makeMachine({
id: 'offline-old',
active: false,
metadata: { host: 'ha', platform: 'linux', happyCliVersion: '0.19.0' },
}),
])
expect(skewed.map((m) => m.id)).toEqual(['old'])
})
it('uses displayName when present', () => {
expect(machineDisplayHost(makeMachine({
id: 'm1',
metadata: {
host: 'proxmox.local',
platform: 'linux',
happyCliVersion: '0.20.0',
displayName: 'Proxmox box',
},
}))).toBe('Proxmox box')
})
})
describe('RunnerVersionSkewBanner', () => {
beforeEach(() => {
window.sessionStorage.clear()
resetRunnerSkewBannerMemoryForTests()
setRunnerSkewMinimized(false)
clearRunnerSkewTempDismiss()
restartMachineRunnerMock.mockClear()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders a compact banner with minimize and snooze actions', () => {
useMachinesMock.mockReturnValue({
machines: [
makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'expanded')
expect(screen.getByText(/1 runner\(s\) out of date/)).toBeInTheDocument()
expect(screen.getByTestId('runner-version-skew-minimize')).toBeInTheDocument()
expect(screen.getByTestId('runner-version-skew-dismiss')).toBeInTheDocument()
expect(screen.getByTestId('runner-version-skew-restart-old')).toBeInTheDocument()
})
it('minimizes so the strip stays small', () => {
useMachinesMock.mockReturnValue({
machines: [
makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
fireEvent.click(screen.getByTestId('runner-version-skew-minimize'))
expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'minimized')
expect(screen.getByTestId('runner-version-skew-expand')).toBeInTheDocument()
})
it('temp-dismisses so sessions are reachable', () => {
useMachinesMock.mockReturnValue({
machines: [
makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
fireEvent.click(screen.getByTestId('runner-version-skew-dismiss'))
expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument()
})
it('disables Restart when no newer CLI is on disk', () => {
useMachinesMock.mockReturnValue({
machines: [
makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
const restart = screen.getByTestId('runner-version-skew-restart-old')
expect(restart).toBeDisabled()
expect(restart).toHaveTextContent(/Upgrade CLI first/)
})
it('disables Restart when newer CLI is on disk but runner is unsupervised', () => {
useMachinesMock.mockReturnValue({
machines: [
makeMachine({
id: 'old',
metadata: {
host: 'laptop',
platform: 'linux',
happyCliVersion: '0.20.0',
startedCliMtimeMs: 100,
installedCliMtimeMs: 200,
},
}),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
expect(screen.getByTestId('runner-version-skew-restart-old')).toBeDisabled()
})
it('calls restartMachineRunner when Restart is clicked on a supervised host with newer CLI', async () => {
useMachinesMock.mockReturnValue({
machines: [
makeMachine({
id: 'old',
metadata: {
host: 'proxmox',
platform: 'linux',
happyCliVersion: '0.20.0',
startedCliMtimeMs: 100,
installedCliMtimeMs: 200,
supervisedRestart: true,
},
}),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
fireEvent.click(screen.getByTestId('runner-version-skew-restart-old'))
await waitFor(() => {
expect(restartMachineRunnerMock).toHaveBeenCalledWith('old')
})
})
it('minimizes even when sessionStorage setItem throws QuotaExceededError', () => {
const proto = Object.getPrototypeOf(window.sessionStorage) as Storage
vi.spyOn(proto, 'setItem').mockImplementation(() => {
throw new DOMException('quota', 'QuotaExceededError')
})
useMachinesMock.mockReturnValue({
machines: [
makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
expect(() => fireEvent.click(screen.getByTestId('runner-version-skew-minimize'))).not.toThrow()
expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'minimized')
})
it('hides when all online machines advertise required capabilities', async () => {
useMachinesMock.mockReturnValue({
machines: [
makeMachine({
id: 'new',
metadata: {
host: 'oos',
platform: 'linux',
happyCliVersion: '0.23.0',
capabilities: [...CURRENT_MACHINE_CAPABILITIES],
},
}),
],
isLoading: false,
error: null,
})
render(
<I18nProvider>
<RunnerVersionSkewBanner />
</I18nProvider>,
)
await waitFor(() => {
expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument()
})
})
})