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
17 changes: 17 additions & 0 deletions frontend/src/__tests__/reasonConfirm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,20 @@ describe('ReasonConfirm capacity refusal', () => {
expect(screen.getByText(/Check your access, then retry/)).toBeTruthy()
})
})

describe('ReasonConfirm pending indicator (dmfdeploy/dmfdeploy#861)', () => {
it('shows a progress indicator and announces progress only while pending', () => {
const props = { title: 'T', description: 'D', pendingLabel: 'Working…', onConfirm: vi.fn(), onCancel: () => {} }
const { rerender } = render(<ReasonConfirm {...props} />)
expect(screen.queryByTestId('reason-confirm-spinner')).toBeNull()
expect(screen.getByRole('status').textContent).toBe('')

rerender(<ReasonConfirm {...props} pending />)
expect(screen.getByTestId('reason-confirm-spinner')).toBeTruthy()
expect(screen.getByRole('status').textContent).toBe('In progress')
expect(document.querySelector('.throbber-spin')).not.toBeNull()

rerender(<ReasonConfirm {...props} />)
expect(screen.queryByTestId('reason-confirm-spinner')).toBeNull()
})
})
247 changes: 247 additions & 0 deletions frontend/src/__tests__/switchSourcePending.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* dmfdeploy/dmfdeploy#861: a source switch in progress must not say the
* selected source is gone.
*
* The topology query polls while the panel is armed. A poll that lands while
* the switch request is in flight can show the target as active_source, fail,
* return malformed data or leave no other source. While the request is
* pending the panel keeps the pending indicator and shows the submitted
* target in a disabled select, with no pre-submit "unknown or stale" line.
* Before submit, and after an error settles while the control is still
* rendered, the stale-target check applies to the current target.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ConfigureStage from '../pages/MediaWorkloads/stages/ConfigureStage'
import { InventoryWritesBlockedProvider } from '../lib/inventoryFreshness'
import type { MediaWorkload } from '../api/types'

afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

function json(b: unknown, status = 200) {
return new Response(JSON.stringify(b), { status, headers: { 'Content-Type': 'application/json' } })
}

const WORKLOAD = {
slug: 'studio-a',
name: 'studio-a',
lifecycle: 'operate',
health: 'ok',
instances: [
{
instance: 'viewer-1',
netbox_id: 1,
function_key: 'crosspoint',
live_view: false,
requested_state: 'active',
observed_state: 'running',
reconcile_pending: false,
placement: { node: 'node-1', ports: [], protocol: null },
workload_assignment: 'ok',
},
],
functions: [{ function_key: 'crosspoint', count: 1, running: 1, reconcile_pending: 0 }],
} as unknown as MediaWorkload

const SOURCES = [
{ id: 'source-a', flow_id: 'f1', pattern: 'smpte' },
{ id: 'source-b', flow_id: 'f2', pattern: 'ball' },
]

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

function setup() {
let active = 'source-a'
let sources = SOURCES
let mode: TopologyMode = 'ok'
let settlePost: (outcome: 'ok' | 'fail') => void = () => {}
const gate = new Promise<'ok' | 'fail'>((resolve) => {
settlePost = resolve
})
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = (typeof input === 'string' ? input : (input as Request).url).toString()
if (init?.method === 'POST') {
if ((await gate) === 'fail') return json({ error: 'upstream failed' }, 500)
return json({
request_id: 'r1',
status: 'active',
outcome: 'active',
source_instance: 'source-b',
reason: 'go',
actor: 'ops',
role: 'engineer',
})
}
if (url.endsWith('/topology')) {
if (mode === 'error') return json({ error: 'boom' }, 500)
return json({
receiver_instance: 'viewer-1',
sources: mode === 'malformed' ? 'not-a-list' : sources,
active_source: active,
provenance: 'observed-flow',
observed_at: new Date().toISOString(),
})
}
return json({})
}),
)
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const tree = () => (
<QueryClientProvider client={qc}>
<InventoryWritesBlockedProvider value={false}>
<ConfigureStage
workload={WORKLOAD}
state="available"
actions={['switch-source']}
onBusyChange={() => {}}
onSwitchResult={() => {}}
onJobStart={() => {}}
/>
</InventoryWritesBlockedProvider>
</QueryClientProvider>
)
const view = render(tree())
// Delivers a poll result, then waits until the query holds it, so the
// assertions that follow cannot pass just because the poll never landed.
const poll = async (next: { active?: string; sources?: typeof SOURCES; mode?: TopologyMode }) => {
if (next.active) active = next.active
if (next.sources) sources = next.sources
mode = next.mode ?? 'ok'
await act(async () => {
await qc.refetchQueries({ queryKey: KEY })
})
await waitFor(() => {
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 expect(qc.getQueryData<{ active_source: string }>(KEY)?.active_source).toBe(active)
})
// The query notifies React on a later task; let that render happen.
await act(async () => {
await new Promise((r) => setTimeout(r, 0))
})
}
return { poll, settlePost, qc, view, tree }
}

async function armAndPick(target: string) {
fireEvent.click(await screen.findByRole('button', { name: 'Switch source' }))
fireEvent.change(screen.getByTestId('reason-switch-source'), { target: { value: 'go' } })
fireEvent.change(screen.getByRole('combobox'), { target: { value: target } })
}

async function submitAndWaitPending() {
fireEvent.click(screen.getByTestId('confirm-switch-source'))
await waitFor(() =>
expect(screen.getByTestId('confirm-switch-source').textContent).toContain('Switching…'),
)
}

