mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-26 22:30:50 +00:00
* 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>
304 lines
9.7 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|