From c6b8821e75d55dffda61344afc1c20d2a2dbab3b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:19:59 -0700 Subject: [PATCH 1/7] FEAT: Add live scenario progress dashboard Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d02c2d5-b499-4f78-a04d-03bffa750817 --- frontend/src/App.test.tsx | 100 ++- frontend/src/App.tsx | 30 +- .../src/components/Chat/ChatWindow.styles.ts | 22 + .../src/components/Chat/ChatWindow.test.tsx | 58 +- frontend/src/components/Chat/ChatWindow.tsx | 27 + .../Scenarios/ScenarioFlow.test.tsx | 211 +++++ .../Scenarios/ScenarioRunPage.styles.ts | 298 +++++++ .../Scenarios/ScenarioRunPage.test.tsx | 359 ++++++++ .../components/Scenarios/ScenarioRunPage.tsx | 790 ++++++++++++++++++ .../Scenarios/ScenarioRunStarted.styles.ts | 44 - .../Scenarios/ScenarioRunStarted.test.tsx | 139 --- .../Scenarios/ScenarioRunStarted.tsx | 125 --- .../src/hooks/useScenarioRunProgress.test.tsx | 434 ++++++++++ frontend/src/hooks/useScenarioRunProgress.tsx | 129 +++ frontend/src/services/api.test.ts | 28 +- frontend/src/services/api.ts | 12 +- frontend/src/utils/routeParams.test.ts | 52 +- frontend/src/utils/routeParams.ts | 50 ++ .../src/utils/scenarioRunProgress.test.ts | 272 ++++++ frontend/src/utils/scenarioRunProgress.ts | 452 ++++++++++ pyrit/models/__init__.py | 2 + pyrit/models/scenario_progress.py | 10 + pyrit/scenario/core/scenario.py | 37 +- 23 files changed, 3346 insertions(+), 335 deletions(-) create mode 100644 frontend/src/components/Scenarios/ScenarioFlow.test.tsx create mode 100644 frontend/src/components/Scenarios/ScenarioRunPage.styles.ts create mode 100644 frontend/src/components/Scenarios/ScenarioRunPage.test.tsx create mode 100644 frontend/src/components/Scenarios/ScenarioRunPage.tsx delete mode 100644 frontend/src/components/Scenarios/ScenarioRunStarted.styles.ts delete mode 100644 frontend/src/components/Scenarios/ScenarioRunStarted.test.tsx delete mode 100644 frontend/src/components/Scenarios/ScenarioRunStarted.tsx create mode 100644 frontend/src/hooks/useScenarioRunProgress.test.tsx create mode 100644 frontend/src/hooks/useScenarioRunProgress.tsx create mode 100644 frontend/src/utils/scenarioRunProgress.test.ts create mode 100644 frontend/src/utils/scenarioRunProgress.ts diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4d9187855a..7cdbab55f1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -127,6 +127,7 @@ jest.mock("./components/Layout/MainLayout", () => { }); jest.mock("./components/Chat/ChatWindow", () => { + const { useLocation } = jest.requireActual("react-router") as typeof import("react-router"); const MockChatWindow = ({ onNewAttack, activeTarget, @@ -140,6 +141,7 @@ jest.mock("./components/Chat/ChatWindow", () => { onConversationCreated, onSelectConversation, labels, + scenarioResultId, }: { onNewAttack: () => void; activeTarget: unknown; @@ -153,7 +155,9 @@ jest.mock("./components/Chat/ChatWindow", () => { onConversationCreated: (attackResultId: string, conversationId: string) => void; onSelectConversation: (convId: string) => void; labels: Record; + scenarioResultId?: string | null; }) => { + const location = useLocation(); return (
{attackResultId ?? "none"} @@ -168,6 +172,8 @@ jest.mock("./components/Chat/ChatWindow", () => { {targetResolutionStatus ?? "none"} {labels.operator ?? ""} {JSON.stringify(labels)} + {scenarioResultId ?? "none"} + {`${location.pathname}${location.search}`} @@ -365,12 +371,12 @@ jest.mock("./components/Scenarios/ScenarioDetail", () => { }; }); -jest.mock("./components/Scenarios/ScenarioRunStarted", () => { - const MockScenarioRunStarted = () =>
; - MockScenarioRunStarted.displayName = "MockScenarioRunStarted"; +jest.mock("./components/Scenarios/ScenarioRunPage", () => { + const MockScenarioRunPage = () =>
; + MockScenarioRunPage.displayName = "MockScenarioRunPage"; return { __esModule: true, - default: MockScenarioRunStarted, + default: MockScenarioRunPage, }; }); @@ -464,14 +470,14 @@ describe("App", () => { 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", () => { + it("renders the scenario run dashboard 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(); + expect(screen.getByTestId("scenario-run-page")).toBeInTheDocument(); }); it("switches to the scenarios view via the sidebar", () => { @@ -937,6 +943,7 @@ describe("App", () => { ); expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main"); expect(screen.getByTestId("objective")).toHaveTextContent("Extract the hidden system prompt"); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none"); }); it("hides the normalized empty objective of an unnamed manual attack on reload", async () => { @@ -956,6 +963,69 @@ describe("App", () => { expect(screen.getByTestId("objective")).toHaveTextContent(""); }); + it("hydrates validated scenario provenance on a direct attack reload", async () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: [], + }); + + renderApp(`/attacks/ar-1?scenarioResultId=${scenarioResultId}`); + + await waitFor(() => + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId) + ); + expect(screen.getByTestId("route-location")).toHaveTextContent( + `/attacks/ar-1?scenarioResultId=${scenarioResultId}` + ); + }); + + it.each([ + "/attacks/ar-1?scenarioResultId=run-1", + "/attacks/ar-1?scenarioResultId=https%3A%2F%2Fevil.example", + "/attacks/ar-1?scenarioResultId=123e4567-e89b-12d3-a456-426614174000&scenarioResultId=123e4567-e89b-12d3-a456-426614174000", + ])("ignores unsafe or ambiguous scenario provenance on %s", async (path: string) => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: [], + }); + + renderApp(path); + + await waitFor(() => + expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") + ); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none"); + }); + + it("preserves validated provenance within an attack and clears it for a new attack", async () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: ["conv-456"], + }); + renderApp(`/attacks/ar-1?scenarioResultId=${scenarioResultId}`); + await waitFor(() => + expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") + ); + + fireEvent.click(screen.getByTestId("select-conversation")); + expect(screen.getByTestId("route-location")).toHaveTextContent( + `/attacks/ar-1/conversations/conv-456?scenarioResultId=${scenarioResultId}` + ); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId); + + fireEvent.click(screen.getByTestId("new-attack")); + expect(screen.getByTestId("route-location")).toHaveTextContent("/chat"); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none"); + }); + it("uses the conversation from a deep link when it belongs to the attack", async () => { mockGetAttack.mockResolvedValue({ attack_result_id: "ar-1", @@ -985,6 +1055,24 @@ describe("App", () => { ); }); + it("retains validated provenance while canonicalizing an unknown conversation route", async () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: [], + }); + renderApp(`/attacks/ar-1/conversations/bogus?scenarioResultId=${scenarioResultId}`); + + await waitFor(() => + expect(screen.getByTestId("route-location")).toHaveTextContent( + `/attacks/ar-1?scenarioResultId=${scenarioResultId}` + ) + ); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId); + }); + it("hydrates history filters from the URL query string", () => { renderApp("/history?outcome=success&attackType=PromptSendingAttack"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d03806696f..54f3aa1e2a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ import Configuration from './components/Configuration/Configuration' 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 ScenarioRunPage from './components/Scenarios/ScenarioRunPage' import FeedbackDialog from './components/Feedback/FeedbackDialog' import type { HistoryFilters } from './components/History/historyFilters' import { ConnectionBanner } from './components/ConnectionBanner' @@ -34,6 +34,11 @@ import { import { attacksApi, authApi, versionApi } from './services/api' import { toApiError } from './services/errors' import { useTour } from './hooks/useTour' +import { + attackConversationRoutePath, + attackRoutePath, + scenarioRunProvenance, +} from './utils/routeParams' const AUTO_DISMISS_MS = 5_000 @@ -80,10 +85,6 @@ interface LoadedAttack { status: AttackLoadStatus } -const attackPath = (attackId: string) => `/attacks/${attackId}` -const conversationPath = (attackId: string, conversationId: string) => - `/attacks/${attackId}/conversations/${conversationId}` - function ConnectionBannerContainer() { const { status, reconnectCount } = useConnectionHealth() // Track how many reconnects the user has already had the banner dismissed for. @@ -163,6 +164,10 @@ function App() { // the History nav button can restore filters after visiting another view. const [searchParams, setSearchParams] = useSearchParams() const historyFilters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]) + const scenarioResultId = useMemo( + () => scenarioRunProvenance(searchParams), + [searchParams], + ) const lastHistorySearch = useRef('') useEffect(() => { if (location.pathname === VIEW_PATHS.history) { @@ -342,10 +347,10 @@ function App() { routeConversationId === readyAttack.mainConversationId || readyAttack.relatedConversationIds.includes(routeConversationId) if (!isKnown) { - navigate(attackPath(readyAttack.id), { replace: true }) + navigate(attackRoutePath(readyAttack.id, scenarioResultId), { replace: true }) } } - }, [readyAttack, routeConversationId, navigate]) + }, [readyAttack, routeConversationId, navigate, scenarioResultId]) const handleNavigate = useCallback((view: ViewName) => { // Re-attach the last filter query so returning to history restores filters. @@ -394,16 +399,16 @@ function App() { }) // Replace when promoting an empty /chat to its attack url (first message); // push when branching from an existing attack so Back returns to the source. - navigate(attackPath(arId), { replace: routeAttackId === null }) + navigate(attackRoutePath(arId), { replace: routeAttackId === null }) }, [activeTarget, handleSetActiveTarget, routeAttackId, navigate]) const handleSelectConversation = useCallback((convId: string) => { if (!routeAttackId) return - navigate(conversationPath(routeAttackId, convId)) - }, [routeAttackId, navigate]) + navigate(attackConversationRoutePath(routeAttackId, convId, scenarioResultId)) + }, [routeAttackId, navigate, scenarioResultId]) const handleOpenAttack = useCallback((openAttackResultId: string) => { - navigate(attackPath(openAttackResultId)) + navigate(attackRoutePath(openAttackResultId)) }, [navigate]) const chatElement = isAttackNotFound || isAttackError ? ( @@ -432,6 +437,7 @@ function App() { isLoadingAttack={isLoadingAttack} relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0} objective={readyAttack ? readyAttack.objective : ''} + scenarioResultId={readyAttack ? scenarioResultId : null} /> ) @@ -503,7 +509,7 @@ function App() { /> } /> - } /> + } /> } /> = ({ children, -}) => {children}; +}) => ( + + {children} + +); function mockMatchMedia(matchesNarrowScreen: boolean): void { (window.matchMedia as jest.Mock).mockImplementation((query: string) => ({ @@ -319,6 +324,57 @@ describe("ChatWindow Integration", () => { expect(screen.getByRole("textbox")).toBeInTheDocument(); }); + it("shows a safe scenario-run breadcrumb only when provenance is present", () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + const { rerender } = render( + + + + ); + + expect(screen.getByRole("navigation", { name: "Attack provenance" })).toBeInTheDocument(); + expect(screen.getByRole("link", { + name: `Return to scenario run ${scenarioResultId}`, + })).toHaveAttribute("href", `/scenario-history/${scenarioResultId}`); + + rerender( + + + + ); + expect(screen.queryByRole("navigation", { name: "Attack provenance" })).not.toBeInTheDocument(); + }); + + it("returns to the originating scenario run from the breadcrumb", async () => { + const user = userEvent.setup(); + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + render( + + + + } + /> + Originating scenario run} + /> + + + + ); + + await user.click(screen.getByRole("link", { + name: `Return to scenario run ${scenarioResultId}`, + })); + + expect(screen.getByRole("heading", { + level: 1, + name: "Originating scenario run", + })).toBeInTheDocument(); + }); + it("defaults to raw mode when no Markdown preference is stored", () => { render( diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 12dc5f7d98..bc7286d86e 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -2,6 +2,9 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import type { ChangeEvent } from 'react' import { Button, + Breadcrumb, + BreadcrumbDivider, + BreadcrumbItem, Drawer, Menu, MenuItem, @@ -18,6 +21,7 @@ import { } from '@fluentui/react-components' import type { SwitchOnChangeData } from '@fluentui/react-components' import { AddRegular, ArrowDownloadRegular, PanelRightRegular } from '@fluentui/react-icons' +import { Link } from 'react-router' import MessageList from './MessageList' import SystemPromptBanner from './SystemPromptBanner' import ChatInputArea from './ChatInputArea' @@ -42,6 +46,7 @@ import type { TargetInfo, } from '../../types' import { isTargetResolutionBlocking, targetInfoMatchesTarget } from '../../utils/targetIdentity' +import { scenarioRunRoutePath } from '../../utils/routeParams' import type { ViewName } from '../Sidebar/Navigation' import { useChatWindowStyles } from './ChatWindow.styles' @@ -98,6 +103,8 @@ interface ChatWindowProps { relatedConversationCount?: number /** The loaded attack's objective (empty for new/manual attacks). */ objective?: string + /** Validated scenario-run provenance for attacks opened from a run dashboard. */ + scenarioResultId?: string | null } export default function ChatWindow({ @@ -118,6 +125,7 @@ export default function ChatWindow({ isLoadingAttack, relatedConversationCount, objective = '', + scenarioResultId, }: ChatWindowProps) { const styles = useChatWindowStyles() const restoreFocusTargetAttributes = useRestoreFocusTarget() @@ -760,6 +768,25 @@ export default function ChatWindow({ /> )}
+ {scenarioResultId && ( +
+ + + Scenario History + + + + + Scenario run {scenarioResultId.slice(0, 8)} + + + +
+ )}
{activeTarget ? ( diff --git a/frontend/src/components/Scenarios/ScenarioFlow.test.tsx b/frontend/src/components/Scenarios/ScenarioFlow.test.tsx new file mode 100644 index 0000000000..1d660cf10c --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioFlow.test.tsx @@ -0,0 +1,211 @@ +import { 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, useLocation } from 'react-router' + +import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import { scenariosApi, targetsApi } from '@/services/api' +import type { + RegisteredScenario, + ScenarioDefaultRunSizeEstimate, + TargetInstance, +} from '@/types' +import type { ScenarioRunProgressState } from '@/utils/scenarioRunProgress' + +import ScenarioCatalog from './ScenarioCatalog' +import ScenarioDetail from './ScenarioDetail' +import ScenarioRunPage from './ScenarioRunPage' + +jest.mock('@/hooks/useScenarioRunProgress', () => ({ + useScenarioRunProgress: jest.fn(), +})) + +jest.mock('@/services/api', () => ({ + scenariosApi: { + cancelRun: jest.fn(), + estimateRun: jest.fn(), + getScenario: jest.fn(), + listCatalog: jest.fn(), + startRun: jest.fn(), + }, + targetsApi: { + listTargets: jest.fn(), + }, +})) + +const mockUseScenarioRunProgress = useScenarioRunProgress as jest.Mock +const mockEstimateRun = scenariosApi.estimateRun as jest.Mock +const mockGetScenario = scenariosApi.getScenario as jest.Mock +const mockListCatalog = scenariosApi.listCatalog as jest.Mock +const mockStartRun = scenariosApi.startRun as jest.Mock +const mockListTargets = targetsApi.listTargets as jest.Mock + +const SCENARIO_NAME = 'foundry.red_team_agent' +const RUN_ID = '123e4567-e89b-12d3-a456-426614174000' + +const SCENARIO: RegisteredScenario = { + scenario_name: SCENARIO_NAME, + scenario_type: 'RedTeamAgentScenario', + scenario_version: 1, + description: 'Red teams a configured target.', + description_markdown: 'Red teams a configured target.', + default_technique: 'default_technique', + default_techniques: ['crescendo'], + aggregate_techniques: ['default_technique'], + aggregate_technique_expansions: { + default_technique: ['crescendo'], + }, + all_techniques: ['crescendo'], + default_datasets: ['harmbench'], + default_dataset_summaries: [], + baseline_policy: 'enabled', + include_baseline_by_default: true, + supported_parameters: [], + default_run_size: { + version: 1, + status: 'exact', + total_attack_count: 2, + components: [], + datasets: [], + note: null, + retries_included: false, + }, +} + +const TARGET: TargetInstance = { + target_registry_name: 'target-a', + identifier: { + class_name: 'OpenAIChatTarget', + hash: 'target-a-hash', + }, +} + +const ESTIMATE: ScenarioDefaultRunSizeEstimate = { + version: 1, + status: 'exact', + total_attack_count: 2, + components: [{ + label: 'Configured attacks', + count: 2, + factors: [], + is_baseline: false, + note: null, + }], + datasets: [], + note: null, + retries_included: false, +} + +const RUN_STATE: ScenarioRunProgressState = { + loadStatus: 'ready', + run: { + scenario_result_id: RUN_ID, + scenario_name: 'RedTeamAgentScenario', + scenario_registry_name: SCENARIO_NAME, + scenario_version: 1, + status: 'IN_PROGRESS', + created_at: '2026-08-07T18:00:00Z', + }, + plan: { + version: 1, + scenario_registry_name: SCENARIO_NAME, + atomic_groups: [], + seed_groups: [], + }, + planComplete: true, + activeAtomicGroupIds: [], + results: [], + cursor: 'cursor-0', + hasMore: false, + error: null, + stale: false, +} + +function LocationProbe() { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} + +function renderFlow(): void { + render( + + + + + } /> + + )} + /> + } /> + + + , + ) +} + +describe('Scenario catalog-to-run integration', () => { + beforeEach(() => { + jest.clearAllMocks() + mockListCatalog.mockResolvedValue({ + items: [SCENARIO], + pagination: { limit: 200, has_more: false }, + }) + mockGetScenario.mockResolvedValue(SCENARIO) + mockListTargets.mockResolvedValue({ + items: [TARGET], + pagination: { limit: 200, has_more: false }, + }) + mockEstimateRun.mockResolvedValue(ESTIMATE) + mockStartRun.mockResolvedValue({ scenario_result_id: RUN_ID }) + mockUseScenarioRunProgress.mockReturnValue({ + state: RUN_STATE, + retry: jest.fn(), + applyRunSummary: jest.fn(), + }) + }) + + it('carries one configured request from catalog detail through estimate, launch, and run hydration', async () => { + const user = userEvent.setup() + renderFlow() + + await user.click(await screen.findByRole('link', { name: SCENARIO_NAME })) + expect(await screen.findByRole('heading', { level: 1, name: SCENARIO_NAME })).toBeInTheDocument() + + const expectedEstimateRequest = { + target_name: TARGET.target_registry_name, + techniques: ['default_technique'], + include_baseline: true, + } + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith( + SCENARIO_NAME, + expectedEstimateRequest, + expect.any(AbortSignal), + )) + expect(within(screen.getByRole('complementary', { name: 'Run preview' })) + .getByText('2 planned attacks')).toBeInTheDocument() + + await user.click(screen.getByTestId('launch-scenario-btn')) + + await waitFor(() => expect(mockStartRun).toHaveBeenCalledWith({ + scenario_name: SCENARIO_NAME, + target_name: TARGET.target_registry_name, + techniques: expectedEstimateRequest.techniques, + max_concurrency: 10, + max_retries: 0, + include_baseline: expectedEstimateRequest.include_baseline, + labels: { operator: 'integration-test' }, + })) + expect(await screen.findByTestId('scenario-run-page')).toBeInTheDocument() + expect(screen.getByLabelText('Current route')).toHaveTextContent( + `/scenario-history/${RUN_ID}`, + ) + expect(screen.getByRole('heading', { level: 1, name: SCENARIO_NAME })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts new file mode 100644 index 0000000000..4620bffa77 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts @@ -0,0 +1,298 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { + MINIMUM_TOUCH_TARGET_SIZE, + NARROW_VIEWPORT_QUERY, + mobileTouchTarget, +} from '@/styles/touchTargets' + +export const useScenarioRunPageStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + minWidth: 0, + overflowY: 'auto', + overflowX: 'hidden', + backgroundColor: tokens.colorNeutralBackground2, + }, + content: { + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: '96rem', + gap: tokens.spacingVerticalXL, + padding: tokens.spacingVerticalXXL, + marginInline: 'auto', + [NARROW_VIEWPORT_QUERY]: { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + gap: tokens.spacingVerticalL, + }, + }, + backLink: { + display: 'inline-flex', + alignItems: 'center', + alignSelf: 'flex-start', + gap: tokens.spacingHorizontalXS, + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + color: tokens.colorBrandForegroundLink, + textDecorationLine: 'none', + ':hover': { + textDecorationLine: 'underline', + }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + }, + header: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalXL, + [NARROW_VIEWPORT_QUERY]: { + flexDirection: 'column', + alignItems: 'stretch', + }, + }, + headerIdentity: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + gap: tokens.spacingVerticalXS, + }, + titleRow: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + runId: { + color: tokens.colorNeutralForeground3, + overflowWrap: 'anywhere', + }, + headerActions: { + display: 'flex', + flexShrink: 0, + gap: tokens.spacingHorizontalS, + [NARROW_VIEWPORT_QUERY]: { + width: '100%', + }, + }, + touchTarget: { + ...mobileTouchTarget, + }, + wideButton: { + [NARROW_VIEWPORT_QUERY]: { + flexGrow: 1, + }, + }, + metadata: { + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(10rem, 1fr))', + gap: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXL}`, + paddingTop: tokens.spacingVerticalM, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: '1fr', + }, + }, + metadataItem: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + }, + metadataLabel: { + color: tokens.colorNeutralForeground3, + }, + section: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + sectionHeading: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalM, + }, + sectionHint: { + color: tokens.colorNeutralForeground3, + }, + progressSurface: { + display: 'grid', + gridTemplateColumns: 'minmax(14rem, 2fr) repeat(2, minmax(8rem, 1fr))', + gap: tokens.spacingHorizontalXL, + alignItems: 'center', + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: '1fr', + gap: tokens.spacingVerticalM, + }, + }, + progressPrimary: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + minWidth: 0, + }, + progressText: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalM, + }, + metric: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + metricLabel: { + color: tokens.colorNeutralForeground3, + }, + metricValue: { + fontVariantNumeric: 'tabular-nums', + }, + summaryGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(15rem, 1fr))', + gap: tokens.spacingHorizontalM, + }, + summaryItem: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + padding: tokens.spacingVerticalL, + borderTop: `1px solid ${tokens.colorNeutralStroke1}`, + backgroundColor: tokens.colorNeutralBackground1, + }, + summaryTitle: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalS, + }, + summaryStats: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: tokens.spacingHorizontalS, + }, + summaryStat: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + tableScroll: { + width: '100%', + overflowX: 'auto', + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + table: { + minWidth: '64rem', + tableLayout: 'auto', + }, + attemptsTable: { + minWidth: '68rem', + tableLayout: 'auto', + }, + clickableAttemptRow: { + cursor: 'pointer', + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '-2px', + }, + }, + nowrap: { + whiteSpace: 'nowrap', + fontVariantNumeric: 'tabular-nums', + }, + preview: { + display: 'block', + maxWidth: '24rem', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + attackLink: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: MINIMUM_TOUCH_TARGET_SIZE, + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + textDecorationLine: 'none', + borderRadius: tokens.borderRadiusMedium, + ':hover': { + backgroundColor: tokens.colorSubtleBackgroundHover, + }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + }, + objectiveButton: { + maxWidth: '26rem', + justifyContent: 'flex-start', + ...mobileTouchTarget, + }, + emptyState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalS, + minHeight: '8rem', + padding: tokens.spacingVerticalXXL, + color: tokens.colorNeutralForeground3, + textAlign: 'center', + }, + centeredState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + minHeight: '18rem', + textAlign: 'center', + }, + loadingBlock: { + width: 'min(42rem, 100%)', + }, + dialogContent: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + overflowWrap: 'anywhere', + }, + detailGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: '1fr', + }, + }, + objective: { + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + }, + liveStatus: { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }, +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx new file mode 100644 index 0000000000..4c3c772308 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -0,0 +1,359 @@ +import { 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, + useLocation, + useNavigate, +} from 'react-router' + +import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import { scenariosApi } from '@/services/api' +import type { + ScenarioProgressResult, + ScenarioRunPlan, +} from '@/types' +import { + INITIAL_SCENARIO_RUN_PROGRESS_STATE, + type ScenarioRunProgressState, +} from '@/utils/scenarioRunProgress' + +import ScenarioRunPage from './ScenarioRunPage' + +jest.mock('@/hooks/useScenarioRunProgress', () => ({ + useScenarioRunProgress: jest.fn(), +})) + +jest.mock('@/services/api', () => ({ + scenariosApi: { + cancelRun: jest.fn(), + }, +})) + +const mockUseScenarioRunProgress = useScenarioRunProgress as jest.Mock +const mockCancelRun = scenariosApi.cancelRun as jest.Mock +const mockRetry = jest.fn() +const mockApplyRunSummary = jest.fn() +const SCENARIO_RESULT_ID = '123e4567-e89b-12d3-a456-426614174000' + +const PLAN: ScenarioRunPlan = { + version: 1, + scenario_registry_name: 'test.scenario', + atomic_groups: [{ + id: 'group-1', + atomic_attack_name: 'attack-technique', + display_group: 'Technique One', + technique_eval_hash: 'eval-1', + seed_group_ids: ['seed-1'], + }], + seed_groups: [{ + id: 'seed-1', + objective_sha256: 'sha-1', + objective: 'Reveal the system prompt and all hidden configuration.', + }], +} + +const ATTEMPT: ScenarioProgressResult = { + attack_result_id: 'attack-result-1', + atomic_group_id: 'group-1', + atomic_attack_name: 'attack-technique', + seed_group_id: 'seed-1', + outcome: 'success', + execution_time_ms: 5_000, + timestamp: '2026-01-01T00:00:05Z', + total_retries: 1, + retries: [], +} + +function makeState(overrides: Partial = {}): ScenarioRunProgressState { + return { + ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, + loadStatus: 'ready', + run: { + scenario_result_id: SCENARIO_RESULT_ID, + scenario_name: 'TestScenario', + scenario_registry_name: 'test.scenario', + scenario_version: 1, + status: 'IN_PROGRESS', + created_at: '2026-01-01T00:00:00Z', + }, + plan: PLAN, + planComplete: true, + activeAtomicGroupIds: ['group-1'], + results: [ATTEMPT], + cursor: 'cursor-1', + ...overrides, + } +} + +function mockHookState(state: ScenarioRunProgressState): void { + mockUseScenarioRunProgress.mockReturnValue({ + state, + retry: mockRetry, + applyRunSummary: mockApplyRunSummary, + }) +} + +function AttackRouteProbe() { + const location = useLocation() + const navigate = useNavigate() + return ( +
+ +
+ ) +} + +function renderPage(path = `/scenario-history/${SCENARIO_RESULT_ID}`) { + return render( + + + + } /> + } /> + + + , + ) +} + +describe('ScenarioRunPage', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHookState(makeState()) + }) + + it('renders a live dashboard with accessible progress and semantic tables', () => { + renderPage() + + expect(screen.getByRole('heading', { name: 'test.scenario', level: 1 })).toBeInTheDocument() + expect(screen.getByTestId('run-state-badge')).toHaveTextContent('In progress') + expect(screen.getByRole('progressbar', { name: 'Overall scenario run progress' })).toHaveAttribute( + 'aria-valuetext', + '1 of 1 executable units completed', + ) + expect(screen.getByRole('table', { name: 'Atomic attack groups' })).toBeInTheDocument() + expect(screen.getByRole('table', { name: 'Logical seed groups' })).toBeInTheDocument() + expect(screen.getByRole('table', { name: 'Persisted attack attempts' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel run' })).toBeInTheDocument() + expect(screen.queryByRole('columnheader', { name: 'Actions' })).not.toBeInTheDocument() + }) + + it('keeps legacy runs useful without misleading totals, ETA, or a progress bar', () => { + mockHookState(makeState({ planComplete: false })) + + renderPage() + + expect(screen.getByText(/legacy run has no complete persisted execution plan/i)).toBeInTheDocument() + expect(screen.getAllByText(/1 known completed units; planned total unavailable/i)).toHaveLength(2) + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(screen.getByText('Progress percentage unavailable')).toBeInTheDocument() + expect(screen.getAllByText('Unavailable').length).toBeGreaterThan(0) + expect(screen.getAllByText('1/total unavailable').length).toBeGreaterThan(0) + expect(screen.queryByText('1/1')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open attack attack-result-1' })).toBeInTheDocument() + }) + + it('shows a stale warning and retries from the explicit action', async () => { + const user = userEvent.setup() + mockHookState(makeState({ stale: true, error: 'Network unavailable' })) + + renderPage() + await user.click(screen.getByRole('button', { name: 'Retry' })) + + expect(mockRetry).toHaveBeenCalledTimes(1) + expect(screen.getByText(/showing the last successfully loaded progress/i)).toBeInTheDocument() + }) + + it('cancels after confirmation and immediately applies the returned terminal state', async () => { + const user = userEvent.setup() + const cancelledRun = { + scenario_result_id: 'run-1', + scenario_name: 'TestScenario', + scenario_registry_name: 'test.scenario', + scenario_version: 1, + status: 'CANCELLED', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + completed_at: '2026-01-01T00:01:00Z', + techniques_used: [], + total_attacks: 1, + completed_attacks: 1, + objective_achieved_rate: 100, + failed_attacks: [], + attack_retries: [], + total_retries: 0, + labels: {}, + } + mockCancelRun.mockResolvedValueOnce(cancelledRun) + + renderPage() + await user.click(screen.getByRole('button', { name: 'Cancel run' })) + const dialog = screen.getByRole('dialog', { name: 'Cancel this scenario run?' }) + await user.click(within(dialog).getByRole('button', { name: 'Cancel run' })) + + await waitFor(() => expect(mockApplyRunSummary).toHaveBeenCalledWith(cancelledRun)) + expect(mockCancelRun).toHaveBeenCalledWith(SCENARIO_RESULT_ID) + }) + + it('keeps the confirmation open and shows cancel conflicts', async () => { + const user = userEvent.setup() + mockCancelRun.mockRejectedValueOnce(new Error('Cannot cancel a completed run.')) + + renderPage() + await user.click(screen.getByRole('button', { name: 'Cancel run' })) + const dialog = screen.getByRole('dialog', { name: 'Cancel this scenario run?' }) + await user.click(within(dialog).getByRole('button', { name: 'Cancel run' })) + + expect(await within(dialog).findByText('Cannot cancel a completed run.')).toBeInTheDocument() + expect(mockApplyRunSummary).not.toHaveBeenCalled() + }) + + it('shows full objective details and restores focus on close', async () => { + const user = userEvent.setup() + renderPage() + const detailsButton = screen.getByRole('button', { + name: 'View details for attack attempt attack-result-1', + }) + + await user.click(detailsButton) + const dialog = screen.getByRole('dialog', { name: 'Attack attempt details' }) + expect(within(dialog).getByText(PLAN.seed_groups[0].objective)).toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: 'Close' })) + + await waitFor(() => expect(detailsButton).toHaveFocus()) + }) + + it('puts the essential attack link in the first column with bounded provenance', () => { + renderPage() + + const attackLink = screen.getByRole('link', { name: 'Open attack attack-result-1' }) + expect(attackLink).toHaveAttribute( + 'href', + `/attacks/attack-result-1?scenarioResultId=${SCENARIO_RESULT_ID}`, + ) + expect(attackLink).toHaveTextContent('attack-result-1') + const attemptsTable = screen.getByRole('table', { name: 'Persisted attack attempts' }) + expect(within(attemptsTable).getByRole('columnheader', { name: 'Attack' })).toBeInTheDocument() + const firstBodyRow = within(attemptsTable).getAllByRole('row')[1] + expect(within(firstBodyRow).getAllByRole('cell')[0]).toContainElement( + attackLink, + ) + }) + + it('navigates from non-interactive row content and browser Back returns to the run', async () => { + const user = userEvent.setup() + renderPage() + + const attemptRow = screen.getByRole('row', { + name: 'Open attack attack-result-1', + }) + await user.click(within(attemptRow).getByText('Technique One')) + + expect(screen.getByTestId('attack-route')).toBeInTheDocument() + expect(screen.getByTestId('attack-route')).toHaveAttribute( + 'data-location', + `/attacks/attack-result-1?scenarioResultId=${SCENARIO_RESULT_ID}`, + ) + + await user.click(screen.getByRole('button', { name: 'Browser back' })) + + expect(screen.getByRole('heading', { name: 'test.scenario', level: 1 })).toBeInTheDocument() + }) + + it('supports Enter and Space row activation', async () => { + const user = userEvent.setup() + renderPage() + const row = screen.getByRole('row', { name: 'Open attack attack-result-1' }) + + row.focus() + await user.keyboard('{Enter}') + expect(screen.getByTestId('attack-route')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Browser back' })) + + const restoredRow = screen.getByRole('row', { name: 'Open attack attack-result-1' }) + restoredRow.focus() + await user.keyboard(' ') + expect(screen.getByTestId('attack-route')).toBeInTheDocument() + }) + + it('does not hijack modified, non-primary, or nested-control clicks', async () => { + const user = userEvent.setup() + renderPage() + const row = screen.getByRole('row', { name: 'Open attack attack-result-1' }) + + fireEvent.click(row, { ctrlKey: true }) + fireEvent.click(row, { metaKey: true }) + fireEvent.click(row, { shiftKey: true }) + fireEvent.click(row, { altKey: true }) + fireEvent.click(row, { button: 1 }) + expect(screen.queryByTestId('attack-route')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { + name: 'View details for attack attempt attack-result-1', + })) + expect(screen.getByRole('dialog', { name: 'Attack attempt details' })).toBeInTheDocument() + expect(screen.queryByTestId('attack-route')).not.toBeInTheDocument() + }) + + it('leaves modified first-column link clicks to native new-tab behavior', () => { + renderPage() + const link = screen.getByRole('link', { name: 'Open attack attack-result-1' }) + const modifiedClick = new MouseEvent('click', { + bubbles: true, + cancelable: true, + ctrlKey: true, + }) + + expect(link.dispatchEvent(modifiedClick)).toBe(true) + expect(modifiedClick.defaultPrevented).toBe(false) + expect(screen.queryByTestId('attack-route')).not.toBeInTheDocument() + }) + + it('renders loading, not-found, and initial error states with accessible recovery', () => { + mockHookState({ ...INITIAL_SCENARIO_RUN_PROGRESS_STATE }) + const { unmount } = renderPage() + expect(screen.getByLabelText('Loading scenario run')).toBeInTheDocument() + unmount() + + mockHookState({ + ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, + loadStatus: 'not-found', + error: 'Run not found', + }) + const notFound = renderPage() + expect(screen.getByRole('heading', { name: 'Scenario run not found' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + notFound.unmount() + + mockHookState({ + ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, + loadStatus: 'error', + error: 'Backend unavailable', + }) + renderPage() + expect(screen.getByRole('heading', { name: 'Unable to load scenario run' })).toBeInTheDocument() + expect(screen.getByText('Backend unavailable')).toBeInTheDocument() + }) + + it('decodes route IDs and does not offer cancellation for terminal runs', () => { + mockHookState(makeState({ + run: { + scenario_result_id: 'run/1', + scenario_name: 'TestScenario', + scenario_registry_name: 'test.scenario', + scenario_version: 1, + status: 'COMPLETED', + created_at: '2026-01-01T00:00:00Z', + completed_at: '2026-01-01T00:01:00Z', + }, + })) + + renderPage('/scenario-history/run%2F1') + + expect(mockUseScenarioRunProgress).toHaveBeenCalledWith('run/1') + expect(screen.queryByRole('button', { name: 'Cancel run' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx new file mode 100644 index 0000000000..b2516de87e --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -0,0 +1,790 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { + Badge, + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + MessageBar, + MessageBarActions, + MessageBarBody, + mergeClasses, + ProgressBar, + Skeleton, + SkeletonItem, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + Text, +} from '@fluentui/react-components' +import { + ArrowLeftRegular, + ArrowSyncRegular, + CheckmarkCircleRegular, + DismissCircleRegular, + ErrorCircleRegular, + EyeRegular, + StopRegular, +} from '@fluentui/react-icons' +import { Link, useNavigate, useParams } from 'react-router' + +import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import { scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { + ScenarioProgressResult, + ScenarioRunState, +} from '@/types' +import { + attackRoutePath, + routerPathParamValue, +} from '@/utils/routeParams' +import { + getAtomicGroupRollups, + getElapsedMilliseconds, + getEtaMilliseconds, + getOverallProgress, + getSeedGroupRollups, + getTechniqueRollups, + isTerminalRunState, +} from '@/utils/scenarioRunProgress' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +const CLOCK_REFRESH_INTERVAL_MS = 1_000 +const OBJECTIVE_PREVIEW_LENGTH = 96 +const INTERACTIVE_ELEMENT_SELECTOR = 'a, button, input, select, textarea, [role="button"], [role="link"]' + +const RUN_BADGE_COLORS: Record = { + CREATED: 'informative', + IN_PROGRESS: 'brand', + COMPLETED: 'success', + FAILED: 'danger', + CANCELLED: 'warning', +} + +const OUTCOME_BADGE_COLORS: Record = { + success: 'success', + failure: 'danger', + error: 'warning', + undetermined: 'informative', +} + +export default function ScenarioRunPage() { + const { scenarioResultId: encodedId } = useParams<{ scenarioResultId: string }>() + return +} + +interface ScenarioRunPageContentProps { + readonly scenarioResultId: string +} + +function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProps) { + const styles = useScenarioRunPageStyles() + const navigate = useNavigate() + const { state, retry, applyRunSummary } = useScenarioRunProgress(scenarioResultId) + const [nowMilliseconds, setNowMilliseconds] = useState(() => Date.now()) + const [cancelDialogOpen, setCancelDialogOpen] = useState(false) + const [cancelling, setCancelling] = useState(false) + const [cancelError, setCancelError] = useState(null) + const [selectedAttempt, setSelectedAttempt] = useState(null) + const detailsTriggerRef = useRef(null) + + const overall = useMemo(() => getOverallProgress(state), [state]) + const techniques = useMemo(() => getTechniqueRollups(state), [state]) + const seedGroups = useMemo(() => getSeedGroupRollups(state), [state]) + const atomicGroups = useMemo(() => getAtomicGroupRollups(state), [state]) + const seedObjectives = useMemo( + () => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed.objective]) ?? []), + [state.plan], + ) + const atomicGroupNames = useMemo( + () => new Map(atomicGroups.map((group) => [group.id, group.displayGroup])), + [atomicGroups], + ) + + useEffect(() => { + if (!state.run || isTerminalRunState(state.run.status)) { + return + } + const timer = setInterval(() => setNowMilliseconds(Date.now()), CLOCK_REFRESH_INTERVAL_MS) + return () => clearInterval(timer) + }, [state.run]) + + const closeAttemptDetails = (): void => { + setSelectedAttempt(null) + requestAnimationFrame(() => detailsTriggerRef.current?.focus()) + } + + const openAttemptDetails = ( + attempt: ScenarioProgressResult, + trigger: HTMLButtonElement, + ): void => { + detailsTriggerRef.current = trigger + setSelectedAttempt(attempt) + } + + const handleCancel = async (): Promise => { + setCancelling(true) + setCancelError(null) + try { + const run = await scenariosApi.cancelRun(scenarioResultId) + applyRunSummary(run) + setCancelDialogOpen(false) + } catch (error: unknown) { + setCancelError(toApiError(error).detail) + } finally { + setCancelling(false) + } + } + + if (state.loadStatus === 'loading' && !state.run) { + return ( +
+
+ + Back to scanners + +
+ + +
+ +
+ +
+ Loading scenario run... +
+
+
+ ) + } + + if (state.loadStatus === 'not-found' && !state.run) { + return ( +
+
+ + Back to scanners + +
+ + Scenario run not found + {state.error} + +
+
+
+ ) + } + + if (state.loadStatus === 'error' && !state.run) { + return ( +
+
+ + Back to scanners + +
+ + Unable to load scenario run + {state.error} + +
+
+
+ ) + } + + if (!state.run) { + return null + } + + const run = state.run + const canCancel = run.status === 'CREATED' || run.status === 'IN_PROGRESS' + const elapsed = getElapsedMilliseconds(run, nowMilliseconds) + const eta = getEtaMilliseconds(state, nowMilliseconds) + const progressText = overall.planned === null + ? `${overall.completed} known completed units; planned total unavailable` + : `${overall.completed} of ${overall.planned} executable units completed` + + return ( +
+
+ + Back to scanners + + +
+
+
+ + {run.scenario_registry_name ?? run.scenario_name} + + + {formatRunState(run.status)} + +
+ {run.scenario_registry_name && run.scenario_registry_name !== run.scenario_name && ( + {run.scenario_name} + )} + + Run ID: {run.scenario_result_id} + +
+ {canCancel && ( +
+ +
+ )} +
+ +
+
+ Scenario version + {run.scenario_version} +
+
+ Created + {formatTimestamp(run.created_at)} +
+
+ Completed + {run.completed_at ? formatTimestamp(run.completed_at) : 'Not yet'} +
+
+ + {state.stale && ( + + + Live updates paused. Showing the last successfully loaded progress. {state.error} + + + + + + )} + + {run.status === 'FAILED' && ( + + + This run ended before all planned executable units completed. Persisted attempts remain available below. + + + )} + + {!state.planComplete && ( + + + This legacy run has no complete persisted execution plan. Known groups and attempts are shown, but planned totals and ETA are unavailable. + + + )} + +
+
+ + Overall progress + + {progressText} +
+
+
+
+ {progressText} + {overall.percent !== null && {overall.percent}%} +
+ {overall.percent !== null ? ( + + ) : ( + Progress percentage unavailable + )} +
+
+ Elapsed + + {formatDuration(elapsed)} + +
+
+ Estimated remaining + + {eta === null ? 'Unavailable' : formatDuration(eta)} + +
+
+ + {isTerminalRunState(run.status) ? `Run ${formatRunState(run.status)}` : ''} + +
+ +
+
+ + Technique summary + + Success is measured over evaluated non-error units. +
+ {techniques.length === 0 ? ( + + ) : ( +
+ {techniques.map((technique) => ( +
+
+ {technique.displayGroup} + {formatSuccess(technique.succeeded, technique.evaluated, technique.successPercent)} +
+ + {technique.atomicAttackNames.join(', ')} + +
+ + + +
+
+ ))} +
+ )} +
+ +
+
+ + Atomic attack groups + + Running groups are listed first. +
+ {atomicGroups.length === 0 ? ( + + ) : ( +
+ + + + Status + Display group + Attack + Completed + Success + Errors + Retries + + + + {atomicGroups.map((group) => ( + + + {group.displayGroup} + {group.atomicAttackName || 'Persisted attack'} + + {formatCompletion(group.completed, group.planned, state.planComplete)} + + + {formatSuccess(group.succeeded, group.evaluated, group.successPercent)} + + {group.errors} + {group.retries} + + ))} + +
+
+ )} +
+ +
+
+ + Logical seed groups + + Aggregated across techniques. +
+ {seedGroups.length === 0 ? ( + + ) : ( +
+ + + + Objective + Completed + Success + Errors + Retries + + + + {seedGroups.map((seed) => ( + + + {objectivePreview(seed.objective, seed.id)} + + + {formatCompletion(seed.completed, seed.planned, state.planComplete)} + + + {formatSuccess(seed.succeeded, seed.evaluated, seed.successPercent)} + + {seed.errors} + {seed.retries} + + ))} + +
+
+ )} +
+ +
+
+ + Persisted attack attempts + + {state.results.length} attempts +
+ {state.results.length === 0 ? ( + + ) : ( +
+ + + + Attack + Outcome + Group + Seed + Objective + Execution + Retries / error + Timestamp + + + + {[...state.results].reverse().map((attempt) => { + const attackDestination = attackRoutePath( + attempt.attack_result_id, + scenarioResultId, + ) + return ( + { + if (!shouldIgnoreAttemptRowClick(event)) { + navigate(attackDestination) + } + }} + onKeyDown={(event) => { + if ( + (event.key === 'Enter' || event.key === ' ') + && !hasActivationModifier(event) + && !isInteractiveTarget(event.target) + ) { + event.preventDefault() + navigate(attackDestination) + } + }} + > + + event.stopPropagation()} + > + + {attempt.attack_result_id} + + + + + + {formatOutcome(attempt.outcome)} + + + {atomicGroupNames.get(attempt.atomic_group_id) ?? attempt.atomic_attack_name} + {attempt.seed_group_id} + + + + {formatDuration(attempt.execution_time_ms)} + + {attempt.outcome === 'error' + ? attempt.error_message ?? attempt.error_type ?? 'Error' + : `${attempt.total_retries} retries`} + + {formatTimestamp(attempt.timestamp)} + + ) + })} + +
+
+ )} +
+
+ + { + if (!cancelling) { + setCancelDialogOpen(data.open) + } + }} + > + + + Cancel this scenario run? + + + In-flight work will be stopped. Attempts already persisted will remain available in this dashboard. + + {cancelError && ( + + {cancelError} + + )} + + + + + + + + + + { + if (!data.open) { + closeAttemptDetails() + } + }} + > + + + Attack attempt details + {selectedAttempt && ( + +
+ Objective + + {seedObjectives.get(selectedAttempt.seed_group_id) ?? 'Objective text unavailable for this legacy attempt.'} + +
+
+ + + + + + + + +
+ {selectedAttempt.outcome === 'error' && ( + + + {selectedAttempt.error_type ? `${selectedAttempt.error_type}: ` : ''} + {selectedAttempt.error_message ?? 'No error detail was persisted.'} + + + )} +
+ )} + + + +
+
+
+
+ ) +} + +interface MetricProps { + readonly label: string + readonly value: string +} + +function Metric({ label, value }: MetricProps) { + const styles = useScenarioRunPageStyles() + return ( +
+ {label} + {value} +
+ ) +} + +interface EmptyStateProps { + readonly text: string +} + +function EmptyState({ text }: EmptyStateProps) { + const styles = useScenarioRunPageStyles() + return ( +
+ {text} +
+ ) +} + +interface AtomicStatusBadgeProps { + readonly status: 'Running' | 'Pending' | 'Incomplete' | 'Completed' +} + +function AtomicStatusBadge({ status }: AtomicStatusBadgeProps) { + const color = status === 'Running' + ? 'brand' + : status === 'Completed' + ? 'success' + : status === 'Incomplete' + ? 'warning' + : 'informative' + return {status} +} + +function formatRunState(status: ScenarioRunState): string { + return status.toLowerCase().replace('_', ' ').replace(/^\w/, (letter) => letter.toUpperCase()) +} + +function formatOutcome(outcome: ScenarioProgressResult['outcome']): string { + return outcome.replace(/^\w/, (letter) => letter.toUpperCase()) +} + +function statusIcon(status: ScenarioRunState): React.ReactElement { + if (status === 'COMPLETED') { + return + } + if (status === 'FAILED') { + return + } + if (status === 'CANCELLED') { + return + } + return +} + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) { + return 'Unavailable' + } + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function formatDuration(milliseconds: number): string { + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + return 'Unavailable' + } + const totalSeconds = Math.floor(milliseconds / 1_000) + const hours = Math.floor(totalSeconds / 3_600) + const minutes = Math.floor((totalSeconds % 3_600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return `${hours}h ${minutes}m` + } + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + return `${seconds}s` +} + +function formatSuccess(succeeded: number, evaluated: number, percent: number | null): string { + return percent === null ? `${succeeded}/${evaluated} —` : `${succeeded}/${evaluated} (${percent}%)` +} + +function formatCompletion(completed: number, planned: number, planComplete: boolean): string { + return planComplete ? `${completed}/${planned}` : `${completed}/total unavailable` +} + +function objectivePreview(objective: string | null, fallbackId: string): string { + if (!objective) { + return `Objective unavailable (${fallbackId})` + } + if (objective.length <= OBJECTIVE_PREVIEW_LENGTH) { + return objective + } + return `${objective.slice(0, OBJECTIVE_PREVIEW_LENGTH - 1)}…` +} + +function shouldIgnoreAttemptRowClick(event: React.MouseEvent): boolean { + return event.button !== 0 + || hasActivationModifier(event) + || isInteractiveTarget(event.target) +} + +function hasActivationModifier( + event: Pick + | Pick, +): boolean { + return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey +} + +function isInteractiveTarget(target: EventTarget): boolean { + return target instanceof Element && target.closest(INTERACTIVE_ELEMENT_SELECTOR) !== null +} diff --git a/frontend/src/components/Scenarios/ScenarioRunStarted.styles.ts b/frontend/src/components/Scenarios/ScenarioRunStarted.styles.ts deleted file mode 100644 index a405d923c1..0000000000 --- a/frontend/src/components/Scenarios/ScenarioRunStarted.styles.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index db78c912a9..0000000000 --- a/frontend/src/components/Scenarios/ScenarioRunStarted.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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 scanners/i })).toHaveAttribute('href', '/scanner') - }) -}) diff --git a/frontend/src/components/Scenarios/ScenarioRunStarted.tsx b/frontend/src/components/Scenarios/ScenarioRunStarted.tsx deleted file mode 100644 index 4cc7c3c575..0000000000 --- a/frontend/src/components/Scenarios/ScenarioRunStarted.tsx +++ /dev/null @@ -1,125 +0,0 @@ -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 scanners - - - 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/hooks/useScenarioRunProgress.test.tsx b/frontend/src/hooks/useScenarioRunProgress.test.tsx new file mode 100644 index 0000000000..d0a7791a08 --- /dev/null +++ b/frontend/src/hooks/useScenarioRunProgress.test.tsx @@ -0,0 +1,434 @@ +import { act, renderHook, waitFor } from '@testing-library/react' + +import { scenariosApi } from '@/services/api' +import type { + ScenarioProgressResult, + ScenarioRunProgress, + ScenarioRunSummary, +} from '@/types' + +import { + SCENARIO_RUN_POLL_INTERVAL_MS, + useScenarioRunProgress, +} from './useScenarioRunProgress' + +jest.mock('@/services/api', () => ({ + scenariosApi: { + getRunProgress: jest.fn(), + }, +})) + +const mockGetRunProgress = scenariosApi.getRunProgress as jest.Mock + +function makeResult(id: string): ScenarioProgressResult { + return { + attack_result_id: id, + atomic_group_id: 'group-1', + atomic_attack_name: 'attack-1', + seed_group_id: 'seed-1', + outcome: 'success', + execution_time_ms: 1_000, + timestamp: '2026-01-01T00:00:01Z', + total_retries: 0, + retries: [], + } +} + +function makePage(overrides: Partial = {}): ScenarioRunProgress { + return { + run: { + scenario_result_id: 'run-1', + scenario_name: 'TestScenario', + scenario_registry_name: 'test.scenario', + scenario_version: 1, + status: 'IN_PROGRESS', + created_at: '2026-01-01T00:00:00Z', + }, + plan: { + version: 1, + scenario_registry_name: 'test.scenario', + atomic_groups: [], + seed_groups: [], + }, + reset: false, + active_atomic_group_ids: [], + results: [], + next_cursor: null, + has_more: false, + plan_complete: true, + ...overrides, + } +} + +function makeSummary(overrides: Partial = {}): ScenarioRunSummary { + return { + scenario_result_id: 'run-1', + scenario_name: 'TestScenario', + scenario_version: 1, + status: 'IN_PROGRESS', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:01Z', + techniques_used: [], + total_attacks: 1, + completed_attacks: 0, + objective_achieved_rate: 0, + failed_attacks: [], + attack_retries: [], + total_retries: 0, + labels: {}, + ...overrides, + } +} + +describe('useScenarioRunProgress', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('loads the plan and immediately drains all available delta pages', async () => { + mockGetRunProgress + .mockResolvedValueOnce(makePage({ + results: [makeResult('attempt-1')], + next_cursor: 'cursor-1', + has_more: true, + })) + .mockResolvedValueOnce(makePage({ + plan: null, + results: [makeResult('attempt-2')], + next_cursor: 'cursor-2', + has_more: false, + })) + + const { result, unmount } = renderHook(() => useScenarioRunProgress('run-1')) + + await waitFor(() => expect(result.current.state.results).toHaveLength(2)) + expect(mockGetRunProgress).toHaveBeenNthCalledWith( + 1, + 'run-1', + { since: undefined, limit: 500 }, + expect.any(AbortSignal), + ) + expect(mockGetRunProgress).toHaveBeenNthCalledWith( + 2, + 'run-1', + { since: 'cursor-1', limit: 500 }, + expect.any(AbortSignal), + ) + unmount() + }) + + it('polls after 2.5 seconds from the last successfully applied cursor', async () => { + jest.useFakeTimers() + mockGetRunProgress + .mockResolvedValueOnce(makePage({ next_cursor: 'cursor-1' })) + .mockResolvedValueOnce(makePage({ plan: null, next_cursor: 'cursor-2' })) + + const { unmount } = renderHook(() => useScenarioRunProgress('run-1')) + await act(async () => Promise.resolve()) + + await act(async () => { + await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) + }) + + expect(mockGetRunProgress).toHaveBeenNthCalledWith( + 2, + 'run-1', + { since: 'cursor-1', limit: 500 }, + expect.any(AbortSignal), + ) + unmount() + }) + + it('isolates cursors when the run ID changes while preserving same-run polling', async () => { + jest.useFakeTimers() + mockGetRunProgress + .mockResolvedValueOnce(makePage({ next_cursor: 'run-a-cursor' })) + .mockResolvedValueOnce(makePage({ + run: { ...makePage().run, scenario_result_id: 'run-b' }, + next_cursor: 'run-b-cursor', + })) + .mockResolvedValueOnce(makePage({ + run: { ...makePage().run, scenario_result_id: 'run-b' }, + plan: null, + next_cursor: 'run-b-next-cursor', + })) + + const { rerender, unmount } = renderHook( + ({ runId }) => useScenarioRunProgress(runId), + { initialProps: { runId: 'run-a' } }, + ) + await act(async () => Promise.resolve()) + + rerender({ runId: 'run-b' }) + await act(async () => Promise.resolve()) + + expect(mockGetRunProgress).toHaveBeenNthCalledWith( + 2, + 'run-b', + { since: undefined, limit: 500 }, + expect.any(AbortSignal), + ) + + await act(async () => { + await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) + }) + expect(mockGetRunProgress).toHaveBeenNthCalledWith( + 3, + 'run-b', + { since: 'run-b-cursor', limit: 500 }, + expect.any(AbortSignal), + ) + unmount() + }) + + it('transitions a queued run to active progress on a later poll', async () => { + jest.useFakeTimers() + mockGetRunProgress + .mockResolvedValueOnce(makePage({ + run: { ...makePage().run, status: 'QUEUED', queue_position: 1 }, + next_cursor: 'cursor-1', + })) + .mockResolvedValueOnce(makePage({ + run: { ...makePage().run, status: 'IN_PROGRESS', queue_position: null }, + plan: null, + next_cursor: 'cursor-1', + })) + + const { result, unmount } = renderHook(() => useScenarioRunProgress('run-1')) + await waitFor(() => expect(result.current.state.run?.status).toBe('QUEUED')) + await act(async () => { + await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) + }) + + expect(result.current.state.run?.status).toBe('IN_PROGRESS') + unmount() + }) + + it('does not overlap polls while a request remains in flight', async () => { + jest.useFakeTimers() + let resolvePoll: ((page: ScenarioRunProgress) => void) | undefined + mockGetRunProgress + .mockResolvedValueOnce(makePage({ next_cursor: 'cursor-1' })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolvePoll = resolve + })) + + const { unmount } = renderHook(() => useScenarioRunProgress('run-1')) + await act(async () => Promise.resolve()) + await act(async () => { + await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS * 4) + }) + + expect(mockGetRunProgress).toHaveBeenCalledTimes(2) + await act(async () => { + resolvePoll?.(makePage({ plan: null, next_cursor: 'cursor-2' })) + }) + unmount() + }) + + it('stops permanently when a terminal page is received', async () => { + jest.useFakeTimers() + mockGetRunProgress.mockResolvedValueOnce(makePage({ + run: { ...makePage().run, status: 'COMPLETED', completed_at: '2026-01-01T00:01:00Z' }, + })) + + const { unmount } = renderHook(() => useScenarioRunProgress('run-1')) + await act(async () => Promise.resolve()) + await act(async () => { + await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS * 3) + }) + + expect(mockGetRunProgress).toHaveBeenCalledTimes(1) + unmount() + }) + + it('aborts a stale request when the route ID changes', async () => { + const signals: AbortSignal[] = [] + mockGetRunProgress.mockImplementation( + (_runId: string, _params: unknown, signal: AbortSignal) => { + signals.push(signal) + return new Promise(() => {}) + }, + ) + + const { rerender, unmount } = renderHook( + ({ runId }) => useScenarioRunProgress(runId), + { initialProps: { runId: 'run-1' } }, + ) + await waitFor(() => expect(signals).toHaveLength(1)) + + rerender({ runId: 'run-2' }) + + expect(signals[0].aborted).toBe(true) + await waitFor(() => expect(signals).toHaveLength(2)) + unmount() + expect(signals[1].aborted).toBe(true) + }) + + it('treats a blank run ID as not found without issuing a request', async () => { + const { result } = renderHook(() => useScenarioRunProgress(' ')) + + await waitFor(() => expect(result.current.state.loadStatus).toBe('not-found')) + expect(mockGetRunProgress).not.toHaveBeenCalled() + }) + + it('treats an HTTP 404 as not found', async () => { + mockGetRunProgress.mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 404, + data: { detail: 'Scenario run not found.' }, + }, + }) + + const { result } = renderHook(() => useScenarioRunProgress('missing-run')) + + await waitFor(() => expect(result.current.state.loadStatus).toBe('not-found')) + expect(result.current.state.error).toBe('Scenario run not found.') + }) + + it('ignores a stale page that resolves after the run ID changes', async () => { + let resolveOldRequest: ((page: ScenarioRunProgress) => void) | undefined + mockGetRunProgress.mockImplementation((runId: string) => { + if (runId === 'run-1') { + return new Promise((resolve) => { + resolveOldRequest = resolve + }) + } + return Promise.resolve(makePage({ + run: { + ...makePage().run, + scenario_result_id: 'run-2', + }, + })) + }) + + const { result, rerender, unmount } = renderHook( + ({ runId }) => useScenarioRunProgress(runId), + { initialProps: { runId: 'run-1' } }, + ) + await waitFor(() => expect(mockGetRunProgress).toHaveBeenCalledTimes(1)) + rerender({ runId: 'run-2' }) + await waitFor(() => expect(result.current.state.run?.scenario_result_id).toBe('run-2')) + + await act(async () => { + resolveOldRequest?.(makePage()) + }) + expect(result.current.state.run?.scenario_result_id).toBe('run-2') + unmount() + }) + + it('ignores a stale failure after the run ID changes', async () => { + let rejectOldRequest: ((reason?: unknown) => void) | undefined + mockGetRunProgress.mockImplementation((runId: string) => { + if (runId === 'run-1') { + return new Promise((_resolve, reject) => { + rejectOldRequest = reject + }) + } + return Promise.resolve(makePage({ + run: { + ...makePage().run, + scenario_result_id: 'run-2', + }, + })) + }) + + const { result, rerender, unmount } = renderHook( + ({ runId }) => useScenarioRunProgress(runId), + { initialProps: { runId: 'run-1' } }, + ) + await waitFor(() => expect(mockGetRunProgress).toHaveBeenCalledTimes(1)) + rerender({ runId: 'run-2' }) + await waitFor(() => expect(result.current.state.run?.scenario_result_id).toBe('run-2')) + + await act(async () => { + rejectOldRequest?.(new Error('late failure')) + }) + expect(result.current.state.error).toBeNull() + unmount() + }) + + it('retries from the last good cursor after a transient failure', async () => { + jest.useFakeTimers() + mockGetRunProgress + .mockResolvedValueOnce(makePage({ next_cursor: 'cursor-1' })) + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(makePage({ plan: null, next_cursor: 'cursor-2' })) + + const { result, unmount } = renderHook(() => useScenarioRunProgress('run-1')) + await act(async () => Promise.resolve()) + await act(async () => { + await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) + }) + expect(result.current.state.stale).toBe(true) + + act(() => result.current.retry()) + await act(async () => Promise.resolve()) + + expect(mockGetRunProgress).toHaveBeenNthCalledWith( + 3, + 'run-1', + { since: 'cursor-1', limit: 500 }, + expect.any(AbortSignal), + ) + unmount() + }) + + it('fetches final persisted deltas after applying a cancellation summary', async () => { + mockGetRunProgress + .mockResolvedValueOnce(makePage({ next_cursor: 'cursor-1' })) + .mockResolvedValueOnce(makePage({ + run: { + ...makePage().run, + status: 'CANCELLED', + completed_at: '2026-01-01T00:00:02Z', + }, + plan: null, + results: [makeResult('final-attempt')], + next_cursor: 'cursor-2', + })) + + const { result, unmount } = renderHook(() => useScenarioRunProgress('run-1')) + await waitFor(() => expect(mockGetRunProgress).toHaveBeenCalledTimes(1)) + + act(() => { + result.current.applyRunSummary(makeSummary({ + status: 'CANCELLED', + updated_at: '2026-01-01T00:00:02Z', + completed_attacks: 1, + objective_achieved_rate: 100, + })) + }) + + await waitFor(() => expect(result.current.state.results).toEqual([makeResult('final-attempt')])) + expect(mockGetRunProgress).toHaveBeenLastCalledWith( + 'run-1', + { since: 'cursor-1', limit: 500 }, + expect.any(AbortSignal), + ) + unmount() + }) + + it('applies a nonterminal run summary without forcing a catch-up request', async () => { + mockGetRunProgress.mockResolvedValueOnce(makePage({ next_cursor: 'cursor-1' })) + const { result, unmount } = renderHook(() => useScenarioRunProgress('run-1')) + await waitFor(() => expect(result.current.state.cursor).toBe('cursor-1')) + mockGetRunProgress.mockClear() + + act(() => { + result.current.applyRunSummary(makeSummary({ + status: 'IN_PROGRESS', + updated_at: '2026-01-01T00:00:02Z', + })) + }) + + expect(result.current.state.run?.status).toBe('IN_PROGRESS') + expect(mockGetRunProgress).not.toHaveBeenCalled() + unmount() + }) +}) diff --git a/frontend/src/hooks/useScenarioRunProgress.tsx b/frontend/src/hooks/useScenarioRunProgress.tsx new file mode 100644 index 0000000000..58c3a7bd2b --- /dev/null +++ b/frontend/src/hooks/useScenarioRunProgress.tsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from 'react' + +import { scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { ScenarioRunSummary } from '@/types' +import { + INITIAL_SCENARIO_RUN_PROGRESS_STATE, + isTerminalRunState, + scenarioRunProgressReducer, + type ScenarioRunProgressState, +} from '@/utils/scenarioRunProgress' + +export const SCENARIO_RUN_POLL_INTERVAL_MS = 2_500 +const PROGRESS_PAGE_LIMIT = 500 + +export interface UseScenarioRunProgressResult { + readonly state: ScenarioRunProgressState + readonly retry: () => void + readonly applyRunSummary: (run: ScenarioRunSummary) => void +} + +export function useScenarioRunProgress(scenarioResultId: string): UseScenarioRunProgressResult { + const [state, dispatch] = useReducer( + scenarioRunProgressReducer, + INITIAL_SCENARIO_RUN_PROGRESS_STATE, + ) + const [retryEpoch, setRetryEpoch] = useState(0) + const cursorRef = useRef(null) + const cursorScenarioResultIdRef = useRef(scenarioResultId) + const abortControllerRef = useRef(null) + const timerRef = useRef | null>(null) + const pollingStoppedRef = useRef(false) + + useEffect(() => { + if (cursorScenarioResultIdRef.current !== scenarioResultId) { + cursorScenarioResultIdRef.current = scenarioResultId + cursorRef.current = null + } + + let active = true + pollingStoppedRef.current = false + + const clearPollTimer = (): void => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + + const fetchPage = async (since: string | null): Promise => { + if (!active || pollingStoppedRef.current) { + return + } + const controller = new AbortController() + abortControllerRef.current = controller + try { + const page = await scenariosApi.getRunProgress( + scenarioResultId, + { since: since ?? undefined, limit: PROGRESS_PAGE_LIMIT }, + controller.signal, + ) + if (!active || pollingStoppedRef.current) { + return + } + + const appliedCursor = page.next_cursor ?? since + cursorRef.current = appliedCursor + dispatch({ type: 'apply-page', page, fresh: since === null }) + + if (page.has_more) { + await fetchPage(appliedCursor) + return + } + if (isTerminalRunState(page.run.status)) { + pollingStoppedRef.current = true + return + } + clearPollTimer() + timerRef.current = setTimeout(() => { + timerRef.current = null + void fetchPage(cursorRef.current) + }, SCENARIO_RUN_POLL_INTERVAL_MS) + } catch (error: unknown) { + if (!active || controller.signal.aborted) { + return + } + const apiError = toApiError(error) + dispatch({ + type: 'request-failed', + message: apiError.detail, + notFound: apiError.status === 404, + }) + } + } + + if (!scenarioResultId.trim()) { + dispatch({ + type: 'request-failed', + message: 'The scenario run ID in this URL is missing or invalid.', + notFound: true, + }) + } else { + void fetchPage(cursorRef.current) + } + + return () => { + active = false + clearPollTimer() + abortControllerRef.current?.abort() + abortControllerRef.current = null + } + }, [scenarioResultId, retryEpoch]) + + const retry = useCallback((): void => { + dispatch({ type: 'retry' }) + pollingStoppedRef.current = false + setRetryEpoch((epoch) => epoch + 1) + }, []) + + const applyRunSummary = useCallback((run: ScenarioRunSummary): void => { + dispatch({ type: 'apply-run-summary', run }) + if (isTerminalRunState(run.status)) { + pollingStoppedRef.current = false + setRetryEpoch((epoch) => epoch + 1) + } + }, []) + + return { state, retry, applyRunSummary } +} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index e4cf5983ba..ee1e72c2e9 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -759,11 +759,37 @@ describe("api service", () => { }; (apiClient.get as jest.Mock).mockResolvedValueOnce(mockResponse); - await scenariosApi.getRunProgress("sr-1", { since: "cursor-1", limit: 50 }); + const controller = new AbortController(); + await scenariosApi.getRunProgress( + "sr-1", + { since: "cursor-1", limit: 50 }, + controller.signal, + ); expect(apiClient.get).toHaveBeenCalledWith("/scenarios/runs/sr-1/progress", { params: { since: "cursor-1", limit: 50 }, + signal: controller.signal, }); }); + + it("cancels a scenario run by id", async () => { + const mockResponse = { + data: { + scenario_result_id: "sr-1", + status: "CANCELLED", + }, + }; + const controller = new AbortController(); + (apiClient.post as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await scenariosApi.cancelRun("sr/1", controller.signal); + + expect(apiClient.post).toHaveBeenCalledWith( + "/scenarios/runs/sr%2F1/cancel", + undefined, + { signal: controller.signal }, + ); + expect(result.status).toBe("CANCELLED"); + }); }); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0fc41d0853..4238e04013 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -457,10 +457,20 @@ export const scenariosApi = { getRunProgress: async ( scenarioResultId: string, params?: { since?: string; limit?: number }, + signal?: AbortSignal, ): Promise => { const response = await apiClient.get( `/scenarios/runs/${encodeURIComponent(scenarioResultId)}/progress`, - { params }, + { params, signal }, + ) + return response.data + }, + + cancelRun: async (scenarioResultId: string, signal?: AbortSignal): Promise => { + const response = await apiClient.post( + `/scenarios/runs/${encodeURIComponent(scenarioResultId)}/cancel`, + undefined, + { signal }, ) return response.data }, diff --git a/frontend/src/utils/routeParams.test.ts b/frontend/src/utils/routeParams.test.ts index 87bdfa2369..ec23355b50 100644 --- a/frontend/src/utils/routeParams.test.ts +++ b/frontend/src/utils/routeParams.test.ts @@ -1,6 +1,18 @@ -import { routerPathParamValue } from './routeParams' +import { + attackConversationRoutePath, + attackRoutePath, + routerPathParamValue, + scenarioRunProvenance, + scenarioRunRoutePath, +} from './routeParams' + +const SCENARIO_RESULT_ID = '123e4567-e89b-12d3-a456-426614174000' describe('routerPathParamValue', () => { + it('returns an empty value for a missing route parameter', () => { + expect(routerPathParamValue(undefined)).toBe('') + }) + it('restores slashes re-escaped by React Router', () => { expect(routerPathParamValue('foundry%2Fred_team_agent')).toBe('foundry/red_team_agent') }) @@ -10,3 +22,41 @@ describe('routerPathParamValue', () => { expect(routerPathParamValue('%zz')).toBe('%zz') }) }) + +describe('scenario run provenance routes', () => { + it('reads one canonical UUID and ignores unrelated query values', () => { + const params = new URLSearchParams(`tab=messages&scenarioResultId=${SCENARIO_RESULT_ID}`) + + expect(scenarioRunProvenance(params)).toBe(SCENARIO_RESULT_ID) + }) + + it.each([ + '', + 'scenarioResultId=run-1', + 'scenarioResultId=https%3A%2F%2Fevil.example%2Freturn', + `scenarioResultId=${'a'.repeat(100)}`, + `scenarioResultId=${SCENARIO_RESULT_ID}&scenarioResultId=${SCENARIO_RESULT_ID}`, + ])('rejects missing, unsafe, or ambiguous provenance: %s', (query: string) => { + expect(scenarioRunProvenance(new URLSearchParams(query))).toBeNull() + }) + + it('builds encoded attack and conversation destinations with bounded provenance', () => { + expect(attackRoutePath('attack/1', SCENARIO_RESULT_ID)).toBe( + `/attacks/attack%2F1?scenarioResultId=${SCENARIO_RESULT_ID}`, + ) + expect(attackConversationRoutePath('attack/1', 'conversation/1', SCENARIO_RESULT_ID)).toBe( + `/attacks/attack%2F1/conversations/conversation%2F1?scenarioResultId=${SCENARIO_RESULT_ID}`, + ) + }) + + it('omits invalid provenance instead of serializing it', () => { + expect(attackRoutePath('attack-1', 'https://evil.example')).toBe('/attacks/attack-1') + expect(attackConversationRoutePath('attack-1', 'conversation-1', 'run-1')).toBe( + '/attacks/attack-1/conversations/conversation-1', + ) + }) + + it('builds an encoded scenario-run route from a trusted persisted ID', () => { + expect(scenarioRunRoutePath('run/1')).toBe('/scenario-history/run%2F1') + }) +}) diff --git a/frontend/src/utils/routeParams.ts b/frontend/src/utils/routeParams.ts index b028a8b16b..f2c7127a41 100644 --- a/frontend/src/utils/routeParams.ts +++ b/frontend/src/utils/routeParams.ts @@ -1,3 +1,6 @@ +const SCENARIO_RESULT_ID_QUERY_KEY = 'scenarioResultId' +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + /** * Returns the original value represented by a React Router path parameter. * @@ -9,3 +12,50 @@ export function routerPathParamValue(value: string | undefined): string { return (value ?? '').replace(/%2F/gi, '/') } + +/** Returns one validated scenario-run provenance UUID from a route query. */ +export function scenarioRunProvenance(searchParams: URLSearchParams): string | null { + const values = searchParams.getAll(SCENARIO_RESULT_ID_QUERY_KEY) + if (values.length !== 1 || !UUID_PATTERN.test(values[0])) { + return null + } + return values[0] +} + +/** Builds an attack-detail route with optional bounded scenario-run provenance. */ +export function attackRoutePath( + attackResultId: string, + scenarioResultId?: string | null, +): string { + return appendScenarioRunProvenance( + `/attacks/${encodeURIComponent(attackResultId)}`, + scenarioResultId, + ) +} + +/** Builds an attack-conversation route with optional bounded scenario-run provenance. */ +export function attackConversationRoutePath( + attackResultId: string, + conversationId: string, + scenarioResultId?: string | null, +): string { + return appendScenarioRunProvenance( + `/attacks/${encodeURIComponent(attackResultId)}/conversations/${encodeURIComponent(conversationId)}`, + scenarioResultId, + ) +} + +/** Builds the route for one scenario run. Callers must pass a trusted persisted ID. */ +export function scenarioRunRoutePath(scenarioResultId: string): string { + return `/scenario-history/${encodeURIComponent(scenarioResultId)}` +} + +function appendScenarioRunProvenance(path: string, scenarioResultId?: string | null): string { + if (!scenarioResultId || !UUID_PATTERN.test(scenarioResultId)) { + return path + } + const searchParams = new URLSearchParams({ + [SCENARIO_RESULT_ID_QUERY_KEY]: scenarioResultId, + }) + return `${path}?${searchParams.toString()}` +} diff --git a/frontend/src/utils/scenarioRunProgress.test.ts b/frontend/src/utils/scenarioRunProgress.test.ts new file mode 100644 index 0000000000..414b35bd17 --- /dev/null +++ b/frontend/src/utils/scenarioRunProgress.test.ts @@ -0,0 +1,272 @@ +import type { + ScenarioProgressResult, + ScenarioRunPlan, + ScenarioRunProgress, +} from '@/types' + +import { + INITIAL_SCENARIO_RUN_PROGRESS_STATE, + getAtomicGroupRollups, + getElapsedMilliseconds, + getEtaMilliseconds, + getOverallProgress, + getSeedGroupRollups, + getTechniqueRollups, + scenarioRunProgressReducer, + type ScenarioRunProgressState, +} from './scenarioRunProgress' + +const PLAN: ScenarioRunPlan = { + version: 1, + scenario_registry_name: 'test.scenario', + atomic_groups: [ + { + id: 'group-a', + atomic_attack_name: 'attack-a', + display_group: 'Technique A', + technique_eval_hash: 'eval-a', + seed_group_ids: ['seed-1', 'seed-2'], + }, + { + id: 'group-b', + atomic_attack_name: 'attack-b', + display_group: 'Technique B', + technique_eval_hash: 'eval-b', + seed_group_ids: ['seed-1'], + }, + ], + seed_groups: [ + { id: 'seed-1', objective_sha256: 'sha-1', objective: 'First objective' }, + { id: 'seed-2', objective_sha256: 'sha-2', objective: 'Second objective' }, + ], +} + +function makeResult( + id: string, + atomicGroupId: string, + seedGroupId: string, + outcome: ScenarioProgressResult['outcome'], + minute: number, + overrides: Partial = {}, +): ScenarioProgressResult { + return { + attack_result_id: id, + atomic_group_id: atomicGroupId, + atomic_attack_name: atomicGroupId === 'group-a' ? 'attack-a' : 'attack-b', + seed_group_id: seedGroupId, + outcome, + execution_time_ms: 1_000, + timestamp: `2026-01-01T00:${String(minute).padStart(2, '0')}:00Z`, + total_retries: 0, + retries: [], + ...overrides, + } +} + +function makePage(overrides: Partial = {}): ScenarioRunProgress { + return { + run: { + scenario_result_id: 'run-1', + scenario_name: 'TestScenario', + scenario_registry_name: 'test.scenario', + scenario_version: 1, + status: 'IN_PROGRESS', + created_at: '2026-01-01T00:00:00Z', + }, + plan: PLAN, + reset: false, + active_atomic_group_ids: [], + results: [], + next_cursor: 'cursor-1', + has_more: false, + plan_complete: true, + ...overrides, + } +} + +function readyState(results: ScenarioProgressResult[]): ScenarioRunProgressState { + return scenarioRunProgressReducer(INITIAL_SCENARIO_RUN_PROGRESS_STATE, { + type: 'apply-page', + page: makePage({ results }), + fresh: true, + }) +} + +describe('scenarioRunProgressReducer', () => { + it('merges duplicated pages idempotently by attack result id', () => { + const result = makeResult('attempt-1', 'group-a', 'seed-1', 'success', 1) + const first = readyState([result]) + const duplicate = scenarioRunProgressReducer(first, { + type: 'apply-page', + page: makePage({ plan: null, results: [result], next_cursor: 'cursor-1' }), + fresh: false, + }) + + expect(duplicate.results).toEqual([result]) + expect(duplicate.cursor).toBe('cursor-1') + }) + + it('atomically resets prior results when the server requests reset', () => { + const first = readyState([makeResult('old', 'group-a', 'seed-1', 'success', 1)]) + const replacement = makeResult('new', 'group-b', 'seed-1', 'failure', 2) + const reset = scenarioRunProgressReducer(first, { + type: 'apply-page', + page: makePage({ reset: true, results: [replacement], next_cursor: 'cursor-2' }), + fresh: false, + }) + + expect(reset.results).toEqual([replacement]) + expect(reset.cursor).toBe('cursor-2') + }) + + it('retains last-good data and marks it stale after a transient failure', () => { + const first = readyState([makeResult('attempt-1', 'group-a', 'seed-1', 'success', 1)]) + const failed = scenarioRunProgressReducer(first, { + type: 'request-failed', + message: 'Network unavailable', + notFound: false, + }) + + expect(failed.results).toHaveLength(1) + expect(failed.loadStatus).toBe('ready') + expect(failed.stale).toBe(true) + expect(failed.error).toBe('Network unavailable') + }) +}) + +describe('scenario run progress calculations', () => { + it('counts executable units once across multiple attempts and completes from the latest non-error outcome', () => { + const state = readyState([ + makeResult('error-1', 'group-a', 'seed-1', 'error', 1), + makeResult('failure-1', 'group-a', 'seed-1', 'failure', 2), + makeResult('success-1', 'group-a', 'seed-1', 'success', 3), + makeResult('error-2', 'group-a', 'seed-1', 'error', 4), + ]) + + expect(getOverallProgress(state)).toEqual({ completed: 1, planned: 3, percent: 33 }) + expect(getTechniqueRollups(state)[0]).toMatchObject({ + completed: 1, + planned: 2, + succeeded: 1, + evaluated: 1, + errors: 2, + retries: 3, + }) + }) + + it('keeps an error-only unit attempted but incomplete', () => { + const state = readyState([ + makeResult('error-1', 'group-a', 'seed-1', 'error', 1, { total_retries: 2 }), + ]) + + expect(getOverallProgress(state).completed).toBe(0) + expect(getAtomicGroupRollups(state)[0]).toMatchObject({ + completed: 0, + errors: 1, + retries: 2, + status: 'Pending', + }) + }) + + it('does not infer a planned total or percentage for legacy runs', () => { + const state = { + ...readyState([makeResult('attempt-1', 'group-a', 'seed-1', 'success', 1)]), + planComplete: false, + } + + expect(getOverallProgress(state)).toEqual({ completed: 1, planned: null, percent: null }) + expect(getEtaMilliseconds(state, Date.parse('2026-01-01T00:10:00Z'))).toBeNull() + }) + + it('calculates technique and seed rollups across techniques', () => { + const state = readyState([ + makeResult('a-1', 'group-a', 'seed-1', 'success', 1), + makeResult('a-2', 'group-a', 'seed-2', 'failure', 2), + makeResult('b-1', 'group-b', 'seed-1', 'failure', 3), + ]) + + expect(getTechniqueRollups(state)).toEqual([ + expect.objectContaining({ + displayGroup: 'Technique A', + completed: 2, + planned: 2, + succeeded: 1, + evaluated: 2, + successPercent: 50, + }), + expect.objectContaining({ + displayGroup: 'Technique B', + completed: 1, + planned: 1, + succeeded: 0, + evaluated: 1, + successPercent: 0, + }), + ]) + expect(getSeedGroupRollups(state)[0]).toMatchObject({ + id: 'seed-1', + completed: 2, + planned: 2, + succeeded: 1, + evaluated: 2, + successPercent: 50, + }) + }) + + it('sorts atomic states and lets active IDs win while a run is nonterminal', () => { + const state = { + ...readyState([ + makeResult('a-1', 'group-a', 'seed-1', 'success', 1), + makeResult('a-2', 'group-a', 'seed-2', 'failure', 2), + ]), + activeAtomicGroupIds: ['group-a'], + } + + expect(getAtomicGroupRollups(state).map((group) => [group.id, group.status])).toEqual([ + ['group-a', 'Running'], + ['group-b', 'Pending'], + ]) + }) + + it('marks unfinished groups incomplete in terminal runs', () => { + const state = { + ...readyState([makeResult('a-1', 'group-a', 'seed-1', 'success', 1)]), + run: { ...makePage().run, status: 'FAILED' as const, completed_at: '2026-01-01T00:05:00Z' }, + } + + expect(getAtomicGroupRollups(state).map((group) => [group.id, group.status])).toEqual([ + ['group-a', 'Incomplete'], + ['group-b', 'Incomplete'], + ]) + }) + + it('uses now for active elapsed time and completed_at for terminal elapsed time', () => { + const active = makePage().run + expect(getElapsedMilliseconds(active, Date.parse('2026-01-01T00:05:00Z'))).toBe(300_000) + + const terminal = { + ...active, + status: 'COMPLETED' as const, + completed_at: '2026-01-01T00:03:00Z', + } + expect(getElapsedMilliseconds(terminal, Date.parse('2026-01-01T00:05:00Z'))).toBe(180_000) + }) + + it('calculates ETA from observed wall-clock completion rate and hides unsafe estimates', () => { + const state = readyState([makeResult('a-1', 'group-a', 'seed-1', 'success', 1)]) + expect(getEtaMilliseconds(state, Date.parse('2026-01-01T00:02:00Z'))).toBe(240_000) + + expect(getEtaMilliseconds( + { ...state, results: [] }, + Date.parse('2026-01-01T00:02:00Z'), + )).toBeNull() + const run = state.run + expect(run).not.toBeNull() + if (run) { + expect(getEtaMilliseconds( + { ...state, run: { ...run, status: 'COMPLETED' } }, + Date.parse('2026-01-01T00:02:00Z'), + )).toBeNull() + } + }) +}) diff --git a/frontend/src/utils/scenarioRunProgress.ts b/frontend/src/utils/scenarioRunProgress.ts new file mode 100644 index 0000000000..3b8d3fbe23 --- /dev/null +++ b/frontend/src/utils/scenarioRunProgress.ts @@ -0,0 +1,452 @@ +import type { + ScenarioProgressHeader, + ScenarioProgressResult, + ScenarioRunPlan, + ScenarioRunPlanAtomicGroup, + ScenarioRunState, + ScenarioRunSummary, +} from '@/types' + +export type ScenarioRunLoadStatus = 'loading' | 'ready' | 'not-found' | 'error' +export type AtomicGroupStatus = 'Running' | 'Pending' | 'Incomplete' | 'Completed' + +export interface ScenarioRunProgressState { + readonly loadStatus: ScenarioRunLoadStatus + readonly run: ScenarioProgressHeader | null + readonly plan: ScenarioRunPlan | null + readonly planComplete: boolean + readonly activeAtomicGroupIds: string[] + readonly results: ScenarioProgressResult[] + readonly cursor: string | null + readonly hasMore: boolean + readonly error: string | null + readonly stale: boolean +} + +export type ScenarioRunProgressAction = + | { readonly type: 'apply-page'; readonly page: import('@/types').ScenarioRunProgress; readonly fresh: boolean } + | { readonly type: 'request-failed'; readonly message: string; readonly notFound: boolean } + | { readonly type: 'retry' } + | { readonly type: 'apply-run-summary'; readonly run: ScenarioRunSummary } + +export interface OverallProgress { + readonly completed: number + readonly planned: number | null + readonly percent: number | null +} + +export interface Rollup { + readonly completed: number + readonly planned: number + readonly succeeded: number + readonly evaluated: number + readonly successPercent: number | null + readonly errors: number + readonly retries: number +} + +export interface TechniqueRollup extends Rollup { + readonly id: string + readonly displayGroup: string + readonly atomicAttackNames: string[] +} + +export interface SeedGroupRollup extends Rollup { + readonly id: string + readonly objective: string | null +} + +export interface AtomicGroupRollup extends Rollup { + readonly id: string + readonly atomicAttackName: string + readonly displayGroup: string + readonly status: AtomicGroupStatus +} + +interface UnitAttempts { + readonly atomicGroupId: string + readonly seedGroupId: string + readonly attempts: ScenarioProgressResult[] + readonly latestAttempt: ScenarioProgressResult + readonly latestNonError: ScenarioProgressResult | null +} + +const TERMINAL_STATES: ReadonlySet = new Set(['COMPLETED', 'FAILED', 'CANCELLED']) +const ATOMIC_STATUS_ORDER: Record = { + Running: 0, + Pending: 1, + Incomplete: 2, + Completed: 3, +} + +export const INITIAL_SCENARIO_RUN_PROGRESS_STATE: ScenarioRunProgressState = { + loadStatus: 'loading', + run: null, + plan: null, + planComplete: false, + activeAtomicGroupIds: [], + results: [], + cursor: null, + hasMore: false, + error: null, + stale: false, +} + +export function isTerminalRunState(status: ScenarioRunState): boolean { + return TERMINAL_STATES.has(status) +} + +export function scenarioRunProgressReducer( + state: ScenarioRunProgressState, + action: ScenarioRunProgressAction, +): ScenarioRunProgressState { + if (action.type === 'request-failed') { + const hasGoodData = state.run !== null + return { + ...state, + loadStatus: action.notFound && !hasGoodData ? 'not-found' : hasGoodData ? 'ready' : 'error', + error: action.message, + stale: hasGoodData, + hasMore: false, + } + } + + if (action.type === 'retry') { + return { + ...state, + loadStatus: state.run ? 'ready' : 'loading', + error: null, + stale: false, + } + } + + if (action.type === 'apply-run-summary') { + return { + ...state, + loadStatus: 'ready', + run: { + scenario_result_id: action.run.scenario_result_id, + scenario_name: action.run.scenario_name, + scenario_registry_name: action.run.scenario_registry_name, + scenario_version: action.run.scenario_version, + status: action.run.status, + created_at: action.run.created_at, + completed_at: action.run.completed_at, + }, + activeAtomicGroupIds: [], + error: null, + stale: false, + hasMore: false, + } + } + + const shouldReset = action.fresh || action.page.reset || action.page.plan !== null + const resultsById = new Map() + if (!shouldReset) { + for (const result of state.results) { + resultsById.set(result.attack_result_id, result) + } + } + for (const result of action.page.results) { + resultsById.set(result.attack_result_id, result) + } + + const results = [...resultsById.values()].sort(compareAttempts) + return { + loadStatus: 'ready', + run: action.page.run, + plan: action.page.plan ?? (shouldReset ? null : state.plan), + planComplete: action.page.plan_complete, + activeAtomicGroupIds: [...new Set(action.page.active_atomic_group_ids)], + results, + cursor: action.page.next_cursor ?? state.cursor, + hasMore: action.page.has_more, + error: null, + stale: false, + } +} + +export function getOverallProgress(state: ScenarioRunProgressState): OverallProgress { + const units = buildUnitAttempts(state.results) + const completed = [...units.values()].filter((unit) => unit.latestNonError !== null).length + if (!state.planComplete || !state.plan) { + return { completed, planned: null, percent: null } + } + + const planned = state.plan.atomic_groups.reduce( + (total, group) => total + new Set(group.seed_group_ids).size, + 0, + ) + const plannedKeys = buildPlannedUnitKeys(state.plan.atomic_groups) + const plannedCompleted = [...units.entries()].filter( + ([key, unit]) => plannedKeys.has(key) && unit.latestNonError !== null, + ).length + return { + completed: plannedCompleted, + planned, + percent: planned > 0 ? boundedPercent(plannedCompleted, planned) : 0, + } +} + +export function getElapsedMilliseconds( + run: ScenarioProgressHeader, + nowMilliseconds: number, +): number { + const created = Date.parse(run.created_at) + const terminalEnd = run.completed_at ? Date.parse(run.completed_at) : Number.NaN + const end = isTerminalRunState(run.status) && Number.isFinite(terminalEnd) + ? terminalEnd + : nowMilliseconds + if (!Number.isFinite(created) || !Number.isFinite(end)) { + return 0 + } + return Math.max(0, end - created) +} + +export function getEtaMilliseconds( + state: ScenarioRunProgressState, + nowMilliseconds: number, +): number | null { + if (!state.run || !state.planComplete || isTerminalRunState(state.run.status)) { + return null + } + const progress = getOverallProgress(state) + if (progress.planned === null || progress.planned <= 0 || progress.completed <= 0) { + return null + } + const remaining = Math.max(0, progress.planned - progress.completed) + if (remaining === 0) { + return 0 + } + const elapsed = getElapsedMilliseconds(state.run, nowMilliseconds) + if (elapsed <= 0) { + return null + } + const estimate = (elapsed / progress.completed) * remaining + return Number.isFinite(estimate) && estimate >= 0 ? estimate : null +} + +export function getTechniqueRollups(state: ScenarioRunProgressState): TechniqueRollup[] { + const groupMetadata = buildGroupMetadata(state) + const units = buildUnitAttempts(state.results) + const rollups = new Map() + + for (const group of groupMetadata.values()) { + const existing = rollups.get(group.display_group) + const base = existing ?? { + id: group.display_group, + displayGroup: group.display_group, + atomicAttackNames: [], + completed: 0, + planned: 0, + succeeded: 0, + evaluated: 0, + successPercent: null, + errors: 0, + retries: 0, + } + const groupRollup = aggregateGroup(group.id, group.seed_group_ids, units) + rollups.set(group.display_group, { + ...base, + atomicAttackNames: [...new Set([...base.atomicAttackNames, group.atomic_attack_name])], + completed: base.completed + groupRollup.completed, + planned: base.planned + groupRollup.planned, + succeeded: base.succeeded + groupRollup.succeeded, + evaluated: base.evaluated + groupRollup.evaluated, + successPercent: null, + errors: base.errors + groupRollup.errors, + retries: base.retries + groupRollup.retries, + }) + } + + return [...rollups.values()] + .map((rollup) => ({ + ...rollup, + successPercent: rollup.evaluated > 0 ? boundedPercent(rollup.succeeded, rollup.evaluated) : null, + })) + .sort((left, right) => left.displayGroup.localeCompare(right.displayGroup)) +} + +export function getSeedGroupRollups(state: ScenarioRunProgressState): SeedGroupRollup[] { + const groups = buildGroupMetadata(state) + const units = buildUnitAttempts(state.results) + const objectives = new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed.objective]) ?? []) + const seedIds = new Set(objectives.keys()) + for (const group of groups.values()) { + for (const seedId of group.seed_group_ids) { + seedIds.add(seedId) + } + } + + return [...seedIds].map((seedId) => { + const relevantGroups = [...groups.values()].filter((group) => group.seed_group_ids.includes(seedId)) + const relevantUnits = relevantGroups + .map((group) => units.get(unitKey(group.id, seedId))) + .filter((unit): unit is UnitAttempts => unit !== undefined) + const rollup = aggregateUnits(relevantUnits, relevantGroups.length) + return { id: seedId, objective: objectives.get(seedId) ?? null, ...rollup } + }).sort((left, right) => { + const leftLabel = left.objective ?? left.id + const rightLabel = right.objective ?? right.id + return leftLabel.localeCompare(rightLabel) + }) +} + +export function getAtomicGroupRollups(state: ScenarioRunProgressState): AtomicGroupRollup[] { + const groups = buildGroupMetadata(state) + const units = buildUnitAttempts(state.results) + const terminal = state.run ? isTerminalRunState(state.run.status) : false + const activeIds = new Set(state.activeAtomicGroupIds) + + return [...groups.values()].map((group) => { + const rollup = aggregateGroup(group.id, group.seed_group_ids, units) + let status: AtomicGroupStatus + if (!terminal && activeIds.has(group.id)) { + status = 'Running' + } else if (rollup.completed >= rollup.planned && rollup.planned > 0) { + status = 'Completed' + } else if (terminal) { + status = 'Incomplete' + } else { + status = 'Pending' + } + return { + id: group.id, + atomicAttackName: group.atomic_attack_name, + displayGroup: group.display_group, + status, + ...rollup, + } + }).sort((left, right) => { + const statusDifference = ATOMIC_STATUS_ORDER[left.status] - ATOMIC_STATUS_ORDER[right.status] + if (statusDifference !== 0) { + return statusDifference + } + return left.displayGroup.localeCompare(right.displayGroup) + || left.atomicAttackName.localeCompare(right.atomicAttackName) + }) +} + +function buildGroupMetadata(state: ScenarioRunProgressState): Map { + const groups = new Map() + for (const group of state.plan?.atomic_groups ?? []) { + groups.set(group.id, { ...group, seed_group_ids: [...new Set(group.seed_group_ids)] }) + } + for (const result of state.results) { + const existing = groups.get(result.atomic_group_id) + if (existing) { + if (!existing.seed_group_ids.includes(result.seed_group_id)) { + groups.set(existing.id, { + ...existing, + seed_group_ids: [...existing.seed_group_ids, result.seed_group_id], + }) + } + continue + } + groups.set(result.atomic_group_id, { + id: result.atomic_group_id, + atomic_attack_name: result.atomic_attack_name, + display_group: result.atomic_attack_name || 'Persisted attack group', + technique_eval_hash: '', + seed_group_ids: [result.seed_group_id], + }) + } + return groups +} + +function buildUnitAttempts(results: ScenarioProgressResult[]): Map { + const grouped = new Map() + for (const result of results) { + const key = unitKey(result.atomic_group_id, result.seed_group_id) + const attempts = grouped.get(key) ?? [] + attempts.push(result) + grouped.set(key, attempts) + } + + const units = new Map() + for (const [key, unsortedAttempts] of grouped) { + const attempts = [...unsortedAttempts].sort(compareAttempts) + const latestAttempt = attempts[attempts.length - 1] + let latestNonError: ScenarioProgressResult | null = null + for (const attempt of attempts) { + if (attempt.outcome !== 'error') { + latestNonError = attempt + } + } + units.set(key, { + atomicGroupId: latestAttempt.atomic_group_id, + seedGroupId: latestAttempt.seed_group_id, + attempts, + latestAttempt, + latestNonError, + }) + } + return units +} + +function aggregateGroup( + atomicGroupId: string, + seedGroupIds: string[], + units: Map, +): Rollup { + const relevantUnits = [...new Set(seedGroupIds)] + .map((seedGroupId) => units.get(unitKey(atomicGroupId, seedGroupId))) + .filter((unit): unit is UnitAttempts => unit !== undefined) + return aggregateUnits(relevantUnits, new Set(seedGroupIds).size) +} + +function aggregateUnits(units: UnitAttempts[], planned: number): Rollup { + let completed = 0 + let succeeded = 0 + let errors = 0 + let retries = 0 + for (const unit of units) { + if (unit.latestNonError) { + completed += 1 + if (unit.latestNonError.outcome === 'success') { + succeeded += 1 + } + } + errors += unit.attempts.filter((attempt) => attempt.outcome === 'error').length + retries += Math.max(0, unit.attempts.length - 1) + retries += unit.attempts.reduce((total, attempt) => total + Math.max(0, attempt.total_retries), 0) + } + return { + completed, + planned, + succeeded, + evaluated: completed, + successPercent: completed > 0 ? boundedPercent(succeeded, completed) : null, + errors, + retries, + } +} + +function buildPlannedUnitKeys(groups: ScenarioRunPlanAtomicGroup[]): Set { + const keys = new Set() + for (const group of groups) { + for (const seedGroupId of group.seed_group_ids) { + keys.add(unitKey(group.id, seedGroupId)) + } + } + return keys +} + +function unitKey(atomicGroupId: string, seedGroupId: string): string { + return `${atomicGroupId}\u0000${seedGroupId}` +} + +function compareAttempts(left: ScenarioProgressResult, right: ScenarioProgressResult): number { + const timestampDifference = Date.parse(left.timestamp) - Date.parse(right.timestamp) + if (Number.isFinite(timestampDifference) && timestampDifference !== 0) { + return timestampDifference + } + return left.attack_result_id.localeCompare(right.attack_result_id) +} + +function boundedPercent(numerator: number, denominator: number): number { + if (denominator <= 0) { + return 0 + } + return Math.min(100, Math.max(0, Math.round((numerator / denominator) * 100))) +} diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index 5b666173d8..aaaaed0c58 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -113,6 +113,7 @@ ScenarioProgressResult, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, + ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioRunProgress, ) @@ -265,6 +266,7 @@ "ScenarioProgressResult": "pyrit.models.scenario_progress", "ScenarioRunPlan": "pyrit.models.scenario_progress", "ScenarioRunPlanAtomicGroup": "pyrit.models.scenario_progress", + "ScenarioRunPlanGroupKind": "pyrit.models.scenario_progress", "ScenarioRunPlanSeedGroup": "pyrit.models.scenario_progress", "ScenarioRunProgress": "pyrit.models.scenario_progress", "Seed": "pyrit.models.seeds", diff --git a/pyrit/models/scenario_progress.py b/pyrit/models/scenario_progress.py index 89fc6888c3..6ef887f09b 100644 --- a/pyrit/models/scenario_progress.py +++ b/pyrit/models/scenario_progress.py @@ -4,6 +4,7 @@ """Canonical models for durable scenario run plans and incremental progress.""" from datetime import datetime +from enum import Enum from typing import Any, Literal from pydantic import AwareDatetime, BaseModel, Field, model_validator @@ -17,6 +18,14 @@ SCENARIO_RUN_PLAN_VERSION = 1 +class ScenarioRunPlanGroupKind(str, Enum): + """Semantic kind of a planned scenario progress group.""" + + __slots__ = () + + ATTACK = "attack" + + class ScenarioRunPlanSeedGroup(BaseModel): """A de-duplicated logical seed group in a scenario run plan.""" @@ -33,6 +42,7 @@ class ScenarioRunPlanAtomicGroup(BaseModel): display_group: str technique_eval_hash: str seed_group_ids: list[str] + group_kind: ScenarioRunPlanGroupKind | None = None class ScenarioRunPlan(BaseModel): diff --git a/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index 6973d7cdc5..3c363da32d 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -15,7 +15,7 @@ from collections.abc import Sequence from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, final +from typing import TYPE_CHECKING, Any, ClassVar, Literal, final try: # Built-in on Python 3.11+. Fall back to the ``exceptiongroup`` backport on 3.10 @@ -44,6 +44,7 @@ ScenarioResult, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, + ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioRunSizeComponent, ScenarioRunSizeEstimate, @@ -56,7 +57,11 @@ from pyrit.registry import ScorerRegistry from pyrit.registry.resolution import resolve_declared_params, resolve_reference_value from pyrit.scenario.core.atomic_attack import AtomicAttack -from pyrit.scenario.core.dataset_configuration import DatasetAttackConfiguration, read_only_dataset_resolution +from pyrit.scenario.core.dataset_configuration import ( + CompoundDatasetAttackConfiguration, + DatasetAttackConfiguration, + read_only_dataset_resolution, +) from pyrit.scenario.core.scenario_context import ScenarioContext from pyrit.scenario.core.scenario_target_defaults import get_default_scorer_target from pyrit.scenario.core.scenario_technique import ScenarioTechnique @@ -136,6 +141,10 @@ class Scenario(ABC): #: Whether the default estimator must mirror matrix-builder seed compatibility. RUN_SIZE_USES_FACTORY_COMPATIBILITY: ClassVar[bool] = False + #: How a generic dataset-size run override is interpreted. ``None`` derives the + #: standard behavior from the default configuration. + DATASET_SIZE_LIMIT_OVERRIDE_SCOPE: ClassVar[Literal["per_dataset", "combined", "unsupported"] | None] = None + def __init_subclass__(cls, **kwargs: Any) -> None: """ Enforce the keyword-only constructor contract on subclasses. @@ -258,6 +267,19 @@ def __init__( # before _build_atomic_attacks_async is awaited so overrides can read it. self._include_baseline: bool = False + def get_dataset_size_limit_override_scope(self) -> Literal["per_dataset", "combined", "unsupported"]: + """ + Return how this scenario interprets a generic dataset-size run override. + + Returns: + Literal: The explicit override scope exposed through the scenario catalog. + """ + if self.DATASET_SIZE_LIMIT_OVERRIDE_SCOPE is not None: + return self.DATASET_SIZE_LIMIT_OVERRIDE_SCOPE + if isinstance(self._default_dataset_config, CompoundDatasetAttackConfiguration): + return "per_dataset" + return "per_dataset" if len(self._default_dataset_config.dataset_names) <= 1 else "combined" + @property def name(self) -> str: """The name of the scenario.""" @@ -802,7 +824,7 @@ async def _resolve_dataset_groups_for_estimate_async( selected_count = len(selected_groups.get(name, [])) selection_note = None if selected_count != logical_count: - selection_note = f"The default selection uses {selected_count} of {logical_count} logical seed groups." + selection_note = f"The default selection uses {selected_count} of {logical_count} available objectives." datasets.append( ScenarioDatasetSummary( name=name, @@ -964,7 +986,7 @@ async def initialize_async(self) -> None: self._apply_persisted_objectives(stored_result=stored_result) reconstructed_plan = self._build_run_plan() metadata = dict(stored_result.metadata) - metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = reconstructed_plan.model_dump(mode="json") + metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = reconstructed_plan.model_dump(mode="json", exclude_none=True) self._memory.update_scenario_metadata( scenario_result_id=self._scenario_result_id, metadata=metadata, @@ -1020,7 +1042,7 @@ def _build_initial_scenario_metadata(self) -> dict[str, Any]: seen.add(sha) hashes.append(sha) metadata["objective_hashes"] = hashes - metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = self._build_run_plan().model_dump(mode="json") + metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = self._build_run_plan().model_dump(mode="json", exclude_none=True) return metadata def _build_run_plan(self) -> ScenarioRunPlan: @@ -1058,6 +1080,11 @@ def _build_run_plan(self) -> ScenarioRunPlan: display_group=atomic_attack.display_group, technique_eval_hash=technique_eval_hash, seed_group_ids=seed_group_ids, + group_kind=getattr( + atomic_attack, + "_progress_group_kind", + ScenarioRunPlanGroupKind.ATTACK, + ), ) ) return ScenarioRunPlan( From cda766eef3e6672d5674064347f2b97dac3a1cc8 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:16:31 -0700 Subject: [PATCH 2/7] TEST: Align persisted run plan assertion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/unit/scenario/core/test_scenario.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/scenario/core/test_scenario.py b/tests/unit/scenario/core/test_scenario.py index e8d15c4ce1..535d9a0d09 100644 --- a/tests/unit/scenario/core/test_scenario.py +++ b/tests/unit/scenario/core/test_scenario.py @@ -307,7 +307,7 @@ async def test_initialize_async_deduplicates_logical_seed_groups_in_run_plan(sel expected_seed_id = duplicate_seed_groups[0].logical_id assert persisted_plan["atomic_groups"][0]["seed_group_ids"] == [expected_seed_id] assert [seed_group["id"] for seed_group in persisted_plan["seed_groups"]] == [expected_seed_id] - assert scenario._build_run_plan().model_dump(mode="json") == persisted_plan + assert scenario._build_run_plan().model_dump(mode="json", exclude_none=True) == persisted_plan assert atomic_attack.seed_groups is duplicate_seed_groups assert len(atomic_attack.seed_groups) == 2 From 2905c8244514a50881a0ba78a30c809936af70db Mon Sep 17 00:00:00 2001 From: Behnam Ousat Date: Wed, 2 Sep 2026 10:31:52 -0700 Subject: [PATCH 3/7] REFACTOR: Move backend contracts to consuming PRs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece2d4e-f111-4a4a-80ba-39a59edd1298 --- pyrit/models/__init__.py | 2 -- pyrit/models/scenario_progress.py | 12 -------- pyrit/scenario/core/scenario.py | 37 +++-------------------- tests/unit/scenario/core/test_scenario.py | 2 +- 4 files changed, 6 insertions(+), 47 deletions(-) diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index aaaaed0c58..5b666173d8 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -113,7 +113,6 @@ ScenarioProgressResult, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, - ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioRunProgress, ) @@ -266,7 +265,6 @@ "ScenarioProgressResult": "pyrit.models.scenario_progress", "ScenarioRunPlan": "pyrit.models.scenario_progress", "ScenarioRunPlanAtomicGroup": "pyrit.models.scenario_progress", - "ScenarioRunPlanGroupKind": "pyrit.models.scenario_progress", "ScenarioRunPlanSeedGroup": "pyrit.models.scenario_progress", "ScenarioRunProgress": "pyrit.models.scenario_progress", "Seed": "pyrit.models.seeds", diff --git a/pyrit/models/scenario_progress.py b/pyrit/models/scenario_progress.py index 6ef887f09b..8acbc377ff 100644 --- a/pyrit/models/scenario_progress.py +++ b/pyrit/models/scenario_progress.py @@ -4,7 +4,6 @@ """Canonical models for durable scenario run plans and incremental progress.""" from datetime import datetime -from enum import Enum from typing import Any, Literal from pydantic import AwareDatetime, BaseModel, Field, model_validator @@ -18,14 +17,6 @@ SCENARIO_RUN_PLAN_VERSION = 1 -class ScenarioRunPlanGroupKind(str, Enum): - """Semantic kind of a planned scenario progress group.""" - - __slots__ = () - - ATTACK = "attack" - - class ScenarioRunPlanSeedGroup(BaseModel): """A de-duplicated logical seed group in a scenario run plan.""" @@ -42,9 +33,6 @@ class ScenarioRunPlanAtomicGroup(BaseModel): display_group: str technique_eval_hash: str seed_group_ids: list[str] - group_kind: ScenarioRunPlanGroupKind | None = None - - class ScenarioRunPlan(BaseModel): """Versioned normalized execution plan persisted in ScenarioResult metadata.""" diff --git a/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index 3c363da32d..6973d7cdc5 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -15,7 +15,7 @@ from collections.abc import Sequence from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Literal, final +from typing import TYPE_CHECKING, Any, ClassVar, final try: # Built-in on Python 3.11+. Fall back to the ``exceptiongroup`` backport on 3.10 @@ -44,7 +44,6 @@ ScenarioResult, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, - ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioRunSizeComponent, ScenarioRunSizeEstimate, @@ -57,11 +56,7 @@ from pyrit.registry import ScorerRegistry from pyrit.registry.resolution import resolve_declared_params, resolve_reference_value from pyrit.scenario.core.atomic_attack import AtomicAttack -from pyrit.scenario.core.dataset_configuration import ( - CompoundDatasetAttackConfiguration, - DatasetAttackConfiguration, - read_only_dataset_resolution, -) +from pyrit.scenario.core.dataset_configuration import DatasetAttackConfiguration, read_only_dataset_resolution from pyrit.scenario.core.scenario_context import ScenarioContext from pyrit.scenario.core.scenario_target_defaults import get_default_scorer_target from pyrit.scenario.core.scenario_technique import ScenarioTechnique @@ -141,10 +136,6 @@ class Scenario(ABC): #: Whether the default estimator must mirror matrix-builder seed compatibility. RUN_SIZE_USES_FACTORY_COMPATIBILITY: ClassVar[bool] = False - #: How a generic dataset-size run override is interpreted. ``None`` derives the - #: standard behavior from the default configuration. - DATASET_SIZE_LIMIT_OVERRIDE_SCOPE: ClassVar[Literal["per_dataset", "combined", "unsupported"] | None] = None - def __init_subclass__(cls, **kwargs: Any) -> None: """ Enforce the keyword-only constructor contract on subclasses. @@ -267,19 +258,6 @@ def __init__( # before _build_atomic_attacks_async is awaited so overrides can read it. self._include_baseline: bool = False - def get_dataset_size_limit_override_scope(self) -> Literal["per_dataset", "combined", "unsupported"]: - """ - Return how this scenario interprets a generic dataset-size run override. - - Returns: - Literal: The explicit override scope exposed through the scenario catalog. - """ - if self.DATASET_SIZE_LIMIT_OVERRIDE_SCOPE is not None: - return self.DATASET_SIZE_LIMIT_OVERRIDE_SCOPE - if isinstance(self._default_dataset_config, CompoundDatasetAttackConfiguration): - return "per_dataset" - return "per_dataset" if len(self._default_dataset_config.dataset_names) <= 1 else "combined" - @property def name(self) -> str: """The name of the scenario.""" @@ -824,7 +802,7 @@ async def _resolve_dataset_groups_for_estimate_async( selected_count = len(selected_groups.get(name, [])) selection_note = None if selected_count != logical_count: - selection_note = f"The default selection uses {selected_count} of {logical_count} available objectives." + selection_note = f"The default selection uses {selected_count} of {logical_count} logical seed groups." datasets.append( ScenarioDatasetSummary( name=name, @@ -986,7 +964,7 @@ async def initialize_async(self) -> None: self._apply_persisted_objectives(stored_result=stored_result) reconstructed_plan = self._build_run_plan() metadata = dict(stored_result.metadata) - metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = reconstructed_plan.model_dump(mode="json", exclude_none=True) + metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = reconstructed_plan.model_dump(mode="json") self._memory.update_scenario_metadata( scenario_result_id=self._scenario_result_id, metadata=metadata, @@ -1042,7 +1020,7 @@ def _build_initial_scenario_metadata(self) -> dict[str, Any]: seen.add(sha) hashes.append(sha) metadata["objective_hashes"] = hashes - metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = self._build_run_plan().model_dump(mode="json", exclude_none=True) + metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = self._build_run_plan().model_dump(mode="json") return metadata def _build_run_plan(self) -> ScenarioRunPlan: @@ -1080,11 +1058,6 @@ def _build_run_plan(self) -> ScenarioRunPlan: display_group=atomic_attack.display_group, technique_eval_hash=technique_eval_hash, seed_group_ids=seed_group_ids, - group_kind=getattr( - atomic_attack, - "_progress_group_kind", - ScenarioRunPlanGroupKind.ATTACK, - ), ) ) return ScenarioRunPlan( diff --git a/tests/unit/scenario/core/test_scenario.py b/tests/unit/scenario/core/test_scenario.py index 535d9a0d09..e8d15c4ce1 100644 --- a/tests/unit/scenario/core/test_scenario.py +++ b/tests/unit/scenario/core/test_scenario.py @@ -307,7 +307,7 @@ async def test_initialize_async_deduplicates_logical_seed_groups_in_run_plan(sel expected_seed_id = duplicate_seed_groups[0].logical_id assert persisted_plan["atomic_groups"][0]["seed_group_ids"] == [expected_seed_id] assert [seed_group["id"] for seed_group in persisted_plan["seed_groups"]] == [expected_seed_id] - assert scenario._build_run_plan().model_dump(mode="json", exclude_none=True) == persisted_plan + assert scenario._build_run_plan().model_dump(mode="json") == persisted_plan assert atomic_attack.seed_groups is duplicate_seed_groups assert len(atomic_attack.seed_groups) == 2 From ba04657c71b16856072e4a3f81745db8a97abde3 Mon Sep 17 00:00:00 2001 From: Behnam Ousat Date: Wed, 2 Sep 2026 11:05:17 -0700 Subject: [PATCH 4/7] STYLE: Remove residual backend diff Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece2d4e-f111-4a4a-80ba-39a59edd1298 --- pyrit/models/scenario_progress.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyrit/models/scenario_progress.py b/pyrit/models/scenario_progress.py index 8acbc377ff..89fc6888c3 100644 --- a/pyrit/models/scenario_progress.py +++ b/pyrit/models/scenario_progress.py @@ -33,6 +33,8 @@ class ScenarioRunPlanAtomicGroup(BaseModel): display_group: str technique_eval_hash: str seed_group_ids: list[str] + + class ScenarioRunPlan(BaseModel): """Versioned normalized execution plan persisted in ScenarioResult metadata.""" From 43fecf3064348b5fac1144aff1003b1c0c647873 Mon Sep 17 00:00:00 2001 From: Behnam Ousat Date: Wed, 2 Sep 2026 12:13:22 -0700 Subject: [PATCH 5/7] TEST: Update scenario flow fixtures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece2d4e-f111-4a4a-80ba-39a59edd1298 --- .../Scenarios/ScenarioFlow.test.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Scenarios/ScenarioFlow.test.tsx b/frontend/src/components/Scenarios/ScenarioFlow.test.tsx index 1d660cf10c..5bc1b8846b 100644 --- a/frontend/src/components/Scenarios/ScenarioFlow.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioFlow.test.tsx @@ -7,7 +7,7 @@ import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' import { scenariosApi, targetsApi } from '@/services/api' import type { RegisteredScenario, - ScenarioDefaultRunSizeEstimate, + ScenarioRunSizeEstimateResponse, TargetInstance, } from '@/types' import type { ScenarioRunProgressState } from '@/utils/scenarioRunProgress' @@ -56,19 +56,20 @@ const SCENARIO: RegisteredScenario = { default_technique: ['crescendo'], }, all_techniques: ['crescendo'], + technique_summaries: [{ + name: 'crescendo', + description: null, + tags: [], + }], default_datasets: ['harmbench'], - default_dataset_summaries: [], baseline_policy: 'enabled', include_baseline_by_default: true, supported_parameters: [], default_run_size: { - version: 1, - status: 'exact', - total_attack_count: 2, + estimated_attack_count: 2, components: [], datasets: [], note: null, - retries_included: false, }, } @@ -80,20 +81,16 @@ const TARGET: TargetInstance = { }, } -const ESTIMATE: ScenarioDefaultRunSizeEstimate = { - version: 1, - status: 'exact', - total_attack_count: 2, +const ESTIMATE: ScenarioRunSizeEstimateResponse = { + estimated_attack_count: 2, components: [{ label: 'Configured attacks', count: 2, - factors: [], is_baseline: false, note: null, }], datasets: [], note: null, - retries_included: false, } const RUN_STATE: ScenarioRunProgressState = { @@ -180,7 +177,7 @@ describe('Scenario catalog-to-run integration', () => { const expectedEstimateRequest = { target_name: TARGET.target_registry_name, - techniques: ['default_technique'], + techniques: SCENARIO.default_techniques, include_baseline: true, } await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith( @@ -188,10 +185,12 @@ describe('Scenario catalog-to-run integration', () => { expectedEstimateRequest, expect.any(AbortSignal), )) - expect(within(screen.getByRole('complementary', { name: 'Run preview' })) - .getByText('2 planned attacks')).toBeInTheDocument() + const estimate = screen.getByRole('region', { name: 'Run estimate' }) + expect(within(estimate).getByText('Total atomic attacks').parentElement).toHaveTextContent('2') await user.click(screen.getByTestId('launch-scenario-btn')) + const preview = await screen.findByRole('dialog', { hidden: true }) + await user.click(within(preview).getByTestId('confirm-launch-scenario-btn')) await waitFor(() => expect(mockStartRun).toHaveBeenCalledWith({ scenario_name: SCENARIO_NAME, From b494b737788e596e6fe6d4ea35ce0e63607a41a2 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:46:24 -0700 Subject: [PATCH 6/7] FEAT: Add scenario run history Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d02c2d5-b499-4f78-a04d-03bffa750817 --- frontend/e2e/scenario-history.spec.ts | 588 ++++++++++++++++++ frontend/src/App.test.tsx | 36 +- frontend/src/App.tsx | 50 +- .../History/ScenarioHistory.styles.ts | 120 ++++ .../History/ScenarioHistory.test.tsx | 279 +++++++++ .../components/History/ScenarioHistory.tsx | 485 +++++++++++++++ .../History/scenarioHistoryFilters.test.ts | 57 ++ .../History/scenarioHistoryFilters.ts | 58 ++ .../Scenarios/ScenarioRunPage.test.tsx | 29 + .../components/Scenarios/ScenarioRunPage.tsx | 103 ++- .../components/Sidebar/Navigation.test.tsx | 21 +- .../src/components/Sidebar/Navigation.tsx | 12 + frontend/src/services/api.test.ts | 26 + frontend/src/services/api.ts | 22 +- frontend/src/types/index.ts | 55 +- frontend/src/utils/scenarioRunProgress.ts | 6 + pyrit/backend/models/scenarios.py | 4 + pyrit/backend/routes/labels.py | 8 +- pyrit/backend/routes/scenarios.py | 59 +- .../backend/services/scenario_run_service.py | 516 ++++++++++++++- pyrit/cli/api_client.py | 24 +- pyrit/memory/__init__.py | 11 +- .../8d1e3f5a7b9c_index_scenario_history.py | 35 ++ pyrit/memory/azure_sql_memory.py | 90 ++- pyrit/memory/memory_interface.py | 277 ++++++++- pyrit/memory/memory_models.py | 5 +- pyrit/memory/sqlite_memory.py | 75 ++- pyrit/models/catalog/scenario.py | 50 ++ pyrit/models/scenario_progress.py | 10 + pyrit/scenario/scenarios/airt/jailbreak.py | 19 +- tests/unit/backend/test_api_routes.py | 27 +- .../unit/backend/test_scenario_run_routes.py | 80 ++- .../unit/backend/test_scenario_run_service.py | 287 ++++++++- tests/unit/cli/test_api_client.py | 29 +- .../test_interface_scenario_history.py | 237 +++++++ tests/unit/memory/test_azure_sql_memory.py | 36 +- tests/unit/scenario/airt/test_jailbreak.py | 7 + 37 files changed, 3752 insertions(+), 81 deletions(-) create mode 100644 frontend/e2e/scenario-history.spec.ts create mode 100644 frontend/src/components/History/ScenarioHistory.styles.ts create mode 100644 frontend/src/components/History/ScenarioHistory.test.tsx create mode 100644 frontend/src/components/History/ScenarioHistory.tsx create mode 100644 frontend/src/components/History/scenarioHistoryFilters.test.ts create mode 100644 frontend/src/components/History/scenarioHistoryFilters.ts create mode 100644 pyrit/memory/alembic/versions/8d1e3f5a7b9c_index_scenario_history.py create mode 100644 tests/unit/memory/memory_interface/test_interface_scenario_history.py diff --git a/frontend/e2e/scenario-history.spec.ts b/frontend/e2e/scenario-history.spec.ts new file mode 100644 index 0000000000..90f0c4a7ec --- /dev/null +++ b/frontend/e2e/scenario-history.spec.ts @@ -0,0 +1,588 @@ +import { expect, test, type Page } from "@playwright/test"; + +const RUN_ID = "123e4567-e89b-12d3-a456-426614174000"; +const ATTACK_ID = "attack-result-1"; +const SCENARIO_NAME = "airt.jailbreak"; +const RAW_IMAGE_HTML = 'unsafe'; + +const scenarioDescription = `Jailbreak scenario implementation for PyRIT. + +Tests how vulnerable a model is to jailbreak templates. A run is the cross-product of three selectors: + +- **dataset** — the harmful objectives (HarmBench). +- **techniques** — compatible direct deliveries. Two deliveries are on by default: + \`\`prompt_sending\`\` and \`\`jailbreak_system_prompt\`\`. +- **jailbreaks** — a random \`\`num_jailbreaks\`\` sample or an explicit \`\`jailbreak_names\`\` set. + +${RAW_IMAGE_HTML}`; + +const datasetSummary = { + 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 logical group is excluded.", +}; + +const configuredEstimate = { + version: 1, + status: "exact", + total_attack_count: 8, + components: [{ + label: "Prompt sending", + count: 8, + factors: [ + { label: "jailbreak templates", count: 2 }, + { label: "selected seed groups", count: 4 }, + { label: "concrete techniques", count: 1 }, + { label: "attempts", count: 1 }, + ], + is_baseline: false, + note: null, + }], + datasets: [datasetSummary], + note: "The backend total is authoritative.", + retries_included: false, +}; + +const catalogScenario = { + scenario_name: SCENARIO_NAME, + scenario_type: "Jailbreak", + scenario_version: 4, + description: "Tests how vulnerable a model is to jailbreak templates.", + description_markdown: scenarioDescription, + 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"], + default_dataset_summaries: [datasetSummary], + baseline_policy: "enabled", + include_baseline_by_default: false, + supported_parameters: [ + { + name: "num_jailbreaks", + type_name: "int", + required: false, + default: null, + choices: null, + is_list: false, + description: "Draw this many random jailbreak templates for the run.", + }, + { + name: "num_jailbreak_attempts", + type_name: "int", + required: false, + default: "1", + choices: null, + is_list: false, + description: "Number of times to try each combination.", + }, + { + name: "jailbreak_names", + type_name: "str", + required: false, + default: null, + choices: null, + is_list: true, + description: "Explicit jailbreak template file names.", + }, + ], + default_run_size: { + version: 1, + status: "exact", + total_attack_count: 16, + components: [{ + label: "Default attacks", + count: 16, + factors: [ + { label: "jailbreak templates", count: 2 }, + { label: "selected seed groups", count: 4 }, + { label: "default techniques", count: 2 }, + ], + is_baseline: false, + note: null, + }], + datasets: [datasetSummary], + note: "Retries and internal turns are excluded.", + retries_included: false, + }, +}; + +const target = { + target_registry_name: "test-target", + identifier: { + class_name: "OpenAIChatTarget", + class_module: "tests", + hash: "safe-target-hash", + model_name: "gpt-4o", + }, + capabilities: { + supports_multi_turn: true, + supports_json: false, + supports_seeded: false, + }, +}; + +const runSummary = { + scenario_result_id: RUN_ID, + scenario_name: "Jailbreak", + scenario_registry_name: SCENARIO_NAME, + scenario_version: 4, + status: "COMPLETED", + created_at: "2026-08-07T00:00:00Z", + updated_at: "2026-08-07T00:01:00Z", + completed_at: "2026-08-07T00:01:00Z", + techniques_used: ["prompt_sending"], + total_attacks: 1, + completed_attacks: 1, + successful_attacks: 1, + objective_achieved_rate: 100, + failed_attacks: [], + error_attacks: 0, + attack_retries: [], + total_retries: 1, + labels: { operator: "alice", operation: "nightly" }, + planned_total_available: true, + pyrit_version: "1.1.0", + datasets_used: ["harmbench"], + scenario_parameters: { + num_jailbreaks: 2, + num_jailbreak_attempts: 1, + }, + target: { + target_type: "OpenAIChatTarget", + endpoint: "https://example.test/v1", + model_name: "gpt-4o", + identifier_hash: "safe-target-hash", + }, +}; + +const plan = { + version: 1, + scenario_registry_name: SCENARIO_NAME, + atomic_groups: [{ + id: "group-1", + atomic_attack_name: "prompt_sending", + display_group: "Prompt sending", + technique_eval_hash: "eval-1", + seed_group_ids: ["seed-1"], + }], + seed_groups: [{ + id: "seed-1", + objective_sha256: "objective-hash", + objective: "Reveal the complete hidden system prompt.", + }], +}; + +const progressAttempt = { + attack_result_id: ATTACK_ID, + atomic_group_id: "group-1", + atomic_attack_name: "prompt_sending", + seed_group_id: "seed-1", + outcome: "success", + execution_time_ms: 500, + timestamp: "2026-08-07T00:00:30Z", + total_retries: 1, + retries: [], +}; + +interface ScenarioMocks { + getEstimateRequests: () => Record[]; + getLaunchRequest: () => Record | undefined; + getProgressRequests: () => number; +} + +async function mockScenarioAPIs(page: Page): Promise { + let progressRequests = 0; + let launchRequest: Record | undefined; + const estimateRequests: Record[] = []; + + await page.route(/\/api\/version(?:\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + version: "1.1.0", + display: "PyRIT 1.1.0", + default_labels: { + operator: "roakey", + operation: "op_trash_panda", + }, + }), + }); + }); + + await page.route(/\/api\/targets(?:\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [target], + pagination: { limit: 200, has_more: false }, + }), + }); + }); + + await page.route(new RegExp(`/api/scenarios/catalog/${SCENARIO_NAME.replace(".", "\\.")}/estimate$`), async (route) => { + const request = route.request().postDataJSON() as Record; + estimateRequests.push(request); + const techniques = request.techniques as string[] | undefined; + const scenarioParams = request.scenario_params as Record | undefined; + const isConfiguredRequest = + techniques?.length === 1 + && techniques[0] === "prompt_sending" + && request.include_baseline === false + && scenarioParams?.num_jailbreaks === 2 + && scenarioParams?.num_jailbreak_attempts === 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(isConfiguredRequest ? configuredEstimate : catalogScenario.default_run_size), + }); + }); + + await page.route(new RegExp(`/api/scenarios/catalog/${SCENARIO_NAME.replace(".", "\\.")}$`), async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(catalogScenario), + }); + }); + + await page.route(/\/api\/scenarios\/catalog(?:\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [catalogScenario], + pagination: { limit: 200, has_more: false }, + }), + }); + }); + + await page.route(/\/api\/labels(?:\?|$)/, async (route) => { + const source = new URL(route.request().url()).searchParams.get("source") ?? "attacks"; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + source, + labels: { + operator: ["alice", "bob"], + operation: ["nightly"], + team: ["safety"], + }, + }), + }); + }); + + await page.route(new RegExp(`/api/scenarios/runs/${RUN_ID}/progress(?:\\?|$)`), async (route) => { + progressRequests += 1; + const isInitialPage = !new URL(route.request().url()).searchParams.has("since"); + const completed = progressRequests > 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + run: { + scenario_result_id: RUN_ID, + scenario_name: "Jailbreak", + scenario_registry_name: SCENARIO_NAME, + scenario_version: 4, + status: completed ? "COMPLETED" : "IN_PROGRESS", + created_at: runSummary.created_at, + completed_at: completed ? runSummary.completed_at : null, + pyrit_version: runSummary.pyrit_version, + target: runSummary.target, + techniques_used: runSummary.techniques_used, + datasets_used: runSummary.datasets_used, + scenario_parameters: runSummary.scenario_parameters, + labels: runSummary.labels, + }, + plan, + reset: isInitialPage, + active_atomic_group_ids: completed ? [] : ["group-1"], + results: isInitialPage ? [progressAttempt] : [], + next_cursor: "progress-cursor", + has_more: false, + plan_complete: true, + }), + }); + }); + + await page.route(/\/api\/scenarios\/runs(?:\?|$)/, async (route) => { + if (route.request().method() === "POST") { + launchRequest = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ ...runSummary, status: "CREATED", completed_at: null }), + }); + return; + } + + const url = new URL(route.request().url()); + const labelFilters = url.searchParams.getAll("label"); + const items = labelFilters.includes("operator:bob") ? [] : [runSummary]; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items, + pagination: { limit: 25, has_more: false, next_cursor: null }, + }), + }); + }); + + await page.route(new RegExp(`/api/attacks/${ATTACK_ID}(?:\\?|$)`), async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + attack_result_id: ATTACK_ID, + conversation_id: "conversation-1", + attack_type: "SingleTurnAttack", + target: runSummary.target, + converters: [], + outcome: "success", + message_count: 0, + related_conversation_ids: [], + labels: {}, + created_at: runSummary.created_at, + updated_at: runSummary.updated_at, + }), + }); + }); + + await page.route(new RegExp(`/api/attacks/${ATTACK_ID}/conversations`), async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + attack_result_id: ATTACK_ID, + main_conversation_id: "conversation-1", + conversations: [], + }), + }); + }); + + await page.route(new RegExp(`/api/attacks/${ATTACK_ID}/messages`), async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ conversation_id: "conversation-1", messages: [] }), + }); + }); + + return { + getEstimateRequests: () => estimateRequests, + getLaunchRequest: () => launchRequest, + getProgressRequests: () => progressRequests, + }; +} + +async function configurePromptSendingRun(page: Page): Promise { + await expect(page.getByTestId("scenario-target-select")).toHaveValue("test-target"); + await page.getByTestId("technique-prompt_sending").click(); + await page.getByTestId("scenario-param-num_jailbreaks").fill("2"); + await page.getByTestId("scenario-param-num_jailbreak_attempts").fill("1"); + await expect(page.getByTestId("baseline-checkbox")).not.toBeChecked(); + await expect(page.getByText("8 planned attacks")).toBeVisible(); +} + +test.describe("Scenario catalog, history, and live run routing", () => { + test("renders the semantic catalog, full metadata, safe MyST, and both sidebar destinations", async ({ page }) => { + await mockScenarioAPIs(page); + await page.goto("/scanner"); + + const primaryNavigation = page.getByRole("navigation", { name: "Primary" }); + const primaryButtons = primaryNavigation.getByRole("button"); + await expect(primaryButtons).toHaveCount(7); + expect(await primaryButtons.evaluateAll((buttons) => + buttons.map((button) => button.getAttribute("aria-label")))).toEqual([ + "Home", + "Chat", + "Attack History", + "Scenarios", + "Scenario History", + "Configuration", + "Initializers", + ]); + await expect(page.getByTitle("Scenarios")).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("table", { name: "Registered scenarios" })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Default run size" })).toBeVisible(); + + const row = page.getByTestId(`scenario-card-${SCENARIO_NAME}`); + await row.getByRole("button", { name: "Configure run" }).click(); + await expect(page).toHaveURL(`/scanner/${SCENARIO_NAME}`); + await expect(page.getByRole("heading", { name: SCENARIO_NAME, level: 1 })).toBeVisible(); + const description = page.getByTestId("scenario-detail-description"); + await expect(description.getByText("dataset")).toHaveCSS("font-weight", /^(600|700)$/); + await expect(description.locator("code").filter({ hasText: "num_jailbreaks" })).toBeVisible(); + await expect(description.locator("img")).toHaveCount(0); + await expect(description).toContainText(RAW_IMAGE_HTML); + + await page.getByTitle("Scenario History").click(); + await expect(page).toHaveURL("/scenario-history"); + await expect(page.getByTitle("Scenario History")).toHaveAttribute("aria-current", "page"); + await page.getByTitle("Scenarios").click(); + await expect(page).toHaveURL("/scanner"); + await expect(page.getByTitle("Scenarios")).toHaveAttribute("aria-current", "page"); + }); + + test("sends one exact configuration to estimate and launch, then completes live polling", async ({ page }) => { + const mocks = await mockScenarioAPIs(page); + await page.goto(`/scanner/${SCENARIO_NAME}`); + + const form = page.getByRole("form", { name: "Scenario run configuration" }); + const preview = page.getByRole("complementary", { name: "Run preview" }); + const formBox = await form.boundingBox(); + const previewBox = await preview.boundingBox(); + expect(formBox).not.toBeNull(); + expect(previewBox).not.toBeNull(); + expect(previewBox!.x).toBeGreaterThan(formBox!.x + formBox!.width); + expect(previewBox!.y).toBeLessThan(formBox!.y + formBox!.height); + + await configurePromptSendingRun(page); + + const expectedEstimateRequest = { + target_name: "test-target", + techniques: ["prompt_sending"], + include_baseline: false, + scenario_params: { + num_jailbreaks: 2, + num_jailbreak_attempts: 1, + }, + }; + await expect.poll(() => { + const requests = mocks.getEstimateRequests(); + return requests[requests.length - 1]; + }).toEqual(expectedEstimateRequest); + await expect(preview.getByText("Prompt sending: 2 jailbreak templates × 4 selected seed groups × 1 concrete techniques × 1 attempts = 8")).toBeVisible(); + await expect(preview).not.toContainText("context_compliance"); + + await page.getByTestId("launch-scenario-btn").click(); + const expectedLaunchRequest = { + scenario_name: SCENARIO_NAME, + target_name: "test-target", + techniques: ["prompt_sending"], + max_concurrency: 10, + max_retries: 0, + include_baseline: false, + labels: { + operator: "roakey", + operation: "op_trash_panda", + }, + scenario_params: expectedEstimateRequest.scenario_params, + }; + await expect.poll(mocks.getLaunchRequest).toEqual(expectedLaunchRequest); + expect(mocks.getLaunchRequest()?.techniques).toEqual(expectedEstimateRequest.techniques); + expect(mocks.getLaunchRequest()?.scenario_params).toEqual(expectedEstimateRequest.scenario_params); + expect(mocks.getLaunchRequest()?.include_baseline).toBe(expectedEstimateRequest.include_baseline); + expect(mocks.getLaunchRequest()?.techniques).not.toContain("default"); + expect(mocks.getLaunchRequest()?.techniques).not.toContain("context_compliance"); + + await expect(page).toHaveURL(`/scenario-history/${RUN_ID}`); + await expect(page.getByTestId("run-state-badge")).toHaveText("In progress"); + await expect(page.getByText("gpt-4o").first()).toBeVisible(); + await expect(page.getByText("harmbench")).toBeVisible(); + await expect(page.getByTestId("run-state-badge")).toHaveText("Completed", { timeout: 6_000 }); + expect(mocks.getProgressRequests()).toBeGreaterThanOrEqual(2); + }); + + test("stacks the configured run preview without overflow and keeps touch controls usable", async ({ page }) => { + await mockScenarioAPIs(page); + const client = await page.context().newCDPSession(page); + await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/scanner/${SCENARIO_NAME}`); + await configurePromptSendingRun(page); + + const formBox = await page.getByRole("form", { name: "Scenario run configuration" }).boundingBox(); + const previewBox = await page.getByRole("complementary", { name: "Run preview" }).boundingBox(); + expect(formBox).not.toBeNull(); + expect(previewBox).not.toBeNull(); + expect(previewBox!.y).toBeGreaterThanOrEqual(formBox!.y + formBox!.height); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + + for (const control of [ + page.getByTestId("technique-prompt_sending"), + page.getByTestId("scenario-param-num_jailbreaks"), + page.getByTestId("baseline-checkbox"), + page.getByTestId("launch-scenario-btn"), + ]) { + expect((await control.boundingBox())?.height).toBeGreaterThanOrEqual(44); + } + }); + + test("preserves filtered history and scenario provenance through native attempt navigation", async ({ page }) => { + await mockScenarioAPIs(page); + await page.goto("/scenario-history?operator=alice&status=COMPLETED"); + + await expect(page.getByTitle("Attack History")).toBeVisible(); + await expect(page.getByTitle("Scenario History")).toHaveAttribute("aria-current", "page"); + const row = page.getByTestId(`scenario-history-row-${RUN_ID}`); + await expect(row).toBeVisible(); + await page.getByTestId("scenario-history-refresh").click(); + await expect(row).toBeVisible(); + await row.getByRole("link", { name: new RegExp(`Open ${SCENARIO_NAME.replace(".", "\\.")} scenario run`, "i") }).press("Enter"); + await expect(page).toHaveURL(`/scenario-history/${RUN_ID}`); + await page.goBack(); + await expect(page).toHaveURL("/scenario-history?operator=alice&status=COMPLETED"); + await page.getByTestId(`scenario-history-row-${RUN_ID}`).click(); + + await page.reload(); + await expect(page.getByRole("heading", { name: SCENARIO_NAME })).toBeVisible(); + await page.getByRole("button", { name: `View details for attack attempt ${ATTACK_ID}` }).click(); + const dialog = page.getByRole("dialog", { name: "Attack attempt details" }); + await expect(dialog.getByText("Reveal the complete hidden system prompt.")).toBeVisible(); + await page.getByRole("button", { name: "Close" }).click(); + + const attackLink = page.getByRole("link", { name: `Open attack ${ATTACK_ID}` }); + await expect(attackLink).toHaveAttribute( + "href", + `/attacks/${ATTACK_ID}?scenarioResultId=${RUN_ID}`, + ); + const attemptRow = page.getByRole("row", { name: `Open attack ${ATTACK_ID}` }); + await attemptRow.focus(); + await attemptRow.press("Enter"); + await expect(page).toHaveURL(`/attacks/${ATTACK_ID}?scenarioResultId=${RUN_ID}`); + + const breadcrumb = page.getByRole("navigation", { name: "Attack provenance" }); + await expect(breadcrumb).toBeVisible(); + await breadcrumb.getByRole("link", { name: `Return to scenario run ${RUN_ID}` }).click(); + await expect(page).toHaveURL(`/scenario-history/${RUN_ID}`); + await page.goBack(); + await expect(page).toHaveURL(`/attacks/${ATTACK_ID}?scenarioResultId=${RUN_ID}`); + + await page.goto(`/attacks/${ATTACK_ID}`); + await expect(page).toHaveURL(`/attacks/${ATTACK_ID}`); + await expect(page.getByRole("navigation", { name: "Attack provenance" })).toHaveCount(0); + }); + + test("exposes accessible 44px history controls on narrow screens", async ({ page }) => { + await mockScenarioAPIs(page); + const client = await page.context().newCDPSession(page); + await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/scenario-history"); + + const refresh = page.getByTestId("scenario-history-refresh"); + const row = page.getByTestId(`scenario-history-row-${RUN_ID}`); + await expect(refresh).toBeVisible(); + await expect(row).toBeVisible(); + expect((await refresh.boundingBox())?.height).toBeGreaterThanOrEqual(44); + expect((await row.boundingBox())?.height).toBeGreaterThanOrEqual(44); + }); +}); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7cdbab55f1..9c2a640196 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -115,6 +115,9 @@ jest.mock("./components/Layout/MainLayout", () => { + {children}
); @@ -380,6 +383,15 @@ jest.mock("./components/Scenarios/ScenarioRunPage", () => { }; }); +jest.mock("./components/History/ScenarioHistory", () => { + const MockScenarioHistory = () =>
; + MockScenarioHistory.displayName = "MockScenarioHistory"; + return { + __esModule: true, + default: MockScenarioHistory, + }; +}); + 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. "/targets"). @@ -475,11 +487,21 @@ describe("App", () => { expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", - "scenarios" + "scenarioHistory" ); expect(screen.getByTestId("scenario-run-page")).toBeInTheDocument(); }); + it("renders scenario history as a distinct URL-backed view", () => { + renderApp("/scenario-history?operator=alice"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "scenarioHistory" + ); + expect(screen.getByTestId("scenario-history")).toBeInTheDocument(); + }); + it("switches to the scenarios view via the sidebar", () => { renderApp(); @@ -492,6 +514,18 @@ describe("App", () => { expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument(); }); + it("switches to scenario history via its distinct sidebar destination", () => { + renderApp(); + + fireEvent.click(screen.getByTestId("nav-scenario-history")); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "scenarioHistory" + ); + expect(screen.getByTestId("scenario-history")).toBeInTheDocument(); + }); + it("passes the active target and labels to the scenario detail view", () => { renderApp("/scanner/foundry.red_team_agent"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54f3aa1e2a..c111157cd5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import TargetConfig from './components/Config/TargetConfig' import Initializers from './components/Initializers/Initializers' import Configuration from './components/Configuration/Configuration' import AttackHistory from './components/History/AttackHistory' +import ScenarioHistory from './components/History/ScenarioHistory' import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' import ScenarioDetail from './components/Scenarios/ScenarioDetail' import ScenarioRunPage from './components/Scenarios/ScenarioRunPage' @@ -23,6 +24,11 @@ import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnec import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults' import { readStoredGlobalLabels, persistGlobalLabels } from './components/Labels/labelStorage' import { filtersFromSearchParams, filtersToSearchParams } from './components/History/historyFilters' +import { + scenarioHistoryFiltersFromSearchParams, + scenarioHistoryFiltersToSearchParams, +} from './components/History/scenarioHistoryFilters' +import type { ScenarioHistoryFilters } from './components/History/scenarioHistoryFilters' import type { ViewName } from './components/Sidebar/Navigation' import type { TargetInfo } from './types' import { @@ -51,6 +57,7 @@ const VIEW_PATHS: Record = { initializers: '/initializers', scenarios: '/scanner', configuration: '/config', + scenarioHistory: '/scenario-history', } /** @@ -60,9 +67,12 @@ const VIEW_PATHS: Record = { * single canonical `VIEW_PATHS` entry. */ function viewFromPath(pathname: string): ViewName { - if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) || pathname.startsWith('/scenario-history/')) { + if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`)) { return 'scenarios' } + if (pathname === VIEW_PATHS.scenarioHistory || pathname.startsWith(`${VIEW_PATHS.scenarioHistory}/`)) { + return 'scenarioHistory' + } const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find( ([, path]) => path === pathname, ) @@ -164,22 +174,34 @@ function App() { // the History nav button can restore filters after visiting another view. const [searchParams, setSearchParams] = useSearchParams() const historyFilters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]) + const scenarioHistoryFilters = useMemo( + () => scenarioHistoryFiltersFromSearchParams(searchParams), + [searchParams], + ) const scenarioResultId = useMemo( () => scenarioRunProvenance(searchParams), [searchParams], ) const lastHistorySearch = useRef('') + const lastScenarioHistorySearch = useRef('') useEffect(() => { if (location.pathname === VIEW_PATHS.history) { lastHistorySearch.current = location.search } + if (location.pathname === VIEW_PATHS.scenarioHistory) { + lastScenarioHistorySearch.current = location.search + } }, [location.pathname, location.search]) const handleFiltersChange = useCallback((filters: HistoryFilters) => { setSearchParams(filtersToSearchParams(filters), { replace: true }) }, [setSearchParams]) - /** App version display, attached to feedback context */ + const handleScenarioHistoryFiltersChange = useCallback((filters: ScenarioHistoryFilters) => { + setSearchParams(scenarioHistoryFiltersToSearchParams(filters), { replace: true }) + }, [setSearchParams]) + + /** App version display, attached to feedback context */ const [appVersion, setAppVersion] = useState('') /** Whether the feedback dialog is currently open */ const [feedbackOpen, setFeedbackOpen] = useState(false) @@ -358,6 +380,10 @@ function App() { navigate(VIEW_PATHS.history + lastHistorySearch.current) return } + if (view === 'scenarioHistory') { + navigate(VIEW_PATHS.scenarioHistory + lastScenarioHistorySearch.current) + return + } navigate(VIEW_PATHS[view]) }, [navigate]) @@ -411,6 +437,15 @@ function App() { navigate(attackRoutePath(openAttackResultId)) }, [navigate]) + const handleOpenScenarioRun = useCallback((scenarioResultId: string) => { + navigate(`${VIEW_PATHS.scenarioHistory}/${encodeURIComponent(scenarioResultId)}`, { + state: { + fromScenarioHistory: true, + scenarioHistorySearch: location.search, + }, + }) + }, [location.search, navigate]) + const chatElement = isAttackNotFound || isAttackError ? ( } /> + + } + /> } /> } /> input': { + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + }, + content: { + flex: 1, + overflow: 'auto', + }, + table: { + minWidth: '1120px', + }, + clickableRow: { + cursor: 'pointer', + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + rowLink: { + color: 'inherit', + display: 'inline-flex', + alignItems: 'center', + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + textDecorationLine: 'none', + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + }, + identity: { + display: 'flex', + flexDirection: 'column', + minWidth: '180px', + }, + secondary: { + color: tokens.colorNeutralForeground3, + }, + nowrap: { + whiteSpace: 'nowrap', + }, + badges: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalXXS, + maxWidth: '240px', + }, + target: { + display: 'flex', + flexDirection: 'column', + maxWidth: '220px', + }, + truncate: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + emptyState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalXXXL, + }, + pagination: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXXL}`, + borderTop: `1px solid ${tokens.colorNeutralStroke1}`, + backgroundColor: tokens.colorNeutralBackground3, + }, + touchTarget: { + ...mobileTouchTarget, + }, + touchTargetHeight: { + ...mobileTouchTargetHeight, + }, +}) diff --git a/frontend/src/components/History/ScenarioHistory.test.tsx b/frontend/src/components/History/ScenarioHistory.test.tsx new file mode 100644 index 0000000000..faf7f43b04 --- /dev/null +++ b/frontend/src/components/History/ScenarioHistory.test.tsx @@ -0,0 +1,279 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { labelsApi, scenariosApi } from '@/services/api' +import type { ScenarioRunListItem } from '@/types' + +import ScenarioHistory from './ScenarioHistory' +import { DEFAULT_SCENARIO_HISTORY_FILTERS } from './scenarioHistoryFilters' + +jest.mock('@/services/api', () => ({ + scenariosApi: { + listCatalog: jest.fn(), + listRuns: jest.fn(), + }, + labelsApi: { + getLabels: jest.fn(), + }, +})) + +const mockedScenariosApi = scenariosApi as jest.Mocked +const mockedLabelsApi = labelsApi as jest.Mocked + +const RUN: ScenarioRunListItem = { + scenario_result_id: 'run-1', + scenario_name: 'RedTeamScenario', + scenario_registry_name: 'foundry.red_team', + scenario_version: 3, + status: 'COMPLETED', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + completed_at: '2026-01-01T00:01:00Z', + techniques_used: ['prompt injection'], + total_attacks: 2, + completed_attacks: 2, + successful_attacks: 1, + objective_achieved_rate: 50, + error_attacks: 1, + total_retries: 2, + labels: { operator: 'alice' }, + planned_total_available: true, + attack_details_available: false, + datasets_used: ['harmbench'], + scenario_parameters: {}, + target: { + target_type: 'OpenAIChatTarget', + model_name: 'gpt-4o', + endpoint: 'https://example.test/v1', + identifier_hash: 'safe-hash', + }, +} + +const defaultProps = { + filters: { ...DEFAULT_SCENARIO_HISTORY_FILTERS }, + onFiltersChange: jest.fn(), + onOpenRun: jest.fn(), + onNavigate: jest.fn(), +} + +function renderHistory(props = defaultProps) { + return render( + + + , + ) +} + +describe('ScenarioHistory', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedScenariosApi.listCatalog.mockResolvedValue({ + items: [{ scenario_name: 'foundry.red_team' }] as Awaited>['items'], + pagination: { limit: 100, has_more: false }, + }) + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'scenarios', + labels: { operator: ['alice'], operation: ['nightly'], team: ['safety'] }, + }) + }) + + it('renders safe run metadata and opens rows by click or keyboard', async () => { + const user = userEvent.setup() + const onOpenRun = jest.fn() + mockedScenariosApi.listRuns.mockResolvedValue({ + items: [RUN], + pagination: { limit: 25, has_more: false }, + }) + renderHistory({ ...defaultProps, onOpenRun }) + + const row = await screen.findByTestId('scenario-history-row-run-1') + expect(screen.getByText('foundry.red_team')).toBeInTheDocument() + expect(screen.getByText('RedTeamScenario · v3')).toBeInTheDocument() + expect(screen.getByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('2/2')).toBeInTheDocument() + expect(screen.getByText('1/2 (50%)')).toBeInTheDocument() + expect(screen.getByText('operator: alice')).toBeInTheDocument() + + await user.click(row) + expect(onOpenRun).toHaveBeenLastCalledWith('run-1') + const link = screen.getByRole('link', { name: 'Open foundry.red_team scenario run' }) + expect(link).toHaveAttribute('href', '/scenario-history/run-1') + link.focus() + await user.keyboard('{Enter}') + expect(onOpenRun).toHaveBeenCalledTimes(2) + + const modifiedClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }) + expect(link.dispatchEvent(modifiedClick)).toBe(true) + expect(onOpenRun).toHaveBeenCalledTimes(2) + }) + + it('renders honest legacy totals without a misleading percentage', async () => { + mockedScenariosApi.listRuns.mockResolvedValue({ + items: [{ + ...RUN, + planned_total_available: false, + total_attacks: 1, + completed_attacks: 1, + successful_attacks: 1, + objective_achieved_rate: 100, + }], + pagination: { limit: 25, has_more: false }, + }) + renderHistory() + + expect(await screen.findByText('1 known / total unknown')).toBeInTheDocument() + expect(screen.getByText('1/1 known results')).toBeInTheDocument() + expect(screen.queryByText('1/1 (100%)')).not.toBeInTheDocument() + }) + + it('isolates option-loading failures from the primary history request', async () => { + mockedScenariosApi.listCatalog.mockRejectedValueOnce(new Error('catalog unavailable')) + mockedScenariosApi.listRuns.mockResolvedValue({ + items: [RUN], + pagination: { limit: 25, has_more: false }, + }) + renderHistory() + + expect(await screen.findByTestId('scenario-history-table')).toBeInTheDocument() + expect(screen.getByText(/filter options could not be loaded: scenario names/i)).toBeInTheDocument() + }) + + it('shows request errors and retries without swallowing the failure', async () => { + const user = userEvent.setup() + mockedScenariosApi.listRuns + .mockRejectedValueOnce(new Error('history unavailable')) + .mockResolvedValueOnce({ + items: [RUN], + pagination: { limit: 25, has_more: false }, + }) + renderHistory() + + expect(await screen.findByTestId('scenario-history-error')).toHaveTextContent('history unavailable') + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByTestId('scenario-history-table')).toBeInTheDocument() + expect(mockedScenariosApi.listRuns).toHaveBeenCalledTimes(2) + }) + + it('distinguishes unfiltered and filtered empty states', async () => { + const user = userEvent.setup() + const onNavigate = jest.fn() + mockedScenariosApi.listRuns.mockResolvedValue({ + items: [], + pagination: { limit: 25, has_more: false }, + }) + const first = renderHistory({ ...defaultProps, onNavigate }) + + expect(await screen.findByText(/launch a scenario/i)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Browse scenarios' })) + expect(onNavigate).toHaveBeenCalledWith('scenarios') + first.unmount() + + renderHistory({ + ...defaultProps, + filters: { ...DEFAULT_SCENARIO_HISTORY_FILTERS, statuses: ['FAILED'] }, + }) + expect(await screen.findByText('Try adjusting your filters.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Browse scenarios' })).not.toBeInTheDocument() + }) + + it('serializes filters, paginates by cursor, and refreshes from the first page', async () => { + const user = userEvent.setup() + mockedScenariosApi.listRuns + .mockResolvedValueOnce({ + items: [RUN], + pagination: { limit: 25, has_more: true, next_cursor: 'next-page' }, + }) + .mockResolvedValue({ + items: [RUN], + pagination: { limit: 25, has_more: false }, + }) + const history = renderHistory({ + ...defaultProps, + filters: { + ...DEFAULT_SCENARIO_HISTORY_FILTERS, + scenarioNames: ['foundry.red_team'], + statuses: ['IN_PROGRESS', 'FAILED'], + operator: ['alice'], + operation: ['nightly'], + otherLabels: ['team:safety'], + }, + }) + + await screen.findByTestId('scenario-history-table') + expect(mockedScenariosApi.listRuns).toHaveBeenNthCalledWith(1, { + limit: 25, + cursor: undefined, + scenario_names: ['foundry.red_team'], + run_statuses: ['IN_PROGRESS', 'FAILED'], + label: ['operator:alice', 'operation:nightly', 'team:safety'], + }) + + await user.click(screen.getByRole('button', { name: 'Next' })) + await waitFor(() => expect(mockedScenariosApi.listRuns).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: 'next-page' }), + )) + expect(screen.getByText('Page 2')).toBeInTheDocument() + + history.rerender( + + + , + ) + await waitFor(() => expect(mockedScenariosApi.listRuns).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ cursor: undefined, run_statuses: ['COMPLETED'] }), + )) + expect(await screen.findByText('Page 1')).toBeInTheDocument() + + await user.click(screen.getByTestId('scenario-history-refresh')) + await waitFor(() => expect(mockedScenariosApi.listRuns).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ cursor: undefined }), + )) + }) + + it('hides stale pagination while changed filters are loading', async () => { + let resolveFilteredRequest: ((value: Awaited>) => void) | undefined + mockedScenariosApi.listRuns + .mockResolvedValueOnce({ + items: [RUN], + pagination: { limit: 25, has_more: true, next_cursor: 'stale-cursor' }, + }) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFilteredRequest = resolve + })) + + const history = renderHistory() + expect(await screen.findByRole('button', { name: 'Next' })).toBeEnabled() + + history.rerender( + + + , + ) + + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument() + expect(screen.getByText('Loading scenario history...')).toBeInTheDocument() + await waitFor(() => expect(mockedScenariosApi.listRuns).toHaveBeenCalledTimes(2)) + expect(mockedScenariosApi.listRuns).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: undefined, run_statuses: ['FAILED'] }), + ) + + resolveFilteredRequest?.({ + items: [RUN], + pagination: { limit: 25, has_more: false }, + }) + expect(await screen.findByTestId('scenario-history-table')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/History/ScenarioHistory.tsx b/frontend/src/components/History/ScenarioHistory.tsx new file mode 100644 index 0000000000..55c3af498e --- /dev/null +++ b/frontend/src/components/History/ScenarioHistory.tsx @@ -0,0 +1,485 @@ +import { useCallback, useEffect, useState } from 'react' + +import { + Badge, + Button, + Combobox, + MessageBar, + MessageBarBody, + mergeClasses, + Option, + Spinner, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + Text, + Tooltip, +} from '@fluentui/react-components' +import { + ArrowLeftRegular, + ArrowRightRegular, + ArrowSyncRegular, + FilterDismissRegular, + FilterRegular, + ScriptRegular, +} from '@fluentui/react-icons' + +import { labelsApi, scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { ScenarioRunListItem, ScenarioRunState } from '@/types' +import { fetchAllPages } from '@/utils/fetchAllPages' + +import type { ViewName } from '../Sidebar/Navigation' +import { useScenarioHistoryStyles } from './ScenarioHistory.styles' +import { + DEFAULT_SCENARIO_HISTORY_FILTERS, + SCENARIO_RUN_STATES, + type ScenarioHistoryFilters, +} from './scenarioHistoryFilters' + +const PAGE_SIZE = 25 + +interface ScenarioHistoryProps { + filters: ScenarioHistoryFilters + onFiltersChange: (filters: ScenarioHistoryFilters) => void + onOpenRun: (scenarioResultId: string) => void + onNavigate: (view: ViewName) => void +} + +interface MultiFilterProps { + label: string + placeholder: string + selected: string[] + options: readonly string[] + onSelect: (values: string[]) => void + testId: string + className: string +} + +function MultiFilter({ + label, + placeholder, + selected, + options, + onSelect, + testId, + className, +}: MultiFilterProps) { + return ( + onSelect(data.selectedOptions)} + data-testid={testId} + > + {options.map((option) => )} + + ) +} + +export default function ScenarioHistory({ + filters, + onFiltersChange, + onOpenRun, + onNavigate, +}: ScenarioHistoryProps) { + const styles = useScenarioHistoryStyles() + const [runs, setRuns] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [optionsError, setOptionsError] = useState(null) + const [scenarioOptions, setScenarioOptions] = useState([]) + const [operatorOptions, setOperatorOptions] = useState([]) + const [operationOptions, setOperationOptions] = useState([]) + const [otherLabelOptions, setOtherLabelOptions] = useState([]) + const [page, setPage] = useState(0) + const [nextCursor, setNextCursor] = useState() + const [hasMore, setHasMore] = useState(false) + const filterKey = JSON.stringify([ + filters.scenarioNames, + filters.statuses, + filters.operator, + filters.operation, + filters.otherLabels, + ]) + const [settledFilterKey, setSettledFilterKey] = useState(null) + const [fetchToken, setFetchToken] = useState({ + cursor: undefined as string | undefined, + filterKey, + nonce: 0, + }) + + const requestPage = useCallback((cursor?: string) => { + setLoading(true) + setError(null) + setFetchToken((previous) => ({ cursor, filterKey, nonce: previous.nonce + 1 })) + }, [filterKey]) + + useEffect(() => { + let cancelled = false + Promise.allSettled([ + fetchAllPages((cursor) => scenariosApi.listCatalog(100, cursor)), + labelsApi.getLabels('scenarios'), + ]).then(([catalogResult, labelsResult]) => { + if (cancelled) return + const failures: string[] = [] + if (catalogResult.status === 'fulfilled') { + setScenarioOptions(catalogResult.value.map((scenario) => scenario.scenario_name).sort()) + } else { + failures.push('scenario names') + } + if (labelsResult.status === 'fulfilled') { + const operators = labelsResult.value.labels.operator ?? [] + const operations = labelsResult.value.labels.operation ?? [] + const others = Object.entries(labelsResult.value.labels) + .filter(([key]) => key !== 'operator' && key !== 'operation' && key !== 'source') + .flatMap(([key, values]) => values.map((value) => `${key}:${value}`)) + setOperatorOptions([...operators].sort()) + setOperationOptions([...operations].sort()) + setOtherLabelOptions(others.sort()) + } else { + failures.push('labels') + } + setOptionsError(failures.length > 0 ? `Some filter options could not be loaded: ${failures.join(', ')}.` : null) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + let cancelled = false + const effectiveCursor = fetchToken.filterKey === filterKey ? fetchToken.cursor : undefined + const label = [ + ...filters.operator.map((value) => `operator:${value}`), + ...filters.operation.map((value) => `operation:${value}`), + ...filters.otherLabels, + ] + scenariosApi.listRuns({ + limit: PAGE_SIZE, + cursor: effectiveCursor, + scenario_names: filters.scenarioNames.length > 0 ? filters.scenarioNames : undefined, + run_statuses: filters.statuses.length > 0 ? filters.statuses : undefined, + label: label.length > 0 ? label : undefined, + }).then((response) => { + if (cancelled) return + setRuns(response.items) + setHasMore(response.pagination.has_more) + setNextCursor(response.pagination.next_cursor ?? undefined) + setSettledFilterKey(filterKey) + setError(null) + if (!effectiveCursor) setPage(0) + }).catch((requestError: unknown) => { + if (cancelled) return + setRuns([]) + setHasMore(false) + setNextCursor(undefined) + setSettledFilterKey(filterKey) + setError(toApiError(requestError).detail) + if (!effectiveCursor) setPage(0) + }).finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [ + fetchToken, + filterKey, + filters.scenarioNames, + filters.statuses, + filters.operator, + filters.operation, + filters.otherLabels, + ]) + + const setFilter = ( + key: K, + value: ScenarioHistoryFilters[K], + ): void => { + onFiltersChange({ ...filters, [key]: value }) + } + const hasFilters = filters.scenarioNames.length > 0 + || filters.statuses.length > 0 + || filters.operator.length > 0 + || filters.operation.length > 0 + || filters.otherLabels.length > 0 + const filtersPending = settledFilterKey !== filterKey + const displayLoading = loading || filtersPending + + return ( +
+
+
+ Scenario History + +
+
+ + {hasFilters && ( + + )} + setFilter('scenarioNames', values)} + testId="scenario-filter" + className={styles.filterDropdown} + /> + setFilter('statuses', values as ScenarioRunState[])} + testId="scenario-status-filter" + className={styles.filterDropdown} + /> + setFilter('operator', values)} + testId="scenario-operator-filter" + className={styles.filterDropdown} + /> + setFilter('operation', values)} + testId="scenario-operation-filter" + className={styles.filterDropdown} + /> + setFilter('otherLabels', values)} + testId="scenario-label-filter" + className={styles.filterDropdown} + /> +
+ {optionsError && ( + + {optionsError} + + )} +
+ +
+ {displayLoading ? ( +
+ ) : error ? ( +
+ {error} + +
+ ) : runs.length === 0 ? ( +
+ No scenario runs found + {hasFilters ? 'Try adjusting your filters.' : 'Launch a scenario to see its progress and results here.'} + {!hasFilters && ( + + )} +
+ ) : ( + + )} +
+ + {!displayLoading && !error && runs.length > 0 && ( +
+ + Page {page + 1} + +
+ )} +
+ ) +} + +interface ScenarioHistoryTableProps { + runs: ScenarioRunListItem[] + onOpenRun: (scenarioResultId: string) => void +} + +function ScenarioHistoryTable({ runs, onOpenRun }: ScenarioHistoryTableProps) { + const styles = useScenarioHistoryStyles() + return ( + + + + Scenario + State + Target + Created + Completed / elapsed + Work + Success + Errors / retries + Labels + + + + {runs.map((run) => ( + onOpenRun(run.scenario_result_id)} + > + + { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + event.stopPropagation() + return + } + event.preventDefault() + event.stopPropagation() + onOpenRun(run.scenario_result_id) + }} + > + + {run.scenario_registry_name ?? run.scenario_name} + + {run.scenario_registry_name && run.scenario_registry_name !== run.scenario_name + ? `${run.scenario_name} · v${run.scenario_version}` + : `v${run.scenario_version}`} + + + + + {formatState(run.status)} + + {run.target ? ( + +
+ {run.target.model_name ?? run.target.target_type} + + {run.target.target_type} + +
+
+ ) : 'Unavailable'} +
+ {formatTimestamp(run.created_at)} + +
+ {run.completed_at ? formatTimestamp(run.completed_at) : 'Not yet'} + {formatElapsed(run)} +
+
+ + {run.planned_total_available !== false && run.total_attacks !== null + ? `${run.completed_attacks}/${run.total_attacks}` + : `${run.completed_attacks} known / total unknown`} + + + {formatSuccess(run)} + + {run.error_attacks} / {run.total_retries} + +
+ {Object.entries(run.labels).map(([key, value]) => ( + {key}: {value} + ))} +
+
+
+ ))} +
+
+ ) +} + +function formatState(value: string): string { + return value.toLowerCase().replace(/_/g, ' ').replace(/^\w/, (letter: string) => letter.toUpperCase()) +} + +function formatTimestamp(value: string): string { + return new Date(value).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatElapsed(run: ScenarioRunListItem): string { + const start = Date.parse(run.created_at) + const end = run.completed_at ? Date.parse(run.completed_at) : Date.now() + const seconds = Math.max(0, Math.floor((end - start) / 1000)) + if (seconds < 60) return `${seconds}s elapsed` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m elapsed` + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m elapsed` +} + +function formatSuccess(run: ScenarioRunListItem): string { + const successful = run.successful_attacks + if (run.planned_total_available === false) { + return `${successful}/${run.completed_attacks} known results` + } + if (run.completed_attacks === 0) { + return '0/0' + } + return `${successful}/${run.completed_attacks} (${run.objective_achieved_rate}%)` +} diff --git a/frontend/src/components/History/scenarioHistoryFilters.test.ts b/frontend/src/components/History/scenarioHistoryFilters.test.ts new file mode 100644 index 0000000000..7f3ce048b1 --- /dev/null +++ b/frontend/src/components/History/scenarioHistoryFilters.test.ts @@ -0,0 +1,57 @@ +import { + DEFAULT_SCENARIO_HISTORY_FILTERS, + SCENARIO_RUN_STATES, + scenarioHistoryFiltersFromSearchParams, + scenarioHistoryFiltersToSearchParams, +} from './scenarioHistoryFilters' + +describe('scenario history URL filters', () => { + it('round-trips repeated filters and label search text', () => { + const filters = { + scenarioNames: ['red.team', 'benchmark'], + statuses: ['IN_PROGRESS', 'FAILED'] as const, + operator: ['alice', 'bob'], + operation: ['nightly'], + otherLabels: ['team:security', 'team:safety'], + labelSearchText: 'team', + } + + const params = scenarioHistoryFiltersToSearchParams({ + ...filters, + statuses: [...filters.statuses], + }) + + expect(params.getAll('scenario')).toEqual(['red.team', 'benchmark']) + expect(params.getAll('status')).toEqual(['IN_PROGRESS', 'FAILED']) + expect(scenarioHistoryFiltersFromSearchParams(params)).toEqual({ + ...filters, + statuses: [...filters.statuses], + }) + }) + + it('ignores synthetic and invalid run states without dropping valid filters', () => { + const params = new URLSearchParams('status=COMPLETED&status=QUEUED&status=UNKNOWN&operator=alice') + + expect(scenarioHistoryFiltersFromSearchParams(params)).toEqual({ + ...DEFAULT_SCENARIO_HISTORY_FILTERS, + statuses: ['COMPLETED'], + operator: ['alice'], + }) + }) + + it('round-trips every persisted run state', () => { + const filters = { + ...DEFAULT_SCENARIO_HISTORY_FILTERS, + statuses: [...SCENARIO_RUN_STATES], + } + + const params = scenarioHistoryFiltersToSearchParams(filters) + + expect(params.getAll('status')).toEqual(SCENARIO_RUN_STATES) + expect(scenarioHistoryFiltersFromSearchParams(params)).toEqual(filters) + }) + + it('omits empty filters from the URL', () => { + expect(scenarioHistoryFiltersToSearchParams(DEFAULT_SCENARIO_HISTORY_FILTERS).toString()).toBe('') + }) +}) diff --git a/frontend/src/components/History/scenarioHistoryFilters.ts b/frontend/src/components/History/scenarioHistoryFilters.ts new file mode 100644 index 0000000000..3f78640d6a --- /dev/null +++ b/frontend/src/components/History/scenarioHistoryFilters.ts @@ -0,0 +1,58 @@ +import type { ScenarioRunState } from '@/types' + +export interface ScenarioHistoryFilters { + scenarioNames: string[] + statuses: ScenarioRunState[] + operator: string[] + operation: string[] + otherLabels: string[] + labelSearchText: string +} + +export const DEFAULT_SCENARIO_HISTORY_FILTERS: ScenarioHistoryFilters = { + scenarioNames: [], + statuses: [], + operator: [], + operation: [], + otherLabels: [], + labelSearchText: '', +} + +export const SCENARIO_RUN_STATES: readonly ScenarioRunState[] = [ + 'CREATED', + 'IN_PROGRESS', + 'COMPLETED', + 'FAILED', + 'CANCELLED', +] + +const RUN_STATES = new Set(SCENARIO_RUN_STATES) + +export function scenarioHistoryFiltersFromSearchParams( + params: URLSearchParams, +): ScenarioHistoryFilters { + const statuses = params + .getAll('status') + .filter((status): status is ScenarioRunState => RUN_STATES.has(status)) + return { + scenarioNames: params.getAll('scenario'), + statuses, + operator: params.getAll('operator'), + operation: params.getAll('operation'), + otherLabels: params.getAll('label'), + labelSearchText: params.get('labelSearch') ?? '', + } +} + +export function scenarioHistoryFiltersToSearchParams( + filters: ScenarioHistoryFilters, +): URLSearchParams { + const params = new URLSearchParams() + for (const scenarioName of filters.scenarioNames) params.append('scenario', scenarioName) + for (const status of filters.statuses) params.append('status', status) + for (const operator of filters.operator) params.append('operator', operator) + for (const operation of filters.operation) params.append('operation', operation) + for (const label of filters.otherLabels) params.append('label', label) + if (filters.labelSearchText) params.set('labelSearch', filters.labelSearchText) + return params +} diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx index 4c3c772308..3d34013d48 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -141,6 +141,35 @@ describe('ScenarioRunPage', () => { expect(screen.queryByRole('columnheader', { name: 'Actions' })).not.toBeInTheDocument() }) + it('renders contract-backed safe target and run configuration metadata', () => { + mockHookState(makeState({ + run: { + ...makeState().run!, + target: { + target_type: 'OpenAIChatTarget', + endpoint: 'https://example.test/v1', + model_name: 'gpt-4o', + identifier_hash: 'safe-hash', + }, + techniques_used: ['Technique One'], + datasets_used: ['harmbench'], + scenario_parameters: { max_turns: 5 }, + labels: { operator: 'alice' }, + pyrit_version: '0.10.0', + }, + })) + + renderPage() + + expect(screen.getByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('https://example.test/v1')).toBeInTheDocument() + expect(screen.getByText('safe-hash')).toBeInTheDocument() + expect(screen.getByText('harmbench')).toBeInTheDocument() + expect(screen.getByText('max_turns: 5')).toBeInTheDocument() + expect(screen.getByText('operator: alice')).toBeInTheDocument() + expect(screen.getByText('0.10.0')).toBeInTheDocument() + }) + it('keeps legacy runs useful without misleading totals, ETA, or a progress bar', () => { mockHookState(makeState({ planComplete: false })) diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index b2516de87e..fa21c5dee4 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -33,7 +33,7 @@ import { EyeRegular, StopRegular, } from '@fluentui/react-icons' -import { Link, useNavigate, useParams } from 'react-router' +import { Link, useLocation, useNavigate, useParams } from 'react-router' import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' import { scenariosApi } from '@/services/api' @@ -64,6 +64,7 @@ const INTERACTIVE_ELEMENT_SELECTOR = 'a, button, input, select, textarea, [role= const RUN_BADGE_COLORS: Record = { CREATED: 'informative', + QUEUED: 'informative', IN_PROGRESS: 'brand', COMPLETED: 'success', FAILED: 'danger', @@ -88,6 +89,7 @@ interface ScenarioRunPageContentProps { function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProps) { const styles = useScenarioRunPageStyles() + const location = useLocation() const navigate = useNavigate() const { state, retry, applyRunSummary } = useScenarioRunProgress(scenarioResultId) const [nowMilliseconds, setNowMilliseconds] = useState(() => Date.now()) @@ -96,6 +98,19 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp const [cancelError, setCancelError] = useState(null) const [selectedAttempt, setSelectedAttempt] = useState(null) const detailsTriggerRef = useRef(null) + const navigationState = location.state as { + fromScenarioHistory?: boolean + scenarioHistorySearch?: string + scenarioName?: string + } | null + const backPath = navigationState?.fromScenarioHistory + ? `/scenario-history${navigationState.scenarioHistorySearch ?? ''}` + : navigationState?.scenarioName + ? `/scanner/${encodeURIComponent(navigationState.scenarioName)}` + : '/scenario-history' + const backLabel = navigationState?.scenarioName && !navigationState.fromScenarioHistory + ? 'Back to scenario' + : 'Back to scenario history' const overall = useMemo(() => getOverallProgress(state), [state]) const techniques = useMemo(() => getTechniqueRollups(state), [state]) @@ -149,8 +164,8 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp return (
- - Back to scanners + + {backLabel}
@@ -171,8 +186,8 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp return (
- - Back to scanners + + {backLabel}
@@ -191,8 +206,8 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp return (
- - Back to scanners + + {backLabel}
@@ -222,8 +237,8 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp return (
- - Back to scanners + + {backLabel}
@@ -278,8 +293,52 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp Completed {run.completed_at ? formatTimestamp(run.completed_at) : 'Not yet'}
+ {run.target && ( +
+ Target + {run.target.model_name ?? run.target.target_type} + {run.target.target_type} +
+ )} + {run.pyrit_version && ( +
+ PyRIT version + {run.pyrit_version} +
+ )}
+
+
+ + Run configuration + + Persisted, secret-free settings for this run. +
+
+ 0 ? run.techniques_used?.join(', ') ?? '' : 'Unavailable'} + /> + 0 ? run.datasets_used?.join(', ') ?? '' : 'Unavailable'} + /> + + + {run.target?.endpoint && } + {run.target?.identifier_hash && ( + + )} +
+
+ {state.stale && ( @@ -663,6 +722,21 @@ interface MetricProps { readonly value: string } +interface ConfigurationItemProps { + readonly label: string + readonly value: string +} + +function ConfigurationItem({ label, value }: ConfigurationItemProps) { + const styles = useScenarioRunPageStyles() + return ( +
+ {label} + {value} +
+ ) +} + function Metric({ label, value }: MetricProps) { const styles = useScenarioRunPageStyles() return ( @@ -727,6 +801,7 @@ function formatTimestamp(timestamp: string): string { if (Number.isNaN(date.getTime())) { return 'Unavailable' } + return date.toLocaleString(undefined, { month: 'short', day: 'numeric', @@ -737,6 +812,16 @@ function formatTimestamp(timestamp: string): string { }) } +function formatConfiguration(value: Record): string { + const entries = Object.entries(value) + if (entries.length === 0) { + return 'None' + } + return entries + .map(([key, item]) => `${key}: ${typeof item === 'string' ? item : JSON.stringify(item)}`) + .join(', ') +} + function formatDuration(milliseconds: number): string { if (!Number.isFinite(milliseconds) || milliseconds < 0) { return 'Unavailable' diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx index e85813e3bd..8c61db38ae 100644 --- a/frontend/src/components/Sidebar/Navigation.test.tsx +++ b/frontend/src/components/Sidebar/Navigation.test.tsx @@ -126,7 +126,7 @@ describe("Navigation", () => { ).toBeInTheDocument(); }); - it("places Scanner immediately after Attack History without a history placeholder", () => { + it("renders the final primary navigation order", () => { renderWithProvider(); const navigation = screen.getByRole("navigation", { name: "Primary" }); const labels = within(navigation) @@ -138,11 +138,28 @@ describe("Navigation", () => { "Chat", "Attack History", "Scanner", + "Scenario History", "Targets", "Initializers", "Configuration", ]); - expect(screen.queryByRole("button", { name: "Scenario History" })).not.toBeInTheDocument(); + }); + + it("marks Scenario History current and navigates to its dedicated view", async () => { + const user = userEvent.setup(); + const onNavigate = jest.fn(); + renderWithProvider( + , + ); + + const button = screen.getByRole("button", { name: "Scenario History" }); + expect(button).toHaveAttribute("aria-current", "page"); + await user.click(button); + expect(onNavigate).toHaveBeenCalledWith("scenarioHistory"); }); it("calls onNavigate with 'scenarios' when the scenarios button is clicked", async () => { diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index 816eb40fe7..7ba48fb620 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -15,6 +15,7 @@ import { HistoryRegular, PersonFeedbackRegular, ScriptRegular, + TableRegular, WrenchRegular, OpenRegular, WeatherMoonRegular, @@ -32,6 +33,7 @@ export type ViewName = | 'targets' | 'initializers' | 'configuration' + | 'scenarioHistory' | 'scenarios' interface NavigationProps { @@ -120,6 +122,16 @@ export default function Navigation({ onClick={() => onNavigate('scenarios')} /> +