Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions frontend/src/__tests__/reasonConfirmPendingStale.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* dmfdeploy/dmfdeploy#863: once a request is sent, ReasonConfirm must not tell
* the operator to confirm again when the inventory goes stale. The pre-submit
* guidance returns after the request settles.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import ReasonConfirm from '../components/ReasonConfirm'
import { InventoryWritesBlockedProvider } from '../lib/inventoryFreshness'

afterEach(cleanup)

const GUIDANCE = /Confirm again after the next successful read/

function panel(blocked: boolean, pending: boolean) {
return (
<InventoryWritesBlockedProvider value={blocked}>
<ReasonConfirm title="T" description="D" pending={pending} onConfirm={() => {}} onCancel={() => {}} />
</InventoryWritesBlockedProvider>
)
}

describe('ReasonConfirm inventory guidance while pending (#863)', () => {
it('hides the confirm-again guidance while pending and restores it after settle', () => {
const view = render(panel(false, false))
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'go' } })
view.rerender(panel(false, true))
expect(screen.queryByText(GUIDANCE)).toBeNull()
// Inventory goes stale while the request is in flight.
view.rerender(panel(true, true))
expect(screen.queryByText(GUIDANCE)).toBeNull()
expect(screen.queryByText(/cannot be sent right now/)).toBeNull()
// The request settles with the inventory still stale.
view.rerender(panel(true, false))
expect(screen.getByText(GUIDANCE)).toBeTruthy()
expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe('go')
})
})
32 changes: 30 additions & 2 deletions frontend/src/__tests__/switchSourcePending.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const SOURCES = [
]

const KEY = ['media-workloads-topology', 'viewer-1']
type TopologyMode = 'ok' | 'error' | 'malformed'
type TopologyMode = 'ok' | 'error' | 'malformed' | 'null-entry' | 'idless-entry'

