Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,49 @@ describe('AdditionalInitializers', () => {
expect(within(row).queryByRole('textbox', { name: 'Parameters JSON' })).not.toBeInTheDocument()
})

it('should disable Edit while the initializer catalog is unavailable', () => {
render(
<TestWrapper>
<AdditionalInitializers
{...defaultProps}
registeredInitializers={[]}
catalogStatus="error"
/>
</TestWrapper>,
)

const row = screen.getByTestId('initializer-row-additional-1')
const editButton = within(row).getByRole('button', { name: 'Edit' })
expect(editButton).toBeDisabled()
expect(
within(row).getByText('Initializer catalog is unavailable. Edit is disabled until the catalog loads.'),
).toBeInTheDocument()
// The saved parameters stay visible so operators can still see the stored value.
expect(within(row).getByText(/"tags"/)).toBeInTheDocument()
})

it('should disable Edit for a name that is no longer registered', () => {
render(
<TestWrapper>
<AdditionalInitializers
{...defaultProps}
registeredInitializers={[scorerInitializer]}
catalogStatus="loaded"
/>
</TestWrapper>,
)

const row = screen.getByTestId('initializer-row-additional-1')
const editButton = within(row).getByRole('button', { name: 'Edit' })
expect(editButton).toBeDisabled()
// No synthetic catalog entry is rendered: no env vars or parameter summary for the row.
expect(within(row).queryByText(/Required env vars:/)).not.toBeInTheDocument()
expect(within(row).queryByText(/tags \(list\[str\], optional\)/)).not.toBeInTheDocument()
expect(
within(row).queryByText('Initializer catalog is unavailable. Edit is disabled until the catalog loads.'),
).not.toBeInTheDocument()
})

