diff --git a/frontend/e2e/scenario-history.spec.ts b/frontend/e2e/scenario-history.spec.ts index 82ddf2cf85..dd2beff582 100644 --- a/frontend/e2e/scenario-history.spec.ts +++ b/frontend/e2e/scenario-history.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page } from "@playwright/test"; const RUN_ID = "123e4567-e89b-12d3-a456-426614174000"; +const ACTIVE_RUN_ID = "123e4567-e89b-12d3-a456-426614174001"; +const QUEUED_RUN_ID = "123e4567-e89b-12d3-a456-426614174002"; const ATTACK_ID = "attack-result-1"; const SCENARIO_NAME = "airt.jailbreak"; const RAW_IMAGE_HTML = 'unsafe'; @@ -585,4 +587,105 @@ test.describe("Scenario catalog, history, and live run routing", () => { expect((await refresh.boundingBox())?.height).toBeGreaterThanOrEqual(44); expect((await row.boundingBox())?.height).toBeGreaterThanOrEqual(44); }); + + test("renders deterministic FIFO position changes and queued-to-completed handoff", async ({ page }) => { + await mockScenarioAPIs(page); + let progressRequests = 0; + let queueRequests = 0; + + await page.route(new RegExp(`/api/scenarios/runs/${QUEUED_RUN_ID}/progress(?:\\?|$)`), async (route) => { + progressRequests += 1; + const statuses = ["QUEUED", "QUEUED", "IN_PROGRESS", "COMPLETED"] as const; + const status = statuses[Math.min(progressRequests - 1, statuses.length - 1)]; + const queuePosition = status === "QUEUED" ? (progressRequests === 1 ? 2 : 1) : null; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + run: { + ...runSummary, + scenario_result_id: QUEUED_RUN_ID, + status, + completed_at: status === "COMPLETED" ? runSummary.completed_at : null, + queue_position: queuePosition, + active_scenario_result_id: status === "QUEUED" ? ACTIVE_RUN_ID : QUEUED_RUN_ID, + overload_summaries: [{ + component_role: "objective_target", + count: 2, + rate_limit_count: 1, + server_error_count: 1, + status_codes: [429, 503], + latest_timestamp: "2026-08-07T00:00:45Z", + }], + }, + plan: progressRequests === 1 ? plan : null, + reset: progressRequests === 1, + active_atomic_group_ids: status === "IN_PROGRESS" ? ["group-1"] : [], + results: status === "COMPLETED" ? [progressAttempt] : [], + next_cursor: `queue-progress-${progressRequests}`, + has_more: false, + plan_complete: true, + }), + }); + }); + + await page.route(/\/api\/scenarios\/runs\/queue(?:\?|$)/, async (route) => { + queueRequests += 1; + const active = { + scenario_result_id: ACTIVE_RUN_ID, + scenario_name: "Active scenario", + scenario_registry_name: "active.scenario", + state: "IN_PROGRESS", + created_at: "2026-08-07T00:00:00Z", + enqueued_at: "2026-08-07T00:00:00Z", + started_at: "2026-08-07T00:00:01Z", + }; + const queued = { + scenario_result_id: QUEUED_RUN_ID, + scenario_name: "Jailbreak", + scenario_registry_name: SCENARIO_NAME, + state: "QUEUED", + position: queueRequests === 1 ? 2 : 1, + created_at: runSummary.created_at, + enqueued_at: runSummary.created_at, + }; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + revision: queueRequests, + snapshot_at: `2026-08-07T00:00:0${Math.min(queueRequests, 9)}Z`, + active: queueRequests < 3 ? active : queueRequests === 3 ? { ...queued, state: "IN_PROGRESS", position: null } : null, + queued: queueRequests < 3 + ? [ + ...(queueRequests === 1 ? [{ ...queued, scenario_result_id: RUN_ID, position: 1 }] : []), + queued, + ] + : [], + }), + }); + }); + + await page.goto(`/scenario-history/${QUEUED_RUN_ID}`); + + await expect(page.getByTestId("run-state-badge")).toHaveText("Queued"); + await expect(page.getByTestId("queued-run-progress")).toContainText("Position 2"); + await expect(page.getByTestId("queued-run-progress")).not.toContainText("%"); + await expect(page.getByRole("link", { name: new RegExp(ACTIVE_RUN_ID) })).toHaveAttribute( + "href", + `/scenario-history/${ACTIVE_RUN_ID}`, + ); + const warning = page.getByTestId("scenario-overload-warning"); + await expect(warning).toContainText("Objective target"); + await expect(warning).toContainText("2 × HTTP 429/503"); + await expect(warning).toContainText("without adaptive throttling"); + await page.setViewportSize({ width: 390, height: 844 }); + expect((await page.getByRole("button", { name: "Cancel run" }).boundingBox())?.height).toBeGreaterThanOrEqual(44); + await expect(page.getByTestId("queued-run-progress")).toContainText("Position 1", { timeout: 6_000 }); + await expect(page.getByTestId("run-state-badge")).toHaveText("In progress", { timeout: 6_000 }); + await expect(page.getByTestId("run-state-badge")).toHaveText("Completed", { timeout: 6_000 }); + expect(progressRequests).toBe(4); + + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + }); }); diff --git a/frontend/src/components/History/ScenarioHistory.styles.ts b/frontend/src/components/History/ScenarioHistory.styles.ts index 5d83d5776a..088650a03d 100644 --- a/frontend/src/components/History/ScenarioHistory.styles.ts +++ b/frontend/src/components/History/ScenarioHistory.styles.ts @@ -46,6 +46,9 @@ export const useScenarioHistoryStyles = makeStyles({ flex: 1, overflow: 'auto', }, + queue: { + padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalXXL} 0`, + }, table: { minWidth: '1120px', }, diff --git a/frontend/src/components/History/ScenarioHistory.test.tsx b/frontend/src/components/History/ScenarioHistory.test.tsx index f6e2924f8f..507f89cf12 100644 --- a/frontend/src/components/History/ScenarioHistory.test.tsx +++ b/frontend/src/components/History/ScenarioHistory.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { labelsApi, scenariosApi } from '@/services/api' +import { useScenarioQueue } from '@/hooks/useScenarioQueue' import type { ScenarioRunSummary } from '@/types' import ScenarioHistory from './ScenarioHistory' @@ -18,8 +19,13 @@ jest.mock('@/services/api', () => ({ }, })) +jest.mock('@/hooks/useScenarioQueue', () => ({ + useScenarioQueue: jest.fn(), +})) + const mockedScenariosApi = scenariosApi as jest.Mocked const mockedLabelsApi = labelsApi as jest.Mocked +const mockUseScenarioQueue = useScenarioQueue as jest.Mock const RUN: ScenarioRunSummary = { scenario_result_id: 'run-1', @@ -67,6 +73,13 @@ function renderHistory(props = defaultProps) { describe('ScenarioHistory', () => { beforeEach(() => { jest.clearAllMocks() + mockUseScenarioQueue.mockReturnValue({ + snapshot: { revision: 0, snapshot_at: '2026-01-01T00:00:00Z', active: null, queued: [] }, + loading: false, + stale: false, + error: null, + retry: jest.fn(), + }) mockedScenariosApi.listCatalog.mockResolvedValue({ items: [{ scenario_name: 'foundry.red_team' }] as Awaited>['items'], pagination: { limit: 100, has_more: false }, @@ -77,6 +90,10 @@ describe('ScenarioHistory', () => { }) }) + afterEach(() => { + jest.restoreAllMocks() + }) + it('renders safe run metadata and opens rows by click or keyboard', async () => { const user = userEvent.setup() const onOpenRun = jest.fn() @@ -126,6 +143,84 @@ describe('ScenarioHistory', () => { expect(screen.queryByText('1/1 (100%)')).not.toBeInTheDocument() }) + it('renders safe fallbacks when optional run metadata is unavailable', async () => { + jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-01-01T00:00:30Z')) + mockedScenariosApi.listRuns.mockResolvedValue({ + items: [{ + ...RUN, + scenario_name: 'LegacyScenario', + scenario_registry_name: null, + scenario_version: 1, + status: 'IN_PROGRESS', + started_at: '2026-01-01T00:00:20Z', + completed_at: null, + total_attacks: 0, + completed_attacks: 0, + successful_attacks: undefined, + objective_achieved_rate: 0, + failed_attacks: [{ + atomic_attack_name: 'legacy-attack', + objective: 'Legacy objective', + total_retries: 0, + }], + error_attacks: undefined, + total_retries: 0, + labels: {}, + target: { + target_type: 'TextTarget', + endpoint: null, + model_name: null, + }, + }], + pagination: { limit: 25, has_more: false }, + }) + + renderHistory() + + expect(await screen.findByRole('link', { + name: 'Open LegacyScenario scenario run', + })).toBeInTheDocument() + expect(screen.getByText('v1')).toBeInTheDocument() + expect(screen.getAllByText('TextTarget')).toHaveLength(2) + expect(screen.getByText('Not yet')).toBeInTheDocument() + expect(screen.getByText('10s elapsed')).toBeInTheDocument() + expect(screen.getAllByText('0/0')).toHaveLength(2) + expect(screen.getByText('1 / 0')).toBeInTheDocument() + }) + + it('does not display queue wait as execution elapsed time', async () => { + mockedScenariosApi.listRuns.mockResolvedValue({ + items: [{ + ...RUN, + status: 'QUEUED', + started_at: null, + completed_at: null, + }], + pagination: { limit: 25, has_more: false }, + }) + + renderHistory() + + expect(await screen.findByText('Not started')).toBeInTheDocument() + expect(screen.queryByText(/\d+(?:s|m|h).*elapsed$/)).not.toBeInTheDocument() + }) + + it('does not display queue wait for a terminal run that never started', async () => { + mockedScenariosApi.listRuns.mockResolvedValue({ + items: [{ + ...RUN, + status: 'CANCELLED', + started_at: null, + }], + pagination: { limit: 25, has_more: false }, + }) + + renderHistory() + + expect(await screen.findByText('Execution time unavailable')).toBeInTheDocument() + expect(screen.queryByText(/\d+(?:s|m|h).*elapsed$/)).not.toBeInTheDocument() + }) + it('isolates option-loading failures from the primary history request', async () => { mockedScenariosApi.listCatalog.mockRejectedValueOnce(new Error('catalog unavailable')) mockedScenariosApi.listRuns.mockResolvedValue({ diff --git a/frontend/src/components/History/ScenarioHistory.tsx b/frontend/src/components/History/ScenarioHistory.tsx index 4bb81dc640..cb2d4d465b 100644 --- a/frontend/src/components/History/ScenarioHistory.tsx +++ b/frontend/src/components/History/ScenarioHistory.tsx @@ -28,6 +28,7 @@ import { } from '@fluentui/react-icons' import { labelsApi, scenariosApi } from '@/services/api' +import { useScenarioQueue } from '@/hooks/useScenarioQueue' import { toApiError } from '@/services/errors' import type { ScenarioRunState, ScenarioRunSummary } from '@/types' import { fetchAllPages } from '@/utils/fetchAllPages' @@ -39,6 +40,7 @@ import { SCENARIO_RUN_STATES, type ScenarioHistoryFilters, } from './scenarioHistoryFilters' +import ScenarioQueue from '../Scenarios/ScenarioQueue' const PAGE_SIZE = 25 @@ -91,6 +93,7 @@ export default function ScenarioHistory({ onNavigate, }: ScenarioHistoryProps) { const styles = useScenarioHistoryStyles() + const queue = useScenarioQueue() const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -295,6 +298,15 @@ export default function ScenarioHistory({ )} +
+ +
+
{displayLoading ? (
@@ -465,7 +477,12 @@ function formatTimestamp(value: string): string { } function formatElapsed(run: ScenarioRunSummary): string { - const start = Date.parse(run.created_at) + if (!run.started_at) { + return run.status === 'CREATED' || run.status === 'QUEUED' + ? 'Not started' + : 'Execution time unavailable' + } + const start = Date.parse(run.started_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` diff --git a/frontend/src/components/Scenarios/ScenarioQueue.styles.ts b/frontend/src/components/Scenarios/ScenarioQueue.styles.ts new file mode 100644 index 0000000000..c5d614dc45 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioQueue.styles.ts @@ -0,0 +1,76 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { MINIMUM_TOUCH_TARGET_SIZE, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' + +export const useScenarioQueueStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + heading: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalM, + }, + hint: { + color: tokens.colorNeutralForeground3, + }, + list: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + margin: 0, + padding: 0, + listStyleType: 'none', + }, + entry: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: 'auto minmax(0, 1fr)', + }, + }, + link: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + minWidth: 0, + color: tokens.colorBrandForegroundLink, + textDecorationLine: 'none', + ':hover': { + textDecorationLine: 'underline', + }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + }, + runId: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + color: tokens.colorNeutralForeground3, + }, + timestamp: { + color: tokens.colorNeutralForeground3, + whiteSpace: 'nowrap', + [NARROW_VIEWPORT_QUERY]: { + gridColumn: '2', + }, + }, + empty: { + color: tokens.colorNeutralForeground3, + }, +}) diff --git a/frontend/src/components/Scenarios/ScenarioQueue.test.tsx b/frontend/src/components/Scenarios/ScenarioQueue.test.tsx new file mode 100644 index 0000000000..24cfc5fd0c --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioQueue.test.tsx @@ -0,0 +1,138 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import type { ScenarioQueueSnapshot } from '@/types' + +import ScenarioQueue from './ScenarioQueue' + +const SNAPSHOT: ScenarioQueueSnapshot = { + revision: 3, + snapshot_at: '2026-01-01T00:00:03Z', + active: { + scenario_result_id: 'run-active', + scenario_name: 'ActiveScenario', + scenario_registry_name: 'active.scenario', + state: 'IN_PROGRESS', + created_at: '2026-01-01T00:00:00Z', + enqueued_at: '2026-01-01T00:00:00Z', + started_at: '2026-01-01T00:00:01Z', + }, + queued: [{ + scenario_result_id: 'run-waiting', + scenario_name: 'WaitingScenario', + scenario_registry_name: 'waiting.scenario', + state: 'QUEUED', + position: 1, + created_at: '2026-01-01T00:00:02Z', + enqueued_at: '2026-01-01T00:00:02Z', + }], +} + +interface RenderQueueOptions { + readonly currentScenarioResultId?: string + readonly loading?: boolean + readonly stale?: boolean + readonly error?: string | null +} + +function renderQueue( + snapshot: ScenarioQueueSnapshot | null, + { + currentScenarioResultId, + loading = false, + stale = false, + error = null, + }: RenderQueueOptions = {}, +) { + return render( + + + , + ) +} + +describe('ScenarioQueue', () => { + it('renders active and FIFO queued entries as native deep links', () => { + renderQueue(SNAPSHOT, { currentScenarioResultId: 'run-waiting' }) + + const activeLink = screen.getByRole('link', { name: /active\.scenario/i }) + const waitingLink = screen.getByRole('link', { name: /waiting\.scenario/i }) + expect(activeLink).toHaveAttribute('href', '/scenario-history/run-active') + expect(waitingLink).toHaveAttribute('href', '/scenario-history/run-waiting') + expect(waitingLink).toHaveAttribute('aria-current', 'page') + expect(screen.getByText('Active')).toBeInTheDocument() + expect(screen.getByText('Position 1')).toBeInTheDocument() + }) + + it('renders a concise empty state', () => { + renderQueue({ revision: 0, snapshot_at: '2026-01-01T00:00:00Z', active: null, queued: [] }) + + expect(screen.getByText('No active or queued scenarios.')).toBeInTheDocument() + }) + + it('keeps queue links keyboard reachable', async () => { + const user = userEvent.setup() + renderQueue(SNAPSHOT) + + await user.tab() + + expect(screen.getByRole('link', { name: /active\.scenario/i })).toHaveFocus() + }) + + it('renders initial loading and error states without an empty-state flash', () => { + const { rerender } = renderQueue(null, { loading: true }) + + expect(screen.getByText('Loading scenario queue...')).toBeInTheDocument() + expect(screen.queryByText('No active or queued scenarios.')).not.toBeInTheDocument() + + rerender( + + + , + ) + expect(screen.getByText('Queue unavailable.')).toBeInTheDocument() + }) + + it('keeps the last known queue visible when polling becomes stale', () => { + renderQueue(SNAPSHOT, { stale: true, error: 'Temporary failure.' }) + + expect(screen.getByText( + 'Queue updates paused. Showing the last known order. Temporary failure.', + )).toBeInTheDocument() + expect(screen.getByRole('link', { name: /active\.scenario/i })).toBeInTheDocument() + }) + + it('falls back to scenario names, unknown positions, and enqueue time', () => { + renderQueue({ + revision: 4, + snapshot_at: '2026-01-01T00:00:03Z', + active: { + ...SNAPSHOT.active!, + scenario_registry_name: '', + started_at: null, + }, + queued: [{ + ...SNAPSHOT.queued[0], + scenario_registry_name: '', + position: null, + }], + }) + + expect(screen.getByRole('link', { name: /ActiveScenario/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /WaitingScenario/i })).toBeInTheDocument() + expect(screen.getByText('Position —')).toBeInTheDocument() + expect(screen.getAllByText(/^Queued /)).toHaveLength(2) + }) +}) diff --git a/frontend/src/components/Scenarios/ScenarioQueue.tsx b/frontend/src/components/Scenarios/ScenarioQueue.tsx new file mode 100644 index 0000000000..ba122e37b1 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioQueue.tsx @@ -0,0 +1,94 @@ +import { Badge, MessageBar, MessageBarBody, Spinner, Text } from '@fluentui/react-components' + +import type { ScenarioQueueEntry, ScenarioQueueSnapshot } from '@/types' + +import { useScenarioQueueStyles } from './ScenarioQueue.styles' + +interface ScenarioQueueProps { + readonly snapshot: ScenarioQueueSnapshot | null + readonly loading: boolean + readonly stale: boolean + readonly error: string | null + readonly currentScenarioResultId?: string +} + +export default function ScenarioQueue({ + snapshot, + loading, + stale, + error, + currentScenarioResultId, +}: ScenarioQueueProps) { + const styles = useScenarioQueueStyles() + const entries = snapshot + ? [ + ...(snapshot.active ? [snapshot.active] : []), + ...snapshot.queued, + ] + : [] + + return ( +
+
+ Scenario queue + One scenario executes at a time; waiting runs start FIFO. +
+ {stale && error && ( + + Queue updates paused. Showing the last known order. {error} + + )} + {loading && !snapshot ? ( + + ) : error && !snapshot ? ( + {error} + ) : entries.length === 0 ? ( + No active or queued scenarios. + ) : ( +
    + {entries.map((entry) => ( + + ))} +
+ )} +
+ ) +} + +interface ScenarioQueueItemProps { + readonly entry: ScenarioQueueEntry + readonly current: boolean +} + +function ScenarioQueueItem({ entry, current }: ScenarioQueueItemProps) { + const styles = useScenarioQueueStyles() + const active = entry.state === 'IN_PROGRESS' + const label = active ? 'Active' : `Position ${entry.position ?? '—'}` + return ( +
  • + {label} + + {entry.scenario_registry_name || entry.scenario_name} + {entry.scenario_result_id} + + + {active && entry.started_at ? `Started ${formatTimestamp(entry.started_at)}` : `Queued ${formatTimestamp(entry.enqueued_at)}`} + +
  • + ) +} + +function formatTimestamp(timestamp: string): string { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts index 4620bffa77..e0cbbbe5e7 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts +++ b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts @@ -83,6 +83,9 @@ export const useScenarioRunPageStyles = makeStyles({ touchTarget: { ...mobileTouchTarget, }, + cancelButton: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, wideButton: { [NARROW_VIEWPORT_QUERY]: { flexGrow: 1, diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx index 3d34013d48..6e41b8f8dd 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -10,6 +10,7 @@ import { } from 'react-router' import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import { useScenarioQueue } from '@/hooks/useScenarioQueue' import { scenariosApi } from '@/services/api' import type { ScenarioProgressResult, @@ -26,6 +27,10 @@ jest.mock('@/hooks/useScenarioRunProgress', () => ({ useScenarioRunProgress: jest.fn(), })) +jest.mock('@/hooks/useScenarioQueue', () => ({ + useScenarioQueue: jest.fn(), +})) + jest.mock('@/services/api', () => ({ scenariosApi: { cancelRun: jest.fn(), @@ -33,6 +38,7 @@ jest.mock('@/services/api', () => ({ })) const mockUseScenarioRunProgress = useScenarioRunProgress as jest.Mock +const mockUseScenarioQueue = useScenarioQueue as jest.Mock const mockCancelRun = scenariosApi.cancelRun as jest.Mock const mockRetry = jest.fn() const mockApplyRunSummary = jest.fn() @@ -122,6 +128,13 @@ function renderPage(path = `/scenario-history/${SCENARIO_RESULT_ID}`) { describe('ScenarioRunPage', () => { beforeEach(() => { jest.clearAllMocks() + mockUseScenarioQueue.mockReturnValue({ + snapshot: { revision: 0, snapshot_at: '2026-01-01T00:00:00Z', active: null, queued: [] }, + loading: false, + stale: false, + error: null, + retry: jest.fn(), + }) mockHookState(makeState()) }) @@ -196,7 +209,7 @@ describe('ScenarioRunPage', () => { expect(screen.getByText(/showing the last successfully loaded progress/i)).toBeInTheDocument() }) - it('cancels after confirmation and immediately applies the returned terminal state', async () => { + it('cancels a queued run after confirmation and immediately applies the terminal state', async () => { const user = userEvent.setup() const cancelledRun = { scenario_result_id: 'run-1', @@ -217,10 +230,21 @@ describe('ScenarioRunPage', () => { labels: {}, } mockCancelRun.mockResolvedValueOnce(cancelledRun) + mockHookState(makeState({ + run: { + ...makeState().run!, + status: 'QUEUED', + queue_position: 1, + active_scenario_result_id: 'active-run', + }, + results: [], + activeAtomicGroupIds: [], + })) renderPage() await user.click(screen.getByRole('button', { name: 'Cancel run' })) const dialog = screen.getByRole('dialog', { name: 'Cancel this scenario run?' }) + expect(within(dialog).getByText(/removed from the queue and will never execute/i)).toBeInTheDocument() await user.click(within(dialog).getByRole('button', { name: 'Cancel run' })) await waitFor(() => expect(mockApplyRunSummary).toHaveBeenCalledWith(cancelledRun)) @@ -352,6 +376,7 @@ describe('ScenarioRunPage', () => { 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() @@ -367,6 +392,47 @@ describe('ScenarioRunPage', () => { expect(screen.getByText('Backend unavailable')).toBeInTheDocument() }) + it('renders queued position without progress percentage or ETA', () => { + mockHookState(makeState({ + run: { + ...makeState().run!, + status: 'QUEUED', + queue_position: 2, + active_scenario_result_id: 'active-run', + }, + results: [], + activeAtomicGroupIds: [], + })) + + renderPage() + + expect(screen.getByTestId('run-state-badge')).toHaveTextContent('Queued') + expect(screen.getByTestId('queued-run-progress')).toHaveTextContent('Position 2') + expect(screen.getByText(/waiting for active run active-run/i)).toBeInTheDocument() + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(screen.getByText('Available after start')).toBeInTheDocument() + }) + + it('shows structured overload roles, counts, and non-adaptive retry guidance', () => { + mockHookState(makeState({ + overloadSummaries: [{ + component_role: 'adversarial_chat', + count: 3, + rate_limit_count: 2, + server_error_count: 1, + status_codes: [429, 503], + latest_timestamp: '2026-01-01T00:00:06Z', + }], + })) + + renderPage() + + const warning = screen.getByTestId('scenario-overload-warning') + expect(warning).toHaveTextContent('Adversarial chat') + expect(warning).toHaveTextContent('3 × HTTP 429/503') + expect(warning).toHaveTextContent(/without adaptive throttling/i) + }) + it('decodes route IDs and does not offer cancellation for terminal runs', () => { mockHookState(makeState({ run: { diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index 697b1a2f30..c5e5b97acf 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -36,6 +36,7 @@ import { import { Link, useLocation, useNavigate, useParams } from 'react-router' import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import { useScenarioQueue } from '@/hooks/useScenarioQueue' import { scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' import type { @@ -57,6 +58,7 @@ import { } from '@/utils/scenarioRunProgress' import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' +import ScenarioQueue from './ScenarioQueue' const CLOCK_REFRESH_INTERVAL_MS = 1_000 const OBJECTIVE_PREVIEW_LENGTH = 96 @@ -92,6 +94,7 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp const location = useLocation() const navigate = useNavigate() const { state, retry, applyRunSummary } = useScenarioRunProgress(scenarioResultId) + const queue = useScenarioQueue() const [nowMilliseconds, setNowMilliseconds] = useState(() => Date.now()) const [cancelDialogOpen, setCancelDialogOpen] = useState(false) const [cancelling, setCancelling] = useState(false) @@ -227,10 +230,13 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp } const run = state.run - const canCancel = run.status === 'CREATED' || run.status === 'IN_PROGRESS' + const queued = run.status === 'QUEUED' + const canCancel = run.status === 'CREATED' || queued || run.status === 'IN_PROGRESS' const elapsed = getElapsedMilliseconds(run, nowMilliseconds) const eta = getEtaMilliseconds(state, nowMilliseconds) - const progressText = overall.planned === null + const progressText = queued + ? `Queued${run.queue_position ? ` · Position ${run.queue_position}` : ''}` + : overall.planned === null ? `${overall.completed} known completed units; planned total unavailable` : `${overall.completed} of ${overall.planned} executable units completed` @@ -267,7 +273,7 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp
    )} + {queued && ( +
    + Waiting position + {run.queue_position ?? 'Updating'} +
    + )}
    @@ -352,6 +364,23 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp )} + + + {(state.overloadSummaries?.length ?? 0) > 0 && ( + + + Recent target overload detected: {formatOverloadSummaries(state.overloadSummaries ?? [])}. + {' '}PyRIT is retrying these requests without adaptive throttling; concurrency is not automatically reduced yet. + + + )} + {run.status === 'FAILED' && ( @@ -375,6 +404,28 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp {progressText} + {queued ? ( +
    +
    + + Queued{run.queue_position ? ` · Position ${run.queue_position}` : ''} + + + {run.active_scenario_result_id + ? `Waiting for active run ${run.active_scenario_result_id} to finish.` + : 'Waiting for the scheduler to start this run.'} + +
    +
    + Execution progress + Not started +
    +
    + Estimated remaining + Available after start +
    +
    + ) : (
    @@ -404,8 +455,9 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp
    + )} - {isTerminalRunState(run.status) ? `Run ${formatRunState(run.status)}` : ''} + {queued ? progressText : isTerminalRunState(run.status) ? `Run ${formatRunState(run.status)}` : ''}
    @@ -645,7 +697,9 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp Cancel this scenario run? - In-flight work will be stopped. Attempts already persisted will remain available in this dashboard. + {queued + ? 'This run will be removed from the queue and will never execute.' + : 'In-flight work will be stopped. Attempts already persisted will remain available in this dashboard.'} {cancelError && ( @@ -657,6 +711,7 @@ function ScenarioRunPageContent({ scenarioResultId }: ScenarioRunPageContentProp