Skip to content
Open
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
6 changes: 5 additions & 1 deletion .github/workflows/frontend_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ jobs:
config: playwright.paired-outcomes.config.ts
shard: 1
total: 1
- project: scenario-hierarchy
config: playwright.scenario-hierarchy.config.ts
shard: 1
total: 1
outputs:
expected-reports: ${{ strategy.job-total }}
permissions:
Expand Down Expand Up @@ -131,7 +135,7 @@ jobs:
run: npx playwright install --with-deps chromium

- name: Build the isolated fixture frontend
if: matrix.project == 'analytics' || matrix.project == 'paired-outcomes'
if: matrix.project == 'analytics' || matrix.project == 'paired-outcomes' || matrix.project == 'scenario-hierarchy'
run: npm run build

- name: Run E2E shard (no credentials needed)
Expand Down
145 changes: 145 additions & 0 deletions frontend/e2e/fixtures/scenario_hierarchy_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""Disposable saved Scenario run with compound results for hierarchy browser acceptance tests."""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from unittest.mock import patch
from uuid import UUID

from frontend.e2e.fixtures import analytics_backend

if TYPE_CHECKING:
from pyrit.memory import SQLiteMemory

RUN_ID = UUID(int=0x3602)
CHILD_COUNT = 520
MISSING_POSITION = 1
DUPLICATE_POSITION = 3


async def _seed_async(memory: SQLiteMemory) -> None:
from pyrit.common.utils import to_sha256
from pyrit.models import (
SCENARIO_RUN_PLAN_METADATA_KEY,
AtomicAttackIdentifier,
AttackIdentifier,
AttackOutcome,
AttackResult,
AttackResultRole,
ScenarioIdentifier,
ScenarioResult,
ScenarioRunPlan,
ScenarioRunPlanAtomicGroup,
ScenarioRunPlanGroupKind,
ScenarioRunPlanSeedGroup,
ScenarioRunState,
TargetIdentifier,
)

memory.disable_embedding()
timestamp = datetime(2026, 10, 28, tzinfo=UTC)
target = TargetIdentifier(class_name="MockPromptTarget", class_module="hierarchy_browser_fixture")
identifier = AtomicAttackIdentifier.build(
attack_identifier=AttackIdentifier(
class_name="SequentialAttack",
class_module="pyrit.executor.attack",
objective_target=target,
)
)
plan = ScenarioRunPlan(
atomic_groups=[
ScenarioRunPlanAtomicGroup(
id=name,
atomic_attack_name=name,
display_group=display,
technique_eval_hash=f"{name}-eval",
kind=ScenarioRunPlanGroupKind.ATTACK,
seed_group_ids=["seed"],
)
for name, display in [("sequential", "Sequential"), ("single", "Single turn")]
],
seed_groups=[ScenarioRunPlanSeedGroup(id="seed", objective="seed", objective_sha256=to_sha256("seed"))],
)

def result(
index: int,
group: str,
*,
objective: str,
outcome: AttackOutcome = AttackOutcome.FAILURE,
child_ids: list[str] | None = None,
attempt_index: int | None = None,
) -> AttackResult:
return AttackResult(
attack_result_id=str(UUID(int=0x360200000 + index)),
conversation_id=str(UUID(int=0x360300000 + index)),
objective=objective,
outcome=outcome,
timestamp=timestamp + timedelta(seconds=index),
atomic_attack_identifier=identifier,
attribution_parent_id=str(RUN_ID),
attribution_data={
"seed_group_id": "seed",
"parent_eval_hash": f"{group}-eval",
"parent_collection": group,
# Like SequentialAttack: children share the parent's attribution plus their position.
**({"attempt_index": attempt_index} if attempt_index is not None else {}),
"result_role": (
AttackResultRole.ORCHESTRATION.value
if child_ids is not None
else AttackResultRole.TARGET_FACING.value
),
},
metadata={"child_attack_result_ids": child_ids} if child_ids is not None else {},
)

grandchildren = [
result(1000 + i, "sequential", objective=f"Nested step {i + 1}", attempt_index=i + 1) for i in range(2)
]
children = [
result(
10 + i,
"sequential",
objective=f"Step {i + 1}",
outcome=AttackOutcome.SUCCESS if i == 4 else AttackOutcome.FAILURE,
child_ids=[g.attack_result_id for g in grandchildren] if i == 0 else None,
attempt_index=i + 1,
)
for i in range(CHILD_COUNT)
]
child_ids = [child.attack_result_id for child in children]
child_ids[MISSING_POSITION] = str(UUID(int=0x3602DEAD))
child_ids[DUPLICATE_POSITION] = child_ids[0]
parent = result(1, "sequential", objective="Sequential parent", child_ids=child_ids)
single = result(2, "single", objective="Single-turn attempt", outcome=AttackOutcome.SUCCESS)

rows = {"sequential": [parent, *children, *grandchildren], "single": [single]}
await memory.add_attack_results_to_memory_async(attack_results=[row for group in rows.values() for row in group])
await memory.add_scenario_results_to_memory_async(
scenario_results=[
ScenarioResult(
id=RUN_ID,
scenario_identifier=ScenarioIdentifier(
class_name="HierarchyBrowserScenario",
class_module="hierarchy_browser_fixture",
version=1,
objective_target=target,
),
scenario_description="Offline compound-result hierarchy acceptance fixture.",
attack_results=rows,
metadata={SCENARIO_RUN_PLAN_METADATA_KEY: plan.model_dump(mode="json")},
scenario_run_state=ScenarioRunState.COMPLETED,
creation_time=timestamp,
completion_time=timestamp + timedelta(minutes=1),
)
]
)


if __name__ == "__main__":
with patch.object(analytics_backend, "_seed_async", _seed_async):
analytics_backend.main()
86 changes: 86 additions & 0 deletions frontend/e2e/scenario-hierarchy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test, type Page } from '@playwright/test'
import { scenarioRunRoutePath } from '../src/utils/routeParams'

// Seeded by e2e/fixtures/scenario_hierarchy_backend.py
const run = scenarioRunRoutePath('00000000-0000-0000-0000-000000003602')

async function openHierarchy(page: Page) {
await page.goto(run)
const section = page.getByRole('region', { name: 'Result hierarchy', exact: true })
await expect(section.getByText('Sequential parent', { exact: true })).toBeVisible()
return section
}

test('browses a compound result through the real API, page by page, with stable totals', async ({ page }, testInfo) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
const section = await openHierarchy(page)
const totals = section.getByLabel('Run totals')
const before = await totals.textContent()

const firstPage = page.waitForResponse((response) => /\/hierarchy\/nodes\/[^/?]+\?limit=200$/.test(response.url()))
await section.getByRole('button', { name: 'Expand children of sequential: Sequential parent' }).click()
expect((await firstPage).status()).toBe(200)

const table = section.getByRole('table', { name: 'Sequential saved results' })
const row = (text: string) => table.getByRole('row').filter({ hasText: text }).first()
await expect(table.getByText('Step 1', { exact: true })).toBeVisible()
await expect(row('#2')).toContainText('Unresolved reference')
await expect(row('#4')).toContainText('Unresolved reference')
// Children keep the parent's columns: outcome text sits under "Attack outcome", not nested in the row.
await expect(row('Step 5').getByRole('cell').nth(2)).toHaveText('Success')
await expect(section.getByText('Showing 200 of 520 saved children')).toBeVisible()

for (const shown of [400, 520]) {
const next = page.waitForResponse((response) => response.url().includes('children_cursor='))
await section.getByRole('button', { name: 'Load more children' }).click()
expect((await next).status()).toBe(200)
await expect(section.getByText(`Showing ${shown} of 520 saved children`)).toBeVisible()
}
await expect(section.getByRole('button', { name: 'Load more children' })).toHaveCount(0)
await expect(table.getByText('Step 520', { exact: true })).toBeVisible()

await table.getByRole('button', { name: 'Expand children of #1 sequential: Step 1' }).click()
await expect(table.getByText('Nested step 2', { exact: true })).toBeVisible()
expect(await totals.textContent()).toBe(before)

await section.getByRole('button', { name: 'Open details for single: Single-turn attempt' }).click()
await expect(page.getByRole('dialog', { name: 'single' })).toBeVisible()
await page.getByRole('button', { name: 'Close' }).click()
await expect(page.getByRole('dialog')).toHaveCount(0)

await page.getByRole('heading', { name: 'Result hierarchy', exact: true }).evaluate((heading) =>
heading.scrollIntoView({ block: 'start' }),
)
await page.screenshot({ path: testInfo.outputPath('scenario-hierarchy-desktop.png') })
expect(errors).toEqual([])
})

test.describe('on a narrow touch screen', () => {
// Touch targets grow only for coarse pointers, as in touch-targets.spec.ts.
test.use({ hasTouch: true })

test('keeps the hierarchy usable at a narrow viewport and from the keyboard', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
const section = await openHierarchy(page)

const expand = section.getByRole('button', { name: 'Expand children of sequential: Sequential parent' })
await expand.focus()
await page.keyboard.press('Enter')
// The same button is renamed once expanded, so look it up by its new name.
await expect(section.getByRole('button', { name: 'Collapse children of sequential: Sequential parent' })).toHaveAttribute(
'aria-expanded',
'true',
)
await expect(section.getByText('Showing 200 of 520 saved children')).toBeVisible()

const loadMore = section.getByRole('button', { name: 'Load more children' })
const size = await loadMore.boundingBox()
expect(size?.height).toBeGreaterThanOrEqual(44)
const overflow = await section.evaluate((element) => element.scrollWidth > element.clientWidth)
expect(overflow).toBe(false)

await section.scrollIntoViewIfNeeded()
await page.screenshot({ path: testInfo.outputPath('scenario-hierarchy-mobile.png') })
})
})
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test:coverage": "jest --coverage",
"test:e2e": "playwright test",
"test:e2e:paired-outcomes": "npm run build && playwright test --config playwright.paired-outcomes.config.ts",
"test:e2e:scenario-hierarchy": "npm run build && playwright test --config playwright.scenario-hierarchy.config.ts",
"test:e2e:analytics": "npm run build && playwright test --config playwright.analytics.config.ts",
"test:e2e:seeded": "playwright test --project seeded",
"test:e2e:live": "E2E_LIVE_MODE=true playwright test --project live",
Expand Down
2 changes: 1 addition & 1 deletion frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const E2E_BACKEND_URL = `http://127.0.0.1:${E2E_BACKEND_PORT}`;

export default defineConfig({
testDir: "./e2e",
testIgnore: ["**/analytics.spec.ts", "**/paired-outcomes.spec.ts"],
testIgnore: ["**/analytics.spec.ts", "**/paired-outcomes.spec.ts", "**/scenario-hierarchy.spec.ts"],
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
Expand Down
22 changes: 22 additions & 0 deletions frontend/playwright.scenario-hierarchy.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import process from 'node:process'
import { defineConfig } from '@playwright/test'
import analytics from './playwright.analytics.config'

const port = Number(process.env.PYRIT_E2E_HIERARCHY_PORT ?? '18498')
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid scenario hierarchy test port')
const url = `http://127.0.0.1:${port}`

export default defineConfig({
...analytics,
testMatch: 'scenario-hierarchy.spec.ts',
use: {
...analytics.use,
baseURL: url,
storageState: { cookies: [], origins: [{ origin: url, localStorage: [{ name: 'pyrit-tour-completed', value: 'true' }] }] },
},
projects: [{ name: 'scenario-hierarchy', use: { viewport: { width: 1440, height: 1050 } } }],
webServer: {
command: `cd .. && uv run --no-sync python -m frontend.e2e.fixtures.scenario_hierarchy_backend --port ${port}`,
env: { PYTHONIOENCODING: 'utf-8' }, url: `${url}/api/health`, reuseExistingServer: false, timeout: 120_000,
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export function formatAttackSuccess(outcome: ScenarioProgressResult['outcome']):
return outcome.replace(/^\w/, (letter) => letter.toUpperCase())
}

/** The attack's outcome as a word, for places headed "Attack outcome" rather than "Attack success". */
export function formatAttackOutcome(outcome: ScenarioProgressResult['outcome']): string {
return outcome.replace(/^\w/, (letter) => letter.toUpperCase())
}

export function formatScore(score: ScenarioProgressResult['score']): string {
if (!score || score.status === 'undetermined') {
return 'Undetermined'
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Scenarios/ScenarioFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jest.mock('@/services/api', () => ({
scenariosApi: {
cancelRun: jest.fn(),
estimateRun: jest.fn(),
getRunHierarchy: jest.fn(() => new Promise(() => {})),
getScenario: jest.fn(),
listCatalog: jest.fn(),
startRun: jest.fn(),
Expand Down
Loading