it('should show the description as hover text on the initializer name', async () => {
const user = userEvent.setup()

Expand Down
54 changes: 40 additions & 14 deletions frontend/src/components/Initializers/AdditionalInitializers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@ import type {
import { toApiError } from '@/services/errors'
import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles'
import { formatInitializerParameters, formatSupportedParameterSummary } from './initializerFormatting'
import { resolveRegisteredInitializer } from './initializerLookup'
import {
findRegisteredInitializer,
initializerFallbackDescription,
type CatalogStatus,
} from './initializerLookup'
import InitializerParametersDialog from './InitializerParametersDialog'
import { useInitializersStyles } from './Initializers.styles'
import ConfirmDialog from '../ConfirmDialog'

interface AdditionalInitializersProps {
items: AdditionalInitializerSetting[]
registeredInitializers: RegisteredInitializer[]
catalogStatus?: CatalogStatus
creating: boolean
savingInitializerId?: string | null
saveErrors?: Record<string, string>
Expand All @@ -39,7 +44,8 @@ interface AdditionalInitializersProps {

interface AdditionalInitializerCardProps {
item: AdditionalInitializerSetting
initializer: RegisteredInitializer
initializer: RegisteredInitializer | undefined
catalogStatus: CatalogStatus
isSaving: boolean
isApplying: boolean
isDeleting: boolean
Expand All @@ -53,6 +59,7 @@ interface AdditionalInitializerCardProps {
function AdditionalInitializerCard({
item,
initializer,
catalogStatus,
isSaving,
isApplying,
isDeleting,
Expand Down Expand Up @@ -81,36 +88,53 @@ function AdditionalInitializerCard({
}
}

const canEdit = initializer !== undefined && catalogStatus === 'loaded'

return (
<div role="listitem" className={styles.card} data-testid={`initializer-row-${item.id}`}>
<div className={styles.cardHeader}>
<div className={styles.titleGroup}>
<Tooltip content={initializer.description || 'No description available.'} relationship="description" withArrow>
<Tooltip
content={
initializer
? initializer.description || 'No description available.'
: initializerFallbackDescription(catalogStatus)
}
relationship="description"
withArrow
>
<Text weight="semibold" size={400}>{item.initializer_name}</Text>
</Tooltip>
{initializer.required_env_vars.length > 0 && (
{initializer && initializer.required_env_vars.length > 0 && (
<Text className={styles.envVarText}>
Required env vars: {initializer.required_env_vars.join(', ')}
</Text>
)}
{catalogStatus === 'error' && (
<Text size={200} className={styles.envVarText}>
Initializer catalog is unavailable. Edit is disabled until the catalog loads.
</Text>
)}
</div>
</div>

<div className={styles.parameterList}>
{formatSupportedParameterSummary(initializer).map((summary: string) => (
<Text key={summary} className={styles.parameterHint} size={200}>
{summary}
</Text>
))}
</div>
{initializer && (
<div className={styles.parameterList}>
{formatSupportedParameterSummary(initializer).map((summary: string) => (
<Text key={summary} className={styles.parameterHint} size={200}>
{summary}
</Text>
))}
</div>
)}

<div>
<Text weight="semibold" size={300}>Parameters</Text>
<pre className={styles.parametersBlock}>{formatInitializerParameters(item.parameters)}</pre>
</div>

<div className={styles.actionsRow}>
<Button appearance="primary" onClick={() => setEditOpen(true)} disabled={isBusy}>
<Button appearance="primary" onClick={() => setEditOpen(true)} disabled={isBusy || !canEdit}>
Edit
</Button>
<Button
Expand Down Expand Up @@ -142,7 +166,7 @@ function AdditionalInitializerCard({
<InitializerParametersDialog
open
mode="edit"
initializer={initializer}
initializer={initializer ?? null}
initialParameters={item.parameters}
submitting={isSaving}
externalError={saveError}
Expand All @@ -157,6 +181,7 @@ function AdditionalInitializerCard({
export default function AdditionalInitializers({
items,
registeredInitializers,
catalogStatus = 'loaded',
creating,
savingInitializerId = null,
saveErrors = {},
Expand Down Expand Up @@ -237,7 +262,8 @@ export default function AdditionalInitializers({
<AdditionalInitializerCard
key={`${item.id}:${formatInitializerParameters(item.parameters)}:${item.order_index ?? ''}`}
item={item}
initializer={resolveRegisteredInitializer(item.initializer_name, registeredInitializers)}
initializer={findRegisteredInitializer(item.initializer_name, registeredInitializers)}
catalogStatus={catalogStatus}
isSaving={savingInitializerId === item.id}
isApplying={applyingInitializerId === item.id}
isDeleting={deletingInitializerId === item.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,21 @@ describe('AvailableInitializersDialog', () => {
const dialog = await screen.findByRole('dialog', { hidden: true })
expect(within(dialog).getByText('No registered initializers were found.')).toBeInTheDocument()
})

it('reports the catalog as unavailable instead of an empty registry when the request failed', async () => {
const user = userEvent.setup()
render(
<TestWrapper>
<AvailableInitializersDialog registeredInitializers={[]} catalogStatus="error" />
</TestWrapper>,
)

await user.click(screen.getByRole('button', { name: /browse available initializers/i }))

const dialog = await screen.findByRole('dialog', { hidden: true })
expect(
within(dialog).getByText('Initializer catalog is unavailable. The list cannot be shown until the catalog loads.'),
).toBeInTheDocument()
expect(within(dialog).queryByText('No registered initializers were found.')).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ import { AppsListRegular } from '@fluentui/react-icons'
import type { RegisteredInitializer } from '@/types'

import { formatSupportedParameterSummary } from './initializerFormatting'
import type { CatalogStatus } from './initializerLookup'
import { useInitializersStyles } from './Initializers.styles'

interface AvailableInitializersDialogProps {
registeredInitializers: RegisteredInitializer[]
catalogStatus?: CatalogStatus
disabled?: boolean
}

export default function AvailableInitializersDialog({
registeredInitializers,
catalogStatus = 'loaded',
disabled = false,
}: AvailableInitializersDialogProps) {
const styles = useInitializersStyles()
Expand All @@ -50,7 +53,11 @@ export default function AvailableInitializersDialog({
Every initializer registered with PyRIT. This is a read-only reference of what exists and the
parameters each one accepts.
</Text>
{registeredInitializers.length === 0 ? (
{registeredInitializers.length === 0 && catalogStatus === 'error' ? (
<Text className={styles.emptyState}>
Initializer catalog is unavailable. The list cannot be shown until the catalog loads.
</Text>
) : registeredInitializers.length === 0 ? (
<Text className={styles.emptyState}>No registered initializers were found.</Text>
) : (
<div className={styles.dialogList} role="list" aria-label="Available initializers">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe('BaselineInitializers', () => {

const row = screen.getByTestId('baseline-initializer-row-ghost')
expect(within(row).getByText('Initializer is no longer registered.')).toBeInTheDocument()
expect(within(row).getByText(/Required env vars: None/)).toBeInTheDocument()
// No synthetic catalog entry is rendered, so no env-vars line is shown for the row.
expect(within(row).queryByText(/Required env vars:/)).not.toBeInTheDocument()
})
})
26 changes: 19 additions & 7 deletions frontend/src/components/Initializers/BaselineInitializers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@ import { Text } from '@fluentui/react-components'
import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types'

import { formatInitializerParameters } from './initializerFormatting'
import { resolveRegisteredInitializer } from './initializerLookup'
import {
findRegisteredInitializer,
initializerFallbackDescription,
type CatalogStatus,
} from './initializerLookup'
import { useInitializersStyles } from './Initializers.styles'

interface BaselineInitializersProps {
items: BaselineInitializerSetting[]
registeredInitializers: RegisteredInitializer[]
catalogStatus?: CatalogStatus
}

export default function BaselineInitializers({
items,
registeredInitializers,
catalogStatus = 'loaded',
}: BaselineInitializersProps) {
const styles = useInitializersStyles()

Expand All @@ -32,7 +38,7 @@ export default function BaselineInitializers({
) : (
<div className={styles.baselineGroup} role="list" aria-label="Baseline initializers">
{items.map((item: BaselineInitializerSetting) => {
const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers)
const initializer = findRegisteredInitializer(item.initializer_name, registeredInitializers)
return (
<div
key={`${item.initializer_name}:${item.order_index}`}
Expand All @@ -43,12 +49,18 @@ export default function BaselineInitializers({
<div className={styles.baselineHeader}>
<div className={styles.titleGroup}>
<Text weight="semibold" size={400}>{item.initializer_name}</Text>
<Text size={300}>{initializer.description || 'No description available.'}</Text>
<Text size={200} className={styles.metadataText}>
Required env vars: {initializer.required_env_vars.length > 0
? initializer.required_env_vars.join(', ')
: 'None'}
<Text size={300}>
{initializer
? initializer.description || 'No description available.'
: initializerFallbackDescription(catalogStatus)}
</Text>
{initializer && (
<Text size={200} className={styles.metadataText}>
Required env vars: {initializer.required_env_vars.length > 0
? initializer.required_env_vars.join(', ')
: 'None'}
</Text>
)}
<Text size={200} className={styles.metadataText}>Order: {item.order_index}</Text>
</div>
</div>
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/components/Initializers/Initializers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,28 @@ describe('Initializers', () => {
expect(screen.getByText('Service Unavailable')).toBeInTheDocument()
})

it('should drop the loaded catalog and disable Add when a refresh fails after a successful load', async () => {
const user = userEvent.setup()
renderInitializers()

const loadedRow = await screen.findByTestId('baseline-initializer-row-target')
await waitFor(() => expect(loadedRow).toHaveTextContent('Registers targets.'))
expect(loadedRow).toHaveTextContent('Required env vars: AZURE_OPENAI_ENDPOINT')
expect(screen.getByRole('button', { name: 'Add initializer' })).toBeEnabled()

mockedInitializersApi.listRegistered.mockRejectedValueOnce(new Error('Service Unavailable'))
await user.click(screen.getByRole('button', { name: 'Refresh' }))

await waitFor(() => expect(mockedInitializersApi.listRegistered).toHaveBeenCalledTimes(2))
const refreshedRow = await screen.findByTestId('baseline-initializer-row-target')
await waitFor(() => expect(refreshedRow).toHaveTextContent('Catalog metadata temporarily unavailable.'))
expect(refreshedRow).not.toHaveTextContent('Registers targets.')
expect(refreshedRow).not.toHaveTextContent('Required env vars')
expect(screen.getByRole('button', { name: 'Add initializer' })).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'Initializer to add' })).toBeDisabled()
expect(screen.getByText('Service Unavailable')).toBeInTheDocument()
})

it('should remove an additional initializer and show success feedback', async () => {
const user = userEvent.setup()
renderInitializers()
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/Initializers/Initializers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { InitializerSettingsResponse, RegisteredInitializer, UpdateAddition
import AdditionalInitializers from './AdditionalInitializers'
import AvailableInitializersDialog from './AvailableInitializersDialog'
import BaselineInitializers from './BaselineInitializers'
import type { CatalogStatus } from './initializerLookup'
import { useInitializersStyles } from './Initializers.styles'

interface StatusMessage {
Expand All @@ -26,6 +27,7 @@ export default function Initializers() {
const styles = useInitializersStyles()
const [settings, setSettings] = useState<InitializerSettingsResponse>(EMPTY_SETTINGS)
const [registeredInitializers, setRegisteredInitializers] = useState<RegisteredInitializer[]>([])
const [catalogStatus, setCatalogStatus] = useState<CatalogStatus>('loading')
const [loading, setLoading] = useState(true)
const [statusMessage, setStatusMessage] = useState<StatusMessage | null>(null)
const [refetchCount, setRefetchCount] = useState(0)
Expand Down Expand Up @@ -55,7 +57,12 @@ export default function Initializers() {

if (registeredResult.status === 'fulfilled') {
setRegisteredInitializers(registeredResult.value.items)
setCatalogStatus('loaded')
} else {
// Drop the previous catalog: entries left behind would keep rendering stale
// descriptions and env vars, and would keep Add enabled off the first stale name.
setRegisteredInitializers([])
setCatalogStatus('error')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a refresh fails after a successful load, registeredInitializers keeps the old entries while catalogStatus becomes error. That leaves stale metadata visible and the Add controls enabled even though catalog-dependent UI should be unavailable; could we clear the array here or make consumers ignore it unless the status is loaded, with a regression test for success followed by refresh failure?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 749e65e (merged main as acf7bb0 so this sits on current main).

Took the first option — the failure branch now drops the catalog instead of keeping the last good list:

} else {
  // Drop the previous catalog: entries left behind would keep rendering stale
  // descriptions and env vars, and would keep Add enabled off the first stale name.
  setRegisteredInitializers([])
  setCatalogStatus('error')

Both symptoms you named follow from that one change, with no new gating logic:

  • Stale metadata: every consumer resolves entries through findRegisteredInitializer, so an empty list makes the baseline/additional rows fall back to initializerFallbackDescription('error') → "Catalog metadata temporarily unavailable.", and the Required env vars: line stops rendering (it is inside {initializer && ...}).
  • Add controls: the select's existing registeredInitializers.length === 0 guard fires, and initializerName resolves to '', so disabled={creating || !initializerName} disables the button. A stale selectedInitializerName cannot keep it enabled — the section unmounts while loading is true, so that local state resets on remount.

Regression test: should drop the loaded catalog and disable Add when a refresh fails after a successful load (loads successfully and asserts the real description + env vars + enabled Add first, then fails the second listRegistered call). Verified it fails on the pre-fix code — the stale "Registers targets." description is still rendered — and passes with the fix.

Initializers folder: 79/79 passing (was 78 before this test), tsc --noEmit clean, eslint --max-warnings 0 clean, all re-run after the merge commit.

const catalogError = toApiError(registeredResult.reason).detail
setStatusMessage((current: StatusMessage | null) =>
current
Expand Down Expand Up @@ -178,7 +185,8 @@ export default function Initializers() {
<div className={styles.headerActions}>
<AvailableInitializersDialog
registeredInitializers={registeredInitializers}
disabled={loading}
catalogStatus={catalogStatus}
disabled={loading || catalogStatus !== 'loaded'}
/>
<Button
appearance="subtle"
Expand Down Expand Up @@ -207,10 +215,12 @@ export default function Initializers() {
<BaselineInitializers
items={settings.baseline}
registeredInitializers={registeredInitializers}
catalogStatus={catalogStatus}
/>
<AdditionalInitializers
items={settings.additional}
registeredInitializers={registeredInitializers}
catalogStatus={catalogStatus}
creating={creating}
savingInitializerId={savingInitializerId}
saveErrors={saveErrors}
Expand Down
Loading