diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bf5f1fc780..1412df0a38 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -109,6 +109,9 @@ jest.mock("./components/Layout/MainLayout", () => { + {children} ); @@ -318,6 +321,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"). @@ -378,6 +426,67 @@ describe("App", () => { expect(screen.getByTestId("attack-history")).toBeInTheDocument(); }); + it("renders the scenario catalog when deep-linked to /scanner", () => { + renderApp("/scanner"); + + 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 /scanner/:name", () => { + renderApp("/scanner/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("/scanner/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("/scanner/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 ce3a4721d7..b180830e4b 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' @@ -40,10 +43,19 @@ const VIEW_PATHS: Record = { history: '/history', config: '/config', initializers: '/initializers', + scenarios: '/scanner', } -/** 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. Scanner routes are prefix-matched (`/scanner/...` 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, ) @@ -462,6 +474,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 d6f846b940..b6630fbf76 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -30,8 +30,9 @@ import { MoreHorizontalRegular, OpenRegular, } from '@fluentui/react-icons' +import MarkdownContent from '@/components/Markdown/MarkdownContent' + import type { DisplayScore, Message, MessageAttachment, MessageDisplayPiece } 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 319bed9128..a41d1235be 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] + ); + }); } // The catalog fetch mock (see beforeEach) resolves on mount, and its diff --git a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts index a0b3c865e6..9cdad1f625 100644 --- a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts +++ b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts @@ -76,23 +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, - }, - srOnly: { - position: 'absolute', - width: '1px', - height: '1px', - padding: '0', - margin: '-1px', - overflow: 'hidden', - clip: 'rect(0,0,0,0)', - whiteSpace: 'nowrap', - }, }) diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx index 3543a0e3ed..ceb170aa02 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() @@ -152,7 +153,7 @@ 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', hidden: true })) @@ -174,6 +175,22 @@ describe('InitializerParametersDialog', () => { expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['b'] })) }) + it('leaves an optional boolean unset omitted from the submitted parameters', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn().mockResolvedValue(undefined) + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Add' })) + + // Every other optional field is also left blank, so the whole payload is null; + // the key assertion is that the omitted boolean doesn't silently coerce to false. + expect(onSubmit).toHaveBeenCalledWith(null) + }) + it('unchecks a multiselect choice and picks a select value', async () => { const user = userEvent.setup() const onSubmit = jest.fn().mockResolvedValue(undefined) @@ -210,6 +227,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 5848addbe2..6e89534a97 100644 --- a/frontend/src/components/Initializers/InitializerParametersDialog.tsx +++ b/frontend/src/components/Initializers/InitializerParametersDialog.tsx @@ -1,29 +1,20 @@ import { useRef, 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 @@ -49,7 +40,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) const submitInProgressRef = useRef(false) @@ -155,100 +146,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 ( - -
- {label} - {(parameter.choices ?? []).map((choice) => { - const choiceLabelId = `param-${encodeURIComponent(parameter.name)}-${encodeURIComponent(choice)}-label` - return ( - { - 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..269a94772b --- /dev/null +++ b/frontend/src/components/Parameters/ParameterField.styles.ts @@ -0,0 +1,45 @@ +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, + }, + srOnly: { + position: 'absolute', + width: '1px', + height: '1px', + padding: '0', + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0,0,0,0)', + whiteSpace: 'nowrap', + }, + 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..efe5fac2ae --- /dev/null +++ b/frontend/src/components/Parameters/ParameterField.tsx @@ -0,0 +1,138 @@ +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 ( + +
+ + {label} + + {(parameter.choices ?? []).map((choice) => { + const choiceId = `${testId}-${encodeURIComponent(choice)}` + const choiceLabelId = `${choiceId}-label` + return ( + { + 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..7cda2b5ced --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioCatalog.styles.ts @@ -0,0 +1,252 @@ +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: '52rem', + 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: '40%', + }, + sizeColumn: { + width: '20%', + }, + techniqueColumn: { + width: '20%', + }, + datasetColumn: { + width: '20%', + }, + summaryRow: { + color: tokens.colorNeutralForeground1, + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + [NARROW_VIEWPORT_QUERY]: { + display: 'grid', + gridTemplateRows: 'repeat(4, 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: { + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase300, + '& p': { + marginBottom: tokens.spacingVerticalXS, + }, + '& ul, & ol': { + marginBottom: tokens.spacingVerticalXS, + }, + '& h1, & h2, & h3, & h4, & h5, & h6': { + margin: `${tokens.spacingVerticalXS} 0`, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + }, + }, + purposePreviewCollapsed: { + maxHeight: '3.75rem', + overflow: 'hidden', + }, + descriptionToggle: { + alignSelf: 'flex-start', + minWidth: 0, + width: '2rem', + height: '2rem', + padding: 0, + }, + compactStack: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalXS, + minWidth: 0, + }, + secondaryText: { + color: tokens.colorNeutralForeground3, + }, +}) diff --git a/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx b/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx new file mode 100644 index 0000000000..e6a315605e --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx @@ -0,0 +1,593 @@ +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'], + technique_summaries: [ + { + name: 'default_technique', + description: 'Runs the default attack.', + tags: ['default'], + }, + ], + 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: { + estimated_attack_count: null, + minimum_attack_count: null, + maximum_attack_count: null, + components: [], + datasets: [], + note: 'Default sizing is not available.', + }, + ...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(screen.getByRole('heading', { name: 'Scanner' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Read the scanner documentation' })).toHaveAttribute( + 'href', + 'https://microsoft.github.io/PyRIT/scanner/0_scanner/', + ) + expect(mockListCatalog).toHaveBeenCalledTimes(1) + }) + + it('renders Markdown descriptions and lets users expand long previews', async () => { + const user = userEvent.setup() + const clientHeight = jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(60) + const scrollHeight = jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(120) + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ + scenario_name: 'scenario.markdown', + description_markdown: [ + 'Tests **formatted text** and ``inline_code``.', + '', + 'This additional detail makes the description long enough to collapse in the catalog preview. ', + 'Users can expand the row to read the complete scenario purpose without leaving the catalog.', + ].join('\n'), + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-scenario.markdown') + expect(within(row).getByText('formatted text').tagName).toBe('STRONG') + expect(within(row).getByText('inline_code').tagName).toBe('CODE') + + const expandButton = await within(row).findByRole('button', { + name: 'Expand description for scenario.markdown', + }) + expect(expandButton).toHaveAttribute('aria-expanded', 'false') + + await user.click(expandButton) + + expect(within(row).getByRole('button', { + name: 'Collapse description for scenario.markdown', + })).toHaveAttribute('aria-expanded', 'true') + clientHeight.mockRestore() + scrollHeight.mockRestore() + }) + + it('does not show an expand control for a fully visible description', async () => { + const clientHeight = jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(60) + const scrollHeight = jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(61) + mockListCatalog.mockResolvedValueOnce({ + items: [makeScenario({ scenario_name: 'scenario.visible' })], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-scenario.visible') + expect(within(row).queryByRole('button', { + name: 'Expand description for scenario.visible', + })).not.toBeInTheDocument() + clientHeight.mockRestore() + scrollHeight.mockRestore() + }) + + 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(4) + expect(headers.map((header) => header.textContent)).toEqual([ + 'Scenario / purpose', + 'Default datasets', + '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(4) + expect(cells.every((cell) => cell.classList.contains('scenario-catalog-cell-padding'))).toBe(true) + expect(within(cells[0]).getByRole('link', { name: 'foundry.red_team_agent' })).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', '/scanner/foundry%2Fred_team_agent') + }) + + it('shows the total default objectives followed by the dataset names', 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('6 objectives')).toBeInTheDocument() + expect(within(row).getByText('population-a · population-b')).toBeInTheDocument() + }) + + it('shows an adaptive estimate as a plain attack range', async () => { + mockListCatalog.mockResolvedValueOnce({ + items: [ + makeScenario({ + scenario_name: 'adaptive.text_adaptive', + default_run_size: { + estimated_attack_count: null, + minimum_attack_count: 21, + maximum_attack_count: 42, + components: [ + { + label: 'Baseline', + count: 21, + is_baseline: true, + note: null, + }, + { + label: 'Adaptive objectives', + count: 21, + is_baseline: false, + note: null, + }, + ], + datasets: [], + note: null, + }, + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-adaptive.text_adaptive') + expect(within(row).getByText('21-42 attacks')).toBeInTheDocument() + expect(within(row).queryByText(/progress units|attack attempts/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).getByText('harmbench')).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: { + estimated_attack_count: null, + minimum_attack_count: 12, + maximum_attack_count: 20, + components: [ + { + label: 'Default attacks', + count: 20, + 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.', + }, + ], + note: 'Retries and internal turns are excluded.', + }, + }), + ], + pagination: { limit: 200, has_more: false }, + }) + + render() + + const row = await screen.findByTestId('scenario-card-airt.jailbreak') + expect(within(row).getByText('4 objectives')).toBeInTheDocument() + expect(within(row).getByText('harmbench')).toBeInTheDocument() + expect(within(row).getByText('2 techniques')).toBeInTheDocument() + expect(within(row).getByText('12-20 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..ba42c74297 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioCatalog.tsx @@ -0,0 +1,429 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' + +import { + Button, + Input, + Link as FluentLink, + mergeClasses, + MessageBar, + MessageBarBody, + Spinner, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + Text, +} from '@fluentui/react-components' +import { + ArrowSyncRegular, + ChevronDownRegular, + ChevronUpRegular, + SearchRegular, +} from '@fluentui/react-icons' +import { Link } from 'react-router' + +import MarkdownContent from '@/components/Markdown/MarkdownContent' +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 { normalizeScenarioMarkdown } from './scenarioMarkdown' +import { mapScenarioRunEstimate } from './scenarioRunEstimateAdapter' +import { techniqueSetName } from './scenarioTechniqueSets' + +/** Items requested per catalog page while paging through the full list. */ +const CATALOG_PAGE_SIZE = 200 +const DESCRIPTION_OVERFLOW_TOLERANCE_PX = 2 +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 DefaultDatasetSummary({ + datasets, + declaredDatasets, +}: { + datasets: ScenarioDatasetSummary[] + declaredDatasets: string[] +}) { + const styles = useScenarioCatalogStyles() + + if (datasets.length === 0 && declaredDatasets.length === 0) { + return No default datasets + } + + if (datasets.length === 0) { + return ( +
+ Population counts unavailable + {declaredDatasets.join(' · ')} +
+ ) + } + + const objectiveCount = datasets.reduce( + (total, dataset) => total + dataset.selected_seed_group_count, + 0, + ) + const datasetNames = declaredDatasets.length > 0 + ? declaredDatasets + : datasets.map((dataset) => dataset.name) + + return ( +
+ {formatObjectiveCount(objectiveCount)} + {datasetNames.join(' · ')} +
+ ) +} + +interface ScenarioCatalogRowProps { + scenario: RegisteredScenario +} + +interface ScenarioDescriptionProps { + content: string + scenarioName: string +} + +function ScenarioDescription({ content, scenarioName }: ScenarioDescriptionProps) { + const styles = useScenarioCatalogStyles() + const descriptionId = useId() + const descriptionRef = useRef(null) + const [descriptionExpanded, setDescriptionExpanded] = useState(false) + const [descriptionClipped, setDescriptionClipped] = useState(false) + + useEffect(() => { + const description = descriptionRef.current + if (!description) { + return + } + + const updateClippedState = () => { + if (!descriptionExpanded) { + setDescriptionClipped( + description.scrollHeight - description.clientHeight > DESCRIPTION_OVERFLOW_TOLERANCE_PX, + ) + } + } + updateClippedState() + + const resizeObserver = new ResizeObserver(updateClippedState) + resizeObserver.observe(description) + return () => resizeObserver.disconnect() + }, [content, descriptionExpanded]) + + return ( + <> +
+ +
+ {descriptionClipped && ( + +
+
+ + {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 + + + Default datasets + + + 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..8deb3cca30 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioDetail.styles.ts @@ -0,0 +1,252 @@ +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, + fontSize: tokens.fontSizeBase400, + lineHeight: tokens.lineHeightBase500, + }, + 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, + }, + }, + }, + techniqueList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + selectionControl: { + ...mobileTouchTargetHeight, + }, + techniqueOption: { + display: 'grid', + gridTemplateColumns: 'minmax(12rem, 35%) minmax(0, 1fr)', + gap: tokens.spacingHorizontalM, + alignItems: 'start', + padding: tokens.spacingVerticalS, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: 'minmax(0, 1fr)', + gap: tokens.spacingVerticalXS, + }, + }, + techniqueDetails: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + minWidth: 0, + }, + techniqueTags: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalXS, + }, + techniqueTag: { + ...mobileTouchTarget, + }, + 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..93e9729ca1 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx @@ -0,0 +1,966 @@ +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, + ScenarioRunSizeEstimateResponse, + 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' + const aggregateTechniques = overrides.aggregate_techniques ?? ['all', 'default'] + const defaultTechniques = overrides.default_techniques + ?? (aggregateTechniques.includes(defaultTechnique) ? ['default_technique'] : [defaultTechnique]) + const allTechniques = overrides.all_techniques ?? ['default_technique', 'crescendo'] + const techniqueSummaries = overrides.technique_summaries ?? allTechniques.map((name) => ({ + name, + description: `${name} description.`, + tags: name === 'default_technique' ? ['default', 'single_turn'] : ['multi_turn'], + })) + 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: allTechniques, + technique_summaries: techniqueSummaries, + default_datasets: ['harmbench'], + default_dataset_summaries: [], + baseline_policy: 'enabled', + include_baseline_by_default: true, + supported_parameters: [], + default_run_size: { + estimated_attack_count: null, + components: [], + datasets: [], + note: 'Default sizing is unavailable.', + }, + ...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): ScenarioRunSizeEstimateResponse { + return { + estimated_attack_count: total, + minimum_attack_count: total === null ? 8 : null, + maximum_attack_count: total === null ? 12 : null, + components: total === null + ? [{ label: 'Possible attacks', count: 12, is_baseline: false, note: null }] + : [ + { + label: 'Configured attacks', + count: total, + is_baseline: false, + note: null, + }, + ], + datasets: [], + note: null, + } +} + +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('/scanner/foundry.red_team_agent') + expect(screen.getByText('Loading scenario...')).toBeInTheDocument() + }) + + it('decodes the scenario name from the URL exactly once', async () => { + renderDetail('/scanner/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('/scanner/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('/scanner/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('/scanner/%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('/scanner/missing.scenario') + + expect(await screen.findByTestId('scenario-not-found')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /back to scenarios/i })).toHaveAttribute('href', '/scanner') + 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('/scanner/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('estimates without a target and directs to Configuration before launch', async () => { + jest.useFakeTimers() + const onNavigate = jest.fn() + mockListTargets.mockResolvedValueOnce({ items: [], pagination: { limit: 200, has_more: false } }) + mockEstimateRun.mockResolvedValueOnce(makeEstimate(8)) + + renderDetail('/scanner/foundry.red_team_agent', { onNavigate }) + await flushRenderedPromises() + await advanceTimers(300) + + expect(screen.getByTestId('scenario-target-select')).toHaveValue('') + expect(mockEstimateRun).toHaveBeenCalledWith( + 'foundry.red_team_agent', + { + techniques: ['default_technique'], + include_baseline: true, + }, + expect.any(AbortSignal), + ) + expect(screen.getByText('8 attacks')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Configure target to launch' })) + expect(onNavigate).toHaveBeenCalledWith('config') + }) + + it('defaults the target selector to the active target when it is among the fetched targets', async () => { + renderDetail('/scanner/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('/scanner/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('/scanner/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('/scanner/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: ScenarioRunSizeEstimateResponse) => void = () => {} + let resolveSecond: (estimate: ScenarioRunSizeEstimateResponse) => void = () => {} + mockEstimateRun + .mockReturnValueOnce(new Promise((resolve) => { + resolveFirst = resolve + })) + .mockReturnValueOnce(new Promise((resolve) => { + resolveSecond = resolve + })) + + renderDetail('/scanner/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 attacks')).toBeInTheDocument() + + resolveFirst(makeEstimate(8)) + await flushRenderedPromises() + expect(within(preview).getByText('12 attacks')).toBeInTheDocument() + expect(within(preview).queryByText('8 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('/scanner/foundry.red_team_agent') + await flushRenderedPromises() + await advanceTimers(300) + await flushRenderedPromises() + expect(screen.getByText('8 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 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('/scanner/foundry.red_team_agent') + await flushRenderedPromises() + + await user.click(screen.getByTestId('technique-default_technique')) + 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('/scanner/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('8-12 attacks')).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('/scanner/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 individual techniques from the resolved defaults', async () => { + renderDetail('/scanner/foundry.red_team_agent') + + await screen.findByTestId('scenario-target-select') + expect(screen.getByTestId('technique-default_technique')).toBeChecked() + expect(screen.getByTestId('technique-crescendo')).not.toBeChecked() + expect(screen.queryByText('Aggregate preset')).not.toBeInTheDocument() + expect(screen.queryByText('Backend-resolved preset members')).not.toBeInTheDocument() + }) + + it('shows technique descriptions and tags', 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'], + technique_summaries: [ + { + name: 'prompt_sending', + description: 'Sends the objective directly.', + tags: ['default', 'single_turn'], + }, + { + name: 'jailbreak_system_prompt', + description: 'Places the jailbreak in the system prompt.', + tags: ['default', 'single_turn'], + }, + ], + }), + ) + + renderDetail('/scanner/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + expect(screen.getByText('Sends the objective directly.')).toBeInTheDocument() + expect(screen.getByText('Places the jailbreak in the system prompt.')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Clear Recommended techniques' })).toHaveLength(2) + expect(screen.getAllByRole('button', { name: 'Clear Single-turn techniques' })).toHaveLength(3) + }) + + it('renders only concrete techniques and de-duplicates their names', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + aggregate_techniques: ['default_technique', 'all_garak'], + all_techniques: ['default_technique', 'crescendo', 'prompt_sending', 'all_garak'], + }), + ) + const user = userEvent.setup() + + renderDetail('/scanner/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + expect(screen.queryByTestId('technique-all_garak')).not.toBeInTheDocument() + expect(screen.getAllByTestId('technique-crescendo')).toHaveLength(1) + + await user.click(screen.getByTestId('technique-crescendo')) + 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('selects and clears all members of a tag', async () => { + mockGetScenario.mockResolvedValue( + makeScenario({ + default_techniques: ['default_technique'], + all_techniques: ['default_technique', 'crescendo', 'many_shot'], + technique_summaries: [ + { name: 'default_technique', description: 'Direct attack.', tags: ['single_turn'] }, + { name: 'crescendo', description: 'Escalating attack.', tags: ['multi_turn'] }, + { name: 'many_shot', description: 'Many-shot attack.', tags: ['multi_turn'] }, + ], + }), + ) + const user = userEvent.setup() + renderDetail('/scanner/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getAllByRole('button', { name: 'Select Multi-turn techniques' })[0]) + expect(screen.getByTestId('technique-crescendo')).toBeChecked() + expect(screen.getByTestId('technique-many_shot')).toBeChecked() + + await user.click(screen.getAllByRole('button', { name: 'Clear Multi-turn techniques' })[0]) + expect(screen.getByTestId('technique-crescendo')).not.toBeChecked() + expect(screen.getByTestId('technique-many_shot')).not.toBeChecked() + expect(screen.getByTestId('technique-default_technique')).toBeChecked() + }) + + 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('/scanner/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('/scanner/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('technique-default_technique')) + + expect(await screen.findByRole('alert')).toHaveTextContent('Select at least one attack 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('/scanner/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + const checkbox = screen.getByTestId('baseline-checkbox') + expect(checkbox).toBeChecked() + expect(screen.queryByRole('heading', { name: 'Baseline' })).not.toBeInTheDocument() + + 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('includes the baseline when a shared tag selects or clears its members', async () => { + const user = userEvent.setup() + renderDetail('/scanner/foundry.red_team_agent') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getAllByRole('button', { name: 'Clear Single-turn techniques' })[0]) + expect(screen.getByTestId('baseline-checkbox')).not.toBeChecked() + expect(screen.getByTestId('technique-default_technique')).not.toBeChecked() + + await user.click(screen.getAllByRole('button', { name: 'Select Single-turn techniques' })[0]) + expect(screen.getByTestId('baseline-checkbox')).toBeChecked() + expect(screen.getByTestId('technique-default_technique')).toBeChecked() + }) + + 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('/scanner/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('/scanner/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('/scanner/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('/scanner/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('/scanner/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('/scanner/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('/scanner/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('/scanner/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('/scanner/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({ + estimated_attack_count: 8, + components: [ + { + label: 'Prompt sending', + count: 8, + 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.', + }) + + renderDetail('/scanner/airt.jailbreak') + await screen.findByTestId('scenario-target-select') + + await user.click(screen.getByTestId('technique-jailbreak_system_prompt')) + 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).queryByText('baseline')).not.toBeInTheDocument() + expect(within(preview).getByText('8 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('/scanner/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('/scanner/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('/scanner/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('/scanner/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-default_technique')) + 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..bb6a6349f9 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx @@ -0,0 +1,1056 @@ +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react' + +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + Badge, + Button, + Checkbox, + Field, + Input, + MessageBar, + MessageBarBody, + Select, + Spinner, + SpinButton, + Text, + ToggleButton, +} 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, + ScenarioTechniqueSummary, + 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' +import { techniqueSetName } from './scenarioTechniqueSets' + +/** 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' + +interface TechniqueOptions { + techniques: ScenarioTechniqueSummary[] + defaultTechniques: string[] +} + +function uniqueTechniqueOptions(scenario: RegisteredScenario): TechniqueOptions { + const aggregateNames = new Set(scenario.aggregate_techniques) + const summariesByName = new Map( + scenario.technique_summaries.map((summary) => [summary.name, summary]), + ) + const techniques: ScenarioTechniqueSummary[] = [] + const seen = new Set() + for (const name of scenario.all_techniques) { + if (!aggregateNames.has(name) && !seen.has(name)) { + techniques.push(summariesByName.get(name) ?? { name, description: null, tags: [] }) + seen.add(name) + } + } + const concreteNames = new Set(techniques.map((technique) => technique.name)) + const defaultTechniques = scenario.default_techniques.filter((name) => concreteNames.has(name)) + if (defaultTechniques.length === 0 && concreteNames.has(scenario.default_technique)) { + defaultTechniques.push(scenario.default_technique) + } + return { techniques, defaultTechniques } +} + +interface SelectableTechnique extends ScenarioTechniqueSummary { + isBaseline: boolean + disabled: boolean +} + +const BASELINE_TECHNIQUE: ScenarioTechniqueSummary = { + name: 'baseline', + description: 'Sends each objective directly to the target for comparison.', + tags: ['baseline', 'single_turn'], +} + +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 BuildEstimateRequestInput { + scenario: RegisteredScenario + targetName: string + techniques: string[] + dynamicParameters: Parameter[] + scenarioParamValues: Record + datasetOverride: string + maxDatasetSize: string + includeBaseline: boolean +} + +interface BuildRunRequestInput extends BuildEstimateRequestInput { + maxConcurrency: number + maxRetries: number + labels: Record +} + +type BuildEstimateRequestResult = + | { + ok: true + request: ScenarioRunSizeEstimateRequest + } + | { + ok: false + error: string + } + +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 buildEstimateRequest({ + targetName, + techniques, + dynamicParameters, + scenarioParamValues, + datasetOverride, + maxDatasetSize, + includeBaseline, +}: BuildEstimateRequestInput): BuildEstimateRequestResult { + 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 + } + const datasetNames = parseDatasetNames(datasetOverride) + const request: ScenarioRunSizeEstimateRequest = { + techniques, + include_baseline: includeBaseline, + } + if (targetName) { + request.target_name = targetName + } + 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 buildRunRequest(input: BuildRunRequestInput): BuildRunRequestResult { + if (!input.targetName) { + return { ok: false, error: 'Select a target.' } + } + const estimateResult = buildEstimateRequest(input) + if (!estimateResult.ok) { + return estimateResult + } + if ( + !Number.isInteger(input.maxConcurrency) + || input.maxConcurrency < MIN_MAX_CONCURRENCY + || input.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(input.maxRetries) + || input.maxRetries < MIN_MAX_RETRIES + || input.maxRetries > MAX_MAX_RETRIES + ) { + return { + ok: false, + error: `Max retries must be an integer from ${MIN_MAX_RETRIES} to ${MAX_MAX_RETRIES}.`, + } + } + + const estimateRequest = estimateResult.request + const request: RunScenarioRequest = { + scenario_name: input.scenario.scenario_name, + target_name: input.targetName, + techniques: estimateRequest.techniques, + max_concurrency: input.maxConcurrency, + max_retries: input.maxRetries, + include_baseline: estimateRequest.include_baseline, + labels: input.labels, + } + if (estimateRequest.dataset_names !== undefined) { + request.dataset_names = estimateRequest.dataset_names + } + if (estimateRequest.max_dataset_size !== undefined) { + request.max_dataset_size = estimateRequest.max_dataset_size + } + if (estimateRequest.scenario_params !== undefined) { + request.scenario_params = estimateRequest.scenario_params + } + return { ok: true, request } +} + +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 + } + + return ( + + ) +} + +interface ScenarioLaunchFormProps { + scenario: RegisteredScenario + targets: TargetInstance[] + activeTarget: TargetInstance | null + labels: Record + onNavigate: (view: ViewName) => void +} + +function ScenarioLaunchForm({ scenario, targets, activeTarget, labels, onNavigate }: ScenarioLaunchFormProps) { + const styles = useScenarioDetailStyles() + const navigate = useNavigate() + const formId = `scenario-launch-${encodeURIComponent(scenario.scenario_name).replace(/%/g, '-')}` + + const { techniques: techniqueOptions, defaultTechniques } = 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 [selectedTechniques, setSelectedTechniques] = useState(() => defaultTechniques) + 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 selectableTechniques = useMemo( + () => [ + { + ...BASELINE_TECHNIQUE, + isBaseline: true, + disabled: isBaselineForbidden, + }, + ...techniqueOptions.map((technique) => ({ + ...technique, + isBaseline: false, + disabled: false, + })), + ], + [isBaselineForbidden, techniqueOptions], + ) + const techniques = selectedTechniques + const estimateResult = useMemo( + () => buildEstimateRequest({ + scenario, + targetName, + techniques, + dynamicParameters, + scenarioParamValues, + datasetOverride, + maxDatasetSize, + includeBaseline: isBaselineForbidden ? false : baselineChecked, + }), + [ + baselineChecked, + datasetOverride, + dynamicParameters, + isBaselineForbidden, + maxDatasetSize, + scenario, + scenarioParamValues, + targetName, + techniques, + ], + ) + 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( + () => estimateResult.ok ? estimateResult.request : null, + [estimateResult], + ) + 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 (!estimateResult.ok) { + estimateState = { + status: 'unavailable', + scope: 'request', + label: 'Complete the required configuration to request an estimate.', + note: estimateResult.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 handleTechniqueChange = (technique: SelectableTechnique, checked: boolean): void => { + if (technique.isBaseline) { + setBaselineChecked(checked) + } else { + setSelectedTechniques((current) => { + if (checked) { + return current.includes(technique.name) + ? current + : [...current, technique.name] + } + return current.filter((name) => name !== technique.name) + }) + } + setValidationError(null) + } + + const isTechniqueSelected = (technique: SelectableTechnique): boolean => ( + technique.isBaseline ? baselineChecked : selectedTechniques.includes(technique.name) + ) + + const handleTagChange = (tag: string): void => { + const members = selectableTechniques.filter( + (technique) => !technique.disabled && technique.tags.includes(tag), + ) + const shouldSelect = members.some((technique) => !isTechniqueSelected(technique)) + const memberNames = new Set( + members.filter((technique) => !technique.isBaseline).map((technique) => technique.name), + ) + setSelectedTechniques((current) => { + const selected = new Set(current) + for (const name of memberNames) { + if (shouldSelect) selected.add(name) + else selected.delete(name) + } + return techniqueOptions.map((technique) => technique.name).filter((name) => selected.has(name)) + }) + if (members.some((technique) => technique.isBaseline)) { + setBaselineChecked(shouldSelect) + } + 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 = selectedTechniques.length === 0 + const previewDatasets = parseDatasetNames(datasetOverride) + const effectiveDatasets = previewDatasets.length > 0 ? previewDatasets : scenario.default_datasets + + return ( +
+
+ + Back to scenarios + + +
+ + {scenario.scenario_name} + + +
+ +
+
+ {validationError && ( + + {validationError} + + )} + {apiError && ( + + {apiError} + + )} + +
+ Target + + + + {targets.length === 0 && ( + + )} +
+ +
+ + Techniques + + + Select individual techniques, or use a tag to select or clear all techniques with that tag. + + {techniqueSelectionInvalid && ( + + Select at least one attack technique. + + )} +
+ {selectableTechniques.map((technique) => ( +
+ handleTechniqueChange(technique, data.checked === true)} + data-testid={technique.isBaseline ? 'baseline-checkbox' : `technique-${technique.name}`} + /> +
+ {technique.description && ( + {technique.description} + )} + {technique.tags.length > 0 && ( +
+ {technique.tags.map((tag) => { + const tagMembers = selectableTechniques.filter( + (candidate) => !candidate.disabled && candidate.tags.includes(tag), + ) + const tagSelected = tagMembers.length > 0 && tagMembers.every(isTechniqueSelected) + return ( + handleTagChange(tag)} + aria-label={`${tagSelected ? 'Clear' : 'Select'} ${techniqueSetName(tag)} techniques`} + > + {techniqueSetName(tag)} + + ) + })} +
+ )} + {technique.disabled && ( + + This scenario does not support a baseline comparison. + + )} +
+
+ ))} +
+
+ + {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..08de8ac380 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from 'react' + +import { render, screen } from '@testing-library/react' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { ScenarioRunEstimateState, ScenarioRunSizeEstimateResponse } from '@/types' + +import { + ScenarioRunEstimateDetails, + ScenarioRunEstimateSummary, +} from './ScenarioRunEstimate' +import { mapScenarioRunEstimate } from './scenarioRunEstimateAdapter' + +function TestWrapper({ children }: { children: ReactNode }) { + return {children} +} + +const EXACT_ESTIMATE: ScenarioRunSizeEstimateResponse = { + estimated_attack_count: 8, + components: [ + { + label: 'Prompt sending', + count: 6, + is_baseline: false, + note: 'One planned attack per selected objective and template.', + }, + { + label: 'Baseline attack', + count: 2, + is_baseline: true, + note: null, + }, + ], + 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.', +} + +describe('ScenarioRunEstimate', () => { + it('renders the authoritative total, components, dataset counts, caps, and notes', () => { + const state = mapScenarioRunEstimate(EXACT_ESTIMATE, 'request') + + render( + + + , + ) + + expect(screen.getByText('8 attacks')).toBeInTheDocument() + expect(screen.getByText('Prompt sending')).toBeInTheDocument() + expect(screen.getByText('Baseline attack')).toBeInTheDocument() + expect(screen.getByText('Baseline')).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: 6 + Baseline attack: 2; backend total = 8', + )).toBeInTheDocument() + expect(screen.getByText('The backend total is authoritative.')).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, + estimated_attack_count: null, + minimum_attack_count: 12, + maximum_attack_count: 20, + components: [{ label: 'Possible attacks', count: 20, is_baseline: false, note: null }], + datasets: [], + note: null, + }, 'default') + rerender( + + + , + ) + expect(screen.getByText('Conditional estimate')).toBeInTheDocument() + expect(screen.getByText('12-20 attacks')).toBeInTheDocument() + expect(screen.getByText('Default configuration')).toBeInTheDocument() + expect(screen.getByText( + 'Possible attacks: 20; backend total is conditional', + )).toBeInTheDocument() + + const unavailable = mapScenarioRunEstimate({ + ...EXACT_ESTIMATE, + estimated_attack_count: null, + minimum_attack_count: null, + maximum_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 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..0e50ed5975 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx @@ -0,0 +1,292 @@ +import { Badge, Spinner, Text } from '@fluentui/react-components' + +import type { + ScenarioRunEstimate, + ScenarioRunEstimateComponent, + ScenarioRunEstimateState, +} from '@/types' + +import { useScenarioRunEstimateStyles } from './ScenarioRunEstimate.styles' + +interface ScenarioRunEstimateSummaryProps { + state: ScenarioRunEstimateState + compact?: boolean +} + +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, 'attack', 'attacks') + } + if (estimate.minimum != null && estimate.maximum != null) { + return estimate.minimum === estimate.maximum + ? countLabel(estimate.minimum, 'attack', 'attacks') + : `${formatEstimateValue(estimate.minimum)}-${formatEstimateValue(estimate.maximum)} attacks` + } + if (estimate.maximum != null) { + return `Up to ${countLabel(estimate.maximum, 'attack', 'attacks')}` + } + if (estimate.minimum != null) { + return `At least ${countLabel(estimate.minimum, 'attack', 'attacks')}` + } + return 'Attack count varies' +} + +function formatComponentFormula(component: ScenarioRunEstimateComponent): string { + return `${component.label}: ${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, compact = false }: ScenarioRunEstimateSummaryProps) { + const styles = useScenarioRunEstimateStyles() + const estimate = stateEstimate(state) + + return ( +
+
+ {!compact && {statusLabel(state)}} + {estimate && ( + + {formatPlannedAttackSummary(estimate)} + + )} + {compact && !estimate && {statusLabel(state)}} +
+ {!compact && {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.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.'} + +
+
+ ) +} 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..1ec1424ffd --- /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', '/scanner') + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunStarted.tsx b/frontend/src/components/Scenarios/ScenarioRunStarted.tsx new file mode 100644 index 0000000000..b633bd24b0 --- /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..68b06fb402 --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunEstimateAdapter.ts @@ -0,0 +1,90 @@ +import type { + ScenarioRunEstimate, + ScenarioRunEstimateDataset, + ScenarioRunEstimateDatasetCap, + ScenarioRunEstimateResult, + ScenarioRunSizeEstimateResponse, +} 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 mapDatasetCaps( + datasetId: string, + caps: ScenarioRunSizeEstimateResponse['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: ScenarioRunSizeEstimateResponse['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: ScenarioRunSizeEstimateResponse, + scope: ScenarioRunEstimate['scope'], +): ScenarioRunEstimateResult { + if ( + response.estimated_attack_count === null + && response.minimum_attack_count == null + && response.maximum_attack_count == null + && response.components.length === 0 + ) { + 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 = { + scope, + total: response.estimated_attack_count, + minimum: response.minimum_attack_count ?? null, + maximum: response.maximum_attack_count ?? null, + components: response.components.map((component) => { + const id = nextStableId('component', component.label, componentOccurrences) + return { + id, + label: component.label, + count: component.count, + isBaseline: component.is_baseline, + note: component.note, + } + }), + datasets: mapDatasets(response.datasets), + note: response.note, + } + + return response.estimated_attack_count === null + ? { status: 'conditional', estimate } + : { status: 'available', 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')} /> +