diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7dc58ee46e..cd206abc1c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -101,6 +101,9 @@ jest.mock("./components/Layout/MainLayout", () => { + {children} ); @@ -294,6 +297,51 @@ jest.mock("./components/Home/Home", () => { }; }); +jest.mock("./components/Scenarios/ScenarioCatalog", () => { + const MockScenarioCatalog = () =>
; + MockScenarioCatalog.displayName = "MockScenarioCatalog"; + return { + __esModule: true, + default: MockScenarioCatalog, + }; +}); + +jest.mock("./components/Scenarios/ScenarioDetail", () => { + const MockScenarioDetail = ({ + activeTarget, + labels, + onNavigate, + }: { + activeTarget: unknown; + labels: Record; + onNavigate: (view: string) => void; + }) => { + return ( +
+ {activeTarget ? "yes" : "no"} + {JSON.stringify(labels)} + +
+ ); + }; + MockScenarioDetail.displayName = "MockScenarioDetail"; + return { + __esModule: true, + default: MockScenarioDetail, + }; +}); + +jest.mock("./components/Scenarios/ScenarioRunStarted", () => { + const MockScenarioRunStarted = () =>
; + MockScenarioRunStarted.displayName = "MockScenarioRunStarted"; + return { + __esModule: true, + default: MockScenarioRunStarted, + }; +}); + describe("App", () => { // App reads the active view from the URL, so every render needs a router. // initialPath lets a test deep-link straight to a view (e.g. "/config"). @@ -349,6 +397,67 @@ describe("App", () => { expect(screen.getByTestId("attack-history")).toBeInTheDocument(); }); + it("renders the scenario catalog when deep-linked to /scenarios", () => { + renderApp("/scenarios"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "scenarios" + ); + expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument(); + }); + + it("renders the scenario detail view and marks the sidebar current when deep-linked to /scenarios/:name", () => { + renderApp("/scenarios/foundry.red_team_agent"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "scenarios" + ); + expect(screen.getByTestId("scenario-detail")).toBeInTheDocument(); + }); + + it("renders the scenario run-started shell and marks the sidebar current when deep-linked to /scenario-history/:id", () => { + renderApp("/scenario-history/sr-123"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "scenarios" + ); + expect(screen.getByTestId("scenario-run-started")).toBeInTheDocument(); + }); + + it("switches to the scenarios view via the sidebar", () => { + renderApp(); + + fireEvent.click(screen.getByTestId("nav-scenarios")); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "scenarios" + ); + expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument(); + }); + + it("passes the active target and labels to the scenario detail view", () => { + renderApp("/scenarios/foundry.red_team_agent"); + + expect(screen.getByTestId("scenario-detail-has-target")).toHaveTextContent("no"); + expect(screen.getByTestId("scenario-detail-labels-json")).toHaveTextContent("operator"); + }); + + it("navigates from scenario detail to config when it requests it", () => { + renderApp("/scenarios/foundry.red_team_agent"); + + fireEvent.click(screen.getByTestId("scenario-detail-go-config")); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "config" + ); + expect(screen.getByTestId("target-config")).toBeInTheDocument(); + }); + it("redirects an unknown path back to home", () => { renderApp("/does-not-exist"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6fa52e34b..0dfeaa5640 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,9 @@ import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' import Initializers from './components/Initializers/Initializers' import AttackHistory from './components/History/AttackHistory' +import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' +import ScenarioDetail from './components/Scenarios/ScenarioDetail' +import ScenarioRunStarted from './components/Scenarios/ScenarioRunStarted' import FeedbackDialog from './components/Feedback/FeedbackDialog' import type { HistoryFilters } from './components/History/historyFilters' import { ConnectionBanner } from './components/ConnectionBanner' @@ -38,10 +41,19 @@ const VIEW_PATHS: Record = { history: '/history', config: '/config', initializers: '/initializers', + scenarios: '/scenarios', } -/** Resolves the active view from a URL path, defaulting to home for unknown paths. */ +/** + * Resolves the active view from a URL path, defaulting to home for unknown + * paths. Scenario routes are prefix-matched (`/scenarios/...` and + * `/scenario-history/...`) since they carry a path parameter rather than a + * single canonical `VIEW_PATHS` entry. + */ function viewFromPath(pathname: string): ViewName { + if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) || pathname.startsWith('/scenario-history/')) { + return 'scenarios' + } const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find( ([, path]) => path === pathname, ) @@ -400,6 +412,18 @@ function App() { } /> } /> + } /> + + } + /> + } /> c.converter_type === type) const defaults: Record = {} for (const p of newConverter?.parameters ?? []) { - if (p.default != null) { + if (typeof p.default === 'string') { defaults[p.name] = p.default } } diff --git a/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx b/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx index 4f733bb778..fbccf69a4c 100644 --- a/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx +++ b/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx @@ -11,9 +11,10 @@ interface ParamInputProps { } function ConverterParameterChoiceViewer({ param, value, onChange }: ParamInputProps) { + const stringDefault = typeof param.default === 'string' ? param.default : '' return ( onChange(param.name, data.value)} className={isMissing ? styles.paramInputError : undefined} data-testid={`param-${param.name}`} @@ -53,11 +55,12 @@ function ParameterFileViewer({ param, value, isMissing, onChange, onBrowse }: Pa function ConverterParameterViewer({ param, value, isMissing, onChange }: ParamInputProps) { const styles = useConverterPanelStyles() + const stringDefault = typeof param.default === 'string' ? param.default : undefined return ( onChange(param.name, data.value)} className={isMissing ? styles.paramInputError : undefined} data-testid={`param-${param.name}`} @@ -106,9 +109,9 @@ export default function ConverterParams({ converter, paramValues, paramsExpanded {param.type_name === 'bool' ? ( onParamChange(param.name, data.checked ? 'true' : 'false')} - label={(paramValues[param.name] ?? param.default ?? 'false').toLowerCase() === 'true' ? 'True' : 'False'} + label={(paramValues[param.name] ?? (typeof param.default === 'string' ? param.default : 'false')).toLowerCase() === 'true' ? 'True' : 'False'} data-testid={`param-${param.name}`} /> ) : param.choices ? ( diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index b072b866f3..a472285c30 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -11,8 +11,9 @@ import { mergeClasses, } from '@fluentui/react-components' import { ArrowDownloadRegular, ArrowReplyRegular, ArrowForwardRegular, ChatAddRegular, BranchForkRegular, OpenRegular } from '@fluentui/react-icons' +import MarkdownContent from '@/components/Markdown/MarkdownContent' + import { Message, MessageAttachment } from '../../types' -import MarkdownContent from './MarkdownContent' import { useMessageListStyles } from './MessageList.styles' interface MessageListProps { diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx index a137ea254e..c5a4572c85 100644 --- a/frontend/src/components/Config/CreateTargetDialog.test.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx @@ -141,7 +141,12 @@ async function selectTargetType(value: string): Promise { await waitFor(() => { expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }); - restoreDialogAccessibility(); + await waitFor(() => { + restoreDialogAccessibility(); + expect(screen.getByRole("combobox", { name: /target type/i })).toHaveTextContent( + TARGET_DISPLAY_NAMES[value] + ); + }); } describe("parseWeight", () => { diff --git a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts index 04b43a0acf..9cdad1f625 100644 --- a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts +++ b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts @@ -76,13 +76,4 @@ export const useAdditionalInitializersStyles = makeStyles({ flexDirection: 'column', gap: tokens.spacingVerticalM, }, - fieldHint: { - color: tokens.colorNeutralForeground3, - marginTop: tokens.spacingVerticalXXS, - }, - checkboxGroup: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXXS, - }, }) diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx index d0fda42461..fc1c4caa53 100644 --- a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx +++ b/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx @@ -75,7 +75,8 @@ describe('InitializerParametersDialog', () => { expect(screen.getByText('Add kitchen_sink initializer')).toBeInTheDocument() expect(screen.getByText(/Required env vars: DEMO_TOKEN/)).toBeInTheDocument() - expect(screen.getByTestId('param-flag')).toHaveAttribute('role', 'switch') + 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() @@ -140,13 +141,29 @@ describe('InitializerParametersDialog', () => { , ) - await user.click(screen.getByTestId('param-flag')) + 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' })) expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ flag: true, tags: ['a'] })) }) + 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) @@ -183,6 +200,40 @@ describe('InitializerParametersDialog', () => { 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() diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.tsx index 44e8ba832d..af7076c3a1 100644 --- a/frontend/src/components/Initializers/InitializerParametersDialog.tsx +++ b/frontend/src/components/Initializers/InitializerParametersDialog.tsx @@ -1,29 +1,20 @@ import { useState } from 'react' import { Button, - Checkbox, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, - Field, - Input, - Select, - Switch, Text, } from '@fluentui/react-components' -import type { Parameter, RegisteredInitializer } from '@/types' +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' -import { - buildParametersFromForm, - getInitialFormValues, - getParameterControlKind, - type ParameterFormValue, -} from './initializerParameterForm' interface InitializerParametersDialogProps { open: boolean @@ -47,7 +38,7 @@ export default function InitializerParametersDialog({ const styles = useAdditionalInitializersStyles() const parameters = initializer?.supported_parameters ?? [] const [values, setValues] = useState>(() => - getInitialFormValues(parameters, initialParameters), + getInitialFormValues(parameters, initialParameters, { prefillDefaults: mode === 'add' }), ) const [error, setError] = useState(null) @@ -135,94 +126,3 @@ export default function InitializerParametersDialog({ ) } - -interface ParameterFieldProps { - parameter: Parameter - value: ParameterFormValue - disabled: boolean - onChange: (name: string, value: ParameterFormValue) => void -} - -function ParameterField({ parameter, value, disabled, onChange }: ParameterFieldProps) { - const styles = useAdditionalInitializersStyles() - const kind = getParameterControlKind(parameter) - const label = parameter.required ? `${parameter.name} *` : parameter.name - - if (kind === 'boolean') { - const checked = value === 'true' - return ( - - onChange(parameter.name, data.checked ? 'true' : 'false')} - data-testid={`param-${parameter.name}`} - /> - - ) - } - - if (kind === 'multiselect') { - const selected = Array.isArray(value) ? value : [] - return ( - -
- {(parameter.choices ?? []).map((choice) => ( - { - const next = data.checked - ? [...selected, choice] - : selected.filter((entry) => entry !== choice) - onChange(parameter.name, next) - }} - data-testid={`param-${parameter.name}-${choice}`} - /> - ))} -
-
- ) - } - - const stringValue = typeof value === 'string' ? value : '' - - if (kind === 'select') { - return ( - - - - ) - } - - const hint = - parameter.description ?? (kind === 'list' ? 'Comma-separated list of values.' : parameter.type_name) - - return ( - - onChange(parameter.name, data.value)} - data-testid={`param-${parameter.name}`} - /> - - ) -} diff --git a/frontend/src/components/Initializers/initializerParameterForm.ts b/frontend/src/components/Initializers/initializerParameterForm.ts deleted file mode 100644 index 9f3db130ec..0000000000 --- a/frontend/src/components/Initializers/initializerParameterForm.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { Parameter } from '@/types' - -/** The control rendered for a parameter, derived from its declared metadata. */ -export type ParameterControlKind = 'boolean' | 'select' | 'multiselect' | 'list' | 'number' | 'text' - -/** Form state value for a single parameter. Multiselect holds the selected choices; everything else is a raw string. */ -export type ParameterFormValue = string | string[] - -export function getParameterControlKind(param: Parameter): ParameterControlKind { - if (param.type_name === 'bool') { - return 'boolean' - } - const hasChoices = (param.choices?.length ?? 0) > 0 - if (param.is_list && hasChoices) { - return 'multiselect' - } - if (hasChoices) { - return 'select' - } - if (param.is_list) { - return 'list' - } - if (param.type_name === 'int' || param.type_name === 'float') { - return 'number' - } - return 'text' -} - -function parseListValue(raw: string): string[] { - return raw - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) -} - -function initialBooleanValue(param: Parameter, initial: unknown): string { - if (initial != null) { - return String(initial).toLowerCase() === 'true' ? 'true' : 'false' - } - if (param.default != null) { - return param.default.toLowerCase() === 'true' ? 'true' : 'false' - } - return 'false' -} - -export function getInitialFormValues( - params: Parameter[], - initialParameters?: Record | null, -): Record { - const values: Record = {} - for (const param of params) { - const initial = initialParameters?.[param.name] - switch (getParameterControlKind(param)) { - case 'boolean': - values[param.name] = initialBooleanValue(param, initial) - break - case 'multiselect': - values[param.name] = Array.isArray(initial) ? initial.map((entry) => String(entry)) : [] - break - case 'list': - values[param.name] = Array.isArray(initial) - ? initial.map((entry) => String(entry)).join(', ') - : initial != null - ? String(initial) - : '' - break - default: - values[param.name] = initial != null ? String(initial) : '' - break - } - } - return values -} - -export type BuildParametersResult = - | { ok: true; parameters: Record | null } - | { ok: false; error: string } - -export function buildParametersFromForm( - params: Parameter[], - values: Record, -): BuildParametersResult { - const parameters: Record = {} - - for (const param of params) { - const value = values[param.name] - const kind = getParameterControlKind(param) - - if (kind === 'boolean') { - parameters[param.name] = value === 'true' - continue - } - - if (kind === 'multiselect') { - const selected = Array.isArray(value) ? value : [] - const invalid = selected.find((entry) => !(param.choices ?? []).includes(entry)) - if (invalid != null) { - return { ok: false, error: `${param.name}: "${invalid}" is not an allowed value.` } - } - if (selected.length === 0) { - if (param.required) { - return { ok: false, error: `${param.name} is required.` } - } - continue - } - parameters[param.name] = selected - continue - } - - const raw = typeof value === 'string' ? value.trim() : '' - - if (kind === 'list') { - const entries = parseListValue(raw) - if (entries.length === 0) { - if (param.required) { - return { ok: false, error: `${param.name} is required.` } - } - continue - } - parameters[param.name] = entries - continue - } - - if (raw.length === 0) { - if (param.required) { - return { ok: false, error: `${param.name} is required.` } - } - continue - } - - if (kind === 'select') { - if (!(param.choices ?? []).includes(raw)) { - return { ok: false, error: `${param.name}: "${raw}" is not an allowed value.` } - } - parameters[param.name] = raw - continue - } - - if (kind === 'number') { - const parsed = Number(raw) - if (!Number.isFinite(parsed)) { - return { ok: false, error: `${param.name} must be a number.` } - } - if (param.type_name === 'int' && !Number.isInteger(parsed)) { - return { ok: false, error: `${param.name} must be an integer.` } - } - parameters[param.name] = parsed - continue - } - - parameters[param.name] = raw - } - - return { ok: true, parameters: Object.keys(parameters).length > 0 ? parameters : null } -} diff --git a/frontend/src/components/Chat/MarkdownContent.styles.ts b/frontend/src/components/Markdown/MarkdownContent.styles.ts similarity index 91% rename from frontend/src/components/Chat/MarkdownContent.styles.ts rename to frontend/src/components/Markdown/MarkdownContent.styles.ts index a02f2dee0f..79e6b9625e 100644 --- a/frontend/src/components/Chat/MarkdownContent.styles.ts +++ b/frontend/src/components/Markdown/MarkdownContent.styles.ts @@ -3,8 +3,8 @@ import { makeStyles, tokens } from '@fluentui/react-components' export const useMarkdownContentStyles = makeStyles({ root: { wordBreak: 'break-word', - // Collapse the outer margins react-markdown adds to the first/last block so - // the rendered content sits flush inside the chat bubble. + // Collapse outer block margins so the renderer composes cleanly in chat, + // catalog, and detail surfaces. '& > :first-child': { marginTop: 0 }, '& > :last-child': { marginBottom: 0 }, '& p': { @@ -50,7 +50,7 @@ export const useMarkdownContentStyles = makeStyles({ '& blockquote': { margin: `0 0 ${tokens.spacingVerticalM} 0`, paddingLeft: tokens.spacingHorizontalM, - borderLeft: `3px solid ${tokens.colorNeutralStroke1}`, + borderLeft: `1px solid ${tokens.colorNeutralStroke1}`, color: tokens.colorNeutralForeground2, }, '& table': { diff --git a/frontend/src/components/Chat/MarkdownContent.test.tsx b/frontend/src/components/Markdown/MarkdownContent.test.tsx similarity index 83% rename from frontend/src/components/Chat/MarkdownContent.test.tsx rename to frontend/src/components/Markdown/MarkdownContent.test.tsx index 6140c42574..bc2afd89f7 100644 --- a/frontend/src/components/Chat/MarkdownContent.test.tsx +++ b/frontend/src/components/Markdown/MarkdownContent.test.tsx @@ -4,9 +4,11 @@ import { FluentProvider, webLightTheme } from '@fluentui/react-components' import MarkdownContent from './MarkdownContent' -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children} +} + +const RAW_IMAGE_HTML = ['<', 'img src=x onerror="alert(1)">'].join('') describe('MarkdownContent', () => { it('renders bold text as a element', () => { @@ -54,13 +56,13 @@ describe('MarkdownContent', () => { it('escapes embedded raw HTML instead of executing it (XSS guard)', () => { render( - hi'} /> + , ) - // The must NOT become a real element — react-markdown escapes it. + // The image markup must not become a real element; react-markdown escapes it. expect(document.querySelector('img')).toBeNull() // The raw markup is shown as literal text instead. - expect(screen.getByText(/hi/)).toBeInTheDocument() + expect(screen.getByText((content: string) => content.includes(`${RAW_IMAGE_HTML}hi`))).toBeInTheDocument() }) it('strips dangerous javascript: link URIs', () => { @@ -76,13 +78,13 @@ describe('MarkdownContent', () => { expect(link?.getAttribute('href') ?? '').not.toContain('javascript:') }) - it('renders inline images as a click-through link, not an auto-loading ', () => { + it('renders inline images as a click-through link, not an auto-loading element', () => { render( , ) - // No is emitted, so nothing is fetched from the untrusted URL on render. + // No image element is emitted, so the untrusted URL is not fetched on render. expect(document.querySelector('img')).toBeNull() // Instead the operator gets a safe link they can choose to open. const link = screen.getByRole('link', { name: 'a cat' }) diff --git a/frontend/src/components/Chat/MarkdownContent.tsx b/frontend/src/components/Markdown/MarkdownContent.tsx similarity index 86% rename from frontend/src/components/Chat/MarkdownContent.tsx rename to frontend/src/components/Markdown/MarkdownContent.tsx index b7801dc7c2..6f27ea456e 100644 --- a/frontend/src/components/Chat/MarkdownContent.tsx +++ b/frontend/src/components/Markdown/MarkdownContent.tsx @@ -1,4 +1,6 @@ import { memo } from 'react' + +import { mergeClasses } from '@fluentui/react-components' import Markdown from 'react-markdown' import type { Components } from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -10,6 +12,8 @@ interface MarkdownContentProps { content: string /** Optional test id applied to the wrapper element. */ testId?: string + /** Optional themed class for the surface embedding the shared renderer. */ + className?: string } // Render every link in a new tab. `rel="noopener noreferrer"` prevents the @@ -18,7 +22,7 @@ interface MarkdownContentProps { // from the parsed source can leak through. // // Inline images (`![alt](url)`) are rendered as a click-through LINK rather than -// an auto-loading . Because the content is untrusted (model-generated), +// an auto-loading image element. Because the content is untrusted (model-generated), // auto-loading would fetch a model-controlled URL on render — a tracking-pixel / // internal-probe vector that silently leaks the operator's IP, a view timestamp, // and any query-encoded data. A link preserves the operator's ability to open @@ -56,11 +60,11 @@ const REMARK_PLUGINS = [remarkGfm] * Memoized because Markdown parsing is comparatively expensive and message * content is stable across the frequent re-renders of the message list. */ -function MarkdownContent({ content, testId }: MarkdownContentProps) { +function MarkdownContent({ content, testId, className }: MarkdownContentProps) { const styles = useMarkdownContentStyles() return ( -
+
{content} diff --git a/frontend/src/components/Parameters/ParameterField.styles.ts b/frontend/src/components/Parameters/ParameterField.styles.ts new file mode 100644 index 0000000000..482e0ef9ef --- /dev/null +++ b/frontend/src/components/Parameters/ParameterField.styles.ts @@ -0,0 +1,35 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { + MINIMUM_TOUCH_TARGET_SIZE, + mobileTouchTargetHeight, + TOUCH_INPUT_QUERY, +} from '@/styles/touchTargets' + +export const useParameterFieldStyles = makeStyles({ + control: { + ...mobileTouchTargetHeight, + '& > select': { + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + '& > input': { + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + }, + selectionControl: { + ...mobileTouchTargetHeight, + }, + checkboxGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + fieldHint: { + color: tokens.colorNeutralForeground3, + marginTop: tokens.spacingVerticalXXS, + }, +}) diff --git a/frontend/src/components/Parameters/ParameterField.tsx b/frontend/src/components/Parameters/ParameterField.tsx new file mode 100644 index 0000000000..ccc498d3e9 --- /dev/null +++ b/frontend/src/components/Parameters/ParameterField.tsx @@ -0,0 +1,129 @@ +import { + Checkbox, + Field, + Input, + Select, +} from '@fluentui/react-components' + +import type { Parameter } from '@/types' + +import { useParameterFieldStyles } from './ParameterField.styles' +import { getParameterControlKind, type ParameterFormValue } from './parameterForm' + +export interface ParameterFieldProps { + parameter: Parameter + value: ParameterFormValue + disabled: boolean + onChange: (name: string, value: ParameterFormValue) => void + /** Prefix for `data-testid` attributes. Defaults to `'param'` (e.g. `param-`). */ + testIdPrefix?: string +} + +/** + * Renders the appropriate Fluent UI control for a declared {@link Parameter}, + * driven by {@link getParameterControlKind}. Shared by every dynamic + * parameter form (initializers, scenario launch) so a parameter always looks + * and behaves the same way regardless of where it's rendered. + * + * A boolean parameter renders as a tri-state select (unset / True / False) + * rather than a switch, so "not set" (omit — use the server default) stays + * distinguishable from an explicitly chosen `False`. + */ +export default function ParameterField({ + parameter, + value, + disabled, + onChange, + testIdPrefix = 'param', +}: ParameterFieldProps) { + const styles = useParameterFieldStyles() + const kind = getParameterControlKind(parameter) + const label = parameter.required ? `${parameter.name} *` : parameter.name + const testId = `${testIdPrefix}-${parameter.name}` + + if (kind === 'boolean') { + const current = value === 'true' || value === 'false' ? value : '' + return ( + + + + ) + } + + if (kind === 'multiselect') { + const selected = Array.isArray(value) ? value : [] + return ( + +
+ {(parameter.choices ?? []).map((choice) => ( + { + const next = data.checked + ? [...selected, choice] + : selected.filter((entry) => entry !== choice) + onChange(parameter.name, next) + }} + data-testid={`${testId}-${choice}`} + /> + ))} +
+
+ ) + } + + const stringValue = typeof value === 'string' ? value : '' + + if (kind === 'select') { + return ( + + + + ) + } + + const placeholder = typeof parameter.default === 'string' ? parameter.default : undefined + const hint = + parameter.description ?? (kind === 'list' ? 'Comma-separated list of values.' : parameter.type_name) + + return ( + + onChange(parameter.name, data.value)} + data-testid={testId} + /> + + ) +} diff --git a/frontend/src/components/Initializers/initializerParameterForm.test.ts b/frontend/src/components/Parameters/parameterForm.test.ts similarity index 55% rename from frontend/src/components/Initializers/initializerParameterForm.test.ts rename to frontend/src/components/Parameters/parameterForm.test.ts index 13bb0ae2db..5c08f20191 100644 --- a/frontend/src/components/Initializers/initializerParameterForm.test.ts +++ b/frontend/src/components/Parameters/parameterForm.test.ts @@ -4,7 +4,8 @@ import { buildParametersFromForm, getInitialFormValues, getParameterControlKind, -} from './initializerParameterForm' + UNSET_BOOLEAN_VALUE, +} from './parameterForm' function makeParameter(overrides: Partial & { name: string }): Parameter { return { @@ -49,17 +50,23 @@ describe('getParameterControlKind', () => { }) describe('getInitialFormValues', () => { - it('derives boolean strings from the provided value and the default', () => { + it('derives boolean strings from the provided value, honoring an explicit false', () => { const params = [ makeParameter({ name: 'a', type_name: 'bool' }), makeParameter({ name: 'b', type_name: 'bool', default: 'true' }), makeParameter({ name: 'c', type_name: 'bool' }), + makeParameter({ name: 'd', type_name: 'bool' }), ] - const values = getInitialFormValues(params, { a: true }) - expect(values).toEqual({ a: 'true', b: 'true', c: 'false' }) + const values = getInitialFormValues(params, { a: true, d: false }) + expect(values).toEqual({ a: 'true', b: 'true', c: UNSET_BOOLEAN_VALUE, d: 'false' }) }) - it('derives multiselect arrays and list strings', () => { + it('leaves an optional boolean with no initial value or default unset', () => { + const params = [makeParameter({ name: 'flag', type_name: 'bool' })] + expect(getInitialFormValues(params)).toEqual({ flag: UNSET_BOOLEAN_VALUE }) + }) + + it('derives multiselect arrays and list strings from initial values', () => { const params = [ makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['x', 'y'] }), makeParameter({ name: 'names', type_name: 'list[str]', is_list: true }), @@ -68,12 +75,43 @@ describe('getInitialFormValues', () => { expect(values).toEqual({ tags: ['x'], names: 'one, two' }) }) - it('stringifies scalar values and defaults to empty strings', () => { + it('honors a declared list default when no initial value is provided', () => { + const params = [ + makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['x', 'y'], default: ['y'] }), + makeParameter({ name: 'names', type_name: 'list[str]', is_list: true, default: ['a', 'b'] }), + ] + expect(getInitialFormValues(params)).toEqual({ tags: ['y'], names: 'a, b' }) + }) + + it('stringifies scalar values, honors a declared scalar default, and defaults to empty strings', () => { const params = [ makeParameter({ name: 'days', type_name: 'int' }), makeParameter({ name: 'label' }), + makeParameter({ name: 'ratio', type_name: 'float', default: '1.5' }), + ] + expect(getInitialFormValues(params, { days: 7 })).toEqual({ days: '7', label: '', ratio: '1.5' }) + }) + + it('preserves explicit null values instead of replacing them with defaults', () => { + const params = [ + makeParameter({ name: 'flag', type_name: 'bool', default: 'true' }), + makeParameter({ name: 'days', type_name: 'int', default: '7' }), + ] + expect(getInitialFormValues(params, { flag: null, days: null })).toEqual({ + flag: UNSET_BOOLEAN_VALUE, + days: '', + }) + }) + + it('can leave absent values unset when editing persisted parameters', () => { + const params = [ + makeParameter({ name: 'flag', type_name: 'bool', default: 'true' }), + makeParameter({ name: 'days', type_name: 'int', default: '7' }), ] - expect(getInitialFormValues(params, { days: 7 })).toEqual({ days: '7', label: '' }) + expect(getInitialFormValues(params, {}, { prefillDefaults: false })).toEqual({ + flag: UNSET_BOOLEAN_VALUE, + days: '', + }) }) }) @@ -102,12 +140,42 @@ describe('buildParametersFromForm', () => { expect(result).toEqual({ ok: false, error: 'days must be an integer.' }) }) + it('coerces a valid float', () => { + const params = [makeParameter({ name: 'ratio', type_name: 'float' })] + const result = buildParametersFromForm(params, { ratio: '1.5' }) + expect(result).toEqual({ ok: true, parameters: { ratio: 1.5 } }) + }) + it('splits a comma-separated list', () => { const params = [makeParameter({ name: 'names', type_name: 'list[str]', is_list: true })] const result = buildParametersFromForm(params, { names: 'a, b ,, c' }) expect(result).toEqual({ ok: true, parameters: { names: ['a', 'b', 'c'] } }) }) + it('coerces list elements to the declared element type', () => { + const params = [makeParameter({ name: 'days', type_name: 'list[int]', is_list: true })] + const result = buildParametersFromForm(params, { days: '1, 2, 3' }) + expect(result).toEqual({ ok: true, parameters: { days: [1, 2, 3] } }) + }) + + it('coerces accepted list[bool] spellings', () => { + const params = [makeParameter({ name: 'flags', type_name: 'list[bool]', is_list: true })] + const result = buildParametersFromForm(params, { flags: 'true, 0, yes, no' }) + expect(result).toEqual({ ok: true, parameters: { flags: [true, false, true, false] } }) + }) + + it('rejects an invalid list[bool] token', () => { + const params = [makeParameter({ name: 'flags', type_name: 'list[bool]', is_list: true })] + const result = buildParametersFromForm(params, { flags: 'true, maybe' }) + expect(result).toEqual({ ok: false, error: 'flags must be true or false.' }) + }) + + it('rejects a non-integer list element for a list[int] parameter', () => { + const params = [makeParameter({ name: 'days', type_name: 'list[int]', is_list: true })] + const result = buildParametersFromForm(params, { days: '1, x' }) + expect(result).toEqual({ ok: false, error: 'days must be a number.' }) + }) + it('keeps selected multiselect choices', () => { const params = [ makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['a', 'b'] }), @@ -116,6 +184,14 @@ describe('buildParametersFromForm', () => { expect(result).toEqual({ ok: true, parameters: { tags: ['a', 'b'] } }) }) + it('coerces constrained multiselect choices declared as list[int]', () => { + const params = [ + makeParameter({ name: 'levels', type_name: 'list[int]', is_list: true, choices: ['1', '2', '3'] }), + ] + const result = buildParametersFromForm(params, { levels: ['1', '3'] }) + expect(result).toEqual({ ok: true, parameters: { levels: [1, 3] } }) + }) + it('rejects a multiselect value outside the allowed set', () => { const params = [ makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['a', 'b'] }), @@ -130,6 +206,12 @@ describe('buildParametersFromForm', () => { expect(result).toEqual({ ok: false, error: 'mode: "medium" is not an allowed value.' }) }) + it('coerces a constrained scalar declared as int (Literal[int]/Enum-of-int)', () => { + const params = [makeParameter({ name: 'level', type_name: 'int', choices: ['1', '2'] })] + const result = buildParametersFromForm(params, { level: '2' }) + expect(result).toEqual({ ok: true, parameters: { level: 2 } }) + }) + it('coerces booleans', () => { const params = [ makeParameter({ name: 'on', type_name: 'bool' }), @@ -139,6 +221,18 @@ describe('buildParametersFromForm', () => { expect(result).toEqual({ ok: true, parameters: { on: true, off: false } }) }) + it('omits an optional boolean left unset', () => { + const params = [makeParameter({ name: 'flag', type_name: 'bool' })] + const result = buildParametersFromForm(params, { flag: UNSET_BOOLEAN_VALUE }) + expect(result).toEqual({ ok: true, parameters: null }) + }) + + it('reports a required boolean left unset', () => { + const params = [makeParameter({ name: 'flag', type_name: 'bool', required: true })] + const result = buildParametersFromForm(params, { flag: UNSET_BOOLEAN_VALUE }) + expect(result).toEqual({ ok: false, error: 'flag is required.' }) + }) + it('reports a required parameter with no value', () => { const params = [makeParameter({ name: 'label', required: true })] const result = buildParametersFromForm(params, { label: '' }) diff --git a/frontend/src/components/Parameters/parameterForm.ts b/frontend/src/components/Parameters/parameterForm.ts new file mode 100644 index 0000000000..a8e2ea28f6 --- /dev/null +++ b/frontend/src/components/Parameters/parameterForm.ts @@ -0,0 +1,258 @@ +import type { Parameter } from '@/types' + +/** + * Shared parameter-form logic reused by every dynamic parameter form in the + * app (initializer parameters, scenario-specific parameters, ...). The + * control kind, form-value shape, default-initialization, and coercion/ + * validation rules all live here so every consumer behaves identically. + */ + +/** The control rendered for a parameter, derived from its declared metadata. */ +export type ParameterControlKind = 'boolean' | 'select' | 'multiselect' | 'list' | 'number' | 'text' + +/** + * Form state value for a single parameter. + * + * A boolean parameter's value is one of `''` (unset — distinct from a + * chosen `false`), `'true'`, or `'false'`. Everything else is a raw string + * (scalar / unconstrained list, comma-joined) or a string array + * (multiselect selections). + */ +export type ParameterFormValue = string | string[] + +/** Sentinel form value meaning "the user has not chosen true or false yet". */ +export const UNSET_BOOLEAN_VALUE = '' + +export interface InitialFormValueOptions { + /** Populate absent values from the parameter declaration. Defaults to true. */ + prefillDefaults?: boolean +} + +export function getParameterControlKind(param: Parameter): ParameterControlKind { + if (param.type_name === 'bool') { + return 'boolean' + } + const hasChoices = (param.choices?.length ?? 0) > 0 + if (param.is_list && hasChoices) { + return 'multiselect' + } + if (hasChoices) { + return 'select' + } + if (param.is_list) { + return 'list' + } + if (param.type_name === 'int' || param.type_name === 'float') { + return 'number' + } + return 'text' +} + +/** + * The element type name for a list parameter's declared type (e.g. `'int'` + * for `'list[int]'`), or the parameter's own `type_name` when it isn't a + * list. Drives per-element coercion for list/multiselect parameters. + */ +function elementTypeName(param: Parameter): string { + if (!param.is_list) { + return param.type_name + } + const match = /^list\[(.+)\]$/.exec(param.type_name) + return match ? match[1] : 'str' +} + +function parseListValue(raw: string): string[] { + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) +} + +/** Derives the initial tri-state boolean form value: `''` (unset), `'true'`, or `'false'`. */ +function initialBooleanValue(source: unknown): string { + if (source == null) { + return UNSET_BOOLEAN_VALUE + } + return String(source).toLowerCase() === 'true' ? 'true' : 'false' +} + +export function getInitialFormValues( + params: Parameter[], + initialParameters?: Record | null, + options: InitialFormValueOptions = {}, +): Record { + const values: Record = {} + const prefillDefaults = options.prefillDefaults ?? true + for (const param of params) { + const hasInitialValue = + initialParameters !== null + && initialParameters !== undefined + && Object.prototype.hasOwnProperty.call(initialParameters, param.name) + const source = hasInitialValue + ? initialParameters[param.name] + : prefillDefaults + ? param.default + : undefined + switch (getParameterControlKind(param)) { + case 'boolean': + values[param.name] = initialBooleanValue(source) + break + case 'multiselect': { + values[param.name] = Array.isArray(source) ? source.map((entry) => String(entry)) : [] + break + } + case 'list': { + values[param.name] = Array.isArray(source) + ? source.map((entry) => String(entry)).join(', ') + : source != null + ? String(source) + : '' + break + } + default: { + values[param.name] = source != null ? String(source) : '' + break + } + } + } + return values +} + +export type BuildParametersResult = + | { ok: true; parameters: Record | null } + | { ok: false; error: string } + +type CoerceResult = { ok: true; value: unknown } | { ok: false; error: string } + +/** Coerces a single string token to the declared scalar type (`int` / `float` / `bool` / anything else passes through as a string). */ +function coerceToken(raw: string, typeName: string, paramName: string): CoerceResult { + if (typeName === 'int') { + const parsed = Number(raw) + if (!Number.isFinite(parsed)) { + return { ok: false, error: `${paramName} must be a number.` } + } + if (!Number.isInteger(parsed)) { + return { ok: false, error: `${paramName} must be an integer.` } + } + return { ok: true, value: parsed } + } + if (typeName === 'float') { + const parsed = Number(raw) + if (!Number.isFinite(parsed)) { + return { ok: false, error: `${paramName} must be a number.` } + } + return { ok: true, value: parsed } + } + if (typeName === 'bool') { + const normalized = raw.toLowerCase() + if (normalized === 'true' || normalized === '1' || normalized === 'yes') { + return { ok: true, value: true } + } + if (normalized === 'false' || normalized === '0' || normalized === 'no') { + return { ok: true, value: false } + } + return { ok: false, error: `${paramName} must be true or false.` } + } + return { ok: true, value: raw } +} + +export function buildParametersFromForm( + params: Parameter[], + values: Record, +): BuildParametersResult { + const parameters: Record = {} + + for (const param of params) { + const value = values[param.name] + const kind = getParameterControlKind(param) + + if (kind === 'boolean') { + if (value !== 'true' && value !== 'false') { + if (param.required) { + return { ok: false, error: `${param.name} is required.` } + } + continue + } + parameters[param.name] = value === 'true' + continue + } + + if (kind === 'multiselect') { + const selected = Array.isArray(value) ? value : [] + const invalid = selected.find((entry) => !(param.choices ?? []).includes(entry)) + if (invalid != null) { + return { ok: false, error: `${param.name}: "${invalid}" is not an allowed value.` } + } + if (selected.length === 0) { + if (param.required) { + return { ok: false, error: `${param.name} is required.` } + } + continue + } + const coercedList: unknown[] = [] + for (const entry of selected) { + const coerced = coerceToken(entry, elementTypeName(param), param.name) + if (!coerced.ok) { + return coerced + } + coercedList.push(coerced.value) + } + parameters[param.name] = coercedList + continue + } + + const raw = typeof value === 'string' ? value.trim() : '' + + if (kind === 'list') { + const entries = parseListValue(raw) + if (entries.length === 0) { + if (param.required) { + return { ok: false, error: `${param.name} is required.` } + } + continue + } + const coercedList: unknown[] = [] + for (const entry of entries) { + const coerced = coerceToken(entry, elementTypeName(param), param.name) + if (!coerced.ok) { + return coerced + } + coercedList.push(coerced.value) + } + parameters[param.name] = coercedList + continue + } + + if (raw.length === 0) { + if (param.required) { + return { ok: false, error: `${param.name} is required.` } + } + continue + } + + if (kind === 'select') { + if (!(param.choices ?? []).includes(raw)) { + return { ok: false, error: `${param.name}: "${raw}" is not an allowed value.` } + } + const coerced = coerceToken(raw, param.type_name, param.name) + if (!coerced.ok) { + return coerced + } + parameters[param.name] = coerced.value + continue + } + + if (kind === 'number') { + const coerced = coerceToken(raw, param.type_name, param.name) + if (!coerced.ok) { + return coerced + } + parameters[param.name] = coerced.value + continue + } + + parameters[param.name] = raw + } + + return { ok: true, parameters: Object.keys(parameters).length > 0 ? parameters : null } +} diff --git a/frontend/src/components/Scenarios/ScenarioCatalog.styles.ts b/frontend/src/components/Scenarios/ScenarioCatalog.styles.ts new file mode 100644 index 0000000000..c92fd5dff9 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioCatalog.styles.ts @@ -0,0 +1,244 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { + MINIMUM_TOUCH_TARGET_SIZE, + mobileTouchTarget, + NARROW_VIEWPORT_QUERY, + TOUCH_INPUT_QUERY, +} from '@/styles/touchTargets' + +export const useScenarioCatalogStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + minWidth: 0, + padding: tokens.spacingVerticalXXL, + overflowX: 'hidden', + overflowY: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + [NARROW_VIEWPORT_QUERY]: { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + header: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingVerticalL, + marginBottom: tokens.spacingVerticalXL, + [NARROW_VIEWPORT_QUERY]: { + flexDirection: 'column', + alignItems: 'stretch', + }, + }, + headerText: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + subtitle: { + color: tokens.colorNeutralForeground3, + }, + explanation: { + maxWidth: '75ch', + margin: `${tokens.spacingVerticalS} 0 0`, + color: tokens.colorNeutralForeground2, + }, + headerActions: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + alignItems: 'center', + [NARROW_VIEWPORT_QUERY]: { + width: '100%', + }, + }, + search: { + minWidth: '16rem', + [NARROW_VIEWPORT_QUERY]: { + minWidth: 0, + flex: 1, + }, + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + touchTarget: { + ...mobileTouchTarget, + }, + centeredState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalXXXL, + textAlign: 'center', + color: tokens.colorNeutralForeground3, + }, + tableContainer: { + minWidth: 0, + overflowX: 'auto', + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + [NARROW_VIEWPORT_QUERY]: { + overflowX: 'visible', + border: 0, + borderRadius: 0, + backgroundColor: 'transparent', + }, + }, + table: { + width: '100%', + minWidth: '64rem', + tableLayout: 'fixed', + [NARROW_VIEWPORT_QUERY]: { + display: 'block', + minWidth: 0, + }, + }, + tableHeader: { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: tokens.colorNeutralBackground1, + [NARROW_VIEWPORT_QUERY]: { + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, + }, + }, + tableHeaderCell: { + paddingTop: tokens.spacingVerticalL, + paddingRight: tokens.spacingHorizontalL, + paddingBottom: tokens.spacingVerticalL, + paddingLeft: tokens.spacingHorizontalL, + }, + tableBody: { + [NARROW_VIEWPORT_QUERY]: { + display: 'block', + }, + }, + scenarioColumn: { + width: '34%', + }, + configureColumn: { + width: '15%', + }, + sizeColumn: { + width: '17%', + }, + techniqueColumn: { + width: '14%', + }, + datasetColumn: { + width: '20%', + }, + summaryRow: { + color: tokens.colorNeutralForeground1, + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + [NARROW_VIEWPORT_QUERY]: { + display: 'grid', + gridTemplateRows: 'repeat(5, max-content)', + height: 'max-content', + width: '100%', + marginBottom: tokens.spacingVerticalM, + overflow: 'hidden', + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + }, + tableCell: { + verticalAlign: 'top', + overflowWrap: 'anywhere', + [NARROW_VIEWPORT_QUERY]: { + display: 'grid', + gridTemplateColumns: 'minmax(7rem, 35%) minmax(0, 1fr)', + gap: tokens.spacingHorizontalM, + height: 'auto', + width: 'auto', + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + ':last-child': { + borderBottom: 0, + }, + }, + }, + tableCellPadding: { + paddingTop: tokens.spacingVerticalL, + paddingRight: tokens.spacingHorizontalL, + paddingBottom: tokens.spacingVerticalL, + paddingLeft: tokens.spacingHorizontalL, + }, + mobileLabel: { + display: 'none', + color: tokens.colorNeutralForeground3, + [NARROW_VIEWPORT_QUERY]: { + display: 'block', + }, + }, + scenarioSummary: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + }, + scenarioLink: { + display: 'inline-flex', + alignItems: 'center', + alignSelf: 'flex-start', + color: tokens.colorBrandForegroundLink, + fontWeight: tokens.fontWeightSemibold, + textDecorationLine: 'none', + overflowWrap: 'anywhere', + ':hover': { + textDecorationLine: 'underline', + }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + purposePreview: { + display: '-webkit-box', + maxWidth: '56ch', + maxHeight: '2.75rem', + overflow: 'hidden', + color: tokens.colorNeutralForeground2, + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + }, + compactStack: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalXS, + minWidth: 0, + }, + secondaryText: { + color: tokens.colorNeutralForeground3, + }, + configureButton: { + ...mobileTouchTarget, + alignSelf: 'flex-start', + [NARROW_VIEWPORT_QUERY]: { + width: '100%', + }, + }, +}) diff --git a/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx b/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx new file mode 100644 index 0000000000..094176536b --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx @@ -0,0 +1,577 @@ +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { MemoryRouter, useLocation } from 'react-router' + +import { scenariosApi } from '@/services/api' +import type { RegisteredScenario } from '@/types' + +import ScenarioCatalog from './ScenarioCatalog' + +jest.mock('@/services/api', () => ({ + scenariosApi: { + listCatalog: jest.fn(), + }, +})) + +const mockListCatalog = scenariosApi.listCatalog as jest.Mock + +const REMOVED_NORMAL_ESTIMATE_LABELS = new RegExp( + [ + ['Run', 'size', 'calculated'].join(' '), + ['Final', 'count', 'set', 'at', 'launch'].join(' '), + ].join('|'), + 'i', +) + +function LocationProbe() { + const location = useLocation() + return {location.pathname} +} + +function TestWrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + + ) +} + +function makeScenario(overrides: Partial & { scenario_name: string }): RegisteredScenario { + const description = overrides.description ?? 'A demo scenario.' + const defaultTechnique = overrides.default_technique ?? 'default_technique' + return { + scenario_type: 'DemoScenario', + scenario_version: 1, + aggregate_techniques: [], + aggregate_technique_expansions: {}, + all_techniques: ['default_technique'], + default_datasets: [], + dataset_size_limit: { + default_scope: 'none', + default_count: null, + override_scope: 'per_dataset', + }, + default_dataset_summaries: [], + baseline_policy: 'enabled', + include_baseline_by_default: true, + supported_parameters: [], + default_run_size: { + version: 1, + status: 'unavailable', + total_attack_count: null, + minimum_attack_count: null, + maximum_attack_count: null, + condition: null, + components: [], + datasets: [], + adaptive_details: null, + note: 'Default sizing is not available.', + retries_included: false, + }, + ...overrides, + description, + description_markdown: overrides.description_markdown ?? description, + default_technique: defaultTechnique, + default_techniques: overrides.default_techniques ?? [defaultTechnique], + } +} + +describe('ScenarioCatalog', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('shows a loading state while fetching', () => { + mockListCatalog.mockReturnValue(new Promise(() => {})) + render() + expect(screen.getByText('Loading scenarios...')).toBeInTheDocument() + }) + + it('renders every scenario from a single page', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ scenario_name: 'foundry.red_team_agent', description: 'Red teams a target.' }), + makeScenario({ scenario_name: 'encoding.base64', description: 'Encodes prompts.' }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + expect(await screen.findByText('foundry.red_team_agent')).toBeInTheDocument() + expect(screen.getByText('encoding.base64')).toBeInTheDocument() + expect(mockListCatalog).toHaveBeenCalledTimes(1) + }) + + it('ignores a catalog response that resolves after unmount', async () => { + let resolveRequest: ((value: { + items: RegisteredScenario[] + pagination: { limit: number; has_more: boolean } + }) => void) | undefined + mockListCatalog.mockImplementationOnce(() => new Promise((resolve) => { + resolveRequest = resolve + })) + + const { unmount } = render() + await waitFor(() => expect(mockListCatalog).toHaveBeenCalledTimes(1)) + unmount() + await act(async () => { + resolveRequest?.({ + items: [makeScenario({ scenario_name: 'late.scenario' })], + pagination: { limit: 200, has_more: false }, + }) + }) + }) + + it('ignores a catalog failure that arrives after unmount', async () => { + let rejectRequest: ((reason?: unknown) => void) | undefined + mockListCatalog.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectRequest = reject + })) + + const { unmount } = render() + await waitFor(() => expect(mockListCatalog).toHaveBeenCalledTimes(1)) + unmount() + await act(async () => { + rejectRequest?.(new Error('late failure')) + }) + }) + + it('renders the exact launch-index column order and applies spacing to every cell', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'foundry.red_team_agent' })], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const table = await screen.findByRole('table', { name: 'Registered scenarios' }) + expect(screen.getByText(/packages objective datasets, technique sets or selected techniques/i)) + .toBeInTheDocument() + const headers = within(table).getAllByRole('columnheader') + expect(headers).toHaveLength(5) + expect(headers.map((header) => header.textContent)).toEqual([ + 'Scenario / purpose', + 'Configure', + 'Default dataset size', + 'Default techniques', + 'Default run size', + ]) + expect(headers.every((cell) => cell.classList.contains('scenario-catalog-cell-padding'))).toBe(true) + const cells = within(screen.getByTestId('scenario-card-foundry.red_team_agent')).getAllByRole('cell') + expect(cells).toHaveLength(5) + expect(cells.every((cell) => cell.classList.contains('scenario-catalog-cell-padding'))).toBe(true) + expect(within(cells[1]).getByRole('button', { name: 'Configure run' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /show details|hide details/i })).not.toBeInTheDocument() + expect(screen.queryByRole('region', { name: /details/i })).not.toBeInTheDocument() + }) + + it('follows the cursor to load every page automatically', async () => { + mockListCatalog + .mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'scenario.page1' })], + pagination: { limit: 1, has_more: true, next_cursor: 'cursor-1' }, + }) + .mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'scenario.page2' })], + pagination: { limit: 1, has_more: false }, + }) + + render() + + expect(await screen.findByText('scenario.page1')).toBeInTheDocument() + expect(screen.getByText('scenario.page2')).toBeInTheDocument() + expect(mockListCatalog).toHaveBeenCalledTimes(2) + expect(mockListCatalog).toHaveBeenNthCalledWith(2, 200, 'cursor-1') + }) + + it('stops paging if the backend repeats a cursor instead of looping forever', async () => { + mockListCatalog.mockResolvedValue({ + items: [makeScenario({ scenario_name: 'scenario.loop' })], + pagination: { limit: 1, has_more: true, next_cursor: 'same-cursor' }, + }) + + render() + + expect(await screen.findAllByText('scenario.loop')).toHaveLength(1) + await waitFor(() => expect(mockListCatalog).toHaveBeenCalledTimes(2)) + // Give any additional (incorrect) fetch a chance to fire before asserting it didn't. + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(mockListCatalog).toHaveBeenCalledTimes(2) + }) + + it('shows an empty state when no scenarios are registered', async () => { + mockListCatalog.mockResolvedValueOnce({ items: [], pagination: { limit: 200, has_more: false } }) + + render() + + expect(await screen.findByTestId('empty-state')).toBeInTheDocument() + }) + + it('shows an error MessageBar with a retry action on failure', async () => { + mockListCatalog.mockRejectedValueOnce(new Error('Network error — check that the backend is running and reachable.')) + + render() + + expect(await screen.findByTestId('error-state')).toBeInTheDocument() + expect(screen.getByText(/Network error/)).toBeInTheDocument() + expect(screen.getByTestId('retry-btn')).toBeInTheDocument() + }) + + it('retries the fetch when Retry is clicked', async () => { + const user = userEvent.setup() + mockListCatalog + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'scenario.recovered' })], + pagination: { limit: 200, has_more: false }, + }) + + render() + + await screen.findByTestId('error-state') + await user.click(screen.getByTestId('retry-btn')) + + expect(await screen.findByText('scenario.recovered')).toBeInTheDocument() + expect(mockListCatalog).toHaveBeenCalledTimes(2) + }) + + it('filters scenarios by the search box across name, description, techniques, and datasets', async () => { + const user = userEvent.setup() + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ scenario_name: 'foundry.red_team_agent', description: 'Red teams a target.' }), + makeScenario({ + scenario_name: 'encoding.base64', + description: 'Applies text encodings.', + default_datasets: ['harmbench'], + default_technique: 'multi_turn', + default_techniques: ['crescendo'], + aggregate_techniques: ['multi_turn'], + aggregate_technique_expansions: { multi_turn: ['crescendo'] }, + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + await screen.findByText('foundry.red_team_agent') + + await user.type(screen.getByLabelText('Search scenarios'), 'Multi-turn') + + expect(screen.queryByText('foundry.red_team_agent')).not.toBeInTheDocument() + expect(screen.getByText('encoding.base64')).toBeInTheDocument() + }) + + it('searches dataset metadata and renders singular counts with no default techniques', async () => { + const user = userEvent.setup() + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ + scenario_name: 'scenario.one', + default_techniques: [], + default_datasets: ['dataset-one'], + default_dataset_summaries: [{ + name: 'dataset-one', + kind: 'dataset', + logical_seed_group_count: 1, + selected_seed_group_count: 1, + configured_caps: [], + selection_note: null, + }], + }), + makeScenario({ + scenario_name: 'scenario.two', + default_datasets: ['dataset-two'], + default_dataset_summaries: [{ + name: 'dataset-two', + kind: 'dataset', + logical_seed_group_count: 2, + selected_seed_group_count: 2, + configured_caps: [], + selection_note: 'Dataset metadata is searchable.', + }], + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + await screen.findByText('scenario.one') + await user.type(screen.getByLabelText('Search scenarios'), 'dataset') + + const firstRow = screen.getByTestId('scenario-card-scenario.one') + expect(within(firstRow).getByText('1 objective')).toBeInTheDocument() + expect(within(firstRow).getByText(/dataset-one/)).toBeInTheDocument() + expect(within(firstRow).getByText('No default techniques')).toBeInTheDocument() + expect(screen.getByText('scenario.two')).toBeInTheDocument() + }) + + it('shows a no-results state when the search matches nothing', async () => { + const user = userEvent.setup() + mockListCatalog.mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'foundry.red_team_agent' })], + pagination: { limit: 200, has_more: false }, + }) + + render() + await screen.findByText('foundry.red_team_agent') + + await user.type(screen.getByLabelText('Search scenarios'), 'no-such-scenario') + + expect(await screen.findByTestId('no-results-state')).toBeInTheDocument() + }) + + it('links each card to its encoded scenario detail route', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'foundry/red_team_agent' })], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const card = await screen.findByRole('link', { name: /foundry\/red_team_agent/i }) + expect(card).toHaveAttribute('href', '/scenarios/foundry%2Fred_team_agent') + }) + + it('navigates from the second-cell Configure button', async () => { + const user = userEvent.setup() + mockListCatalog.mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'foundry/red_team_agent' })], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-foundry/red_team_agent') + const cells = within(row).getAllByRole('cell') + await user.click(within(cells[1]).getByRole('button', { name: 'Configure run' })) + + expect(screen.getByLabelText('Current route')).toHaveTextContent('/scenarios/foundry%2Fred_team_agent') + }) + + it('shows multiple default populations separately instead of summing them', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ + scenario_name: 'scenario.compound', + default_datasets: ['population-a', 'population-b'], + dataset_size_limit: { + default_scope: 'none', + default_count: null, + override_scope: 'per_dataset', + }, + default_dataset_summaries: [ + { + name: 'population-a', + kind: 'dataset', + logical_seed_group_count: 100, + selected_seed_group_count: 4, + configured_caps: [], + selection_note: null, + }, + { + name: 'population-b', + kind: 'synthesized', + logical_seed_group_count: 20, + selected_seed_group_count: 2, + configured_caps: [], + selection_note: null, + }, + ], + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-scenario.compound') + expect(within(row).getByText( + '4 objectives · population-a · 2 objectives · population-b', + )).toBeInTheDocument() + expect(within(row).queryByText('6 objectives')).not.toBeInTheDocument() + }) + + it('shows adaptive progress objectives together with the underlying attempt bound', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ + scenario_name: 'adaptive.text_adaptive', + default_run_size: { + version: 1, + status: 'conditional', + total_attack_count: null, + minimum_attack_count: 21, + maximum_attack_count: 42, + condition: 'target_capabilities', + components: [ + { + label: 'Baseline', + count: 21, + factors: [{ label: 'objectives', count: 21 }], + is_baseline: true, + condition: null, + note: null, + }, + { + label: 'Adaptive objectives', + count: 21, + factors: [{ label: 'compatible objectives', count: 21 }], + is_baseline: false, + condition: null, + note: null, + }, + ], + datasets: [], + adaptive_details: { + objective_count: 21, + selected_candidate_technique_count: 2, + candidate_technique_count: 2, + max_attempts_per_objective: 3, + techniques_per_objective_upper_bound: 2, + technique_attempt_count_upper_bound: 42, + stop_on_first_success: true, + compatibility_may_reduce_attempts: true, + }, + note: null, + retries_included: false, + }, + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-adaptive.text_adaptive') + expect(within(row).getByText('up to 63 attack attempts · 21–42 progress units')).toBeInTheDocument() + expect(within(row).queryByText(/objective envelope/i)).not.toBeInTheDocument() + }) + + it('keeps declared datasets visible when backend population summaries are unavailable', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ + scenario_name: 'scenario.unsized', + default_datasets: ['harmbench'], + dataset_size_limit: { + default_scope: 'none', + default_count: null, + override_scope: 'per_dataset', + }, + default_dataset_summaries: [], + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + const row = await screen.findByTestId('scenario-card-scenario.unsized') + expect(within(row).getByText('Population counts unavailable')).toBeInTheDocument() + expect(within(row).getByRole('button', { name: 'Configure run' })).toBeInTheDocument() + }) + + it('keeps the authoritative default comparison values in the launch row', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ + scenario_name: 'airt.jailbreak', + scenario_version: 4, + default_technique: 'default', + default_techniques: ['prompt_sending', 'jailbreak_system_prompt'], + aggregate_techniques: ['default', 'easy'], + aggregate_technique_expansions: { + default: ['prompt_sending', 'jailbreak_system_prompt'], + easy: ['prompt_sending'], + }, + all_techniques: ['prompt_sending', 'jailbreak_system_prompt', 'flip'], + default_datasets: ['harmbench'], + dataset_size_limit: { + default_scope: 'none', + default_count: null, + override_scope: 'per_dataset', + }, + default_dataset_summaries: [ + { + name: 'harmbench', + kind: 'dataset', + logical_seed_group_count: 400, + selected_seed_group_count: 4, + configured_caps: [ + { + label: 'Jailbreak templates', + count: 2, + configured_on: 'configuration', + dataset_name: null, + }, + ], + selection_note: 'One incompatible group is excluded.', + }, + ], + default_run_size: { + version: 1, + status: 'conditional', + total_attack_count: null, + minimum_attack_count: 12, + maximum_attack_count: 20, + condition: 'target_capabilities', + components: [ + { + label: 'Default attacks', + count: 8, + factors: [ + { label: 'selected seed groups', count: 4 }, + { label: 'default techniques', count: 2 }, + ], + is_baseline: false, + note: null, + }, + ], + datasets: [ + { + name: 'harmbench', + kind: 'dataset', + logical_seed_group_count: 400, + selected_seed_group_count: 4, + configured_caps: [ + { + label: 'Jailbreak templates', + count: 2, + configured_on: 'configuration', + dataset_name: null, + }, + ], + selection_note: 'One incompatible group is excluded.', + }, + ], + adaptive_details: null, + note: 'Retries and internal turns are excluded.', + retries_included: false, + }, + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-airt.jailbreak') + const cells = within(row).getAllByRole('cell') + expect(within(cells[1]).getByRole('button', { name: 'Configure run' })).toBeInTheDocument() + expect(within(row).getByText('4 objectives')).toBeInTheDocument() + expect(within(row).getByText('harmbench · 400 available')).toBeInTheDocument() + expect(within(row).getByText('2 techniques')).toBeInTheDocument() + expect(within(row).getByText('12–20 planned attacks')).toBeInTheDocument() + expect(within(row).queryByText('default')).not.toBeInTheDocument() + expect(within(row).queryByText(/aggregate presets|compatible concrete/i)).not.toBeInTheDocument() + expect(within(row).queryByText(REMOVED_NORMAL_ESTIMATE_LABELS)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /show details|hide details/i })).not.toBeInTheDocument() + expect(screen.queryByRole('region', { name: /details/i })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioCatalog.tsx b/frontend/src/components/Scenarios/ScenarioCatalog.tsx new file mode 100644 index 0000000000..fd809350c0 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioCatalog.tsx @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { + Button, + Input, + mergeClasses, + MessageBar, + MessageBarBody, + Spinner, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + Text, +} from '@fluentui/react-components' +import { + ArrowSyncRegular, + SearchRegular, + SettingsRegular, +} from '@fluentui/react-icons' +import { Link, useNavigate } from 'react-router' + +import { scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { RegisteredScenario, ScenarioDatasetSummary } from '@/types' +import { fetchAllPages } from '@/utils/fetchAllPages' + +import { useScenarioCatalogStyles } from './ScenarioCatalog.styles' +import { + ScenarioRunEstimateSummary, +} from './ScenarioRunEstimate' +import { mapScenarioRunEstimate } from './scenarioRunEstimateAdapter' +import { techniqueSetName } from './scenarioTechniqueSets' + +/** Items requested per catalog page while paging through the full list. */ +const CATALOG_PAGE_SIZE = 200 + +function matchesSearch(scenario: RegisteredScenario, query: string): boolean { + if (!query) { + return true + } + const haystack = [ + scenario.scenario_name, + scenario.description, + scenario.description_markdown, + scenario.scenario_type, + scenario.default_technique, + ...scenario.default_techniques, + ...scenario.aggregate_techniques, + ...scenario.aggregate_techniques.map(techniqueSetName), + ...Object.values(scenario.aggregate_technique_expansions).flat(), + ...scenario.all_techniques, + ...scenario.default_datasets, + ...scenario.default_dataset_summaries.flatMap((dataset) => [ + dataset.name, + dataset.selection_note ?? '', + ...dataset.configured_caps.map((cap) => cap.label), + ]), + ] + .join(' ') + .toLowerCase() + return haystack.includes(query.toLowerCase()) +} + +function uniqueNames(names: string[]): string[] { + return [...new Set(names)] +} + +function formatCount(value: number): string { + return value.toLocaleString() +} + +function formatObjectiveCount(value: number): string { + return `${formatCount(value)} objective${value === 1 ? '' : 's'}` +} + +function DefaultDatasetSizeSummary({ + datasets, + hasDeclaredDatasets, +}: { + datasets: ScenarioDatasetSummary[] + hasDeclaredDatasets: boolean +}) { + const styles = useScenarioCatalogStyles() + + if (datasets.length === 0) { + return ( + + {hasDeclaredDatasets ? 'Population counts unavailable' : 'No default dataset'} + + ) + } + + if (datasets.length === 1) { + const dataset = datasets[0] + return ( +
+ {formatObjectiveCount(dataset.selected_seed_group_count)} + + {dataset.name} · {formatCount(dataset.logical_seed_group_count)} available + +
+ ) + } + + return ( + + {datasets + .map((dataset) => `${formatObjectiveCount(dataset.selected_seed_group_count)} · ${dataset.name}`) + .join(' · ')} + + ) +} + +interface ScenarioCatalogRowProps { + scenario: RegisteredScenario +} + +function ScenarioCatalogRow({ scenario }: ScenarioCatalogRowProps) { + const styles = useScenarioCatalogStyles() + const navigate = useNavigate() + const defaultConcreteTechniques = uniqueNames(scenario.default_techniques) + const estimateState = mapScenarioRunEstimate(scenario.default_run_size, 'default') + const scenarioPath = `/scenarios/${encodeURIComponent(scenario.scenario_name)}` + + return ( + + + + Scenario / purpose + +
+ + {scenario.scenario_name} + + {scenario.description} +
+
+ + + Configure + + + + + + Default dataset size + + 0} + /> + + + + Default techniques + + + {defaultConcreteTechniques.length === 0 + ? 'No default techniques' + : `${defaultConcreteTechniques.length} technique${defaultConcreteTechniques.length === 1 ? '' : 's'}`} + + + + + Default run size + + + +
+ ) +} + +export default function ScenarioCatalog() { + const styles = useScenarioCatalogStyles() + const [scenarios, setScenarios] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [refetchCount, setRefetchCount] = useState(0) + + useEffect(() => { + let cancelled = false + + fetchAllPages( + (cursor) => scenariosApi.listCatalog(CATALOG_PAGE_SIZE, cursor), + undefined, + (scenario) => scenario.scenario_name, + ) + .then((items) => { + if (cancelled) return + setScenarios(items) + setError(null) + }) + .catch((err: unknown) => { + if (cancelled) return + setScenarios([]) + setError(toApiError(err).detail) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [refetchCount]) + + const handleRetry = useCallback(() => { + setLoading(true) + setError(null) + setRefetchCount((count) => count + 1) + }, []) + + const filteredScenarios = useMemo( + () => scenarios.filter((scenario) => matchesSearch(scenario, query)), + [scenarios, query], + ) + + return ( +
+
+
+ + Scenarios + + + Browse registered scenarios and launch a run against a configured target. + + + A scenario packages objective datasets, technique sets or selected techniques, baseline policy, + and scenario-specific axes into a run plan. + +
+
+ } + placeholder="Search scenarios..." + value={query} + onChange={(_, data) => setQuery(data.value)} + aria-label="Search scenarios" + /> + +
+
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+ + {error} + + +
+ ) : scenarios.length === 0 ? ( +
+ No scenarios are registered + Register a scenario via your PyRIT initializers to see it here. +
+ ) : filteredScenarios.length === 0 ? ( +
+ No scenarios match "{query}" + Try a different search term. +
+ ) : ( +
+ + + + + Scenario / purpose + + + Configure + + + Default dataset size + + + Default techniques + + + Default run size + + + + + {filteredScenarios.map((scenario) => ( + + ))} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioDetail.styles.ts b/frontend/src/components/Scenarios/ScenarioDetail.styles.ts new file mode 100644 index 0000000000..f7521d1f0d --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioDetail.styles.ts @@ -0,0 +1,234 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { + MINIMUM_TOUCH_TARGET_SIZE, + mobileTouchTarget, + mobileTouchTargetHeight, + NARROW_VIEWPORT_QUERY, + TOUCH_INPUT_QUERY, +} from '@/styles/touchTargets' + +export const useScenarioDetailStyles = makeStyles({ + root: { + height: '100%', + width: '100%', + minWidth: 0, + padding: tokens.spacingVerticalXXL, + overflowX: 'hidden', + overflowY: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + [NARROW_VIEWPORT_QUERY]: { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + content: { + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: '80rem', + minWidth: 0, + margin: '0 auto', + gap: tokens.spacingVerticalL, + }, + backLink: { + alignSelf: 'flex-start', + }, + headerText: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + description: { + maxWidth: '75ch', + color: tokens.colorNeutralForeground2, + }, + layout: { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) minmax(18rem, 23rem)', + alignItems: 'start', + gap: tokens.spacingHorizontalXXL, + minWidth: 0, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: 'minmax(0, 1fr)', + gap: tokens.spacingVerticalXL, + }, + }, + formColumn: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + minWidth: 0, + }, + section: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + control: { + ...mobileTouchTargetHeight, + '& > select': { + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + '& > input': { + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + }, + checkboxGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + techniqueGroups: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + selectionControl: { + ...mobileTouchTargetHeight, + }, + resolvedMembers: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + paddingLeft: tokens.spacingHorizontalM, + }, + hint: { + color: tokens.colorNeutralForeground3, + }, + advancedSection: { + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + advancedFields: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + paddingTop: tokens.spacingVerticalS, + }, + dynamicParameters: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + touchTarget: { + ...mobileTouchTarget, + }, + centeredState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + minHeight: '20rem', + padding: tokens.spacingVerticalXXXL, + textAlign: 'center', + color: tokens.colorNeutralForeground3, + }, + numberInput: { + maxWidth: '10rem', + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + previewRail: { + position: 'sticky', + top: 0, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + minWidth: 0, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + [NARROW_VIEWPORT_QUERY]: { + position: 'static', + }, + }, + previewHeader: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + previewList: { + display: 'flex', + flexDirection: 'column', + gap: 0, + margin: 0, + }, + previewGroup: { + display: 'grid', + gridTemplateColumns: 'minmax(7rem, 38%) minmax(0, 1fr)', + gap: tokens.spacingHorizontalM, + padding: `${tokens.spacingVerticalM} 0`, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + '& > dt': { + color: tokens.colorNeutralForeground3, + fontWeight: tokens.fontWeightSemibold, + }, + '& > dd': { + minWidth: 0, + margin: 0, + overflowWrap: 'anywhere', + }, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: 'minmax(7rem, 35%) minmax(0, 1fr)', + }, + }, + previewStack: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + previewBadges: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalXXS, + }, + errorText: { + color: tokens.colorPaletteRedForeground1, + }, + parameterPreview: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + margin: 0, + }, + parameterPreviewRow: { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: tokens.spacingHorizontalS, + '& > dt': { + overflowWrap: 'anywhere', + }, + '& > dd': { + margin: 0, + fontWeight: tokens.fontWeightSemibold, + overflowWrap: 'anywhere', + }, + }, + estimateGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + paddingTop: tokens.spacingVerticalM, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + }, + previewActions: { + paddingTop: tokens.spacingVerticalM, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + }, + launchButton: { + width: '100%', + ...mobileTouchTargetHeight, + }, +}) diff --git a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx new file mode 100644 index 0000000000..4fcfc96fb9 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx @@ -0,0 +1,935 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { MemoryRouter, Route, Routes } from 'react-router' + +import { scenariosApi, targetsApi } from '@/services/api' +import type { + RegisteredScenario, + ScenarioDefaultRunSizeEstimate, + TargetInstance, +} from '@/types' + +import ScenarioDetail from './ScenarioDetail' + +jest.mock('@/services/api', () => ({ + scenariosApi: { + estimateRun: jest.fn(), + getScenario: jest.fn(), + startRun: jest.fn(), + }, + targetsApi: { + listTargets: jest.fn(), + }, +})) + +const mockGetScenario = scenariosApi.getScenario as jest.Mock +const mockEstimateRun = scenariosApi.estimateRun as jest.Mock +const mockStartRun = scenariosApi.startRun as jest.Mock +const mockListTargets = targetsApi.listTargets as jest.Mock + +const mockNavigate = jest.fn() +const RAW_IMAGE_HTML = ['<', 'img src=x onerror="alert(1)">'].join('') + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useNavigate: () => mockNavigate, +})) + +function makeScenario(overrides: Partial = {}): RegisteredScenario { + const description = overrides.description ?? 'Red teams a target.' + const defaultTechnique = overrides.default_technique ?? 'default_technique' + const aggregateTechniques = overrides.aggregate_techniques ?? ['default_technique'] + const defaultTechniques = overrides.default_techniques + ?? (aggregateTechniques.includes(defaultTechnique) ? ['crescendo'] : [defaultTechnique]) + return { + scenario_name: 'foundry.red_team_agent', + scenario_type: 'RedTeamAgentScenario', + scenario_version: 1, + aggregate_technique_expansions: overrides.aggregate_technique_expansions + ?? Object.fromEntries( + aggregateTechniques.map((name) => [name, name === defaultTechnique ? defaultTechniques : []]), + ), + all_techniques: ['default_technique', 'crescendo'], + default_datasets: ['harmbench'], + default_dataset_summaries: [], + baseline_policy: 'enabled', + include_baseline_by_default: true, + supported_parameters: [], + default_run_size: { + version: 1, + status: 'unavailable', + total_attack_count: null, + components: [], + datasets: [], + note: 'Default sizing is unavailable.', + retries_included: false, + }, + ...overrides, + description, + description_markdown: overrides.description_markdown ?? description, + default_technique: defaultTechnique, + default_techniques: defaultTechniques, + aggregate_techniques: aggregateTechniques, + } +} + +function makeTarget(name: string): TargetInstance { + return { + target_registry_name: name, + identifier: { class_name: 'OpenAIChatTarget', hash: `${name}-hash` }, + } +} + +function makeEstimate( + total: number | null, + status: ScenarioDefaultRunSizeEstimate['status'] = total === null ? 'conditional' : 'exact', +): ScenarioDefaultRunSizeEstimate { + return { + version: 1, + status, + total_attack_count: total, + components: total === null + ? [] + : [ + { + label: 'Configured attacks', + count: total, + factors: [], + is_baseline: false, + note: null, + }, + ], + datasets: [], + note: null, + retries_included: false, + } +} + +async function flushRenderedPromises(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function advanceTimers(milliseconds: number): Promise { + await act(async () => { + jest.advanceTimersByTime(milliseconds) + await Promise.resolve() + }) +} + +function renderDetail( + path: string, + props: Partial<{ + activeTarget: TargetInstance | null + labels: Record + onNavigate: (view: string) => void + }> = {}, +) { + const defaultProps = { + activeTarget: null, + labels: { operator: 'roakey' }, + onNavigate: jest.fn(), + } + const merged = { ...defaultProps, ...props } + return render( + + + + } + /> + + + , + ) +} + +describe('ScenarioDetail', () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetScenario.mockReset() + mockEstimateRun.mockReset() + mockListTargets.mockReset() + mockStartRun.mockReset() + mockListTargets.mockResolvedValue({ + items: [makeTarget('target-a'), makeTarget('target-b')], + pagination: { limit: 200, has_more: false }, + }) + mockGetScenario.mockResolvedValue(makeScenario()) + mockEstimateRun.mockReturnValue(new Promise(() => {})) + mockStartRun.mockResolvedValue({ scenario_result_id: 'sr-default' }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('shows a loading state while fetching', () => { + mockGetScenario.mockReturnValue(new Promise(() => {})) + mockListTargets.mockReturnValue(new Promise(() => {})) + renderDetail('/scenarios/foundry.red_team_agent') + expect(screen.getByText('Loading scenario...')).toBeInTheDocument() + }) + + it('decodes the scenario name from the URL exactly once', async () => { + renderDetail('/scenarios/foundry.red_team_agent'); + await screen.findByTestId('scenario-target-select') + expect(mockGetScenario).toHaveBeenCalledWith('foundry.red_team_agent') + }) + + it('decodes a slash-bearing encoded scenario name back to the original', async () => { + renderDetail('/scenarios/foundry%2Fred_team_agent') + await waitFor(() => expect(mockGetScenario).toHaveBeenCalledWith('foundry/red_team_agent')) + }) + + it('preserves a literal percent sequence in a scenario registry name', async () => { + renderDetail('/scenarios/discount%2550') + await waitFor(() => expect(mockGetScenario).toHaveBeenCalledWith('discount%50')) + }) + + it('handles a malformed percent sequence without throwing during render', async () => { + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + mockGetScenario.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 404, data: { detail: 'not found' } }, + }) + renderDetail('/scenarios/%zz') + expect(await screen.findByTestId('scenario-not-found')).toBeInTheDocument() + expect(mockGetScenario).toHaveBeenCalledWith('%zz') + consoleWarn.mockRestore() + }) + + it('shows a distinct not-found state for a 404, with a link back to the catalog', async () => { + mockGetScenario.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 404, data: { detail: 'not found' } }, + }) + + renderDetail('/scenarios/missing.scenario') + + expect(await screen.findByTestId('scenario-not-found')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /back to scenarios/i })).toHaveAttribute('href', '/scenarios') + expect(screen.queryByTestId('scenario-error')).not.toBeInTheDocument() + }) + + it('shows a generic error state with retry for a non-404 failure', async () => { + const user = userEvent.setup() + mockGetScenario + .mockRejectedValueOnce({ isAxiosError: true, response: { status: 500, data: { detail: 'boom' } } }) + .mockResolvedValueOnce(makeScenario()) + + renderDetail('/scenarios/foundry.red_team_agent') + + expect(await screen.findByTestId('scenario-error')).toBeInTheDocument() + expect(screen.getByText('boom')).toBeInTheDocument() + expect(screen.queryByTestId('scenario-not-found')).not.toBeInTheDocument() + + await user.click(screen.getByTestId('retry-btn')) + expect(await screen.findByTestId('scenario-target-select')).toBeInTheDocument() + }) + + it('shows a no-targets state directing to Configuration when none are registered', async () => { + const onNavigate = jest.fn() + mockListTargets.mockResolvedValueOnce({ items: [], pagination: { limit: 200, has_more: false } }) + + renderDetail('/scenarios/foundry.red_team_agent', { onNavigate }) + + const user = userEvent.setup() + expect(await screen.findByTestId('no-targets-state')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Configure target' })) + expect(onNavigate).toHaveBeenCalledWith('config') + }) + + it('defaults the target selector to the active target when it is among the fetched targets', async () => { + renderDetail('/scenarios/foundry.red_team_agent', { activeTarget: makeTarget('target-b') }) + + expect(await screen.findByTestId('scenario-target-select')).toHaveValue('target-b') + }) + + it('defaults the target selector to the first fetched target when there is no matching active target', async () => { + renderDetail('/scenarios/foundry.red_team_agent') + + expect(await screen.findByTestId('scenario-target-select')).toHaveValue('target-a') + }) + + it('exposes the configuration form and run preview as ordered landmarks', async () => { + renderDetail('/scenarios/foundry.red_team_agent') + + expect(await screen.findByRole('form', { name: 'Scenario run configuration' })).toBeInTheDocument() + expect(screen.getByRole('complementary', { name: 'Run preview' })).toBeInTheDocument() + }) + + it('debounces preview requests and aborts the superseded request', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + renderDetail('/scenarios/foundry.red_team_agent') + await flushRenderedPromises() + + expect(screen.getByTestId('scenario-target-select')).toBeInTheDocument() + expect(mockEstimateRun).not.toHaveBeenCalled() + + await advanceTimers(300) + expect(mockEstimateRun).toHaveBeenCalledTimes(1) + const firstSignal = mockEstimateRun.mock.calls[0][2] as AbortSignal + expect(firstSignal.aborted).toBe(false) + + await user.selectOptions(screen.getByTestId('scenario-target-select'), 'target-b') + expect(firstSignal.aborted).toBe(true) + await user.selectOptions(screen.getByTestId('scenario-target-select'), 'target-a') + await user.selectOptions(screen.getByTestId('scenario-target-select'), 'target-b') + + await advanceTimers(299) + expect(mockEstimateRun).toHaveBeenCalledTimes(1) + await advanceTimers(1) + expect(mockEstimateRun).toHaveBeenCalledTimes(2) + expect(mockEstimateRun).toHaveBeenLastCalledWith( + 'foundry.red_team_agent', + expect.objectContaining({ target_name: 'target-b' }), + expect.any(AbortSignal), + ) + }) + + it('ignores an out-of-order estimate response even when the request promise does not abort', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + let resolveFirst: (estimate: ScenarioDefaultRunSizeEstimate) => void = () => {} + let resolveSecond: (estimate: ScenarioDefaultRunSizeEstimate) => void = () => {} + mockEstimateRun + .mockReturnValueOnce(new Promise((resolve) => { + resolveFirst = resolve + })) + .mockReturnValueOnce(new Promise((resolve) => { + resolveSecond = resolve + })) + + renderDetail('/scenarios/foundry.red_team_agent') + await flushRenderedPromises() + await advanceTimers(300) + await user.selectOptions(screen.getByTestId('scenario-target-select'), 'target-b') + await advanceTimers(300) + + resolveSecond(makeEstimate(12)) + await flushRenderedPromises() + const preview = screen.getByRole('complementary', { name: 'Run preview' }) + expect(within(preview).getByText('12 planned attacks')).toBeInTheDocument() + + resolveFirst(makeEstimate(8)) + await flushRenderedPromises() + expect(within(preview).getByText('12 planned attacks')).toBeInTheDocument() + expect(within(preview).queryByText('8 planned attacks')).not.toBeInTheDocument() + }) + + it('keeps the last good estimate and entered state after a transient preview failure', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + mockEstimateRun + .mockResolvedValueOnce(makeEstimate(8)) + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 503, data: { detail: 'Preview service unavailable' } }, + }) + + renderDetail('/scenarios/foundry.red_team_agent') + await flushRenderedPromises() + await advanceTimers(300) + await flushRenderedPromises() + expect(screen.getByText('8 planned attacks')).toBeInTheDocument() + + await user.selectOptions(screen.getByTestId('scenario-target-select'), 'target-b') + await advanceTimers(300) + await flushRenderedPromises() + + const preview = screen.getByRole('complementary', { name: 'Run preview' }) + expect(within(preview).getByText('target-b')).toBeInTheDocument() + expect(within(preview).getByText('Previous estimate')).toBeInTheDocument() + expect(within(preview).getByText('8 planned attacks')).toBeInTheDocument() + expect(within(preview).getByText('Preview service unavailable')).toBeInTheDocument() + expect(screen.getByTestId('scenario-target-select')).toHaveValue('target-b') + expect(screen.getByTestId('launch-scenario-btn')).not.toBeDisabled() + }) + + it('does not request a preview while the custom technique selection is empty', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + renderDetail('/scenarios/foundry.red_team_agent') + await flushRenderedPromises() + + await user.click(screen.getByTestId('technique-crescendo')) + await user.click(screen.getByTestId('technique-crescendo')) + await advanceTimers(300) + + expect(mockEstimateRun).not.toHaveBeenCalled() + expect(screen.getByTestId('launch-scenario-btn')).toBeDisabled() + expect(screen.getByText('Complete the required configuration to request an estimate.')) + .toBeInTheDocument() + }) + + it('renders a backend conditional estimate without inventing a total', async () => { + jest.useFakeTimers() + mockEstimateRun.mockResolvedValue(makeEstimate(null)) + renderDetail('/scenarios/foundry.red_team_agent') + await flushRenderedPromises() + await advanceTimers(300) + await flushRenderedPromises() + + const preview = screen.getByRole('complementary', { name: 'Run preview' }) + expect(within(preview).getByText('Conditional estimate')).toBeInTheDocument() + expect(within(preview).getByText('Total depends on configuration')).toBeInTheDocument() + expect(within(preview).queryByText(/planned attacks/)).not.toBeInTheDocument() + }) + + it('renders MyST literals through the shared safe Markdown renderer', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + description: 'Configure this scenario.', + description_markdown: `Set \`\`num_jailbreaks\`\`.\n\n${RAW_IMAGE_HTML}unsafe`, + }), + ) + renderDetail('/scenarios/foundry.red_team_agent') + + const description = await screen.findByTestId('scenario-detail-description') + expect(within(description).getByText('num_jailbreaks').tagName).toBe('CODE') + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect( + within(description).getByText((content: string) => content.includes(`${RAW_IMAGE_HTML}unsafe`)), + ).toBeInTheDocument() + }) + + it('initializes the technique selection from default_technique', async () => { + renderDetail('/scenarios/foundry.red_team_agent') + + await screen.findByTestId('scenario-target-select') + expect(screen.getByTestId('technique-default_technique')).toBeChecked() + expect(screen.getByTestId('technique-crescendo')).not.toBeChecked() + }) + + it('shows catalog-provided aggregate members before the configured estimate resolves', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + default_technique: 'default', + default_techniques: ['prompt_sending', 'jailbreak_system_prompt'], + aggregate_techniques: ['default'], + aggregate_technique_expansions: { + default: ['prompt_sending', 'jailbreak_system_prompt'], + }, + all_techniques: ['prompt_sending', 'jailbreak_system_prompt'], + }), + ) + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + const preview = screen.getByRole('complementary', { name: 'Run preview' }) + expect(within(preview).getByText( + 'Resolves to prompt_sending, jailbreak_system_prompt', + )).toBeInTheDocument() + expect(within(preview).getByText('Loading backend run estimate...')).toBeInTheDocument() + }) + + it('switches from the default preset to a multi-technique custom selection', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + aggregate_techniques: ['default_technique', 'all_garak'], + all_techniques: ['default_technique', 'crescendo', 'prompt_sending', 'all_garak'], + }), + ) + const user = userEvent.setup() + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + // 'all_garak' is both an aggregate and (accidentally) listed under all_techniques — + // it must render exactly once (deduped), under the aggregate group. + expect(screen.getAllByTestId('technique-all_garak')).toHaveLength(1) + + await user.click(screen.getByTestId('technique-crescendo')) + expect(screen.getByTestId('technique-default_technique')).not.toBeChecked() + expect(screen.getByTestId('technique-crescendo')).toBeChecked() + + await user.click(screen.getByTestId('technique-prompt_sending')) + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalled()) + const request = mockStartRun.mock.calls[0][0] + expect(request.techniques).toEqual(['crescendo', 'prompt_sending']) + expect(new Set(request.techniques).size).toBe(request.techniques.length) + }) + + it('selecting a preset replaces the custom concrete list', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + aggregate_techniques: ['default_technique', 'all_garak'], + all_techniques: ['default_technique', 'crescendo'], + }), + ) + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('technique-crescendo')) + await user.click(screen.getByTestId('technique-all_garak')) + expect(screen.getByTestId('technique-all_garak')).toBeChecked() + expect(screen.getByTestId('technique-crescendo')).not.toBeChecked() + + await user.click(screen.getByTestId('launch-scenario-btn')) + await waitFor(() => expect(mockStartRun).toHaveBeenCalled()) + expect(mockStartRun.mock.calls[0][0].techniques).toEqual(['all_garak']) + }) + + it('initializes a concrete default as custom and allows adding another concrete technique', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + default_technique: 'prompt_sending', + aggregate_techniques: ['all_garak'], + all_techniques: ['prompt_sending', 'crescendo'], + }), + ) + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + expect(screen.getByTestId('technique-prompt_sending')).toBeChecked() + await user.click(screen.getByTestId('technique-crescendo')) + expect(screen.getByTestId('technique-prompt_sending')).toBeChecked() + expect(screen.getByTestId('technique-crescendo')).toBeChecked() + + await user.click(screen.getByTestId('launch-scenario-btn')) + await waitFor(() => expect(mockStartRun).toHaveBeenCalled()) + expect(mockStartRun.mock.calls[0][0].techniques).toEqual(['prompt_sending', 'crescendo']) + }) + + it('keeps an explicit invalid custom state when the last concrete technique is removed', async () => { + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('technique-crescendo')) + await user.click(screen.getByTestId('technique-crescendo')) + + expect(await screen.findByRole('alert')).toHaveTextContent('Select at least one technique.') + expect(screen.getByTestId('technique-default_technique')).not.toBeChecked() + expect(screen.getByTestId('launch-scenario-btn')).toBeDisabled() + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it('defaults the baseline checkbox from include_baseline_by_default when enabled, and allows editing', async () => { + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + const checkbox = screen.getByTestId('baseline-checkbox') + expect(checkbox).toBeChecked() + + await user.click(checkbox) + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalled()) + expect(mockStartRun.mock.calls[0][0].include_baseline).toBe(false) + }) + + it('defaults the baseline checkbox to unchecked when the policy is disabled with include_baseline_by_default false', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ baseline_policy: 'disabled', include_baseline_by_default: false }), + ) + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + expect(screen.getByTestId('baseline-checkbox')).not.toBeChecked() + }) + + it('disables and forces the baseline checkbox false when the policy is forbidden', async () => { + mockGetScenario.mockResolvedValue(makeScenario({ baseline_policy: 'forbidden' })) + const user = userEvent.setup() + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + const checkbox = screen.getByTestId('baseline-checkbox') + expect(checkbox).toBeDisabled() + expect(checkbox).not.toBeChecked() + + await user.click(screen.getByTestId('launch-scenario-btn')) + await waitFor(() => expect(mockStartRun).toHaveBeenCalled()) + expect(mockStartRun.mock.calls[0][0].include_baseline).toBe(false) + }) + + it('renders scenario-specific parameters and omits common/opaque parameter names', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + supported_parameters: [ + { name: 'objective_target', type_name: 'any', required: false, default: null, choices: null, is_list: false }, + { name: 'max_concurrency', type_name: 'int', required: false, default: null, choices: null, is_list: false }, + { name: 'technique_converters', type_name: 'any', required: false, default: null, choices: null, is_list: false }, + { name: 'custom_flag', type_name: 'bool', required: false, default: null, choices: null, is_list: false }, + { name: 'iterations', type_name: 'int', required: false, default: '3', choices: null, is_list: false }, + ], + }), + ) + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + expect(screen.queryByTestId('scenario-param-objective_target')).not.toBeInTheDocument() + expect(screen.queryByTestId('scenario-param-max_concurrency')).not.toBeInTheDocument() + expect(screen.queryByTestId('scenario-param-technique_converters')).not.toBeInTheDocument() + expect(screen.getByTestId('scenario-param-custom_flag')).toBeInTheDocument() + expect(screen.getByTestId('scenario-param-iterations')).toHaveValue(3) + }) + + it('reports a validation error for an invalid custom parameter and blocks submission', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + supported_parameters: [ + { name: 'iterations', type_name: 'int', required: false, default: null, choices: null, is_list: false }, + ], + }), + ) + const user = userEvent.setup() + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + // A number-typed HTML input rejects non-numeric characters outright, so a + // decimal (a valid *number* but not a valid *integer*) exercises the same + // coercion/validation path a real user could actually trigger. + fireEvent.change(screen.getByTestId('scenario-param-iterations'), { target: { value: '1.5' } }) + await user.click(screen.getByTestId('launch-scenario-btn')) + + expect(await screen.findByRole('alert')).toHaveTextContent('iterations must be an integer.') + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it('omits the dataset override and max dataset size when left blank, sending default concurrency/retries', async () => { + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByRole('button', { name: 'Advanced options' })) + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalled()) + const request = mockStartRun.mock.calls[0][0] + expect(request).not.toHaveProperty('dataset_names') + expect(request).not.toHaveProperty('max_dataset_size') + expect(request.max_concurrency).toBe(10) + expect(request.max_retries).toBe(0) + }) + + it('includes dataset override and max dataset size when provided', async () => { + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByRole('button', { name: 'Advanced options' })) + await user.type(screen.getByTestId('dataset-override-input'), 'ds_a, ds_b') + await user.type(screen.getByTestId('max-dataset-size-input'), '25') + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalled()) + const request = mockStartRun.mock.calls[0][0] + expect(request.dataset_names).toEqual(['ds_a', 'ds_b']) + expect(request.max_dataset_size).toBe(25) + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith( + 'foundry.red_team_agent', + expect.objectContaining({ + target_name: 'target-a', + techniques: ['default_technique'], + dataset_names: ['ds_a', 'ds_b'], + max_dataset_size: 25, + include_baseline: true, + }), + expect.any(AbortSignal), + )) + expect(mockEstimateRun.mock.calls.at(-1)?.[1]).not.toHaveProperty('labels') + }) + + it('rejects a non-positive-integer max dataset size', async () => { + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByRole('button', { name: 'Advanced options' })) + await user.type(screen.getByTestId('max-dataset-size-input'), '0') + await user.click(screen.getByTestId('launch-scenario-btn')) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Max dataset size must be a positive integer.', + ) + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it('validates advanced concurrency and retry bounds before launching', async () => { + const user = userEvent.setup() + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByRole('button', { name: 'Advanced options' })) + fireEvent.change(screen.getByTestId('max-concurrency-input'), { target: { value: '500' } }) + fireEvent.blur(screen.getByTestId('max-concurrency-input')) + await user.click(screen.getByTestId('launch-scenario-btn')) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Max concurrency must be an integer from 1 to 100.', + ) + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it('sends the exact RunScenarioRequest payload and attaches labels automatically', async () => { + const user = userEvent.setup() + mockStartRun.mockResolvedValueOnce({ scenario_result_id: 'sr-1' }) + + renderDetail('/scenarios/foundry.red_team_agent', { labels: { operator: 'roakey', operation: 'op1' } }) + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalledTimes(1)) + expect(mockStartRun).toHaveBeenCalledWith({ + scenario_name: 'foundry.red_team_agent', + target_name: 'target-a', + techniques: ['default_technique'], + max_concurrency: 10, + max_retries: 0, + include_baseline: true, + labels: { operator: 'roakey', operation: 'op1' }, + }) + }) + + it('sends only prompt_sending for the Jailbreak regression and displays the backend total of 8', async () => { + const user = userEvent.setup() + mockGetScenario.mockResolvedValue( + makeScenario({ + scenario_name: 'airt.jailbreak', + scenario_type: 'Jailbreak', + description: 'Runs jailbreak templates.', + default_technique: 'default', + default_techniques: ['prompt_sending', 'jailbreak_system_prompt'], + aggregate_techniques: ['default'], + aggregate_technique_expansions: { + default: ['prompt_sending', 'jailbreak_system_prompt'], + }, + all_techniques: ['prompt_sending', 'jailbreak_system_prompt', 'flip'], + default_datasets: ['harmbench'], + include_baseline_by_default: true, + supported_parameters: [ + { + name: 'num_jailbreaks', + type_name: 'int', + required: false, + default: null, + choices: null, + is_list: false, + }, + { + name: 'num_jailbreak_attempts', + type_name: 'int', + required: false, + default: '1', + choices: null, + is_list: false, + }, + ], + }), + ) + mockEstimateRun.mockResolvedValue({ + version: 1, + status: 'exact', + total_attack_count: 8, + components: [ + { + label: 'Prompt sending', + count: 8, + factors: [ + { label: 'selected seed groups', count: 4 }, + { label: 'concrete techniques', count: 1 }, + { label: 'jailbreak templates', count: 2 }, + { label: 'attempts', count: 1 }, + ], + is_baseline: false, + note: null, + }, + ], + datasets: [ + { + name: 'harmbench', + kind: 'dataset', + logical_seed_group_count: 5, + selected_seed_group_count: 4, + configured_caps: [ + { + label: 'Jailbreak templates', + count: 2, + configured_on: 'configuration', + dataset_name: null, + }, + ], + selection_note: 'One incompatible group is excluded.', + }, + ], + note: 'The backend total is authoritative.', + retries_included: false, + }) + + renderDetail('/scenarios/airt.jailbreak') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('technique-prompt_sending')) + await user.clear(screen.getByTestId('scenario-param-num_jailbreaks')) + await user.type(screen.getByTestId('scenario-param-num_jailbreaks'), '2') + await user.clear(screen.getByTestId('scenario-param-num_jailbreak_attempts')) + await user.type(screen.getByTestId('scenario-param-num_jailbreak_attempts'), '1') + await user.click(screen.getByTestId('baseline-checkbox')) + + const expectedRunRequest = { + scenario_name: 'airt.jailbreak', + target_name: 'target-a', + techniques: ['prompt_sending'], + max_concurrency: 10, + max_retries: 0, + include_baseline: false, + labels: { operator: 'roakey' }, + scenario_params: { + num_jailbreaks: 2, + num_jailbreak_attempts: 1, + }, + } + const expectedEstimateRequest = { + target_name: 'target-a', + techniques: ['prompt_sending'], + include_baseline: false, + scenario_params: { + num_jailbreaks: 2, + num_jailbreak_attempts: 1, + }, + } + + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith( + 'airt.jailbreak', + expectedEstimateRequest, + expect.any(AbortSignal), + )) + const preview = screen.getByRole('complementary', { name: 'Run preview' }) + expect(within(preview).getByText('prompt_sending')).toBeInTheDocument() + expect(within(preview).getAllByText('harmbench')).toHaveLength(2) + expect(within(preview).getByText('Not included')).toBeInTheDocument() + expect(within(preview).getByText('8 planned attacks')).toBeInTheDocument() + expect(within(preview).getByText('Jailbreak templates: 2 (configuration)')).toBeInTheDocument() + expect(within(preview).getByText('2')).toBeInTheDocument() + + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalledTimes(1)) + expect(mockStartRun).toHaveBeenCalledWith(expectedRunRequest) + expect(mockStartRun.mock.calls[0][0].techniques).not.toContain('default') + expect(expectedEstimateRequest.techniques).toEqual(expectedRunRequest.techniques) + expect(expectedEstimateRequest.scenario_params).toEqual(expectedRunRequest.scenario_params) + expect(expectedEstimateRequest.include_baseline).toBe(expectedRunRequest.include_baseline) + expect(expectedEstimateRequest).not.toHaveProperty('labels') + }) + + it('navigates to the scenario-history route with the encoded run id on success', async () => { + const user = userEvent.setup() + mockStartRun.mockResolvedValueOnce({ scenario_result_id: 'sr/1' }) + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith( + '/scenario-history/sr%2F1', + expect.objectContaining({ state: expect.objectContaining({ scenarioName: 'foundry.red_team_agent' }) }), + ), + ) + }) + + it('shows an API error in a MessageBar and re-enables the button on failure', async () => { + const user = userEvent.setup() + mockStartRun.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 400, data: { detail: 'Invalid target' } }, + }) + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('launch-scenario-btn')) + + expect(await screen.findByText('Invalid target')).toBeInTheDocument() + expect(screen.getByTestId('launch-scenario-btn')).not.toBeDisabled() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('guards against a duplicate submit from a fast double click', async () => { + let resolveStartRun: (value: { scenario_result_id: string }) => void = () => {} + mockStartRun.mockReturnValue( + new Promise((resolve) => { + resolveStartRun = resolve + }), + ) + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + const button = screen.getByTestId('launch-scenario-btn') + // Fire two rapid clicks without waiting between them (userEvent.click awaits internally, + // so dispatch native clicks to simulate a true double-click within one tick). + act(() => { + button.click() + button.click() + }) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalledTimes(1)) + resolveStartRun({ scenario_result_id: 'sr-1' }) + await waitFor(() => expect(button).not.toBeDisabled()) + }) + + it('preserves entered values and preview content after a failed submission', async () => { + const user = userEvent.setup() + mockGetScenario.mockResolvedValue( + makeScenario({ + supported_parameters: [ + { + name: 'attempts', + type_name: 'int', + required: false, + default: 1, + choices: null, + is_list: false, + }, + ], + }), + ) + mockStartRun.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 400, data: { detail: 'boom' } }, + }) + + renderDetail('/scenarios/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.selectOptions(screen.getByTestId('scenario-target-select'), 'target-b') + await user.click(screen.getByTestId('technique-crescendo')) + await user.clear(screen.getByTestId('scenario-param-attempts')) + await user.type(screen.getByTestId('scenario-param-attempts'), '3') + await user.click(screen.getByTestId('launch-scenario-btn')) + + await screen.findByText('boom') + expect(screen.getByTestId('scenario-target-select')).toHaveValue('target-b') + expect(screen.getByTestId('technique-crescendo')).toBeChecked() + expect(screen.getByTestId('technique-default_technique')).not.toBeChecked() + expect(screen.getByTestId('scenario-param-attempts')).toHaveValue(3) + + const preview = screen.getByRole('complementary', { name: 'Run preview' }) + expect(within(preview).getByText('target-b')).toBeInTheDocument() + expect(within(preview).getByText('crescendo')).toBeInTheDocument() + expect(within(preview).getByText('harmbench')).toBeInTheDocument() + expect(within(preview).getByText('3')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioDetail.tsx b/frontend/src/components/Scenarios/ScenarioDetail.tsx new file mode 100644 index 0000000000..408a2ef146 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx @@ -0,0 +1,1081 @@ +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react' + +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + Badge, + Button, + Checkbox, + Field, + Input, + MessageBar, + MessageBarBody, + Radio, + RadioGroup, + Select, + Spinner, + SpinButton, + Text, +} from '@fluentui/react-components' +import { ArrowLeftRegular, ArrowSyncRegular, SettingsRegular } from '@fluentui/react-icons' +import { Link, useNavigate, useParams } from 'react-router' + +import MarkdownContent from '@/components/Markdown/MarkdownContent' +import ParameterField from '@/components/Parameters/ParameterField' +import { + buildParametersFromForm, + getInitialFormValues, + type ParameterFormValue, +} from '@/components/Parameters/parameterForm' +import type { ViewName } from '@/components/Sidebar/Navigation' +import { scenariosApi, targetsApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { + Parameter, + RegisteredScenario, + RunScenarioRequest, + ScenarioRunEstimateResult, + ScenarioRunSizeEstimateRequest, + ScenarioRunEstimateState, + TargetInstance, +} from '@/types' +import { fetchAllPages } from '@/utils/fetchAllPages' +import { routerPathParamValue } from '@/utils/routeParams' + +import { useScenarioDetailStyles } from './ScenarioDetail.styles' +import { ScenarioRunEstimateDetails } from './ScenarioRunEstimate' +import { normalizeScenarioMarkdown } from './scenarioMarkdown' +import { mapScenarioRunEstimate } from './scenarioRunEstimateAdapter' + +/** Items requested per target page while paging through the full list. */ +const TARGET_PAGE_SIZE = 200 + +/** + * Common/opaque parameters every scenario declares via + * `Scenario._common_scenario_parameters` — the launch form already exposes a + * purpose-built control for each of these (target, techniques, datasets, + * labels, concurrency, retries, baseline), and `technique_converters` has no + * UI at all. They're hidden from the dynamic scenario-specific parameter list. + */ +const COMMON_SCENARIO_PARAMETER_NAMES = new Set([ + 'objective_target', + 'scenario_techniques', + 'technique_converters', + 'dataset_config', + 'memory_labels', + 'max_concurrency', + 'max_retries', + 'include_baseline', +]) + +const MIN_MAX_CONCURRENCY = 1 +const MAX_MAX_CONCURRENCY = 100 +const MIN_MAX_RETRIES = 0 +const MAX_MAX_RETRIES = 20 +const DEFAULT_MAX_CONCURRENCY = 10 +const DEFAULT_MAX_RETRIES = 0 +const ESTIMATE_DEBOUNCE_MS = 300 + +/** Resolves a Fluent `SpinButton` change event to a numeric value, preferring the parsed `value` over the raw `displayValue`. */ +function resolveSpinButtonValue(data: { value?: number | null; displayValue?: string }, previous: number): number { + if (typeof data.value === 'number') { + return data.value + } + const parsed = data.displayValue !== undefined ? Number(data.displayValue) : NaN + return Number.isFinite(parsed) ? parsed : previous +} + +type LoadStatus = 'loading' | 'success' | 'not-found' | 'error' + +type TechniqueSelection = + | { + mode: 'preset' + preset: string + } + | { + mode: 'custom' + techniques: string[] + } + +interface TechniqueOptions { + presets: string[] + concrete: string[] + defaultSelection: TechniqueSelection +} + +/** Options rendered for technique selection: exclusive presets first, then concrete techniques. */ +function uniqueTechniqueOptions(scenario: RegisteredScenario): TechniqueOptions { + const aggregateNames = new Set(scenario.aggregate_techniques) + const defaultIsPreset = aggregateNames.has(scenario.default_technique) + const seenPresets = new Set() + const presets: string[] = [] + for (const name of scenario.aggregate_techniques) { + if (!seenPresets.has(name)) { + seenPresets.add(name) + presets.push(name) + } + } + const seenConcrete = new Set() + const concrete: string[] = [] + const concreteCandidates = defaultIsPreset + ? scenario.all_techniques + : [scenario.default_technique, ...scenario.all_techniques] + for (const name of concreteCandidates) { + if (!aggregateNames.has(name) && !seenConcrete.has(name)) { + seenConcrete.add(name) + concrete.push(name) + } + } + const defaultSelection: TechniqueSelection = defaultIsPreset + ? { mode: 'preset', preset: scenario.default_technique } + : { mode: 'custom', techniques: [scenario.default_technique] } + return { presets, concrete, defaultSelection } +} + +function selectedTechniqueNames(selection: TechniqueSelection): string[] { + return selection.mode === 'preset' ? [selection.preset] : selection.techniques +} + +function parseDatasetNames(datasetOverride: string): string[] { + return datasetOverride + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) +} + +function formatParameterPreview(value: ParameterFormValue | undefined): string { + if (Array.isArray(value)) { + return value.length > 0 ? value.join(', ') : 'Not set' + } + return value?.trim() || 'Not set' +} + +interface BuildRunRequestInput { + scenario: RegisteredScenario + targetName: string + techniques: string[] + dynamicParameters: Parameter[] + scenarioParamValues: Record + datasetOverride: string + maxDatasetSize: string + maxConcurrency: number + maxRetries: number + includeBaseline: boolean + labels: Record +} + +type BuildRunRequestResult = + | { + ok: true + request: RunScenarioRequest + } + | { + ok: false + error: string + } + +type SuccessfulEstimateResult = Extract< + ScenarioRunEstimateResult, + { status: 'available' | 'conditional' } +> + +type EstimateRequestState = + | { + status: 'resolved' + requestKey: string + result: ScenarioRunEstimateResult + } + | { + status: 'error' + requestKey: string + error: string + } + +function buildRunRequest({ + scenario, + targetName, + techniques, + dynamicParameters, + scenarioParamValues, + datasetOverride, + maxDatasetSize, + maxConcurrency, + maxRetries, + includeBaseline, + labels, +}: BuildRunRequestInput): BuildRunRequestResult { + if (!targetName) { + return { ok: false, error: 'Select a target.' } + } + if (techniques.length === 0) { + return { ok: false, error: 'Select at least one technique.' } + } + + let scenarioParams: Record | null = null + if (dynamicParameters.length > 0) { + const result = buildParametersFromForm(dynamicParameters, scenarioParamValues) + if (!result.ok) { + return result + } + scenarioParams = result.parameters + } + + let maxDatasetSizeValue: number | undefined + const trimmedMaxDatasetSize = maxDatasetSize.trim() + if (trimmedMaxDatasetSize.length > 0) { + const parsed = Number(trimmedMaxDatasetSize) + if (!Number.isInteger(parsed) || parsed < 1) { + return { ok: false, error: 'Max dataset size must be a positive integer.' } + } + maxDatasetSizeValue = parsed + } + if ( + !Number.isInteger(maxConcurrency) + || maxConcurrency < MIN_MAX_CONCURRENCY + || maxConcurrency > MAX_MAX_CONCURRENCY + ) { + return { + ok: false, + error: `Max concurrency must be an integer from ${MIN_MAX_CONCURRENCY} to ${MAX_MAX_CONCURRENCY}.`, + } + } + if ( + !Number.isInteger(maxRetries) + || maxRetries < MIN_MAX_RETRIES + || maxRetries > MAX_MAX_RETRIES + ) { + return { + ok: false, + error: `Max retries must be an integer from ${MIN_MAX_RETRIES} to ${MAX_MAX_RETRIES}.`, + } + } + + const datasetNames = parseDatasetNames(datasetOverride) + const request: RunScenarioRequest = { + scenario_name: scenario.scenario_name, + target_name: targetName, + techniques, + max_concurrency: maxConcurrency, + max_retries: maxRetries, + include_baseline: includeBaseline, + labels, + } + if (datasetNames.length > 0) { + request.dataset_names = datasetNames + } + if (maxDatasetSizeValue !== undefined) { + request.max_dataset_size = maxDatasetSizeValue + } + if (scenarioParams) { + request.scenario_params = scenarioParams + } + return { ok: true, request } +} + +function buildEstimateRequest(request: RunScenarioRequest): ScenarioRunSizeEstimateRequest { + const estimateRequest: ScenarioRunSizeEstimateRequest = { + target_name: request.target_name, + techniques: request.techniques, + include_baseline: request.include_baseline, + } + if (request.dataset_names !== undefined) { + estimateRequest.dataset_names = request.dataset_names + } + if (request.max_dataset_size !== undefined) { + estimateRequest.max_dataset_size = request.max_dataset_size + } + if (request.dataset_filters !== undefined) { + estimateRequest.dataset_filters = request.dataset_filters + } + if (request.scenario_params !== undefined) { + estimateRequest.scenario_params = request.scenario_params + } + return estimateRequest +} + +interface ScenarioDetailProps { + activeTarget: TargetInstance | null + labels: Record + onNavigate: (view: ViewName) => void +} + +export default function ScenarioDetail(props: ScenarioDetailProps) { + const { scenarioName: encodedScenarioName } = useParams<{ scenarioName: string }>() + // Keying on the raw URL param forces a full remount (and state reset to the + // initial "loading" values) whenever the route navigates from one scenario + // detail page directly to another. + return +} + +interface ScenarioDetailContentProps extends ScenarioDetailProps { + encodedScenarioName: string | undefined +} + +function ScenarioDetailContent({ + encodedScenarioName, + activeTarget, + labels, + onNavigate, +}: ScenarioDetailContentProps) { + const styles = useScenarioDetailStyles() + const decodedScenarioName = routerPathParamValue(encodedScenarioName) + + const [scenario, setScenario] = useState(null) + const [scenarioStatus, setScenarioStatus] = useState('loading') + const [scenarioError, setScenarioError] = useState(null) + const [targets, setTargets] = useState(null) + const [targetsError, setTargetsError] = useState(null) + const [refetchCount, setRefetchCount] = useState(0) + + useEffect(() => { + let cancelled = false + scenariosApi + .getScenario(decodedScenarioName) + .then((data) => { + if (cancelled) return + setScenario(data) + setScenarioStatus('success') + setScenarioError(null) + }) + .catch((err: unknown) => { + if (cancelled) return + const apiError = toApiError(err) + setScenario(null) + setScenarioStatus(apiError.status === 404 ? 'not-found' : 'error') + setScenarioError(apiError.status === 404 ? null : apiError.detail) + }) + return () => { + cancelled = true + } + }, [decodedScenarioName, refetchCount]) + + useEffect(() => { + let cancelled = false + fetchAllPages( + (cursor) => targetsApi.listTargets(TARGET_PAGE_SIZE, cursor), + undefined, + (target) => target.target_registry_name, + ) + .then((items) => { + if (cancelled) return + setTargets(items) + setTargetsError(null) + }) + .catch((err: unknown) => { + if (cancelled) return + setTargets([]) + setTargetsError(toApiError(err).detail) + }) + return () => { + cancelled = true + } + }, [refetchCount]) + + const handleRetry = (): void => { + setScenarioStatus('loading') + setScenarioError(null) + setTargets(null) + setTargetsError(null) + setRefetchCount((count) => count + 1) + } + + if (scenarioStatus === 'loading' || targets === null) { + return ( +
+
+ +
+
+ ) + } + + if (scenarioStatus === 'not-found') { + return ( +
+
+ + Back to scenarios + +
+ Scenario "{decodedScenarioName}" was not found + It may have been renamed or is no longer registered. +
+
+
+ ) + } + + if (scenarioStatus === 'error' || targetsError) { + return ( +
+
+ + Back to scenarios + +
+ + {scenarioError ?? targetsError} + + +
+
+
+ ) + } + + // scenarioStatus === 'success' from here on; both values are set together. + if (!scenario) { + return null + } + + if (targets.length === 0) { + return ( +
+
+ + Back to scenarios + +
+ No targets configured + Configure a target before launching a scenario. + +
+
+
+ ) + } + + return ( + + ) +} + +interface ScenarioLaunchFormProps { + scenario: RegisteredScenario + targets: TargetInstance[] + activeTarget: TargetInstance | null + labels: Record +} + +function ScenarioLaunchForm({ scenario, targets, activeTarget, labels }: ScenarioLaunchFormProps) { + const styles = useScenarioDetailStyles() + const navigate = useNavigate() + const formId = `scenario-launch-${encodeURIComponent(scenario.scenario_name).replace(/%/g, '-')}` + + const { presets, concrete, defaultSelection } = useMemo( + () => uniqueTechniqueOptions(scenario), + [scenario], + ) + const dynamicParameters = useMemo( + () => scenario.supported_parameters.filter( + (parameter) => !COMMON_SCENARIO_PARAMETER_NAMES.has(parameter.name), + ), + [scenario.supported_parameters], + ) + const isBaselineForbidden = scenario.baseline_policy === 'forbidden' + + const [targetName, setTargetName] = useState(() => { + if (activeTarget && targets.some((target) => + target.target_registry_name === activeTarget.target_registry_name)) { + return activeTarget.target_registry_name + } + return targets[0].target_registry_name + }) + const [techniqueSelection, setTechniqueSelection] = useState(() => defaultSelection) + const [baselineChecked, setBaselineChecked] = useState( + () => !isBaselineForbidden && scenario.include_baseline_by_default, + ) + const [datasetOverride, setDatasetOverride] = useState('') + const [maxDatasetSize, setMaxDatasetSize] = useState('') + const [maxConcurrency, setMaxConcurrency] = useState(DEFAULT_MAX_CONCURRENCY) + const [maxRetries, setMaxRetries] = useState(DEFAULT_MAX_RETRIES) + const [scenarioParamValues, setScenarioParamValues] = useState>(() => + getInitialFormValues(dynamicParameters), + ) + const [validationError, setValidationError] = useState(null) + const [apiError, setApiError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [estimateRequestState, setEstimateRequestState] = useState(null) + const [lastGoodEstimate, setLastGoodEstimate] = useState(null) + // Synchronous guard against a double-submit racing ahead of the state update. + const isSubmittingRef = useRef(false) + const estimateSequenceRef = useRef(0) + + const techniques = useMemo( + () => selectedTechniqueNames(techniqueSelection), + [techniqueSelection], + ) + const requestResult = useMemo( + () => buildRunRequest({ + scenario, + targetName, + techniques, + dynamicParameters, + scenarioParamValues, + datasetOverride, + maxDatasetSize, + maxConcurrency, + maxRetries, + includeBaseline: isBaselineForbidden ? false : baselineChecked, + labels, + }), + [ + baselineChecked, + datasetOverride, + dynamicParameters, + isBaselineForbidden, + labels, + maxConcurrency, + maxDatasetSize, + maxRetries, + scenario, + scenarioParamValues, + targetName, + techniques, + ], + ) + const estimateRequest = useMemo( + () => requestResult.ok ? buildEstimateRequest(requestResult.request) : null, + [requestResult], + ) + const estimateRequestKey = useMemo( + () => estimateRequest === null + ? null + : JSON.stringify({ scenarioName: scenario.scenario_name, request: estimateRequest }), + [estimateRequest, scenario.scenario_name], + ) + + useEffect(() => { + if (estimateRequest === null || estimateRequestKey === null) { + return + } + + const requestSequence = estimateSequenceRef.current + 1 + estimateSequenceRef.current = requestSequence + const controller = new AbortController() + + const debounceTimer = window.setTimeout(() => { + scenariosApi + .estimateRun(scenario.scenario_name, estimateRequest, controller.signal) + .then((response) => { + if ( + controller.signal.aborted + || requestSequence !== estimateSequenceRef.current + ) { + return + } + const result = mapScenarioRunEstimate(response, 'request') + setEstimateRequestState({ + status: 'resolved', + requestKey: estimateRequestKey, + result, + }) + if (result.status === 'available' || result.status === 'conditional') { + setLastGoodEstimate(result) + } + }) + .catch((err: unknown) => { + if ( + controller.signal.aborted + || requestSequence !== estimateSequenceRef.current + ) { + return + } + setEstimateRequestState({ + status: 'error', + requestKey: estimateRequestKey, + error: toApiError(err).detail, + }) + }) + }, ESTIMATE_DEBOUNCE_MS) + + return () => { + window.clearTimeout(debounceTimer) + controller.abort() + } + }, [estimateRequest, estimateRequestKey, scenario.scenario_name]) + + let estimateState: ScenarioRunEstimateState + if (!requestResult.ok) { + estimateState = { + status: 'unavailable', + scope: 'request', + label: 'Complete the required configuration to request an estimate.', + note: requestResult.error, + } + } else if ( + estimateRequestState?.requestKey === estimateRequestKey + && estimateRequestState.status === 'resolved' + ) { + estimateState = estimateRequestState.result + } else if ( + estimateRequestState?.requestKey === estimateRequestKey + && estimateRequestState.status === 'error' + ) { + estimateState = lastGoodEstimate + ? { + status: 'stale', + estimate: lastGoodEstimate.estimate, + label: 'Showing the last successful estimate.', + error: estimateRequestState.error, + } + : { + status: 'unavailable', + scope: 'request', + label: 'The backend estimate could not be refreshed.', + note: estimateRequestState.error, + } + } else if (lastGoodEstimate) { + estimateState = { + status: 'refreshing', + estimate: lastGoodEstimate.estimate, + label: 'Updating for the current configuration…', + } + } else { + estimateState = { status: 'loading', scope: 'request' } + } + + const handlePresetChange = (preset: string): void => { + setTechniqueSelection({ mode: 'preset', preset }) + setValidationError(null) + } + + const handleConcreteChange = (name: string, checked: boolean): void => { + setTechniqueSelection((current) => { + if (checked) { + if (current.mode === 'preset') { + return { mode: 'custom', techniques: [name] } + } + return current.techniques.includes(name) + ? current + : { mode: 'custom', techniques: [...current.techniques, name] } + } + if (current.mode === 'preset') { + return current + } + return { + mode: 'custom', + techniques: current.techniques.filter((technique) => technique !== name), + } + }) + setValidationError(null) + } + + const updateScenarioParam = (name: string, value: ParameterFormValue): void => { + setScenarioParamValues((current) => ({ ...current, [name]: value })) + } + + const handleSubmit = async (): Promise => { + if (isSubmittingRef.current) { + return + } + + setApiError(null) + if (!requestResult.ok) { + setValidationError(requestResult.error) + return + } + + isSubmittingRef.current = true + setSubmitting(true) + setValidationError(null) + + try { + const summary = await scenariosApi.startRun(requestResult.request) + navigate(`/scenario-history/${encodeURIComponent(summary.scenario_result_id)}`, { + state: { scenarioName: scenario.scenario_name }, + }) + } catch (err) { + setApiError(toApiError(err).detail) + } finally { + isSubmittingRef.current = false + setSubmitting(false) + } + } + + const handleFormSubmit = (event: FormEvent): void => { + event.preventDefault() + void handleSubmit() + } + + const techniqueSelectionInvalid = + techniqueSelection.mode === 'custom' && techniqueSelection.techniques.length === 0 + const previewDatasets = parseDatasetNames(datasetOverride) + const effectiveDatasets = previewDatasets.length > 0 ? previewDatasets : scenario.default_datasets + const presetMembers = techniqueSelection.mode === 'preset' + ? ( + scenario.aggregate_technique_expansions[techniqueSelection.preset] + ?? (techniqueSelection.preset === scenario.default_technique + ? scenario.default_techniques + : []) + ) + : [] + + return ( +
+
+ + Back to scenarios + + +
+ + {scenario.scenario_name} + + +
+ +
+
+ {validationError && ( + + {validationError} + + )} + {apiError && ( + + {apiError} + + )} + +
+ Target + + + +
+ +
+ + Techniques + + + Selecting a preset replaces any custom list. Selecting the first individual technique + switches to a custom list and clears the preset. + +
+ {presets.length > 0 ? ( + + handlePresetChange(data.value)} + aria-label="Aggregate preset" + > + {presets.map((name) => ( + + ))} + + + ) : ( + + No aggregate presets are registered for this scenario. + + )} + {techniqueSelection.mode === 'preset' && ( +
+ Backend-resolved preset members + {presetMembers.length > 0 ? ( +
+ {presetMembers.map((name) => ( + {name} + ))} +
+ ) : ( + + No concrete members were supplied for this preset. + + )} +
+ )} + + {concrete.length > 0 ? ( +
+ {concrete.map((name) => ( + handleConcreteChange(name, data.checked === true)} + data-testid={`technique-${name}`} + /> + ))} +
+ ) : ( + + No concrete techniques are registered for custom selection. + + )} +
+
+
+ +
+ + Baseline + + + setBaselineChecked(data.checked === true)} + data-testid="baseline-checkbox" + /> + + {isBaselineForbidden && ( + + This scenario forbids a baseline comparison run. + + )} +
+ + {dynamicParameters.length > 0 && ( +
+ + Scenario parameters + +
+ {dynamicParameters.map((parameter) => ( + + ))} +
+
+ )} + + + + Advanced options + +
+ + setDatasetOverride(data.value)} + placeholder={scenario.default_datasets.join(', ') || undefined} + data-testid="dataset-override-input" + /> + + + setMaxDatasetSize(data.value)} + data-testid="max-dataset-size-input" + /> + + + setMaxConcurrency(resolveSpinButtonValue(data, maxConcurrency))} + data-testid="max-concurrency-input" + /> + + + setMaxRetries(resolveSpinButtonValue(data, maxRetries))} + data-testid="max-retries-input" + /> + +
+
+
+
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunEstimate.styles.ts b/frontend/src/components/Scenarios/ScenarioRunEstimate.styles.ts new file mode 100644 index 0000000000..1edd185b6a --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunEstimate.styles.ts @@ -0,0 +1,138 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useScenarioRunEstimateStyles = makeStyles({ + summary: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + }, + summaryHeader: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalXS, + }, + total: { + color: tokens.colorNeutralForeground1, + fontVariantNumeric: 'tabular-nums', + }, + muted: { + color: tokens.colorNeutralForeground3, + }, + details: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + minWidth: 0, + }, + detailGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + minWidth: 0, + }, + componentList: { + display: 'grid', + gap: tokens.spacingVerticalS, + margin: 0, + padding: 0, + listStyleType: 'none', + }, + component: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + paddingLeft: tokens.spacingHorizontalS, + borderLeft: `${tokens.strokeWidthThick} solid ${tokens.colorNeutralStroke2}`, + minWidth: 0, + overflowWrap: 'anywhere', + }, + componentHeader: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalS, + }, + componentCount: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXS, + flexShrink: 0, + fontVariantNumeric: 'tabular-nums', + }, + factorList: { + display: 'flex', + flexWrap: 'wrap', + gap: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + margin: 0, + padding: 0, + listStyleType: 'none', + color: tokens.colorNeutralForeground2, + }, + datasetList: { + display: 'grid', + gap: tokens.spacingVerticalS, + }, + dataset: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusSmall, + minWidth: 0, + overflowWrap: 'anywhere', + }, + datasetHeader: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalXS, + }, + countList: { + display: 'grid', + gap: tokens.spacingVerticalXXS, + margin: 0, + }, + countRow: { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: tokens.spacingHorizontalS, + fontVariantNumeric: 'tabular-nums', + '& dd': { + margin: 0, + fontWeight: tokens.fontWeightSemibold, + }, + }, + capGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + capList: { + display: 'grid', + gap: tokens.spacingVerticalXXS, + margin: 0, + paddingLeft: tokens.spacingHorizontalL, + }, + formula: { + display: 'block', + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + overflowWrap: 'anywhere', + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusSmall, + }, + staleNotice: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorPaletteDarkOrangeForeground1, + backgroundColor: tokens.colorPaletteDarkOrangeBackground1, + borderRadius: tokens.borderRadiusSmall, + }, +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx b/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx new file mode 100644 index 0000000000..aa69a1f15f --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx @@ -0,0 +1,160 @@ +import type { ReactNode } from 'react' + +import { render, screen } from '@testing-library/react' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { ScenarioDefaultRunSizeEstimate, ScenarioRunEstimateState } from '@/types' + +import { + ScenarioRunEstimateDetails, + ScenarioRunEstimateSummary, +} from './ScenarioRunEstimate' +import { mapScenarioRunEstimate } from './scenarioRunEstimateAdapter' + +function TestWrapper({ children }: { children: ReactNode }) { + return {children} +} + +const EXACT_ESTIMATE: ScenarioDefaultRunSizeEstimate = { + version: 1, + status: 'exact', + total_attack_count: 8, + components: [ + { + label: 'Prompt sending', + count: 8, + factors: [ + { label: 'selected seed groups', count: 4 }, + { label: 'jailbreak templates', count: 2 }, + { label: 'techniques', count: 1 }, + { label: 'attempts', count: 1 }, + ], + is_baseline: false, + note: 'One planned attack per selected objective and template.', + }, + { + label: 'Baseline attack', + // Deliberately differs from the authoritative total when added to the + // first component so this test detects accidental client-side summing. + count: 2, + factors: [], + is_baseline: true, + note: 'Fixture component used to guard the authoritative total.', + }, + ], + datasets: [ + { + name: 'harmbench', + kind: 'dataset', + logical_seed_group_count: 4, + selected_seed_group_count: 4, + configured_caps: [ + { + label: 'Jailbreak templates', + count: 2, + configured_on: 'configuration', + dataset_name: null, + }, + ], + selection_note: 'Four compatible objective groups selected.', + }, + ], + note: 'The backend total is authoritative.', + retries_included: false, +} + +describe('ScenarioRunEstimate', () => { + it('renders the authoritative total, ordered factors, dataset counts, caps, and notes', () => { + const state = mapScenarioRunEstimate(EXACT_ESTIMATE, 'request') + + render( + + + , + ) + + expect(screen.getByText('8 planned attacks')).toBeInTheDocument() + expect(screen.queryByText('10 planned attacks')).not.toBeInTheDocument() + expect(screen.getByText('Prompt sending')).toBeInTheDocument() + expect(screen.getByText('Baseline attack')).toBeInTheDocument() + expect(screen.getByText('Baseline')).toBeInTheDocument() + expect(screen.getByText('× 4 selected seed groups')).toBeInTheDocument() + expect(screen.getByText('× 2 jailbreak templates')).toBeInTheDocument() + expect(screen.getByText('harmbench')).toBeInTheDocument() + expect(screen.getByText('Jailbreak templates: 2 (configuration)')).toBeInTheDocument() + expect(screen.getByText('Four compatible objective groups selected.')).toBeInTheDocument() + expect(screen.getByText( + 'Prompt sending: 4 selected seed groups × 2 jailbreak templates × 1 techniques × 1 attempts = 8 + Baseline attack: 2; backend total = 8', + )).toBeInTheDocument() + expect(screen.getByText('The backend total is authoritative.')).toBeInTheDocument() + expect(screen.getByText('Retries are not included. Estimate schema v1.')).toBeInTheDocument() + }) + + it('supports loading, conditional null totals, unavailable, and stale states', () => { + const loading: ScenarioRunEstimateState = { status: 'loading', scope: 'request' } + const { rerender } = render( + + + , + ) + expect(screen.getByText('Loading backend run estimate...')).toBeInTheDocument() + + const conditional = mapScenarioRunEstimate({ + ...EXACT_ESTIMATE, + status: 'conditional', + total_attack_count: null, + components: [], + datasets: [], + note: null, + }, 'default') + rerender( + + + , + ) + expect(screen.getByText('Conditional estimate')).toBeInTheDocument() + expect(screen.getByText('Total depends on configuration')).toBeInTheDocument() + expect(screen.getByText('Default configuration')).toBeInTheDocument() + expect(screen.getByText( + 'No additive components supplied; backend total is conditional', + )).toBeInTheDocument() + + const unavailable = mapScenarioRunEstimate({ + ...EXACT_ESTIMATE, + status: 'unavailable', + total_attack_count: null, + components: [], + datasets: [], + note: 'Target capability is not available.', + }, 'request') + rerender( + + + + , + ) + expect(screen.getAllByText('Estimate unavailable')).toHaveLength(2) + expect(screen.getByText('Configured run size unavailable')).toBeInTheDocument() + expect(screen.getByText('Target capability is not available.')).toBeInTheDocument() + + const exact = mapScenarioRunEstimate(EXACT_ESTIMATE, 'request') + if (exact.status !== 'available') { + throw new Error('Expected exact estimate to map to an available state.') + } + const stale: ScenarioRunEstimateState = { + status: 'stale', + estimate: exact.estimate, + label: 'Showing the last successful estimate.', + error: 'Preview service timed out.', + } + rerender( + + + , + ) + expect(screen.getByText('Previous estimate')).toBeInTheDocument() + expect(screen.getByText('8 planned attacks')).toBeInTheDocument() + expect(screen.getByText('Showing the last successful estimate.')).toBeInTheDocument() + expect(screen.getByText('Preview service timed out.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx b/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx new file mode 100644 index 0000000000..50fb74931a --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx @@ -0,0 +1,354 @@ +import { Badge, Spinner, Text } from '@fluentui/react-components' + +import type { + ScenarioRunEstimate, + ScenarioRunEstimateComponent, + ScenarioRunEstimateState, +} from '@/types' + +import { useScenarioRunEstimateStyles } from './ScenarioRunEstimate.styles' + +interface ScenarioRunEstimateSummaryProps { + state: ScenarioRunEstimateState +} + +interface ScenarioRunEstimateDetailsProps { + state: ScenarioRunEstimateState + idPrefix?: string +} + +function stateEstimate(state: ScenarioRunEstimateState): ScenarioRunEstimate | undefined { + switch (state.status) { + case 'available': + case 'conditional': + case 'refreshing': + case 'stale': + return state.estimate + default: + return undefined + } +} + +function scopeLabel(state: ScenarioRunEstimateState): string { + const scope = state.status === 'loading' || state.status === 'unavailable' + ? state.scope + : state.estimate.scope + return scope === 'default' ? 'Default configuration' : 'Current configuration' +} + +function statusLabel(state: ScenarioRunEstimateState): string { + switch (state.status) { + case 'loading': + return 'Loading estimate' + case 'available': + return 'Backend estimate' + case 'conditional': + return 'Conditional estimate' + case 'refreshing': + return 'Updating estimate' + case 'stale': + return 'Previous estimate' + case 'unavailable': + return 'Estimate unavailable' + } +} + +function statusColor(state: ScenarioRunEstimateState): 'brand' | 'warning' | 'subtle' { + switch (state.status) { + case 'available': + case 'refreshing': + return 'brand' + case 'conditional': + case 'stale': + return 'warning' + default: + return 'subtle' + } +} + +function formatEstimateValue(value: number): string { + return value.toLocaleString() +} + +function countLabel(value: number, singular: string, plural: string): string { + return `${formatEstimateValue(value)} ${value === 1 ? singular : plural}` +} + +function formatPlannedAttackSummary(estimate: ScenarioRunEstimate): string { + if (estimate.total !== null) { + return countLabel(estimate.total, 'planned attack', 'planned attacks') + } + if (estimate.minimum != null && estimate.maximum != null) { + return estimate.minimum === estimate.maximum + ? countLabel(estimate.minimum, 'planned attack', 'planned attacks') + : `${formatEstimateValue(estimate.minimum)}–${formatEstimateValue(estimate.maximum)} planned attacks` + } + if (estimate.maximum != null) { + return `Up to ${countLabel(estimate.maximum, 'planned attack', 'planned attacks')}` + } + if (estimate.minimum != null) { + return `At least ${countLabel(estimate.minimum, 'planned attack', 'planned attacks')}` + } + return 'Total depends on configuration' +} + +function formatProgressUnitSummary(estimate: ScenarioRunEstimate): string { + if (estimate.total !== null) { + return countLabel(estimate.total, 'progress unit', 'progress units') + } + if (estimate.minimum != null && estimate.maximum != null) { + return estimate.minimum === estimate.maximum + ? countLabel(estimate.minimum, 'progress unit', 'progress units') + : `${formatEstimateValue(estimate.minimum)}–${formatEstimateValue(estimate.maximum)} progress units` + } + if (estimate.maximum != null) { + return `Up to ${countLabel(estimate.maximum, 'progress unit', 'progress units')}` + } + if (estimate.minimum != null) { + return `At least ${countLabel(estimate.minimum, 'progress unit', 'progress units')}` + } + return 'Progress units are confirmed at launch.' +} + +function baselineCount(estimate: ScenarioRunEstimate): number { + return estimate.components + .filter((component) => component.isBaseline) + .reduce((sum, component) => sum + component.count, 0) +} + +function formatEstimateSummary(estimate: ScenarioRunEstimate): string { + if (!estimate.adaptiveDetails) { + return formatPlannedAttackSummary(estimate) + } + const attackAttemptUpperBound = estimate.adaptiveDetails.techniqueAttemptCountUpperBound + + baselineCount(estimate) + const attemptSummary = `up to ${countLabel( + attackAttemptUpperBound, + 'attack attempt', + 'attack attempts', + )}` + const hasPlannedAttackBound = estimate.total !== null + || estimate.minimum != null + || estimate.maximum != null + return hasPlannedAttackBound + ? `${attemptSummary} · ${formatProgressUnitSummary(estimate)}` + : `${countLabel(estimate.adaptiveDetails.objectiveCount, 'objective', 'objectives')} · ${attemptSummary}` +} + +function formatComponentFormula(component: ScenarioRunEstimateComponent): string { + if (component.factors.length === 0) { + return `${component.label}: ${formatEstimateValue(component.count)}` + } + const factors = component.factors + .map((factor) => `${formatEstimateValue(factor.count)} ${factor.label}`) + .join(' × ') + return `${component.label}: ${factors} = ${formatEstimateValue(component.count)}` +} + +function formatBackendFormula(estimate: ScenarioRunEstimate): string { + const components = estimate.components.length > 0 + ? estimate.components.map(formatComponentFormula).join(' + ') + : 'No additive components supplied' + const total = estimate.total === null + ? 'backend total is conditional' + : `backend total = ${formatEstimateValue(estimate.total)}` + return `${components}; ${total}` +} + +export function ScenarioRunEstimateSummary({ state }: ScenarioRunEstimateSummaryProps) { + const styles = useScenarioRunEstimateStyles() + const estimate = stateEstimate(state) + + return ( +
+
+ {statusLabel(state)} + {estimate && ( + + {formatEstimateSummary(estimate)} + + )} +
+ {scopeLabel(state)} +
+ ) +} + +function EstimateComponents({ + estimate, + idPrefix, +}: { + estimate: ScenarioRunEstimate + idPrefix: string +}) { + const styles = useScenarioRunEstimateStyles() + const headingId = `${idPrefix}-components` + + return ( +
+ + Planned components + + {estimate.components.length === 0 ? ( + + No additive components supplied by the backend. + + ) : ( +
    + {estimate.components.map((component) => ( +
  1. +
    + {component.label} +
    + {component.isBaseline && ( + Baseline + )} + {formatEstimateValue(component.count)} +
    +
    + {component.factors.length > 0 && ( +
      + {component.factors.map((factor) => ( +
    • + + × {formatEstimateValue(factor.count)} {factor.label} + +
    • + ))} +
    + )} + {component.note && ( + {component.note} + )} +
  2. + ))} +
+ )} +
+ ) +} + +function EstimateDatasets({ + estimate, + idPrefix, +}: { + estimate: ScenarioRunEstimate + idPrefix: string +}) { + const styles = useScenarioRunEstimateStyles() + const headingId = `${idPrefix}-datasets` + + return ( +
+ + Dataset populations + + {estimate.datasets.length === 0 ? ( + + No dataset population details supplied by the backend. + + ) : ( +
+ {estimate.datasets.map((dataset) => ( +
+
+ {dataset.name} + {dataset.kind} +
+
+
+
Logical seed groups
+
{formatEstimateValue(dataset.logicalSeedGroupCount)}
+
+
+
Selected seed groups
+
{formatEstimateValue(dataset.selectedSeedGroupCount)}
+
+
+ {dataset.configuredCaps.length > 0 && ( +
+ Configured caps +
    + {dataset.configuredCaps.map((cap) => ( +
  • + + {cap.label}: {formatEstimateValue(cap.count)} + {' '}({cap.configuredOn}{cap.datasetName ? `: ${cap.datasetName}` : ''}) + +
  • + ))} +
+
+ )} + {dataset.selectionNote && ( + {dataset.selectionNote} + )} +
+ ))} +
+ )} +
+ ) +} + +export function ScenarioRunEstimateDetails({ + state, + idPrefix = 'scenario-run-estimate', +}: ScenarioRunEstimateDetailsProps) { + const styles = useScenarioRunEstimateStyles() + + if (state.status === 'loading') { + return ( +
+ + {scopeLabel(state)} +
+ ) + } + + if (state.status === 'unavailable') { + return ( +
+ + {state.label} + {state.note && {state.note}} +
+ ) + } + + const { estimate } = state + return ( +
+ + {state.status === 'refreshing' && ( + {state.label} + )} + {state.status === 'stale' && ( +
+ {state.label} + {state.error} +
+ )} + + +
+ + Backend formula + + {formatBackendFormula(estimate)} +
+
+ + Estimate notes + + + {estimate.note ?? 'No additional note supplied by the backend.'} + + + Retries are {estimate.retriesIncluded ? 'included' : 'not included'}. + {' '}Estimate schema v{estimate.version}. + +
+
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunStarted.styles.ts b/frontend/src/components/Scenarios/ScenarioRunStarted.styles.ts new file mode 100644 index 0000000000..a405d923c1 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunStarted.styles.ts @@ -0,0 +1,44 @@ +import { makeStyles, tokens } from '@fluentui/react-components' +import { NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' + +export const useScenarioRunStartedStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + minWidth: 0, + maxWidth: '40rem', + padding: tokens.spacingVerticalXXL, + overflowX: 'hidden', + overflowY: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + gap: tokens.spacingVerticalM, + [NARROW_VIEWPORT_QUERY]: { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + backLink: { + alignSelf: 'flex-start', + }, + hint: { + color: tokens.colorNeutralForeground3, + }, + section: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + centeredState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalXXL, + }, +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunStarted.test.tsx b/frontend/src/components/Scenarios/ScenarioRunStarted.test.tsx new file mode 100644 index 0000000000..567483cf45 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunStarted.test.tsx @@ -0,0 +1,139 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { MemoryRouter, Route, Routes } from 'react-router' + +import { scenariosApi } from '@/services/api' + +import ScenarioRunStarted from './ScenarioRunStarted' + +jest.mock('@/services/api', () => ({ + scenariosApi: { + getRun: jest.fn(), + }, +})) + +const mockGetRun = scenariosApi.getRun as jest.Mock + +function renderShell(path: string, state?: unknown) { + return render( + + + + } /> + + + , + ) +} + +function makeRunSummary(overrides: Partial> = {}) { + return { + scenario_result_id: 'sr-1', + scenario_name: 'foundry.red_team_agent', + scenario_version: 0, + status: 'IN_PROGRESS', + created_at: '2026-02-15T00:00:00Z', + updated_at: '2026-02-15T00:00:00Z', + techniques_used: [], + total_attacks: 0, + completed_attacks: 0, + objective_achieved_rate: 0, + failed_attacks: [], + attack_retries: [], + total_retries: 0, + labels: {}, + ...overrides, + } +} + +describe('ScenarioRunStarted', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders an accessible heading and the scenario result id', async () => { + mockGetRun.mockResolvedValueOnce(makeRunSummary()) + + renderShell('/scenario-history/sr-1') + + expect(screen.getByRole('heading', { name: 'Scenario run started' })).toBeInTheDocument() + expect(screen.getByText('sr-1')).toBeInTheDocument() + await screen.findByTestId('run-status') + }) + + it('decodes a percent-encoded scenario result id from the URL and fetches by the decoded id', async () => { + mockGetRun.mockResolvedValueOnce(makeRunSummary({ scenario_result_id: 'sr/1' })) + + renderShell('/scenario-history/sr%2F1') + + await waitFor(() => expect(mockGetRun).toHaveBeenCalledWith('sr/1')) + expect(screen.getByText('sr/1')).toBeInTheDocument() + }) + + it('shows a loading state before the fetch resolves', () => { + mockGetRun.mockReturnValue(new Promise(() => {})) + renderShell('/scenario-history/sr-1') + expect(screen.getByText('Loading run status...')).toBeInTheDocument() + }) + + it('shows the run status once loaded', async () => { + mockGetRun.mockResolvedValueOnce(makeRunSummary({ status: 'COMPLETED' })) + + renderShell('/scenario-history/sr-1') + + expect(await screen.findByTestId('run-status-value')).toHaveTextContent('COMPLETED') + }) + + it('shows an error state with retry on failure, and recovers after retry', async () => { + const user = userEvent.setup() + mockGetRun + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(makeRunSummary()) + + renderShell('/scenario-history/sr-1') + + expect(await screen.findByTestId('run-error')).toBeInTheDocument() + expect(screen.getByText('boom')).toBeInTheDocument() + + await user.click(screen.getByTestId('retry-btn')) + + expect(await screen.findByTestId('run-status')).toBeInTheDocument() + expect(mockGetRun).toHaveBeenCalledTimes(2) + }) + + it('does not poll — it fetches the run exactly once per mount', async () => { + mockGetRun.mockResolvedValueOnce(makeRunSummary()) + renderShell('/scenario-history/sr-1') + + await screen.findByTestId('run-status') + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(mockGetRun).toHaveBeenCalledTimes(1) + }) + + it('shows the scenario name from location state before the fetch resolves', () => { + mockGetRun.mockReturnValue(new Promise(() => {})) + + renderShell('/scenario-history/sr-1', { scenarioName: 'foundry.red_team_agent' }) + + // The loading spinner is showing, but the run id itself is already visible from the URL. + expect(screen.getByText('sr-1')).toBeInTheDocument() + }) + + it('works as a direct deep link with no location state at all', async () => { + mockGetRun.mockResolvedValueOnce(makeRunSummary()) + + renderShell('/scenario-history/sr-1') + + expect(await screen.findByTestId('run-status')).toBeInTheDocument() + expect(screen.getByText(/foundry\.red_team_agent/)).toBeInTheDocument() + }) + + it('links back to the scenario catalog', async () => { + mockGetRun.mockResolvedValueOnce(makeRunSummary()) + renderShell('/scenario-history/sr-1') + + expect(screen.getByRole('link', { name: /back to scenarios/i })).toHaveAttribute('href', '/scenarios') + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunStarted.tsx b/frontend/src/components/Scenarios/ScenarioRunStarted.tsx new file mode 100644 index 0000000000..0c5930b358 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunStarted.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react' + +import { Button, MessageBar, MessageBarBody, Spinner, Text } from '@fluentui/react-components' +import { ArrowLeftRegular, ArrowSyncRegular } from '@fluentui/react-icons' +import { Link, useLocation, useParams } from 'react-router' + +import { scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { ScenarioRunSummary } from '@/types' +import { routerPathParamValue } from '@/utils/routeParams' + +import { useScenarioRunStartedStyles } from './ScenarioRunStarted.styles' + +type LoadStatus = 'loading' | 'success' | 'error' + +/** Optional state forwarded by the launch form's `navigate()` call — shows a scenario name before the fetch resolves. */ +interface ScenarioRunLocationState { + scenarioName?: string +} + +/** + * Minimal acknowledgement shell shown right after launching a scenario run. + * + * Fetches the run once (no polling) to confirm it exists and show its + * current status; it intentionally does not aggregate or poll progress — + * that belongs to a full run-history view, out of scope here. + */ +export default function ScenarioRunStarted() { + const { scenarioResultId: encodedId } = useParams<{ scenarioResultId: string }>() + // Keying on the raw URL param forces a full remount (and state reset to the + // initial "loading" values) if the route ever navigates from one run id + // directly to another, without needing to reset state from inside an effect. + return +} + +interface ScenarioRunStartedContentProps { + encodedId: string | undefined +} + +function ScenarioRunStartedContent({ encodedId }: ScenarioRunStartedContentProps) { + const styles = useScenarioRunStartedStyles() + const location = useLocation() + const locationState = location.state as ScenarioRunLocationState | null + const decodedId = routerPathParamValue(encodedId) + + const [run, setRun] = useState(null) + const [status, setStatus] = useState('loading') + const [error, setError] = useState(null) + const [refetchCount, setRefetchCount] = useState(0) + + useEffect(() => { + let cancelled = false + scenariosApi + .getRun(decodedId) + .then((data) => { + if (cancelled) return + setRun(data) + setStatus('success') + setError(null) + }) + .catch((err: unknown) => { + if (cancelled) return + setRun(null) + setStatus('error') + setError(toApiError(err).detail) + }) + return () => { + cancelled = true + } + }, [decodedId, refetchCount]) + + const handleRetry = (): void => { + setStatus('loading') + setError(null) + setRefetchCount((count) => count + 1) + } + + const displayScenarioName = run?.scenario_name ?? locationState?.scenarioName + + return ( +
+ + Back to scenarios + + + Scenario run started + + Run ID: {decodedId} + + + {status === 'loading' && ( +
+ +
+ )} + + {status === 'error' && ( +
+ + {error} + + +
+ )} + + {status === 'success' && run && ( +
+ {displayScenarioName && ( + Scenario: {displayScenarioName} + )} + + Status: {run.status} + +
+ )} +
+ ) +} diff --git a/frontend/src/components/Scenarios/scenarioMarkdown.test.ts b/frontend/src/components/Scenarios/scenarioMarkdown.test.ts new file mode 100644 index 0000000000..b8941efc9c --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioMarkdown.test.ts @@ -0,0 +1,53 @@ +import { normalizeScenarioMarkdown } from './scenarioMarkdown' + +describe('normalizeScenarioMarkdown', () => { + it('normalizes only double-backtick prose literals without rebuilding whitespace', () => { + const source = [ + 'Jailbreak details', + '', + 'Set ``num_jailbreaks`` before launch.', + '', + '````text', + 'Keep ``literal fence text`` unchanged.', + '````', + '', + ' Keep ``indented code`` unchanged.', + ].join('\r\n') + + expect(normalizeScenarioMarkdown(source)).toBe([ + 'Jailbreak details', + '', + 'Set `num_jailbreaks` before launch.', + '', + '````text', + 'Keep ``literal fence text`` unchanged.', + '````', + '', + ' Keep ``indented code`` unchanged.', + ].join('\r\n')) + }) + + it('preserves escaped literals and double backticks nested in existing code spans', () => { + const source = [ + String.raw`Keep \`\`escaped\`\` unchanged.`, + 'Keep ```outer ``literal`` span``` unchanged.', + 'Keep ``a `nested` code span`` unchanged.', + ].join('\n') + + expect(normalizeScenarioMarkdown(source)).toBe(source) + }) + + it('leaves unmatched delimiters unchanged', () => { + expect(normalizeScenarioMarkdown('Keep ``open intact.')).toBe('Keep ``open intact.') + }) + + it('preserves content inside an unclosed tilde fence', () => { + const source = [ + '~~~text', + 'Keep ``literal fence text`` unchanged.', + '```', + ].join('\n') + + expect(normalizeScenarioMarkdown(source)).toBe(source) + }) +}) diff --git a/frontend/src/components/Scenarios/scenarioMarkdown.ts b/frontend/src/components/Scenarios/scenarioMarkdown.ts new file mode 100644 index 0000000000..0542d0b6bc --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioMarkdown.ts @@ -0,0 +1,132 @@ +interface MarkdownFence { + marker: '`' | '~' + length: number +} + +function countRun(value: string, start: number, marker: string): number { + let end = start + while (value[end] === marker) { + end += 1 + } + return end - start +} + +function isEscaped(value: string, index: number): boolean { + let slashCount = 0 + for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor -= 1) { + slashCount += 1 + } + return slashCount % 2 === 1 +} + +function findClosingBackticks(value: string, start: number, delimiterLength: number): number { + let cursor = start + while (cursor < value.length) { + if (value[cursor] !== '`') { + cursor += 1 + continue + } + const runLength = countRun(value, cursor, '`') + if (!isEscaped(value, cursor) && runLength === delimiterLength) { + return cursor + } + cursor += runLength + } + return -1 +} + +function normalizeProseLine(line: string): string { + const output: string[] = [] + let cursor = 0 + + while (cursor < line.length) { + if (line[cursor] !== '`' || isEscaped(line, cursor)) { + output.push(line[cursor]) + cursor += 1 + continue + } + + const delimiterLength = countRun(line, cursor, '`') + const closingIndex = findClosingBackticks( + line, + cursor + delimiterLength, + delimiterLength, + ) + if (closingIndex < 0) { + output.push(line.slice(cursor, cursor + delimiterLength)) + cursor += delimiterLength + continue + } + + const closingEnd = closingIndex + delimiterLength + const literal = line.slice(cursor + delimiterLength, closingIndex) + const isNarrowMystLiteral = + delimiterLength === 2 + && literal.length > 0 + && literal === literal.trim() + && !literal.includes('`') + output.push( + isNarrowMystLiteral + ? `\`${literal}\`` + : line.slice(cursor, closingEnd), + ) + cursor = closingEnd + } + + return output.join('') +} + +function openingFence(line: string): MarkdownFence | null { + const match = /^ {0,3}(`{3,}|~{3,})/.exec(line) + if (!match) { + return null + } + const run = match[1] + return { + marker: run[0] === '`' ? '`' : '~', + length: run.length, + } +} + +function closesFence(line: string, fence: MarkdownFence): boolean { + const indentLength = /^ {0,3}/.exec(line)?.[0].length ?? 0 + if (line[indentLength] !== fence.marker) { + return false + } + const runLength = countRun(line, indentLength, fence.marker) + return runLength >= fence.length && line.slice(indentLength + runLength).trim().length === 0 +} + +/** + * Converts narrow MyST double-backtick literals in prose to CommonMark code + * spans while preserving source whitespace and every existing code context. + */ +export function normalizeScenarioMarkdown(content: string): string { + let fence: MarkdownFence | null = null + + return content.replace(/[^\r\n]*(?:\r\n|\r|\n|$)/g, (line: string) => { + if (line.length === 0) { + return line + } + const endingMatch = /(\r\n|\r|\n)$/.exec(line) + const ending = endingMatch?.[0] ?? '' + const body = ending ? line.slice(0, -ending.length) : line + + if (fence) { + if (closesFence(body, fence)) { + fence = null + } + return line + } + + const nextFence = openingFence(body) + if (nextFence) { + fence = nextFence + return line + } + if (/^(?: {4}|\t)/.test(body)) { + return line + } + return `${normalizeProseLine(body)}${ending}` + }) +} diff --git a/frontend/src/components/Scenarios/scenarioRunEstimateAdapter.ts b/frontend/src/components/Scenarios/scenarioRunEstimateAdapter.ts new file mode 100644 index 0000000000..aada6c0959 --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunEstimateAdapter.ts @@ -0,0 +1,116 @@ +import type { + ScenarioDefaultRunSizeEstimate, + ScenarioRunEstimate, + ScenarioRunEstimateDataset, + ScenarioRunEstimateDatasetCap, + ScenarioRunEstimateFactor, + ScenarioRunEstimateResult, +} from '@/types' + +function nextStableId(prefix: string, label: string, occurrences: Map): string { + const occurrence = (occurrences.get(label) ?? 0) + 1 + occurrences.set(label, occurrence) + return `${prefix}:${label}:${occurrence}` +} + +function mapFactors( + componentId: string, + factors: ScenarioDefaultRunSizeEstimate['components'][number]['factors'], +): ScenarioRunEstimateFactor[] { + const occurrences = new Map() + return factors.map((factor) => ({ + id: nextStableId(`${componentId}:factor`, factor.label, occurrences), + label: factor.label, + count: factor.count, + })) +} + +function mapDatasetCaps( + datasetId: string, + caps: ScenarioDefaultRunSizeEstimate['datasets'][number]['configured_caps'], +): ScenarioRunEstimateDatasetCap[] { + const occurrences = new Map() + return caps.map((cap) => ({ + id: nextStableId(`${datasetId}:cap`, cap.label, occurrences), + label: cap.label, + count: cap.count, + configuredOn: cap.configured_on, + datasetName: cap.dataset_name, + })) +} + +function mapDatasets( + datasets: ScenarioDefaultRunSizeEstimate['datasets'], +): ScenarioRunEstimateDataset[] { + const occurrences = new Map() + return datasets.map((dataset) => { + const id = nextStableId('dataset', dataset.name, occurrences) + return { + id, + name: dataset.name, + kind: dataset.kind, + logicalSeedGroupCount: dataset.logical_seed_group_count, + selectedSeedGroupCount: dataset.selected_seed_group_count, + configuredCaps: mapDatasetCaps(id, dataset.configured_caps), + selectionNote: dataset.selection_note, + } + }) +} + +export function mapScenarioRunEstimate( + response: ScenarioDefaultRunSizeEstimate, + scope: ScenarioRunEstimate['scope'], +): ScenarioRunEstimateResult { + if (response.status === 'unavailable') { + return { + status: 'unavailable', + scope, + label: scope === 'default' + ? 'Default run size unavailable' + : 'Configured run size unavailable', + note: response.note ?? undefined, + } + } + + const componentOccurrences = new Map() + const estimate: ScenarioRunEstimate = { + version: response.version, + scope, + total: response.total_attack_count, + minimum: response.minimum_attack_count ?? null, + maximum: response.maximum_attack_count ?? null, + condition: response.condition ?? null, + components: response.components.map((component) => { + const id = nextStableId('component', component.label, componentOccurrences) + return { + id, + label: component.label, + count: component.count, + factors: mapFactors(id, component.factors), + isBaseline: component.is_baseline, + condition: component.condition ?? null, + note: component.note, + } + }), + datasets: mapDatasets(response.datasets), + adaptiveDetails: response.adaptive_details + ? { + objectiveCount: response.adaptive_details.objective_count, + selectedCandidateTechniqueCount: response.adaptive_details.selected_candidate_technique_count + ?? response.adaptive_details.candidate_technique_count, + candidateTechniqueCount: response.adaptive_details.candidate_technique_count, + maxAttemptsPerObjective: response.adaptive_details.max_attempts_per_objective, + techniquesPerObjectiveUpperBound: response.adaptive_details.techniques_per_objective_upper_bound, + techniqueAttemptCountUpperBound: response.adaptive_details.technique_attempt_count_upper_bound, + stopOnFirstSuccess: response.adaptive_details.stop_on_first_success, + compatibilityMayReduceAttempts: response.adaptive_details.compatibility_may_reduce_attempts, + } + : null, + note: response.note, + retriesIncluded: response.retries_included, + } + + return response.status === 'exact' + ? { status: 'available', estimate } + : { status: 'conditional', estimate } +} diff --git a/frontend/src/components/Scenarios/scenarioTechniqueSets.ts b/frontend/src/components/Scenarios/scenarioTechniqueSets.ts new file mode 100644 index 0000000000..68e3ecb824 --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioTechniqueSets.ts @@ -0,0 +1,44 @@ +import type { RegisteredScenario } from '@/types' + +const TECHNIQUE_SET_LABELS: Record = { + all: 'All', + core: 'Core', + default: 'Recommended', + extra: 'Extra', + light: 'Light', + multi_turn: 'Multi-turn', + single_turn: 'Single-turn', +} + +function humanizeTechniqueSetName(name: string): string { + const knownLabel = TECHNIQUE_SET_LABELS[name] + if (knownLabel) { + return knownLabel + } + const words = name.replace(/_/g, ' ') + return words.length > 0 ? `${words[0].toUpperCase()}${words.slice(1)}` : name +} + +export function techniqueSetMembers(scenario: RegisteredScenario, name: string): string[] { + const members = scenario.aggregate_technique_expansions[name] + ?? (name === scenario.default_technique ? scenario.default_techniques : []) + return [...new Set(members)] +} + +export function techniqueSetName(name: string): string { + return humanizeTechniqueSetName(name) +} + +export function techniqueSetDisplayName(scenario: RegisteredScenario, name: string): string { + const displayName = techniqueSetName(name) + return name === scenario.default_technique ? `${displayName} (default)` : displayName +} + +export function techniqueSetOptionLabel(scenario: RegisteredScenario, name: string): string { + const count = techniqueSetMembers(scenario, name).length + const countLabel = `${count.toLocaleString()} technique${count === 1 ? '' : 's'}` + const displayName = techniqueSetDisplayName(scenario, name) + return name === scenario.default_technique + ? `${displayName} — ${countLabel}` + : `${displayName} (${countLabel})` +} diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx index 17b83621a0..1db3d96b53 100644 --- a/frontend/src/components/Sidebar/Navigation.test.tsx +++ b/frontend/src/components/Sidebar/Navigation.test.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT license. */ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ThemeProvider, useTheme } from "../../hooks/useTheme"; import Navigation from "./Navigation"; @@ -97,6 +97,52 @@ describe("Navigation", () => { ).toBeInTheDocument(); }); + it("renders the scenarios button", () => { + renderWithProvider(); + expect( + screen.getByRole("button", { name: "Scenarios" }) + ).toBeInTheDocument(); + }); + + it("places Scenarios immediately after Attack History without a history placeholder", () => { + renderWithProvider(); + const navigation = screen.getByRole("navigation", { name: "Primary" }); + const labels = within(navigation) + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label")); + + expect(labels).toEqual([ + "Home", + "Chat", + "Attack History", + "Scenarios", + "Configuration", + "Initializers", + ]); + expect(screen.queryByRole("button", { name: "Scenario History" })).not.toBeInTheDocument(); + }); + + it("calls onNavigate with 'scenarios' when the scenarios button is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = jest.fn(); + renderWithProvider( + + ); + + await user.click(screen.getByRole("button", { name: "Scenarios" })); + expect(onNavigate).toHaveBeenCalledWith("scenarios"); + }); + + it("marks the scenarios button current when it is the active view", () => { + renderWithProvider( + + ); + expect(screen.getByRole("button", { name: "Scenarios" })).toHaveAttribute( + "aria-current", + "page" + ); + }); + it("renders the feedback button and forwards clicks to onOpenFeedback", () => { const onOpenFeedback = jest.fn(); renderWithProvider( diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index 218635db9f..d9c407b639 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -14,6 +14,7 @@ import { SettingsRegular, HistoryRegular, PersonFeedbackRegular, + ScriptRegular, WrenchRegular, OpenRegular, WeatherMoonRegular, @@ -23,7 +24,7 @@ import { useTheme } from '../../hooks/useTheme' import type { ThemeMode } from '../../hooks/useTheme' import { useNavigationStyles } from './Navigation.styles' -export type ViewName = 'home' | 'chat' | 'history' | 'config' | 'initializers' +export type ViewName = 'home' | 'chat' | 'history' | 'config' | 'initializers' | 'scenarios' interface NavigationProps { currentView: ViewName @@ -94,6 +95,17 @@ export default function Navigation({ currentView, onNavigate, onOpenFeedback }: onClick={() => onNavigate('history')} /> +