function setup() {
let active = 'source-a'
Expand Down Expand Up @@ -84,7 +84,14 @@ function setup() {
if (mode === 'error') return json({ error: 'boom' }, 500)
return json({
receiver_instance: 'viewer-1',
sources: mode === 'malformed' ? 'not-a-list' : sources,
sources:
mode === 'malformed'
? 'not-a-list'
: mode === 'null-entry'
? [null]
: mode === 'idless-entry'
? [{}]
: sources,
active_source: active,
provenance: 'observed-flow',
observed_at: new Date().toISOString(),
Expand Down Expand Up @@ -122,6 +129,8 @@ function setup() {
const state = qc.getQueryState(KEY)
if (mode === 'error') expect(state?.status).toBe('error')
else if (mode === 'malformed') expect(qc.getQueryData<{ sources: unknown }>(KEY)?.sources).toBe('not-a-list')
else if (mode === 'null-entry') expect(qc.getQueryData<{ sources: unknown }>(KEY)?.sources).toEqual([null])
else if (mode === 'idless-entry') expect(qc.getQueryData<{ sources: unknown }>(KEY)?.sources).toEqual([{}])
else expect(qc.getQueryData<{ active_source: string }>(KEY)?.active_source).toBe(active)
})
// The query notifies React on a later task; let that render happen.
Expand Down Expand Up @@ -235,6 +244,25 @@ describe('switch source while the request is pending (#861)', () => {
})
})

describe.each(['null-entry', 'idless-entry'] as const)('a sources array with a %s (#863)', (mode) => {
it('fails closed like a non-array when nothing is pending, without throwing', async () => {
const { poll } = setup()
await screen.findByRole('button', { name: 'Switch source' })
await poll({ mode })
expect(screen.queryByRole('button', { name: 'Switch source' })).toBeNull()
expect(screen.queryByText(/Live source is unknown or stale/)).toBeNull()
})

it('keeps the snapshot panel while a switch is pending', async () => {
const { poll, settlePost } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
await poll({ mode })
expectPendingPanel()
settlePost('ok')
})
})

describe('switch source before submit (#861)', () => {
it('a poll that drops the selected target shows the hint and disables Confirm', async () => {
const { poll } = setup()
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/ReasonConfirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ export default function ReasonConfirm({
{errorMessage ?? requestFailureText(error)}
</p>
)}
{writesBlocked && (
{/* Hidden while the request is in flight: it was already sent, so there
is nothing to confirm again. The guidance returns once it settles. */}
{writesBlocked && !pending && (
<p role="status" className="mt-1.5 text-sm text-amber-200">
The inventory is out of date, so this change cannot be sent right now. Your reason is
kept. Confirm again after the next successful read.
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/topologySources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { InstanceTopology } from '../api/types'

/**
* True when `topology.sources` is an array whose every entry is an object
* with a string `id`. Anything else is malformed: reading `s.id` on a `null`
* entry throws, and an id-less entry would be shown or offered as a source
* with no id, so a consumer treats the whole payload as unknown instead.
*/
export function hasValidSources(topology: InstanceTopology | null | undefined): topology is InstanceTopology {
const sources: unknown = topology?.sources
return (
Array.isArray(sources) &&
sources.every(
(s) => typeof s === 'object' && s !== null && typeof (s as { id?: unknown }).id === 'string',
)
)
}
3 changes: 2 additions & 1 deletion frontend/src/pages/MediaWorkloads/WorkloadHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { LOCKED_REASON } from './WorkloadSetup'
import WorkloadTile from './WorkloadTile'
import InstanceLiveModal from './InstanceLiveModal'
import { settleQuery } from '../../lib/queryState'
import { hasValidSources } from '../../lib/topologySources'
import { LIVE_TILE_CAP, useDocumentVisible, usePrefersReducedMotion } from './liveView'
import PageHeading from '../../components/PageHeading'
import { usePageTitle } from '../../hooks/usePageTitle'
Expand Down Expand Up @@ -745,7 +746,7 @@ function InstanceActiveSource({
onResolved: (instance: string, has: boolean) => void
}) {
const topology = useInstanceTopology(instance, { enabled: hasTopology !== false })
const has = !topology.isError && Boolean(topology.data && Array.isArray(topology.data.sources))
const has = !topology.isError && hasValidSources(topology.data)
useEffect(() => onResolved(instance, has), [instance, has, onResolved])

// An instance without a topology renders nothing — not an empty card, not
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import StageCard from './StageCard'
import { SWITCH_REQUEST_FAILED_TEXT, switchFailureText } from '../switchResultText'
import { settleQuery } from '../../../lib/queryState'
import { useGuardWrite } from '../../../lib/inventoryFreshness'
import { hasValidSources } from '../../../lib/topologySources'

/**
* Configure — source selection and the switch, relocated from
Expand Down Expand Up @@ -184,7 +185,7 @@ function InstanceSwitchControl({
const pending = switchMutation.isPending
const frozen = pending ? submitted : null
const live =
topology.failed || !topology.data || !Array.isArray(topology.data.sources) ? null : topology.data
topology.failed || !hasValidSources(topology.data) ? null : topology.data
if (!live && !frozen) return null

const sources = live?.sources ?? []
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/MediaWorkloads/stages/DesignStage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useInstanceTopology } from '../../../api/hooks'
import { hasValidSources } from '../../../lib/topologySources'
import type { CatalogEntry, MediaWorkload } from '../../../api/types'
import type { StageState } from '../../../lib/workloadLifecycle'
import StageCard from './StageCard'
Expand Down Expand Up @@ -192,7 +193,7 @@ function InstanceComposition({ instance, hasTopology }: { instance: string; hasT
//
// (umbrella #385) retrofitted onto settleQuery.
const topology = settleQuery(useInstanceTopology(instance, { enabled: hasTopology !== false }))
if (topology.failed || !topology.data || !Array.isArray(topology.data.sources)) return null
if (topology.failed || !hasValidSources(topology.data)) return null

const { sources, active_source } = topology.data
if (sources.length === 0) return null
Expand Down
Loading