Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ jest.mock("./components/Layout/MainLayout", () => {
<button onClick={() => onNavigate("history")} data-testid="nav-history">
History
</button>
<button onClick={() => onNavigate("scenarios")} data-testid="nav-scenarios">
Scenarios
</button>
{children}
</div>
);
Expand Down Expand Up @@ -294,6 +297,51 @@ jest.mock("./components/Home/Home", () => {
};
});

jest.mock("./components/Scenarios/ScenarioCatalog", () => {
const MockScenarioCatalog = () => <div data-testid="scenario-catalog" />;
MockScenarioCatalog.displayName = "MockScenarioCatalog";
return {
__esModule: true,
default: MockScenarioCatalog,
};
});

jest.mock("./components/Scenarios/ScenarioDetail", () => {
const MockScenarioDetail = ({
activeTarget,
labels,
onNavigate,
}: {
activeTarget: unknown;
labels: Record<string, string>;
onNavigate: (view: string) => void;
}) => {
return (
<div data-testid="scenario-detail">
<span data-testid="scenario-detail-has-target">{activeTarget ? "yes" : "no"}</span>
<span data-testid="scenario-detail-labels-json">{JSON.stringify(labels)}</span>
<button onClick={() => onNavigate("config")} data-testid="scenario-detail-go-config">
Configure target
</button>
</div>
);
};
MockScenarioDetail.displayName = "MockScenarioDetail";
return {
__esModule: true,
default: MockScenarioDetail,
};
});

