diff --git a/src/components/UpdateCard.test.tsx b/src/components/UpdateCard.test.tsx index 70b9704..7678e76 100644 --- a/src/components/UpdateCard.test.tsx +++ b/src/components/UpdateCard.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UpdateCard } from './UpdateCard'; -import type { UpdateEntry } from '@/lib/types'; +import type { UpdateEntry, UpdatePhase } from '@/lib/types'; describe('UpdateCard', () => { it('renders update ID', () => { @@ -79,6 +79,75 @@ describe('UpdateCard', () => { expect(screen.getByText('completed')).toBeInTheDocument(); }); + it('only offers Delete when an update completed with no x-medkit-phase', () => { + const entry: UpdateEntry = { + id: 'update-done', + status: { status: 'completed' }, + }; + + render(); + + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument(); + }); + + it('surfaces Execute alongside Delete when completed maps to phase=prepared', () => { + // Plugins that split prepare/execute expose both terminal states as + // SOVD status=completed. The phase disambiguates: completed + + // prepared means the install still needs to run. + const entry: UpdateEntry = { + id: 'update-prepared', + status: { status: 'completed', 'x-medkit-phase': 'prepared' }, + }; + + render(); + + expect(screen.getByRole('button', { name: /^execute$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('drops Execute once phase advances past prepared', () => { + const entry: UpdateEntry = { + id: 'update-executed', + status: { status: 'completed', 'x-medkit-phase': 'executed' }, + }; + + render(); + + expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('falls back to Delete-only when completed carries an unknown phase', () => { + // Contract: only `prepared` surfaces Execute. Any other (or + // out-of-enum) phase on `completed` is treated as terminal. + const entry: UpdateEntry = { + id: 'update-unknown-phase', + status: { + status: 'completed', + 'x-medkit-phase': 'installing' as UpdatePhase, + }, + }; + + render(); + + expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('ignores phase on failed updates and offers the retry actions', () => { + const entry: UpdateEntry = { + id: 'update-failed-after-prepare', + status: { status: 'failed', 'x-medkit-phase': 'prepared' }, + }; + + render(); + + expect(screen.getByRole('button', { name: /prepare/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^execute$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + it('shows failed badge with error message text', () => { const entry: UpdateEntry = { id: 'update-failed', diff --git a/src/components/UpdateCard.tsx b/src/components/UpdateCard.tsx index b30d9b8..569109f 100644 --- a/src/components/UpdateCard.tsx +++ b/src/components/UpdateCard.tsx @@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Package, Loader2, AlertCircle, FileText } from 'lucide-react'; import { fetchUpdateDetail } from '@/lib/updates-api'; -import type { UpdateEntry, UpdateStatusValue } from '@/lib/types'; +import type { UpdateEntry, UpdateStatus, UpdateStatusValue } from '@/lib/types'; export type UpdateAction = 'prepare' | 'execute' | 'automated' | 'delete'; @@ -58,16 +58,27 @@ function progressBarColor(status: UpdateStatusValue): string { } } -function actionButtonsForStatus(status: UpdateStatusValue): UpdateAction[] { - switch (status) { +function actionButtonsForStatus(status: UpdateStatus): UpdateAction[] { + // SOVD collapses the prepare + execute pipeline into a single + // `completed` terminal status. Plugins that split the pipeline keep the + // real phase on the `x-medkit-phase` vendor field, so when + // status=completed + phase=prepared we are only half done and must + // surface Execute / Delete. Any other completed update (phase missing + // or `executed`) is truly terminal and only Delete applies. The + // `default` keeps the UI safe if a plugin emits a status outside the + // documented enum. + const phase = status['x-medkit-phase']; + switch (status.status) { case 'pending': return ['prepare', 'execute', 'automated', 'delete']; case 'inProgress': return []; case 'completed': - return ['delete']; + return phase === 'prepared' ? ['execute', 'delete'] : ['delete']; case 'failed': return ['prepare', 'execute', 'delete']; + default: + return ['delete']; } } @@ -213,7 +224,7 @@ export function UpdateCard({ entry, baseUrl, busy, onAction }: UpdateCardProps)
{onAction && - actionButtonsForStatus(status.status).map((action) => ( + actionButtonsForStatus(status).map((action) => (