function expectPendingPanel() {
expect(screen.queryByText(HINT)).toBeNull()
expect(screen.queryByText(/Live source is unknown or stale/)).toBeNull()
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('source-b')
expect(select.disabled).toBe(true)
expect(screen.getByTestId('confirm-switch-source').textContent).toContain('Switching…')
expect(screen.getByTestId('reason-confirm-spinner')).toBeTruthy()
}

const HINT = /no longer available/

describe('switch source while the request is pending (#861)', () => {
it('a poll that shows the target as active keeps the submitted target and raises no hint', async () => {
const { poll, settlePost } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
await poll({ active: 'source-b' })
expectPendingPanel()
settlePost('ok')
})

it('a poll that fails keeps the panel, the pending state and the submitted target', async () => {
const { poll, settlePost } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
await poll({ mode: 'error' })
expectPendingPanel()
settlePost('ok')
})

it('a poll with malformed data keeps the panel, the pending state and the submitted target', async () => {
const { poll, settlePost } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
await poll({ mode: 'malformed' })
expectPendingPanel()
settlePost('ok')
})

it('a poll that leaves no other source keeps the panel, the pending state and the submitted target', async () => {
const { poll, settlePost } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
await poll({ active: 'source-b', sources: [SOURCES[1]] })
expectPendingPanel()
settlePost('ok')
})

it('an error settle after a poll made the target active shows the hint and disables Confirm', async () => {
const { poll, settlePost } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
await poll({ active: 'source-b' })
settlePost('fail')
expect(await screen.findByText(HINT)).toBeTruthy()
expect((screen.getByTestId('confirm-switch-source') as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false)
})

it('a success settle closes the panel and shows the result', async () => {
const { poll, settlePost } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
await poll({ active: 'source-b' })
settlePost('ok')
expect(await screen.findByText('Active source: source-b')).toBeTruthy()
expect(screen.queryByRole('combobox')).toBeNull()
})

it('unmounting while pending and remounting shows no stale snapshot or stuck pending state', async () => {
const { settlePost, view, tree } = setup()
await armAndPick('source-b')
await submitAndWaitPending()
view.unmount()
render(tree())
const arm = await screen.findByRole('button', { name: 'Switch source' })
expect(screen.queryByText('Switching…')).toBeNull()
fireEvent.click(arm)
fireEvent.change(screen.getByTestId('reason-switch-source'), { target: { value: 'again' } })
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.disabled).toBe(false)
expect(within(select).getAllByRole('option').map((o) => (o as HTMLOptionElement).value)).toEqual(['', 'source-b'])
fireEvent.change(select, { target: { value: 'source-b' } })
expect((screen.getByTestId('confirm-switch-source') as HTMLButtonElement).disabled).toBe(false)
settlePost('ok')
await act(async () => {})
})
})

describe('switch source before submit (#861)', () => {
it('a poll that drops the selected target shows the hint and disables Confirm', async () => {
const { poll } = setup()
await armAndPick('source-b')
expect((screen.getByTestId('confirm-switch-source') as HTMLButtonElement).disabled).toBe(false)
await poll({ active: 'source-b' })
expect(await screen.findByText(HINT)).toBeTruthy()
expect((screen.getByTestId('confirm-switch-source') as HTMLButtonElement).disabled).toBe(true)
})
})
19 changes: 19 additions & 0 deletions frontend/src/components/ReasonConfirm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useId, useState } from 'react'
import { Disc3 } from 'lucide-react'
import { Input, Select, Textarea } from './FormField'
import { capacityRefusalMessage } from '../lib/capacityRefusal'
import { useInventoryWritesBlocked } from '../lib/inventoryFreshness'
Expand Down Expand Up @@ -69,6 +70,8 @@ export interface ReasonConfirmExtraField {
onChange: (value: string) => void
invalid?: boolean
invalidHint?: string
// Locks the field, e.g. while the write it feeds is in flight.
disabled?: boolean
// umbrella #201 WP5: when provided, renders a <select> of these options
// (e.g. the switch-source target picker) instead of the default free-text
// <input> — every other consumer (the #239 workload field) omits this and
Expand Down Expand Up @@ -159,6 +162,7 @@ export default function ReasonConfirm({
id={extraFieldId}
className="mt-1.5"
value={extraField.value}
disabled={extraField.disabled}
onChange={(e) => extraField.onChange(e.target.value)}
>
<option value="" disabled>
Expand All @@ -177,6 +181,7 @@ export default function ReasonConfirm({
className="mt-1.5"
placeholder={extraField.placeholder}
value={extraField.value}
disabled={extraField.disabled}
onChange={(e) => extraField.onChange(e.target.value)}
/>
)}
Expand Down Expand Up @@ -215,8 +220,22 @@ export default function ReasonConfirm({
onClick={() => onConfirm(reason.trim())}
{...(confirmId ? { 'data-testid': confirmId } : {})}
>
{pending && (
// umbrella #861: the label swap alone was the only sign of work in
// progress. Same glyph and class as AutomationInProgressNotice, so
// the reduced-motion rule in index.css stops it.
<span aria-hidden="true" className="throbber-spin mr-1.5 inline-block align-middle">
<Disc3 className="h-4 w-4" data-testid="reason-confirm-spinner" />
</span>
)}
{pending ? pendingLabel : confirmLabel}
</button>
{/* The live region is always mounted so the change to pending is
announced; the button's own text swap is not. It carries its own
wording so the pending label stays unique on the page. */}
<span role="status" className="sr-only">
{pending ? 'In progress' : ''}
</span>
<button className="btn btn-secondary btn-sm" onClick={onCancel} disabled={pending}>
Cancel
</button>
Expand Down
Loading
Loading