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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions frontend/e2e/scenario-history.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = '<img src=x onerror="alert(1)">unsafe';
Expand Down Expand Up @@ -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);
});
});
3 changes: 3 additions & 0 deletions frontend/src/components/History/ScenarioHistory.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const useScenarioHistoryStyles = makeStyles({
flex: 1,
overflow: 'auto',
},
queue: {
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalXXL} 0`,
},
table: {
minWidth: '1120px',
},
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/History/ScenarioHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,8 +19,13 @@ jest.mock('@/services/api', () => ({
},
}))

jest.mock('@/hooks/useScenarioQueue', () => ({
useScenarioQueue: jest.fn(),
}))

const mockedScenariosApi = scenariosApi as jest.Mocked<typeof scenariosApi>
const mockedLabelsApi = labelsApi as jest.Mocked<typeof labelsApi>
const mockUseScenarioQueue = useScenarioQueue as jest.Mock

const RUN: ScenarioRunSummary = {
scenario_result_id: 'run-1',
Expand Down Expand Up @@ -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<ReturnType<typeof scenariosApi.listCatalog>>['items'],
pagination: { limit: 100, has_more: false },
Expand All @@ -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()
Expand Down Expand Up @@ -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({
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/components/History/ScenarioHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -39,6 +40,7 @@ import {
SCENARIO_RUN_STATES,
type ScenarioHistoryFilters,
} from './scenarioHistoryFilters'
import ScenarioQueue from '../Scenarios/ScenarioQueue'

const PAGE_SIZE = 25

Expand Down Expand Up @@ -91,6 +93,7 @@ export default function ScenarioHistory({
onNavigate,
}: ScenarioHistoryProps) {
const styles = useScenarioHistoryStyles()
const queue = useScenarioQueue()
const [runs, setRuns] = useState<ScenarioRunSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
Expand Down Expand Up @@ -295,6 +298,15 @@ export default function ScenarioHistory({
)}
</header>

<div className={styles.queue}>
<ScenarioQueue
snapshot={queue.snapshot}
loading={queue.loading}
stale={queue.stale}
error={queue.error}
/>
</div>

<div className={styles.content}>
{displayLoading ? (
<div className={styles.emptyState}><Spinner label="Loading scenario history..." /></div>
Expand Down Expand Up @@ -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`
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/Scenarios/ScenarioQueue.styles.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
Loading