- No execution details are available for this display group.
-
+
+ {savedRelationships
+ ? 'No top-level results are loaded for this group. Expand a parent or load more results.'
+ : 'No execution details are available for this display group.'}
+
)
}
return (
- {totalAttempts > attempts.length && (
-
+ {!savedRelationships && totalAttempts > attempts.length && (
+
Showing the latest {attempts.length} of {totalAttempts} executions in this group.
)}
-
}
+
+ {hierarchy.roots.groups.map((group) => (
+
+ ))}
+
+ >
+ )
+}
+
+function mount(onOpenResult: jest.Mock = jest.fn(), runStatus: ScenarioRunState | null = null) {
+ const view = (status: ScenarioRunState | null, scenarioResultId = 'run') => (
+
+
+
+
+
+ )
+ const { rerender } = render(view(runStatus))
+ return {
+ onOpenResult,
+ setRunStatus: (status: ScenarioRunState) => rerender(view(status)),
+ setRunId: (scenarioResultId: string) => rerender(view(runStatus, scenarioResultId)),
+ }
+}
+
+const resultsTable = () => screen.getByRole('table', { name: 'Sequential saved results' })
+/** The row whose first cell shows this text. */
+const rowWith = (text: string) => within(resultsTable()).getByText(text).closest('tr') as HTMLElement
+
+const totalsText = () => screen.getByLabelText('Run totals').textContent
+
+beforeEach(() => {
+ getRoots.mockReset()
+ getNode.mockReset()
+})
+
+test('resets expanded nodes and cached children when the run changes', async () => {
+ const user = userEvent.setup()
+ getRoots.mockResolvedValueOnce(projection([PARENT])).mockResolvedValueOnce(projection([{
+ ...PARENT, objective: 'parent in the new run',
+ }]))
+ getNode
+ .mockResolvedValueOnce(node('parent', {
+ child_attack_result_ids: ['old-child'],
+ children_count: 1,
+ children: [node('old-child', { child_index: 0 })],
+ }))
+ .mockResolvedValueOnce(node('parent', {
+ child_attack_result_ids: ['new-child'],
+ children_count: 1,
+ children: [node('new-child', { child_index: 0 })],
+ }))
+ const view = mount()
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+ expect(await screen.findByText('objective old-child')).toBeVisible()
+
+ view.setRunId('another-run')
+ const expand = await screen.findByRole('button', { name: 'Expand children of attack parent: parent in the new run' })
+ expect(expand).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByText('objective old-child')).not.toBeInTheDocument()
+ await user.click(expand)
+ expect(await screen.findByText('objective new-child')).toBeVisible()
+ expect(getNode).toHaveBeenLastCalledWith('another-run', 'parent', { limit: 200 }, expect.any(AbortSignal))
+})
+
+test('ignores a late root response from a previously selected run', async () => {
+ let resolveOld: ((page: ScenarioHierarchyProjection) => void) | undefined
+ getRoots
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve }))
+ .mockResolvedValueOnce(projection([node('current-run')]))
+ const view = mount()
+ view.setRunId('another-run')
+ expect(await screen.findByText('objective current-run')).toBeVisible()
+ if (resolveOld === undefined) throw new Error('The old run request was not started')
+ const respond = resolveOld
+
+ await act(async () => { respond(projection([node('old-run')])) })
+ expect(screen.queryByText('objective old-run')).not.toBeInTheDocument()
+ expect(screen.getByText('objective current-run')).toBeVisible()
+})
+
+test('does not reuse a late descendant page after restarting its parent', async () => {
+ const user = userEvent.setup()
+ const child = node('child', {
+ child_index: 0,
+ child_attack_result_ids: ['old-grandchild'],
+ children_count: 1,
+ children_truncated: true,
+ })
+ const updatedChild = { ...child, child_attack_result_ids: ['fresh-grandchild'] }
+ let finishOldChild: ((page: ScenarioHierarchyNode) => void) | undefined
+ getRoots.mockResolvedValue(projection([PARENT]))
+ getNode
+ .mockResolvedValueOnce(node('parent', {
+ child_attack_result_ids: ['child'],
+ children: [child],
+ children_count: 2,
+ next_children_cursor: 'parent-next',
+ }))
+ .mockImplementationOnce(() => new Promise((resolve) => { finishOldChild = resolve }))
+ .mockRejectedValueOnce(cursorError(400))
+ .mockResolvedValueOnce(node('parent', {
+ child_attack_result_ids: ['child'],
+ children: [updatedChild],
+ children_count: 1,
+ }))
+ .mockResolvedValueOnce(node('child', {
+ child_attack_result_ids: ['fresh-grandchild'],
+ children: [node('fresh-grandchild', { child_index: 0 })],
+ children_count: 1,
+ }))
+ mount()
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+ await user.click(await screen.findByRole('button', { name: 'Expand children of #1 attack child: objective child' }))
+ await user.click(screen.getByRole('button', { name: 'Load more children' }))
+ await user.click(await screen.findByRole('button', { name: 'Reload children' }))
+ expect(getNode.mock.calls[1][3]).toHaveProperty('aborted', true)
+ const expandChild = await screen.findByRole('button', { name: 'Expand children of #1 attack child: objective child' })
+ if (finishOldChild === undefined) throw new Error('The descendant request was not started')
+ const respond = finishOldChild
+ await act(async () => {
+ respond(node('child', {
+ child_attack_result_ids: ['old-grandchild'],
+ children: [node('old-grandchild', { child_index: 0 })],
+ children_count: 1,
+ }))
+ })
+
+ await user.click(expandChild)
+ expect(getNode).toHaveBeenCalledTimes(5)
+ expect(await screen.findByText('objective fresh-grandchild')).toBeVisible()
+ expect(screen.queryByText('objective old-grandchild')).not.toBeInTheDocument()
+})
+
+test('loads children by position, keeps unresolved references visible, and pages with the node cursor', async () => {
+ const user = userEvent.setup()
+ getRoots.mockResolvedValue(projection([PARENT]))
+ getNode
+ .mockResolvedValueOnce(
+ node('parent', {
+ result_role: 'orchestration',
+ child_attack_result_ids: ['c1', 'missing'],
+ children: [node('c1', { child_index: 0, parent_attack_result_id: 'parent' })],
+ children_count: 3,
+ next_children_cursor: 'children-2',
+ }),
+ )
+ .mockResolvedValueOnce(
+ node('parent', {
+ result_role: 'orchestration',
+ child_offset: 2,
+ child_attack_result_ids: ['c3'],
+ children: [node('c3', { child_index: 2, parent_attack_result_id: 'parent' })],
+ children_count: 3,
+ }),
+ )
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+ await screen.findByText('objective c1')
+
+ expect(getNode).toHaveBeenLastCalledWith('run', 'parent', { limit: 200 }, expect.any(AbortSignal))
+ expect(within(rowWith('objective c1')).getByText('#1')).toBeInTheDocument()
+ expect(within(rowWith('Unresolved reference: missing')).getByText('#2')).toBeInTheDocument()
+ expect(screen.getByText('Showing 2 of 3 saved child positions')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Load more children' }))
+
+ expect(await screen.findByText('objective c3')).toBeInTheDocument()
+ expect(getNode).toHaveBeenLastCalledWith(
+ 'run',
+ 'parent',
+ { limit: 200, children_cursor: 'children-2' },
+ expect.any(AbortSignal),
+ )
+ expect(within(rowWith('objective c3')).getByText('#3')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Load more children' })).not.toBeInTheDocument()
+ expect(getRoots).toHaveBeenCalledTimes(1)
+})
+
+test('run totals come from the backend and do not change as roots and children load', async () => {
+ const user = userEvent.setup()
+ getRoots
+ .mockResolvedValueOnce(projection([PARENT], { has_more: true, next_cursor: 'roots-2' }))
+ .mockResolvedValueOnce(projection([node('second-root')]))
+ getNode.mockResolvedValue(
+ node('parent', {
+ result_role: 'orchestration',
+ child_attack_result_ids: ['c1'],
+ children: [node('c1', { child_index: 0 })],
+ children_count: 1,
+ }),
+ )
+ mount()
+
+ await screen.findByLabelText('Run totals')
+ const before = totalsText()
+ expect(before).toContain('Completed: 7 of 9')
+
+ await user.click(screen.getByRole('button', { name: 'Load more results' }))
+ await screen.findByText('objective second-root')
+ expect(getRoots).toHaveBeenLastCalledWith('run', { max_nodes: 1000, cursor: 'roots-2' }, expect.any(AbortSignal))
+
+ await user.click(screen.getByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+ await screen.findByText('objective c1')
+
+ expect(totalsText()).toBe(before)
+})
+
+test('expanding a node whose preview is complete makes no request', async () => {
+ const user = userEvent.setup()
+ getRoots.mockResolvedValue(
+ projection([
+ node('parent', {
+ result_role: 'orchestration',
+ child_attack_result_ids: ['c1'],
+ children: [node('c1', { child_index: 0 })],
+ children_count: 1,
+ }),
+ ]),
+ )
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+
+ expect(screen.getByText('objective c1')).toBeInTheDocument()
+ expect(getNode).not.toHaveBeenCalled()
+})
+
+test('a stale child cursor asks to reload the children and restarts paging from the first page', async () => {
+ const user = userEvent.setup()
+ getRoots.mockResolvedValue(projection([PARENT]))
+ getNode
+ .mockResolvedValueOnce(
+ node('parent', {
+ child_attack_result_ids: ['c1'],
+ children: [node('c1', { child_index: 0 })],
+ children_count: 3,
+ next_children_cursor: 'children-2',
+ }),
+ )
+ .mockRejectedValueOnce(cursorError(400))
+ .mockResolvedValueOnce(
+ node('parent', {
+ child_attack_result_ids: ['c9'],
+ children: [node('c9', { child_index: 0 })],
+ children_count: 1,
+ }),
+ )
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+ await user.click(await screen.findByRole('button', { name: 'Load more children' }))
+
+ expect(await screen.findByText("This result's saved children changed since they were loaded.")).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Load more children' })).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Reload children' }))
+
+ expect(await screen.findByText('objective c9')).toBeInTheDocument()
+ expect(screen.queryByText('objective c1')).not.toBeInTheDocument()
+ expect(getNode).toHaveBeenLastCalledWith('run', 'parent', { limit: 200 }, expect.any(AbortSignal))
+})
+
+test('a stale root cursor asks to reload the hierarchy', async () => {
+ const user = userEvent.setup()
+ getRoots
+ .mockResolvedValueOnce(projection([node('r1')], { has_more: true, next_cursor: 'roots-2' }))
+ .mockRejectedValueOnce(cursorError(400))
+ .mockResolvedValueOnce(projection([node('fresh')]))
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Load more results' }))
+ expect(await screen.findByText("This run's saved results changed since they were loaded.")).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Reload hierarchy' }))
+
+ expect(await screen.findByText('objective fresh')).toBeInTheDocument()
+ expect(getRoots).toHaveBeenLastCalledWith('run', { max_nodes: 1000 }, expect.any(AbortSignal))
+})
+
+test('a failed first load can be retried', async () => {
+ const user = userEvent.setup()
+ getRoots.mockRejectedValueOnce(cursorError(500)).mockResolvedValueOnce(projection([node('r1')]))
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Retry' }))
+
+ expect(await screen.findByText('objective r1')).toBeInTheDocument()
+})
+
+test('shows empty, truncated and rootless states without inventing roots', async () => {
+ getRoots.mockResolvedValue({
+ ...projection([], { truncated: true }),
+ groups: [],
+ diagnostics: { ...projection([]).diagnostics, rootless_result_ids: ['x', 'y'], rootless_results_count: 2 },
+ })
+ mount()
+
+ expect(await screen.findByText(/2 saved results have no root in this run/)).toHaveTextContent('Their IDs: x, y.')
+ expect(screen.getByText(/Some results are not in this preview/)).toBeInTheDocument()
+ expect(screen.queryByText('No saved results are available for this run yet.')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /Expand/ })).not.toBeInTheDocument()
+})
+
+test('opens details for a node with the clicked button as the focus target', async () => {
+ const user = userEvent.setup()
+ getRoots.mockResolvedValue(projection([node('r1')]))
+ const { onOpenResult } = mount()
+
+ const button = await screen.findByRole('button', { name: 'Open details for attack r1: objective r1' })
+ await user.click(button)
+
+ expect(onOpenResult).toHaveBeenCalledWith(expect.objectContaining({ attack_result_id: 'r1' }), button)
+})
+
+test('an empty run says so', async () => {
+ getRoots.mockResolvedValue({ ...projection([]), groups: [] })
+ mount()
+
+ expect(await screen.findByText('No saved results are available for this run yet.')).toBeInTheDocument()
+})
+
+test('shows the totals the backend reports, not totals counted from loaded rows', async () => {
+ const user = userEvent.setup()
+ getRoots
+ .mockResolvedValueOnce(projection([node('r1')], { has_more: true, next_cursor: 'roots-2' }))
+ .mockResolvedValueOnce(projection([node('r2')], { overall_counts: { ...TOTALS, completed: 8 } }))
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Load more results' }))
+ await screen.findByText('objective r2')
+
+ expect(totalsText()).toContain('Completed: 8 of 9')
+})
+
+test('reloading after expanding starts from a collapsed tree with no stale children', async () => {
+ const user = userEvent.setup()
+ getRoots
+ .mockResolvedValueOnce(projection([PARENT], { has_more: true, next_cursor: 'roots-2' }))
+ .mockRejectedValueOnce(cursorError(400))
+ .mockResolvedValueOnce(projection([PARENT]))
+ getNode.mockResolvedValue(
+ node('parent', { child_attack_result_ids: ['c1'], children: [node('c1', { child_index: 0 })], children_count: 1 }),
+ )
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+ await screen.findByText('objective c1')
+ await user.click(screen.getByRole('button', { name: 'Load more results' }))
+ await user.click(await screen.findByRole('button', { name: 'Reload hierarchy' }))
+
+ const expand = await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' })
+ expect(expand).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByText('objective c1')).not.toBeInTheDocument()
+
+ await user.click(expand)
+ expect(await screen.findByText('objective c1')).toBeInTheDocument()
+ expect(getNode).toHaveBeenCalledTimes(2)
+})
+
+test('notes that a run still in progress is a snapshot and can be reloaded', async () => {
+ const user = userEvent.setup()
+ getRoots
+ .mockResolvedValueOnce(projection([node('r1')], { status: 'IN_PROGRESS' }))
+ .mockResolvedValueOnce(projection([node('r1'), node('r2')], { status: 'COMPLETED' }))
+ mount()
+
+ expect(await screen.findByText(/This run is still in progress/)).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Reload hierarchy' }))
+
+ expect(await screen.findByText('objective r2')).toBeInTheDocument()
+ expect(screen.queryByText(/This run is still in progress/)).not.toBeInTheDocument()
+})
+
+test('a reference back to an ancestor stays unresolved even when the cycle arrives across fetched pages', async () => {
+ const user = userEvent.setup()
+ // Saved links P -> A -> B -> A; each node's children come from its own request.
+ const deferred = (id: string, childId: string, extra: Partial = {}) =>
+ node(id, {
+ result_role: 'orchestration',
+ child_attack_result_ids: [childId],
+ children_truncated: true,
+ children_count: 1,
+ ...extra,
+ })
+ const page = (id: string, child: ScenarioHierarchyNode) =>
+ node(id, { result_role: 'orchestration', child_attack_result_ids: [child.attack_result_id], children: [child], children_count: 1 })
+ getRoots.mockResolvedValue(projection([deferred('P', 'A')]))
+ getNode
+ .mockResolvedValueOnce(page('P', deferred('A', 'B', { child_index: 0 })))
+ .mockResolvedValueOnce(page('A', deferred('B', 'A', { child_index: 0 })))
+ .mockResolvedValueOnce(page('B', deferred('A', 'B', { child_index: 0 })))
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack P: objective P' }))
+ await user.click(await screen.findByRole('button', { name: 'Expand children of #1 attack A: objective A' }))
+ await user.click(await screen.findByRole('button', { name: 'Expand children of #1 attack B: objective B' }))
+
+ const cycle = await screen.findByText(/Unresolved reference: A \(it points back to a result above it\)/)
+ expect(within(cycle.closest('tr') as HTMLElement).getByText('#1')).toBeInTheDocument()
+ // Only the first A can be expanded; the repeat has no expand control.
+ expect(screen.getAllByRole('button', { name: /children of #1 attack A:/ })).toHaveLength(1)
+ expect(getNode).toHaveBeenCalledTimes(3)
+})
+
+test('a failed run that is resumed offers to reload the hierarchy it loaded before', async () => {
+ const user = userEvent.setup()
+ getRoots
+ .mockResolvedValueOnce(projection([node('r1')], { status: 'FAILED' }))
+ .mockResolvedValueOnce(projection([node('r1'), node('r2')], { status: 'IN_PROGRESS' }))
+ const { setRunStatus } = mount(jest.fn(), 'FAILED')
+
+ await screen.findByText('objective r1')
+ expect(screen.queryByRole('button', { name: 'Reload hierarchy' })).not.toBeInTheDocument()
+
+ setRunStatus('QUEUED')
+ expect(screen.getByText(/This run is still in progress/)).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Reload hierarchy' }))
+ expect(await screen.findByText('objective r2')).toBeInTheDocument()
+
+ // Finishing after the reload is a change too; the snapshot is no longer current.
+ setRunStatus('COMPLETED')
+ expect(screen.getByText("This run's status changed since these results loaded.")).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Reload hierarchy' })).toBeInTheDocument()
+})
+
+test('shows results in aligned columns with the attack outcome spelled out', async () => {
+ const user = userEvent.setup()
+ getRoots.mockResolvedValue(
+ projection([
+ node('parent', {
+ result_role: 'orchestration',
+ outcome: 'success',
+ child_attack_result_ids: ['c1'],
+ children: [node('c1', { child_index: 0, outcome: 'error', error_message: 'Target timed out', total_retries: 2 })],
+ children_count: 1,
+ }),
+ ]),
+ )
+ mount()
+
+ await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' }))
+ await screen.findByText('objective c1')
+
+ expect(within(resultsTable()).getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
+ 'Result / objective',
+ 'Result type',
+ 'Attack outcome',
+ 'Duration',
+ 'Retries / error',
+ 'Timestamp',
+ 'Actions',
+ ])
+ // A child row has the same cells in the same columns as its parent; only the first column is indented.
+ const cells = (text: string) => within(rowWith(text)).getAllByRole('cell').map((cell) => cell.textContent)
+ expect(cells('objective parent')).toEqual([
+ 'objective parentattack parent', 'Orchestration', 'Success', '1s', '0', expect.any(String), 'Details',
+ ])
+ expect(cells('objective c1')).toEqual([
+ '#1objective c1attack c1', 'Target-facing', 'Error', '1s', '2Target timed out', expect.any(String), 'Details',
+ ])
+ expect(screen.getByText(/^Result rows are saved attacks\./)).toHaveTextContent('not chat messages or retries')
+})
diff --git a/frontend/src/components/Scenarios/ScenarioResultHierarchy.tsx b/frontend/src/components/Scenarios/ScenarioResultHierarchy.tsx
new file mode 100644
index 0000000000..f41648208a
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioResultHierarchy.tsx
@@ -0,0 +1,109 @@
+import type { ReactNode } from 'react'
+
+import { Button, MessageBar, MessageBarActions, MessageBarBody, Text } from '@fluentui/react-components'
+
+import type { UseScenarioResultHierarchyResult } from '@/hooks/useScenarioResultHierarchy'
+import type { ScenarioRunState } from '@/types'
+
+import { useScenarioResultHierarchyStyles } from './ScenarioResultHierarchy.styles'
+
+interface ScenarioResultHierarchyProps {
+ readonly hierarchy: UseScenarioResultHierarchyResult
+ readonly runStatus?: ScenarioRunState | null
+ readonly children: ReactNode
+}
+
+/** Loading and continuation controls around the existing atomic attack-group tables. */
+export default function ScenarioResultHierarchy({
+ hierarchy,
+ runStatus = null,
+ children,
+}: ScenarioResultHierarchyProps) {
+ const styles = useScenarioResultHierarchyStyles()
+ const { roots } = hierarchy
+ const rootless = roots.diagnostics?.rootless_results_count ?? 0
+ const rootlessSample = roots.diagnostics?.rootless_result_ids ?? []
+ const currentStatus = runStatus ?? roots.runStatus
+ const runActive = currentStatus !== null && ACTIVE_RUN_STATES.has(currentStatus)
+ const statusChanged = runStatus !== null && roots.runStatus !== null && runStatus !== roots.runStatus
+
+ return (
+
+ {roots.status === 'loading' ? (
+
+ Loading result relationships. Available executions are shown as flat rows while they load.
+
+ ) : roots.status === 'error' ? (
+
+
+ Could not load the result relationships: {roots.error}. Available executions are shown as flat rows.
+
+
+
+
+
+ ) : (
+
+ Result rows are saved attacks. Orchestration rows are compound parents: expand them to see the attacks
+ they ran in saved order, not chat messages or retries. Unresolved rows are references, not extra results.
+ Click a result with a saved chat to open it; Details shows metadata. Group metrics come from the run
+ summary, not a count of these rows.
+
+ )}
+ {roots.status === 'ready' && (runActive || statusChanged) && (
+
+
+ {runActive
+ ? 'This run is still in progress, so these rows show the results saved when they loaded.'
+ : "This run's status changed since these results loaded."}
+
+
+
+ )}
+ {children}
+ {roots.status === 'ready' && roots.groups.length === 0 && rootless === 0 && (
+ No saved results are available for this run yet.
+ )}
+ {roots.truncated && (
+
+ Some results are not in this preview, such as deeper levels or results it could not place. Expand a
+ result to load its saved children.
+
+ )}
+ {rootless > 0 && (
+
+ {rootless} saved {rootless === 1 ? 'result has' : 'results have'} no root in this run (for example, a
+ cycle of references) and {rootless === 1 ? 'is' : 'are'} not shown in the tree.
+ {rootlessSample.length > 0 &&
+ ` ${roots.diagnostics?.rootless_results_truncated ? 'Some of their IDs' : 'Their IDs'}: ${rootlessSample.join(', ')}.`}
+
+ )}
+ {roots.staleCursor ? (
+
+ This run's saved results changed since they were loaded.
+
+
+
+
+ ) : roots.status === 'ready' && roots.error ? (
+
+ Could not load more results: {roots.error}
+
+
+
+
+ ) : null}
+ {roots.nextCursor && !roots.staleCursor && !roots.error && (
+