jest.mock("./components/Scenarios/ScenarioRunStarted", () => {
const MockScenarioRunStarted = () => <div data-testid="scenario-run-started" />;
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").
Expand Down Expand Up @@ -349,6 +397,67 @@ describe("App", () => {
expect(screen.getByTestId("attack-history")).toBeInTheDocument();
});

it("renders the scenario catalog when deep-linked to /scenarios", () => {
renderApp("/scenarios");

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument();
});

it("renders the scenario detail view and marks the sidebar current when deep-linked to /scenarios/:name", () => {
renderApp("/scenarios/foundry.red_team_agent");

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-detail")).toBeInTheDocument();
});

it("renders the scenario run-started shell and marks the sidebar current when deep-linked to /scenario-history/:id", () => {
renderApp("/scenario-history/sr-123");

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-run-started")).toBeInTheDocument();
});

it("switches to the scenarios view via the sidebar", () => {
renderApp();

fireEvent.click(screen.getByTestId("nav-scenarios"));

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument();
});

it("passes the active target and labels to the scenario detail view", () => {
renderApp("/scenarios/foundry.red_team_agent");

expect(screen.getByTestId("scenario-detail-has-target")).toHaveTextContent("no");
expect(screen.getByTestId("scenario-detail-labels-json")).toHaveTextContent("operator");
});

it("navigates from scenario detail to config when it requests it", () => {
renderApp("/scenarios/foundry.red_team_agent");

fireEvent.click(screen.getByTestId("scenario-detail-go-config"));

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"config"
);
expect(screen.getByTestId("target-config")).toBeInTheDocument();
});

it("redirects an unknown path back to home", () => {
renderApp("/does-not-exist");

Expand Down
26 changes: 25 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -38,10 +41,19 @@ const VIEW_PATHS: Record<ViewName, string> = {
history: '/history',
config: '/config',
initializers: '/initializers',
scenarios: '/scenarios',
}

/** Resolves the active view from a URL path, defaulting to home for unknown paths. */
/**
* Resolves the active view from a URL path, defaulting to home for unknown
* paths. Scenario routes are prefix-matched (`/scenarios/...` and
* `/scenario-history/...`) since they carry a path parameter rather than a
* single canonical `VIEW_PATHS` entry.
*/
function viewFromPath(pathname: string): ViewName {
if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) || pathname.startsWith('/scenario-history/')) {
return 'scenarios'
}
const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find(
([, path]) => path === pathname,
)
Expand Down Expand Up @@ -400,6 +412,18 @@ function App() {
}
/>
<Route path="/initializers" element={<Initializers />} />
<Route path="/scenarios" element={<ScenarioCatalog />} />
<Route
path="/scenarios/:scenarioName"
element={
<ScenarioDetail
activeTarget={activeTarget}
labels={globalLabels}
onNavigate={handleNavigate}
/>
}
/>
<Route path="/scenario-history/:scenarioResultId" element={<ScenarioRunStarted />} />
<Route
path="/history"
element={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export default function ConverterPanel({ onClose, previewText = '', attachmentDa
const newConverter = converters.find((c) => c.converter_type === type)
const defaults: Record<string, string> = {}
for (const p of newConverter?.parameters ?? []) {
if (p.default != null) {
if (typeof p.default === 'string') {
defaults[p.name] = p.default
}
}
Expand Down
13 changes: 8 additions & 5 deletions frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ interface ParamInputProps {
}

function ConverterParameterChoiceViewer({ param, value, onChange }: ParamInputProps) {
const stringDefault = typeof param.default === 'string' ? param.default : ''
return (
<Select
value={value ?? param.default ?? ''}
value={value ?? stringDefault}
onChange={(_, data) => onChange(param.name, data.value)}
data-testid={`param-${param.name}`}
>
Expand All @@ -28,12 +29,13 @@ function ConverterParameterChoiceViewer({ param, value, onChange }: ParamInputPr

function ParameterFileViewer({ param, value, isMissing, onChange, onBrowse }: ParamInputProps & { onBrowse: (name: string) => void }) {
const styles = useConverterPanelStyles()
const stringDefault = typeof param.default === 'string' ? param.default : 'Select a file...'

return (
<div className={styles.filePickerRow}>
<Input
value={value ?? ''}
placeholder={param.default ?? 'Select a file...'}
placeholder={stringDefault}
onChange={(_, data) => onChange(param.name, data.value)}
className={isMissing ? styles.paramInputError : undefined}
data-testid={`param-${param.name}`}
Expand All @@ -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 (
<Input
value={value ?? ''}
placeholder={param.default ?? undefined}
placeholder={stringDefault}
onChange={(_, data) => onChange(param.name, data.value)}
className={isMissing ? styles.paramInputError : undefined}
data-testid={`param-${param.name}`}
Expand Down Expand Up @@ -106,9 +109,9 @@ export default function ConverterParams({ converter, paramValues, paramsExpanded
</span>
{param.type_name === 'bool' ? (
<Switch
checked={(paramValues[param.name] ?? param.default ?? 'false').toLowerCase() === 'true'}
checked={(paramValues[param.name] ?? (typeof param.default === 'string' ? param.default : 'false')).toLowerCase() === 'true'}
onChange={(_, data) => 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 ? (
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
mergeClasses,
} from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowReplyRegular, ArrowForwardRegular, ChatAddRegular, BranchForkRegular, OpenRegular } from '@fluentui/react-icons'
import MarkdownContent from '@/components/Markdown/MarkdownContent'

import { Message, MessageAttachment } from '../../types'
import MarkdownContent from './MarkdownContent'
import { useMessageListStyles } from './MessageList.styles'

interface MessageListProps {
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/Config/CreateTargetDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,12 @@ async function selectTargetType(value: string): Promise<void> {
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
restoreDialogAccessibility();
await waitFor(() => {
restoreDialogAccessibility();
expect(screen.getByRole("combobox", { name: /target type/i })).toHaveTextContent(
TARGET_DISPLAY_NAMES[value]
);
});
}

describe("parseWeight", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,4 @@ export const useAdditionalInitializersStyles = makeStyles({
flexDirection: 'column',
gap: tokens.spacingVerticalM,
},
fieldHint: {
color: tokens.colorNeutralForeground3,
marginTop: tokens.spacingVerticalXXS,
},
checkboxGroup: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXXS,
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -140,13 +141,29 @@ describe('InitializerParametersDialog', () => {
</TestWrapper>,
)

await user.click(screen.getByTestId('param-flag'))
fireEvent.change(screen.getByTestId('param-flag'), { target: { value: 'true' } })
await user.click(screen.getByTestId('param-tags-a'))
await user.click(screen.getByRole('button', { name: 'Add' }))

expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ flag: true, tags: ['a'] }))
})

it('leaves an optional boolean unset omitted from the submitted parameters', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn().mockResolvedValue(undefined)
render(
<TestWrapper>
<InitializerParametersDialog {...baseProps} onSubmit={onSubmit} initializer={allKindsInitializer} />
</TestWrapper>,
)

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)
Expand Down Expand Up @@ -183,6 +200,40 @@ describe('InitializerParametersDialog', () => {
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})

it('does not pin absent declaration defaults when editing persisted parameters', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn().mockResolvedValue(undefined)
const initializer: RegisteredInitializer = {
...numericInitializer,
supported_parameters: [
{
name: 'days',
type_name: 'int',
required: false,
default: '7',
choices: null,
is_list: false,
},
],
}

render(
<TestWrapper>
<InitializerParametersDialog
{...baseProps}
mode="edit"
initializer={initializer}
initialParameters={{}}
onSubmit={onSubmit}
/>
</TestWrapper>,
)

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()
Expand Down
Loading