diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 85097721fd..9680a3b234 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -179,25 +179,15 @@ Targets can also be auto-populated by adding the `target` initializer to your `~ ### Configuration Editor -The **Configuration** page provides administrator-only editing for the files and scripts used to configure PyRIT. It has three tabs: +The **Configuration** page provides administrator-only editing for the files and scripts used to configure PyRIT. It has four tabs: - **PyRIT Configuration** edits the active `.pyrit_conf` YAML file. The source may be a local file or an Azure Blob URI. Saving validates the configuration before replacing it. - **Environment & Secrets** lists the configured local dotenv files and Azure Key Vault bootstrap secrets. Content is loaded only after selecting a source. Saves validate the dotenv document and reject the update if the source changed since it was loaded. +- **Initializers** shows the read-only startup sequence from the active `.pyrit_conf`, in run order, along with the catalog of registered initializers. - **Custom Initializers** registers or removes Python initializer scripts. This tab requires `allow_custom_initializers: true`; scripts are stored in the configured local directory or Azure Blob container and must define a concrete `PyRITInitializer` subclass. Use **Reload** to discard local edits and fetch the latest source content. Saved configuration and environment changes take effect after restarting PyRIT. Custom initializer scripts execute under the backend service identity, so only trusted administrators should manage them. -### Initializers - -The **Initializers** page (in the left navigation) lets you review and extend how PyRIT sets itself up at startup — for example, the `target` initializer's `tags` and `auto_group` settings. - -The page has two sections: - -- **Baseline initializers** are read-only. They come from your active configuration file (`~/.pyrit/.pyrit_conf`) and run first, in order. -- **Additional initializers** are added in the GUI and saved to the memory database. They run after the baseline, in the order shown. You can add more than one initializer of the same type — each is its own invocation. - -Use **Apply now** to re-run a single initializer immediately against the running backend — handy for picking up an environment or setting change without a restart. Saved additional initializers and `.pyrit_conf` edits otherwise take effect the next time the backend starts. - --- ## Connection Health diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index b3942e0c20..bea4b9c24a 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -168,11 +168,20 @@ async function installTouchTargetMocks(page: Page): Promise { ); return; } + if (apiPath === "/config" && method === "GET") { + await route.fulfill( + jsonResponse({ + content: "initializers: []\n", + source: "C:/Users/test/.pyrit/.pyrit_conf", + version: "touch-target-config-v1", + }) + ); + return; + } if (apiPath === "/initializers/settings" && method === "GET") { await route.fulfill( jsonResponse({ - baseline: [], - additional: [], + configured: [], }) ); return; @@ -455,10 +464,11 @@ test.describe("Mobile touch targets", () => { }); test("keeps the Initializer selector at least 44px", async ({ page }) => { - await page.goto("/initializers"); + await page.goto("/config"); + await page.getByRole("tab", { name: "Initializers", exact: true }).click(); await expectMinimumTouchTarget( - page.getByRole("combobox", { name: "Initializer to add" }) + page.getByRole("button", { name: "Browse available initializers" }) ); await expectNoDocumentOverflow(page); }); @@ -659,9 +669,10 @@ test("preserves compact desktop controls and existing sidebar dimensions", async page.getByRole("button", { name: "Expand inner targets" }) ); - await page.goto("/initializers"); + await page.goto("/config"); + await page.getByRole("tab", { name: "Initializers", exact: true }).click(); await expectCompactDesktopTarget( - page.getByRole("combobox", { name: "Initializer to add" }) + page.getByRole("button", { name: "Browse available initializers" }) ); await startChatWithMessages(page); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d03806696f..4d32f9672e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import ChatWindow from './components/Chat/ChatWindow' import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' -import Initializers from './components/Initializers/Initializers' import Configuration from './components/Configuration/Configuration' import AttackHistory from './components/History/AttackHistory' import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' @@ -43,7 +42,6 @@ const VIEW_PATHS: Record = { chat: '/chat', history: '/history', targets: '/targets', - initializers: '/initializers', scenarios: '/scanner', configuration: '/config', } @@ -491,7 +489,6 @@ function App() { /> } /> - } /> } /> ({ updateEnvironmentFile: jest.fn(), }, initializersApi: { + getSettings: jest.fn(), + listRegistered: jest.fn(), listCustom: jest.fn(), register: jest.fn(), unregister: jest.fn(), @@ -64,6 +66,19 @@ describe('Configuration', () => { }) mockedInitializersApi.register.mockResolvedValue() mockedInitializersApi.unregister.mockResolvedValue() + mockedInitializersApi.getSettings.mockResolvedValue({ + configured: [{ initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }], + }) + mockedInitializersApi.listRegistered.mockResolvedValue({ + items: [{ + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: [], + supported_parameters: [], + }], + pagination: { limit: 200, has_more: false }, + }) }) it('should load and display configuration content', async () => { @@ -204,4 +219,14 @@ describe('Configuration', () => { }) }) + it('should show configured initializers without a runtime apply action', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('tab', { name: 'Initializers' })) + + expect(await screen.findByTestId('configured-initializer-row-0')).toHaveTextContent('Registers targets.') + expect(screen.queryByRole('button', { name: 'Apply now' })).not.toBeInTheDocument() + }) + }) diff --git a/frontend/src/components/Configuration/Configuration.tsx b/frontend/src/components/Configuration/Configuration.tsx index 66980e0ac7..322604798f 100644 --- a/frontend/src/components/Configuration/Configuration.tsx +++ b/frontend/src/components/Configuration/Configuration.tsx @@ -16,6 +16,7 @@ import { ArrowSyncRegular, SaveRegular } from '@fluentui/react-icons' import { configurationApi } from '@/services/api' import { toApiError } from '@/services/errors' import EditorWorkspace from '@/components/EditorWorkspace' +import Initializers from '@/components/Initializers/Initializers' import { useConfigurationStyles } from './Configuration.styles' import CustomInitializerFiles from './CustomInitializerFiles' @@ -27,7 +28,7 @@ interface StatusMessage { text: string } -type ConfigurationTab = 'configuration' | 'environment' | 'custom-initializers' +type ConfigurationTab = 'configuration' | 'environment' | 'initializers' | 'custom-initializers' export default function Configuration() { const styles = useConfigurationStyles() @@ -102,6 +103,7 @@ export default function Configuration() { if ( data.value === 'configuration' || data.value === 'environment' + || data.value === 'initializers' || data.value === 'custom-initializers' ) { setSelectedTab(data.value) @@ -117,6 +119,7 @@ export default function Configuration() { PyRIT Configuration Environment & Secrets + Initializers Custom Initializers @@ -128,6 +131,8 @@ export default function Configuration() { {selectedTab === 'custom-initializers' ? ( + ) : selectedTab === 'initializers' ? ( + ) : selectedTab === 'environment' ? ( ) : loading ? ( diff --git a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts deleted file mode 100644 index 9cdad1f625..0000000000 --- a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { makeStyles, tokens } from '@fluentui/react-components' - -export const useAdditionalInitializersStyles = makeStyles({ - list: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalL, - width: '100%', - }, - card: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - padding: tokens.spacingVerticalL, - border: `1px solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, - backgroundColor: tokens.colorNeutralBackground1, - }, - cardHeader: { - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - flexWrap: 'wrap', - gap: tokens.spacingHorizontalM, - }, - titleGroup: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXXS, - }, - parameterList: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXXS, - marginBottom: tokens.spacingVerticalS, - }, - parameterHint: { - color: tokens.colorNeutralForeground3, - }, - parametersEditor: { - fontFamily: 'Consolas, "Courier New", monospace', - minHeight: '10rem', - width: '100%', - }, - parametersBlock: { - margin: 0, - marginTop: tokens.spacingVerticalXS, - padding: tokens.spacingVerticalM, - borderRadius: tokens.borderRadiusMedium, - backgroundColor: tokens.colorNeutralBackground3, - overflowX: 'auto', - fontFamily: 'Consolas, "Courier New", monospace', - }, - dialogContent: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalS, - }, - actionsRow: { - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - gap: tokens.spacingHorizontalS, - }, - errorText: { - color: tokens.colorPaletteRedForeground1, - marginTop: tokens.spacingVerticalXS, - }, - envVarText: { - color: tokens.colorNeutralForeground3, - display: 'block', - marginTop: tokens.spacingVerticalXXS, - }, - parameterFields: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - }, -}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.test.tsx b/frontend/src/components/Initializers/AdditionalInitializers.test.tsx deleted file mode 100644 index 74a909d626..0000000000 --- a/frontend/src/components/Initializers/AdditionalInitializers.test.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import { useState } from 'react' - -import type { AdditionalInitializerSetting, RegisteredInitializer } from '@/types' - -import AdditionalInitializers from './AdditionalInitializers' - -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) - -const targetInitializer: RegisteredInitializer = { - initializer_name: 'target', - initializer_type: 'TargetInitializer', - description: 'Registers targets.', - required_env_vars: ['AZURE_OPENAI_ENDPOINT'], - supported_parameters: [ - { - name: 'tags', - type_name: 'list[str]', - required: false, - default: null, - choices: null, - is_list: true, - description: 'Target tags.', - }, - ], -} - -const scorerInitializer: RegisteredInitializer = { - initializer_name: 'scorer', - initializer_type: 'ScorerInitializer', - description: 'Registers scorers.', - required_env_vars: [], - supported_parameters: [ - { - name: 'mode', - type_name: 'str', - required: false, - default: null, - choices: null, - is_list: false, - description: 'Scorer mode.', - }, - ], -} - -const noParamInitializer: RegisteredInitializer = { - initializer_name: 'load_default_datasets', - initializer_type: 'DatasetInitializer', - description: 'Loads default datasets.', - required_env_vars: [], - supported_parameters: [], -} - -const taggedTargetInitializer: RegisteredInitializer = { - initializer_name: 'tagged_target', - initializer_type: 'TargetInitializer', - description: 'Registers targets with tags.', - required_env_vars: [], - supported_parameters: [ - { - name: 'tags', - type_name: 'list[str]', - required: false, - default: null, - choices: ['default', 'scorer', 'all'], - is_list: true, - description: 'Target tags.', - }, - ], -} - -const requiredParamInitializer: RegisteredInitializer = { - initializer_name: 'required_param', - initializer_type: 'DatasetInitializer', - description: 'Requires a label.', - required_env_vars: [], - supported_parameters: [ - { - name: 'label', - type_name: 'str', - required: true, - default: null, - choices: null, - is_list: false, - description: 'A required label.', - }, - ], -} - -const refreshInitializer: RegisteredInitializer = { - initializer_name: 'refresh_datasets', - initializer_type: 'DatasetInitializer', - description: 'Refreshes datasets.', - required_env_vars: [], - supported_parameters: [ - { - name: 'days', - type_name: 'int', - required: false, - default: null, - choices: null, - is_list: false, - description: 'Number of days.', - }, - { - name: 'dataset_names', - type_name: 'list[str]', - required: false, - default: null, - choices: null, - is_list: true, - description: 'Dataset names.', - }, - ], -} - -const sampleItems: AdditionalInitializerSetting[] = [ - { - id: 'additional-1', - initializer_name: 'target', - parameters: { tags: ['default'] }, - order_index: 2, - }, - { - id: 'additional-2', - initializer_name: 'scorer', - parameters: null, - order_index: null, - }, -] - -describe('AdditionalInitializers', () => { - const defaultProps = { - items: sampleItems, - registeredInitializers: [targetInitializer, scorerInitializer], - creating: false, - onAdd: jest.fn().mockResolvedValue(true), - onSave: jest.fn().mockResolvedValue(true), - onClearSaveError: jest.fn(), - onApply: jest.fn().mockResolvedValue(undefined), - onRemove: jest.fn().mockResolvedValue(undefined), - } - - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should render additional initializer rows and metadata', () => { - render( - - - , - ) - - expect(screen.getByRole('list', { name: 'Additional initializers' })).toBeInTheDocument() - expect(screen.getByTestId('initializer-row-additional-1')).toHaveTextContent('target') - expect(screen.getByText('Required env vars: AZURE_OPENAI_ENDPOINT')).toBeInTheDocument() - expect(screen.getByText('tags (list[str], optional)')).toBeInTheDocument() - }) - - it('should show the saved parameters read-only without an inline editor', () => { - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - expect(within(row).getByText(/"tags"/)).toBeInTheDocument() - expect(within(row).queryByRole('textbox', { name: 'Parameters JSON' })).not.toBeInTheDocument() - }) - - it('should show the description as hover text on the initializer name', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() - - await user.hover(within(screen.getByTestId('initializer-row-additional-1')).getByText('target')) - - expect(await screen.findByRole('tooltip')).toHaveTextContent('Registers targets.') - }) - - it('should call onSave from the edit dialog, preserving the existing order_index', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - fireEvent.click(within(row).getByRole('button', { name: 'Edit' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Edit target initializer') - const editor = within(dialog).getByTestId('param-tags') - fireEvent.change(editor, { target: { value: 'extra' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) - - expect(defaultProps.onSave).toHaveBeenCalledWith('additional-1', { - parameters: { tags: ['extra'] }, - order_index: 2, - }) - }) - - it('should call onApply with the saved parameters', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - await user.click(within(row).getByRole('button', { name: 'Apply now' })) - - expect(defaultProps.onApply).toHaveBeenCalledWith('additional-1', 'target', { tags: ['default'] }) - }) - - it('should call onRemove with the additional initializer id after confirming', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - await user.click(within(screen.getByTestId('initializer-row-additional-1')).getByRole('button', { name: 'Remove' })) - - const dialog = await screen.findByRole('dialog', { hidden: true }) - expect(within(dialog).getByText(/remove the/i)).toBeInTheDocument() - expect(within(dialog).getByText('target')).toBeInTheDocument() - - await user.click(within(dialog).getByRole('button', { name: 'Remove', hidden: true })) - - expect(defaultProps.onRemove).toHaveBeenCalledWith('additional-1') - }) - - it('should not call onRemove when the confirmation dialog is cancelled', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - await user.click(within(screen.getByTestId('initializer-row-additional-1')).getByRole('button', { name: 'Remove' })) - - const dialog = await screen.findByRole('dialog', { hidden: true }) - await user.click(within(dialog).getByRole('button', { name: 'Cancel', hidden: true })) - - expect(defaultProps.onRemove).not.toHaveBeenCalled() - }) - - it('should show a validation error when a required parameter is missing', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'required_param' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add required_param initializer') - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent( - 'label is required.', - ) - expect(defaultProps.onAdd).not.toHaveBeenCalled() - }) - - it('should submit typed number and list parameters from the add dialog', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'refresh_datasets' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add refresh_datasets initializer') - fireEvent.change(within(dialog).getByTestId('param-days'), { target: { value: '7' } }) - fireEvent.change(within(dialog).getByTestId('param-dataset_names'), { target: { value: 'harmbench, xstest' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(defaultProps.onAdd).toHaveBeenCalledWith('refresh_datasets', { - days: 7, - dataset_names: ['harmbench', 'xstest'], - }) - }) - - it('should submit selected choices from a multiselect parameter', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'tagged_target' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add tagged_target initializer') - await user.click(within(dialog).getByTestId('param-tags-default')) - await user.click(within(dialog).getByTestId('param-tags-scorer')) - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(defaultProps.onAdd).toHaveBeenCalledWith('tagged_target', { tags: ['default', 'scorer'] }) - }) - - it('should keep the edit dialog open and show an inline error when save fails', async () => { - const user = userEvent.setup() - const onSave = jest.fn() - const onClearSaveError = jest.fn() - - function TestComponent() { - const [saveErrors, setSaveErrors] = useState>({}) - - return ( - { - onSave(id, request) - setSaveErrors({ [id]: 'Mock save failure' }) - return false - }} - onClearSaveError={(id) => { - onClearSaveError(id) - setSaveErrors({}) - }} - /> - ) - } - - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - fireEvent.click(within(row).getByRole('button', { name: 'Edit' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Edit target initializer') - const editor = within(dialog).getByTestId('param-tags') - fireEvent.change(editor, { target: { value: 'modified' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) - - expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument() - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent('Mock save failure') - expect(editor).toHaveValue('modified') - - await user.click(within(dialog).getByRole('button', { name: 'Cancel', hidden: true })) - - expect(onClearSaveError).toHaveBeenCalledWith('additional-1') - }) - - it('should hide the parameters editor and submit null for a no-parameter initializer', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'load_default_datasets' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add load_default_datasets initializer') - expect(within(dialog).getByText('This initializer takes no parameters.')).toBeInTheDocument() - expect( - within(dialog).queryByRole('textbox', { name: 'Parameters JSON', hidden: true }), - ).not.toBeInTheDocument() - - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(defaultProps.onAdd).toHaveBeenCalledWith('load_default_datasets', null) - }) - - it('should show a server error inside the add dialog when onAdd fails', async () => { - const user = userEvent.setup() - - const props = { - ...defaultProps, - registeredInitializers: [refreshInitializer], - onAdd: jest.fn().mockRejectedValue(new Error('Invalid days value.')), - } - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'refresh_datasets' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add refresh_datasets initializer') - fireEvent.change(within(dialog).getByTestId('param-days'), { target: { value: '12' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent( - 'Invalid days value.', - ) - expect(dialog).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.tsx b/frontend/src/components/Initializers/AdditionalInitializers.tsx deleted file mode 100644 index 992292ca3c..0000000000 --- a/frontend/src/components/Initializers/AdditionalInitializers.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useState } from 'react' - -import { - Button, - Select, - Text, - Tooltip, -} from '@fluentui/react-components' -import { AddRegular } from '@fluentui/react-icons' - -import type { - AdditionalInitializerSetting, - RegisteredInitializer, - UpdateAdditionalInitializerRequest, -} from '@/types' - -import { toApiError } from '@/services/errors' -import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' -import { formatInitializerParameters, formatSupportedParameterSummary } from './initializerFormatting' -import { resolveRegisteredInitializer } from './initializerLookup' -import InitializerParametersDialog from './InitializerParametersDialog' -import { useInitializersStyles } from './Initializers.styles' -import ConfirmDialog from '../ConfirmDialog' - -interface AdditionalInitializersProps { - items: AdditionalInitializerSetting[] - registeredInitializers: RegisteredInitializer[] - creating: boolean - savingInitializerId?: string | null - saveErrors?: Record - applyingInitializerId?: string | null - deletingInitializerId?: string | null - onAdd: (initializerName: string, parameters: Record | null) => Promise - onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise - onClearSaveError: (id: string) => void - onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise - onRemove: (id: string) => Promise -} - -interface AdditionalInitializerCardProps { - item: AdditionalInitializerSetting - initializer: RegisteredInitializer - isSaving: boolean - isApplying: boolean - isDeleting: boolean - saveError?: string | null - onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise - onClearSaveError: (id: string) => void - onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise - onRemove: (id: string) => Promise -} - -function AdditionalInitializerCard({ - item, - initializer, - isSaving, - isApplying, - isDeleting, - saveError, - onSave, - onClearSaveError, - onApply, - onRemove, -}: AdditionalInitializerCardProps) { - const styles = useAdditionalInitializersStyles() - const [editOpen, setEditOpen] = useState(false) - const [confirmRemoveOpen, setConfirmRemoveOpen] = useState(false) - const isBusy = isSaving || isApplying || isDeleting - - const handleEditSubmit = async (parameters: Record | null): Promise => { - const saved = await onSave(item.id, { parameters, order_index: item.order_index ?? null }) - if (saved) { - setEditOpen(false) - } - } - - const handleEditOpenChange = (open: boolean): void => { - setEditOpen(open) - if (!open) { - onClearSaveError(item.id) - } - } - - return ( -
-
-
- - {item.initializer_name} - - {initializer.required_env_vars.length > 0 && ( - - Required env vars: {initializer.required_env_vars.join(', ')} - - )} -
-
- -
- {formatSupportedParameterSummary(initializer).map((summary: string) => ( - - {summary} - - ))} -
- -
- Parameters -
{formatInitializerParameters(item.parameters)}
-
- -
- - - -
- - { - setConfirmRemoveOpen(false) - void onRemove(item.id) - }} - onCancel={() => setConfirmRemoveOpen(false)} - > - Are you sure you want to remove the {item.initializer_name} initializer? This action cannot be undone. - - - {editOpen && ( - - )} -
- ) -} - -export default function AdditionalInitializers({ - items, - registeredInitializers, - creating, - savingInitializerId = null, - saveErrors = {}, - applyingInitializerId = null, - deletingInitializerId = null, - onAdd, - onSave, - onClearSaveError, - onApply, - onRemove, -}: AdditionalInitializersProps) { - const pageStyles = useInitializersStyles() - const listStyles = useAdditionalInitializersStyles() - const [selectedInitializerName, setSelectedInitializerName] = useState('') - const [addDialogOpen, setAddDialogOpen] = useState(false) - const [addError, setAddError] = useState(null) - const initializerName = selectedInitializerName || registeredInitializers[0]?.initializer_name || '' - const selectedInitializer = registeredInitializers.find( - (initializer) => initializer.initializer_name === initializerName, - ) ?? null - - const handleAdd = async (parameters: Record | null): Promise => { - if (!initializerName) { - return - } - setAddError(null) - try { - const added = await onAdd(initializerName, parameters) - if (added) { - setAddDialogOpen(false) - } - } catch (e) { - setAddError(toApiError(e).detail) - } - } - - return ( -
-
- - Additional initializers - - - Add and edit initializer invocations that run after the baseline. - -
- -
- - -
- - {items.length === 0 ? ( - No additional initializers are configured. - ) : ( -
- {items.map((item: AdditionalInitializerSetting) => ( - - ))} -
- )} - - {addDialogOpen && ( - { - setAddDialogOpen(open) - if (!open) { - setAddError(null) - } - }} - /> - )} -
- ) -} diff --git a/frontend/src/components/Initializers/AvailableInitializersDialog.tsx b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx index 82255129a9..067b3dc08f 100644 --- a/frontend/src/components/Initializers/AvailableInitializersDialog.tsx +++ b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx @@ -57,7 +57,7 @@ export default function AvailableInitializersDialog({ {registeredInitializers.map((initializer: RegisteredInitializer) => (
diff --git a/frontend/src/components/Initializers/BaselineInitializers.tsx b/frontend/src/components/Initializers/BaselineInitializers.tsx deleted file mode 100644 index 46910182ff..0000000000 --- a/frontend/src/components/Initializers/BaselineInitializers.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Text } from '@fluentui/react-components' - -import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types' - -import { formatInitializerParameters } from './initializerFormatting' -import { resolveRegisteredInitializer } from './initializerLookup' -import { useInitializersStyles } from './Initializers.styles' - -interface BaselineInitializersProps { - items: BaselineInitializerSetting[] - registeredInitializers: RegisteredInitializer[] -} - -export default function BaselineInitializers({ - items, - registeredInitializers, -}: BaselineInitializersProps) { - const styles = useInitializersStyles() - - return ( -
-
- - Baseline initializers - - - Read-only initializers from the .pyrit_conf baseline. - -
- {items.length === 0 ? ( - No baseline initializers are configured. - ) : ( -
- {items.map((item: BaselineInitializerSetting) => { - const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers) - return ( -
-
-
- {item.initializer_name} - {initializer.description || 'No description available.'} - - Required env vars: {initializer.required_env_vars.length > 0 - ? initializer.required_env_vars.join(', ') - : 'None'} - - Order: {item.order_index} -
-
-
- Parameters -
{formatInitializerParameters(item.parameters)}
-
-
- ) - })} -
- )} -
- ) -} diff --git a/frontend/src/components/Initializers/BaselineInitializers.test.tsx b/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx similarity index 56% rename from frontend/src/components/Initializers/BaselineInitializers.test.tsx rename to frontend/src/components/Initializers/ConfiguredInitializers.test.tsx index e13b780670..266f1b7e00 100644 --- a/frontend/src/components/Initializers/BaselineInitializers.test.tsx +++ b/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx @@ -1,9 +1,9 @@ import { render, screen, within } from '@testing-library/react' import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types' +import type { ConfiguredInitializerSetting, RegisteredInitializer } from '@/types' -import BaselineInitializers from './BaselineInitializers' +import ConfiguredInitializers from './ConfiguredInitializers' const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} @@ -19,30 +19,40 @@ const registeredInitializers: RegisteredInitializer[] = [ }, ] -describe('BaselineInitializers', () => { - it('renders the empty state when there are no baseline initializers', () => { +describe('ConfiguredInitializers', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders the empty state when no initializers are configured', () => { render( - + , ) - expect(screen.getByText('No baseline initializers are configured.')).toBeInTheDocument() - expect(screen.queryByRole('list', { name: 'Baseline initializers' })).not.toBeInTheDocument() + expect(screen.getByText('No initializers are configured in .pyrit_conf.')).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Configured initializers' })).not.toBeInTheDocument() }) - it('renders each baseline row with description, env vars, order, and parameters', () => { - const items: BaselineInitializerSetting[] = [ + it('renders each configured row with description, env vars, order, and parameters', () => { + const items: ConfiguredInitializerSetting[] = [ { initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }, ] render( - + , ) - const row = screen.getByTestId('baseline-initializer-row-target') + const row = screen.getByTestId('configured-initializer-row-0') expect(within(row).getByText('target')).toBeInTheDocument() expect(within(row).getByText('Registers targets.')).toBeInTheDocument() expect(within(row).getByText(/AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY/)).toBeInTheDocument() @@ -51,17 +61,20 @@ describe('BaselineInitializers', () => { }) it('falls back to a placeholder for a name that is no longer registered', () => { - const items: BaselineInitializerSetting[] = [ + const items: ConfiguredInitializerSetting[] = [ { initializer_name: 'ghost', parameters: null, order_index: 1 }, ] render( - + , ) - const row = screen.getByTestId('baseline-initializer-row-ghost') + const row = screen.getByTestId('configured-initializer-row-1') expect(within(row).getByText('Initializer is no longer registered.')).toBeInTheDocument() expect(within(row).getByText(/Required env vars: None/)).toBeInTheDocument() }) diff --git a/frontend/src/components/Initializers/ConfiguredInitializers.tsx b/frontend/src/components/Initializers/ConfiguredInitializers.tsx new file mode 100644 index 0000000000..18093b3ec6 --- /dev/null +++ b/frontend/src/components/Initializers/ConfiguredInitializers.tsx @@ -0,0 +1,65 @@ +import { Text } from '@fluentui/react-components' + +import type { ConfiguredInitializerSetting, RegisteredInitializer } from '@/types' + +import { formatInitializerParameters } from './initializerFormatting' +import { resolveRegisteredInitializer } from './initializerLookup' +import { useInitializersStyles } from './Initializers.styles' + +interface ConfiguredInitializersProps { + items: ConfiguredInitializerSetting[] + registeredInitializers: RegisteredInitializer[] +} + +export default function ConfiguredInitializers({ + items, + registeredInitializers, +}: ConfiguredInitializersProps) { + const styles = useInitializersStyles() + + return ( +
+
+ + Configured initializers + + + Read-only startup sequence from the active .pyrit_conf. + +
+ {items.length === 0 ? ( + No initializers are configured in .pyrit_conf. + ) : ( +
+ {items.map((item: ConfiguredInitializerSetting) => { + const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers) + const initializerKey = `${item.initializer_name}:${item.order_index}` + return ( +
+
+ {item.initializer_name} + {initializer.description || 'No description available.'} + + Required env vars: {initializer.required_env_vars.length > 0 + ? initializer.required_env_vars.join(', ') + : 'None'} + + Order: {item.order_index} +
+
+ Parameters +
{formatInitializerParameters(item.parameters)}
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx deleted file mode 100644 index ceb170aa02..0000000000 --- a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { FluentProvider, webLightTheme } from '@fluentui/react-components' - -import type { RegisteredInitializer } from '@/types' - -import InitializerParametersDialog from './InitializerParametersDialog' - -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) - -const allKindsInitializer: RegisteredInitializer = { - initializer_name: 'kitchen_sink', - initializer_type: 'DemoInitializer', - description: 'Every control kind.', - required_env_vars: ['DEMO_TOKEN'], - supported_parameters: [ - { name: 'flag', type_name: 'bool', required: false, default: null, choices: null, is_list: false }, - { name: 'level', type_name: 'str', required: false, default: null, choices: ['low', 'high'], is_list: false }, - { name: 'tags', type_name: 'list[str]', required: false, default: null, choices: ['a', 'b'], is_list: true }, - { name: 'names', type_name: 'list[str]', required: false, default: null, choices: null, is_list: true }, - { name: 'days', type_name: 'int', required: false, default: null, choices: null, is_list: false }, - { name: 'label', type_name: 'str', required: false, default: null, choices: null, is_list: false }, - ], -} - -const numericInitializer: RegisteredInitializer = { - initializer_name: 'refresh_datasets', - initializer_type: 'DatasetInitializer', - description: 'Refreshes datasets.', - required_env_vars: [], - supported_parameters: [ - { name: 'days', type_name: 'int', required: false, default: null, choices: null, is_list: false }, - { name: 'names', type_name: 'list[str]', required: false, default: null, choices: null, is_list: true }, - ], -} - -const requiredInitializer: RegisteredInitializer = { - initializer_name: 'required_param', - initializer_type: 'DemoInitializer', - description: 'Requires a label.', - required_env_vars: [], - supported_parameters: [ - { name: 'label', type_name: 'str', required: true, default: null, choices: null, is_list: false }, - ], -} - -const noParamInitializer: RegisteredInitializer = { - initializer_name: 'load_default_datasets', - initializer_type: 'DatasetInitializer', - description: 'Loads default datasets.', - required_env_vars: [], - supported_parameters: [], -} - -describe('InitializerParametersDialog', () => { - const baseProps = { - open: true, - mode: 'add' as const, - onSubmit: jest.fn().mockResolvedValue(undefined), - onOpenChange: jest.fn(), - } - - beforeEach(() => { - jest.clearAllMocks() - }) - - it('renders one control of the right kind for each parameter', () => { - render( - - - , - ) - - expect(screen.getByText('Add kitchen_sink initializer')).toBeInTheDocument() - expect(screen.getByText(/Required env vars: DEMO_TOKEN/)).toBeInTheDocument() - expect(screen.getByTestId('param-flag').tagName).toBe('SELECT') - expect(screen.getByTestId('param-flag')).toHaveValue('') - expect(screen.getByTestId('param-level').tagName).toBe('SELECT') - expect(screen.getByTestId('param-tags-a')).toBeInTheDocument() - expect(screen.getByTestId('param-tags-b')).toBeInTheDocument() - expect(screen.getByTestId('param-names')).toBeInTheDocument() - expect(screen.getByTestId('param-days')).toHaveAttribute('type', 'number') - expect(screen.getByTestId('param-label')).toHaveAttribute('type', 'text') - }) - - it('should give each multiselect checkbox its own accessible name', () => { - render( - - - , - ) - - expect(screen.getByRole('group', { name: 'tags' })).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'a' })).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'b' })).toBeInTheDocument() - }) - - it('shows a no-parameters message and submits null for a parameterless initializer', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - expect(screen.getByText('This initializer takes no parameters.')).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(null) - }) - - it('blocks submit and shows an error when a required field is empty', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(await screen.findByRole('alert')).toHaveTextContent('label is required.') - expect(onSubmit).not.toHaveBeenCalled() - }) - - it('coerces typed number and comma-separated list values on submit', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - fireEvent.change(screen.getByTestId('param-days'), { target: { value: '7' } }) - fireEvent.change(screen.getByTestId('param-names'), { target: { value: 'x, y' } }) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith({ days: 7, names: ['x', 'y'] }) - }) - - it('submits toggled boolean and selected multiselect values', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - fireEvent.change(screen.getByTestId('param-flag'), { target: { value: 'true' } }) - await user.click(screen.getByTestId('param-tags-a')) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ flag: true, tags: ['a'] })) - }) - - it('toggles the intended multiselect option when clicking checkbox label text', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByText('b')) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['b'] })) - }) - - it('leaves an optional boolean unset omitted from the submitted parameters', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Add' })) - - // Every other optional field is also left blank, so the whole payload is null; - // the key assertion is that the omitted boolean doesn't silently coerce to false. - expect(onSubmit).toHaveBeenCalledWith(null) - }) - - it('unchecks a multiselect choice and picks a select value', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByTestId('param-tags-a')) - await user.click(screen.getByTestId('param-tags-a')) - fireEvent.change(screen.getByTestId('param-level'), { target: { value: 'high' } }) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ level: 'high' })) - expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('tags') - }) - - it('prefills existing parameters in edit mode', () => { - render( - - - , - ) - - expect(screen.getByText('Edit refresh_datasets initializer')).toBeInTheDocument() - expect(screen.getByTestId('param-days')).toHaveValue(5) - expect(screen.getByTestId('param-names')).toHaveValue('alpha, beta') - expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() - }) - - it('does not pin absent declaration defaults when editing persisted parameters', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - const initializer: RegisteredInitializer = { - ...numericInitializer, - supported_parameters: [ - { - name: 'days', - type_name: 'int', - required: false, - default: '7', - choices: null, - is_list: false, - }, - ], - } - - render( - - - , - ) - - expect(screen.getByTestId('param-days')).toHaveValue(null) - await user.click(screen.getByRole('button', { name: 'Save' })) - expect(onSubmit).toHaveBeenCalledWith(null) - }) - - it('calls onOpenChange(false) when cancelled', async () => { - const user = userEvent.setup() - const onOpenChange = jest.fn() - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Cancel' })) - - expect(onOpenChange).toHaveBeenCalledWith(false) - }) - - it('disables the actions and shows progress text while submitting', () => { - render( - - - , - ) - - expect(screen.getByRole('button', { name: 'Add...' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() - }) - - it('displays an external error passed via externalError prop', () => { - render( - - - , - ) - - expect(screen.getByRole('alert')).toHaveTextContent('Server rejected the request.') - }) - - it('prefers validation error over externalError', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(await screen.findByRole('alert')).toHaveTextContent('label is required.') - }) -}) diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.tsx deleted file mode 100644 index 6e89534a97..0000000000 --- a/frontend/src/components/Initializers/InitializerParametersDialog.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useRef, useState } from 'react' -import { - Button, - Dialog, - DialogActions, - DialogBody, - DialogContent, - DialogSurface, - DialogTitle, - Text, -} from '@fluentui/react-components' - -import ParameterField from '@/components/Parameters/ParameterField' -import { buildParametersFromForm, getInitialFormValues, type ParameterFormValue } from '@/components/Parameters/parameterForm' -import type { RegisteredInitializer } from '@/types' - -import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' - -interface InitializerParametersDialogProps { - open: boolean - mode: 'add' | 'edit' - initializer: RegisteredInitializer | null - initialParameters?: Record | null - submitting?: boolean - externalError?: string | null - onSubmit: (parameters: Record | null) => void | Promise - onOpenChange: (open: boolean) => void -} - -export default function InitializerParametersDialog({ - open, - mode, - initializer, - initialParameters = null, - submitting = false, - externalError = null, - onSubmit, - onOpenChange, -}: InitializerParametersDialogProps) { - const styles = useAdditionalInitializersStyles() - const parameters = initializer?.supported_parameters ?? [] - const [values, setValues] = useState>(() => - getInitialFormValues(parameters, initialParameters, { prefillDefaults: mode === 'add' }), - ) - const [error, setError] = useState(null) - const submitInProgressRef = useRef(false) - - const acceptsParameters = parameters.length > 0 - - const updateValue = (name: string, value: ParameterFormValue): void => { - setValues((prev) => ({ ...prev, [name]: value })) - setError(null) - } - - const handleSubmit = async (): Promise => { - submitInProgressRef.current = true - try { - await submitForm() - } finally { - submitInProgressRef.current = false - } - } - - const submitForm = async (): Promise => { - if (!acceptsParameters) { - setError(null) - await onSubmit(null) - return - } - - const result = buildParametersFromForm(parameters, values) - if (!result.ok) { - setError(result.error) - return - } - - setError(null) - await onSubmit(result.parameters) - } - - const initializerName = initializer?.initializer_name ?? '' - const title = mode === 'add' ? `Add ${initializerName} initializer` : `Edit ${initializerName} initializer` - const submitLabel = mode === 'add' ? 'Add' : 'Save' - - return ( - { - if (!data.open && submitInProgressRef.current) { - return - } - onOpenChange(data.open) - }} - > - - - {title} - - {initializer && ( - <> - {initializer.description || 'No description available.'} - {initializer.required_env_vars.length > 0 && ( - - Required env vars: {initializer.required_env_vars.join(', ')} - - )} - - )} - {acceptsParameters ? ( -
- {parameters.map((parameter) => ( - - ))} -
- ) : ( - - This initializer takes no parameters. - - )} - {(error || externalError) && ( - - {error || externalError} - - )} -
- - - - -
-
-
- ) -} diff --git a/frontend/src/components/Initializers/Initializers.styles.ts b/frontend/src/components/Initializers/Initializers.styles.ts index 9de345bfe6..07c344252a 100644 --- a/frontend/src/components/Initializers/Initializers.styles.ts +++ b/frontend/src/components/Initializers/Initializers.styles.ts @@ -1,27 +1,17 @@ import { makeStyles, tokens } from '@fluentui/react-components' -import { - MINIMUM_TOUCH_TARGET_SIZE, - TOUCH_INPUT_QUERY, - mobileTouchTargetHeight, -} from '@/styles/touchTargets' +import { mobileTouchTargetHeight } from '@/styles/touchTargets' export const useInitializersStyles = makeStyles({ root: { display: 'flex', flexDirection: 'column', - height: '100%', + flex: 1, width: '100%', minWidth: 0, + minHeight: 0, maxWidth: '100%', gap: tokens.spacingVerticalL, - padding: tokens.spacingVerticalXXL, - overflowX: 'hidden', - overflowY: 'auto', - backgroundColor: tokens.colorNeutralBackground2, - '@media (max-width: 600px)': { - padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, - }, }, header: { display: 'flex', @@ -30,11 +20,6 @@ export const useInitializersStyles = makeStyles({ flexWrap: 'wrap', gap: tokens.spacingVerticalM, }, - headerText: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXS, - }, headerActions: { display: 'flex', alignItems: 'center', @@ -50,30 +35,10 @@ export const useInitializersStyles = makeStyles({ flexDirection: 'column', gap: tokens.spacingVerticalXXS, }, - addInitializerRow: { - display: 'flex', - alignItems: 'center', - gap: tokens.spacingHorizontalM, - flexWrap: 'wrap', - }, - addInitializerSelect: { - minWidth: '220px', - ...mobileTouchTargetHeight, - '& > select': { - [TOUCH_INPUT_QUERY]: { - minHeight: MINIMUM_TOUCH_TARGET_SIZE, - }, - }, - }, touchTarget: { ...mobileTouchTargetHeight, }, - baselineList: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - }, - baselineGroup: { + configuredGroup: { display: 'flex', flexDirection: 'column', border: `1px solid ${tokens.colorNeutralStroke2}`, @@ -81,7 +46,7 @@ export const useInitializersStyles = makeStyles({ backgroundColor: tokens.colorNeutralBackground1, overflow: 'hidden', }, - baselineGroupItem: { + configuredGroupItem: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, @@ -98,7 +63,7 @@ export const useInitializersStyles = makeStyles({ maxHeight: '60vh', overflowY: 'auto', }, - baselineCard: { + availableCard: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, @@ -107,13 +72,6 @@ export const useInitializersStyles = makeStyles({ borderRadius: tokens.borderRadiusLarge, backgroundColor: tokens.colorNeutralBackground1, }, - baselineHeader: { - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - gap: tokens.spacingHorizontalM, - flexWrap: 'wrap', - }, titleGroup: { display: 'flex', flexDirection: 'column', diff --git a/frontend/src/components/Initializers/Initializers.test.tsx b/frontend/src/components/Initializers/Initializers.test.tsx index 3ddc8b6954..9e59f3449c 100644 --- a/frontend/src/components/Initializers/Initializers.test.tsx +++ b/frontend/src/components/Initializers/Initializers.test.tsx @@ -1,11 +1,10 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' import { initializersApi } from '@/services/api' import type { - AdditionalInitializerSetting, - BaselineInitializerSetting, + ConfiguredInitializerSetting, InitializerSettingsResponse, RegisteredInitializer, } from '@/types' @@ -16,10 +15,6 @@ jest.mock('@/services/api', () => ({ initializersApi: { getSettings: jest.fn(), listRegistered: jest.fn(), - createAdditional: jest.fn(), - updateAdditional: jest.fn(), - deleteAdditional: jest.fn(), - applyNow: jest.fn(), }, })) @@ -29,19 +24,6 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ) -async function openDialogByButton( - _user: ReturnType, - buttonName: RegExp | string, - dialogName: string, -): Promise { - const trigger = await screen.findByRole('button', { name: buttonName }) - await waitFor(() => expect(trigger).toBeEnabled()) - fireEvent.click(trigger) - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText(dialogName) - return dialog -} - const targetInitializer: RegisteredInitializer = { initializer_name: 'target', initializer_type: 'TargetInitializer', @@ -60,40 +42,14 @@ const targetInitializer: RegisteredInitializer = { ], } -const scorerInitializer: RegisteredInitializer = { - initializer_name: 'scorer', - initializer_type: 'ScorerInitializer', - description: 'Registers scorers.', - required_env_vars: [], - supported_parameters: [ - { - name: 'tags', - type_name: 'list[str]', - required: false, - default: null, - choices: null, - is_list: true, - description: 'Scorer tags.', - }, - ], -} - -const baselineItem: BaselineInitializerSetting = { +const configuredItem: ConfiguredInitializerSetting = { initializer_name: 'target', - parameters: { tags: ['baseline'] }, + parameters: { tags: ['configured'] }, order_index: 0, } -const additionalItem: AdditionalInitializerSetting = { - id: 'additional-1', - initializer_name: 'scorer', - parameters: { mode: 'strict' }, - order_index: 10, -} - const sampleSettings: InitializerSettingsResponse = { - baseline: [baselineItem], - additional: [additionalItem], + configured: [configuredItem], } function renderInitializers(): void { @@ -109,27 +65,9 @@ describe('Initializers', () => { jest.clearAllMocks() mockedInitializersApi.getSettings.mockResolvedValue(sampleSettings) mockedInitializersApi.listRegistered.mockResolvedValue({ - items: [targetInitializer, scorerInitializer], + items: [targetInitializer], pagination: { limit: 200, has_more: false }, }) - mockedInitializersApi.createAdditional.mockResolvedValue({ - id: 'additional-2', - initializer_name: 'target', - parameters: null, - order_index: null, - }) - mockedInitializersApi.updateAdditional.mockResolvedValue({ - id: 'additional-1', - initializer_name: 'scorer', - parameters: { mode: 'relaxed' }, - order_index: 11, - }) - mockedInitializersApi.deleteAdditional.mockResolvedValue() - mockedInitializersApi.applyNow.mockResolvedValue({ - initializer_name: 'scorer', - status: 'applied', - applied_parameters: { mode: 'strict' }, - }) }) it('should show loading state initially', () => { @@ -140,14 +78,11 @@ describe('Initializers', () => { expect(screen.getByText('Loading initializer settings...')).toBeInTheDocument() }) - it('should render baseline and additional initializers', async () => { + it('should render initializers configured in .pyrit_conf', async () => { renderInitializers() - expect(await screen.findByRole('heading', { level: 1, name: 'Initializers' })).toBeInTheDocument() - expect(await screen.findByRole('heading', { level: 2, name: 'Baseline initializers' })).toBeInTheDocument() - expect(screen.getByRole('heading', { level: 2, name: 'Additional initializers' })).toBeInTheDocument() - expect(screen.getByTestId('baseline-initializer-row-target')).toHaveTextContent('Registers targets.') - expect(screen.getByTestId('initializer-row-additional-1')).toHaveTextContent('scorer') + expect(await screen.findByRole('heading', { level: 2, name: 'Configured initializers' })).toBeInTheDocument() + expect(screen.getByTestId('configured-initializer-row-0')).toHaveTextContent('Registers targets.') }) it('should refresh settings when the refresh button is clicked', async () => { @@ -167,137 +102,23 @@ describe('Initializers', () => { }) }) - it('should render a read-only catalog of all registered initializers in a dialog', async () => { - const user = userEvent.setup() - renderInitializers() - - await screen.findByRole('button', { name: 'Browse available initializers' }) - await openDialogByButton(user, 'Browse available initializers', 'Available initializers') - - const catalogTarget = screen.getByTestId('available-initializer-row-target') - expect(catalogTarget).toHaveTextContent('Registers targets.') - expect(catalogTarget).toHaveTextContent('tags') - expect(screen.getByTestId('available-initializer-row-scorer')).toBeInTheDocument() - }) - - it('should create the selected initializer and show success feedback', async () => { - const user = userEvent.setup() - renderInitializers() - - await screen.findByTestId('initializer-row-additional-1') - const dialog = await openDialogByButton(user, 'Add initializer', 'Add target initializer') - expect(dialog).toBeInTheDocument() - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - await waitFor(() => { - expect(mockedInitializersApi.createAdditional).toHaveBeenCalledWith({ - initializer_name: 'target', - parameters: null, - }) - expect(screen.getByText('Added target initializer.')).toBeInTheDocument() - }) - }) - - it('should let the user choose a non-target initializer to add', async () => { - const user = userEvent.setup() - renderInitializers() - - await screen.findByTestId('initializer-row-additional-1') - const combobox = screen.getByRole('combobox', { name: 'Initializer to add' }) - await user.selectOptions(combobox, 'scorer') - await waitFor(() => expect(combobox).toHaveValue('scorer')) - const dialog = await openDialogByButton(user, /Add initializer|Adding/, 'Add scorer initializer') - expect(dialog).toBeInTheDocument() - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - await waitFor(() => { - expect(mockedInitializersApi.createAdditional).toHaveBeenCalledWith({ - initializer_name: 'scorer', - parameters: null, - }) - expect(screen.getByText('Added scorer initializer.')).toBeInTheDocument() - }) - }) - - it('should save an additional initializer from the edit dialog', async () => { - const user = userEvent.setup() - renderInitializers() - - await screen.findByTestId('initializer-row-additional-1') - const dialog = await openDialogByButton(user, 'Edit', 'Edit scorer initializer') - fireEvent.change(within(dialog).getByTestId('param-tags'), { target: { value: 'relaxed' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) - - await waitFor(() => { - expect(mockedInitializersApi.updateAdditional).toHaveBeenCalledWith('additional-1', { - parameters: { tags: ['relaxed'] }, - order_index: 10, - }) - expect(screen.getByText('Saved additional initializer.')).toBeInTheDocument() - }) - }) - - it('should show save errors in the edit dialog and preserve edits', async () => { + it('should render a read-only catalog of all registered initializers', async () => { const user = userEvent.setup() - mockedInitializersApi.updateAdditional.mockRejectedValue(new Error('Mock save failure')) renderInitializers() - await screen.findByTestId('initializer-row-additional-1') - const dialog = await openDialogByButton(user, 'Edit', 'Edit scorer initializer') - const editor = within(dialog).getByTestId('param-tags') - fireEvent.change(editor, { target: { value: 'relaxed' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) + await user.click(await screen.findByRole('button', { name: 'Browse available initializers' })) - expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument() - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent('Mock save failure') - expect(editor).toHaveValue('relaxed') + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Available initializers')).toBeInTheDocument() + expect(screen.getByTestId('available-initializer-row-target')).toHaveTextContent('Registers targets.') }) - it('should apply an additional initializer', async () => { - const user = userEvent.setup() - renderInitializers() - - const additionalRow = await screen.findByTestId('initializer-row-additional-1') - await user.click(within(additionalRow).getByRole('button', { name: 'Apply now' })) - - await waitFor(() => { - expect(mockedInitializersApi.applyNow).toHaveBeenCalledWith('scorer', { - parameters: { mode: 'strict' }, - }) - expect(screen.getByText('Applied scorer.')).toBeInTheDocument() - }) - }) - - it('should not render an apply button on baseline initializers', async () => { - renderInitializers() - - const baselineRow = await screen.findByTestId('baseline-initializer-row-target') - expect(within(baselineRow).queryByRole('button', { name: 'Apply now' })).not.toBeInTheDocument() - }) - - it('should keep saved settings visible when catalog loading fails', async () => { + it('should keep configured settings visible when catalog loading fails', async () => { mockedInitializersApi.listRegistered.mockRejectedValue(new Error('Service Unavailable')) renderInitializers() - expect(await screen.findByTestId('baseline-initializer-row-target')).toBeInTheDocument() - expect(screen.getByTestId('initializer-row-additional-1')).toBeInTheDocument() + expect(await screen.findByTestId('configured-initializer-row-0')).toBeInTheDocument() expect(screen.getByText('Service Unavailable')).toBeInTheDocument() }) - - it('should remove an additional initializer and show success feedback', async () => { - const user = userEvent.setup() - renderInitializers() - - const row = await screen.findByTestId('initializer-row-additional-1') - await user.click(within(row).getByRole('button', { name: 'Remove' })) - - const dialog = await screen.findByRole('dialog', { hidden: true }) - await user.click(within(dialog).getByRole('button', { name: 'Remove', hidden: true })) - - await waitFor(() => { - expect(mockedInitializersApi.deleteAdditional).toHaveBeenCalledWith('additional-1') - expect(screen.getByText('Removed additional initializer.')).toBeInTheDocument() - }) - }) }) diff --git a/frontend/src/components/Initializers/Initializers.tsx b/frontend/src/components/Initializers/Initializers.tsx index 55c7547d7f..06a7d2f3a5 100644 --- a/frontend/src/components/Initializers/Initializers.tsx +++ b/frontend/src/components/Initializers/Initializers.tsx @@ -5,21 +5,19 @@ import { ArrowSyncRegular } from '@fluentui/react-icons' import { initializersApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { InitializerSettingsResponse, RegisteredInitializer, UpdateAdditionalInitializerRequest } from '@/types' +import type { InitializerSettingsResponse, RegisteredInitializer } from '@/types' -import AdditionalInitializers from './AdditionalInitializers' import AvailableInitializersDialog from './AvailableInitializersDialog' -import BaselineInitializers from './BaselineInitializers' +import ConfiguredInitializers from './ConfiguredInitializers' import { useInitializersStyles } from './Initializers.styles' interface StatusMessage { - intent: 'success' | 'error' + intent: 'error' text: string } const EMPTY_SETTINGS: InitializerSettingsResponse = { - baseline: [], - additional: [], + configured: [], } export default function Initializers() { @@ -29,11 +27,6 @@ export default function Initializers() { const [loading, setLoading] = useState(true) const [statusMessage, setStatusMessage] = useState(null) const [refetchCount, setRefetchCount] = useState(0) - const [creating, setCreating] = useState(false) - const [savingInitializerId, setSavingInitializerId] = useState(null) - const [saveErrors, setSaveErrors] = useState>({}) - const [applyingInitializerId, setApplyingInitializerId] = useState(null) - const [deletingInitializerId, setDeletingInitializerId] = useState(null) useEffect(() => { let cancelled = false @@ -79,102 +72,12 @@ export default function Initializers() { setRefetchCount((currentCount: number) => currentCount + 1) } - const refetchSettingsOnly = async (): Promise => { - const response = await initializersApi.getSettings() - setSettings(response) - } - - const handleAdd = async ( - initializerName: string, - parameters: Record | null, - ): Promise => { - setCreating(true) - try { - await initializersApi.createAdditional({ initializer_name: initializerName, parameters }) - setStatusMessage({ intent: 'success', text: `Added ${initializerName} initializer.` }) - await refetchSettingsOnly() - return true - } catch (error) { - const detail = toApiError(error).detail - setStatusMessage({ intent: 'error', text: detail }) - throw error - } finally { - setCreating(false) - } - } - - const handleSave = async ( - id: string, - request: UpdateAdditionalInitializerRequest, - ): Promise => { - setSavingInitializerId(id) - setSaveErrors((currentErrors) => { - const remainingErrors = { ...currentErrors } - delete remainingErrors[id] - return remainingErrors - }) - try { - await initializersApi.updateAdditional(id, request) - setStatusMessage({ intent: 'success', text: 'Saved additional initializer.' }) - await refetchSettingsOnly() - return true - } catch (error) { - const detail = toApiError(error).detail - setStatusMessage({ intent: 'error', text: detail }) - setSaveErrors((currentErrors) => ({ ...currentErrors, [id]: detail })) - return false - } finally { - setSavingInitializerId(null) - } - } - - const clearSaveError = (id: string): void => { - setSaveErrors((currentErrors) => { - const remainingErrors = { ...currentErrors } - delete remainingErrors[id] - return remainingErrors - }) - } - - const handleApply = async ( - id: string, - initializerName: string, - parameters?: Record | null, - ): Promise => { - setApplyingInitializerId(id) - try { - await initializersApi.applyNow(initializerName, { parameters }) - setStatusMessage({ intent: 'success', text: `Applied ${initializerName}.` }) - } catch (error) { - setStatusMessage({ intent: 'error', text: toApiError(error).detail }) - } finally { - setApplyingInitializerId(null) - } - } - - const handleRemove = async (id: string): Promise => { - setDeletingInitializerId(id) - try { - await initializersApi.deleteAdditional(id) - setStatusMessage({ intent: 'success', text: 'Removed additional initializer.' }) - await refetchSettingsOnly() - } catch (error) { - setStatusMessage({ intent: 'error', text: toApiError(error).detail }) - } finally { - setDeletingInitializerId(null) - } - } - return ( -
+
-
- Initializers - - Browse every registered initializer, review the read-only baseline that ran at startup, and manage - additional initializer invocations that run after it. - -
+ + Browse every registered initializer and review the startup sequence from the active .pyrit_conf. +
) : ( - <> - - - + )} -
+ ) } diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx index e85813e3bd..21b3a26b77 100644 --- a/frontend/src/components/Sidebar/Navigation.test.tsx +++ b/frontend/src/components/Sidebar/Navigation.test.tsx @@ -139,7 +139,6 @@ describe("Navigation", () => { "Attack History", "Scanner", "Targets", - "Initializers", "Configuration", ]); expect(screen.queryByRole("button", { name: "Scenario History" })).not.toBeInTheDocument(); diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index 816eb40fe7..f79620b8e3 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -15,7 +15,6 @@ import { HistoryRegular, PersonFeedbackRegular, ScriptRegular, - WrenchRegular, OpenRegular, WeatherMoonRegular, WeatherSunnyRegular, @@ -30,7 +29,6 @@ export type ViewName = | 'chat' | 'history' | 'targets' - | 'initializers' | 'configuration' | 'scenarios' @@ -131,17 +129,6 @@ export default function Navigation({ onClick={() => onNavigate('targets')} /> -