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
588 changes: 588 additions & 0 deletions frontend/e2e/scenario-history.spec.ts

Large diffs are not rendered by default.

136 changes: 129 additions & 7 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ jest.mock("./components/Layout/MainLayout", () => {
<button onClick={() => onNavigate("scenarios")} data-testid="nav-scenarios">
Scenarios
</button>
<button onClick={() => onNavigate("scenarioHistory")} data-testid="nav-scenario-history">
Scenario History
</button>
{children}
</div>
);
Expand All @@ -127,6 +130,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,
Expand All @@ -140,6 +144,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
onConversationCreated,
onSelectConversation,
labels,
scenarioResultId,
}: {
onNewAttack: () => void;
activeTarget: unknown;
Expand All @@ -153,7 +158,9 @@ jest.mock("./components/Chat/ChatWindow", () => {
onConversationCreated: (attackResultId: string, conversationId: string) => void;
onSelectConversation: (convId: string) => void;
labels: Record<string, string>;
scenarioResultId?: string | null;
}) => {
const location = useLocation();
return (
<div data-testid="chat-window">
<span data-testid="attack-result-id">{attackResultId ?? "none"}</span>
Expand All @@ -168,6 +175,8 @@ jest.mock("./components/Chat/ChatWindow", () => {
<span data-testid="target-resolution-status">{targetResolutionStatus ?? "none"}</span>
<span data-testid="labels-operator">{labels.operator ?? ""}</span>
<span data-testid="labels-json">{JSON.stringify(labels)}</span>
<span data-testid="scenario-result-id">{scenarioResultId ?? "none"}</span>
<span data-testid="route-location">{`${location.pathname}${location.search}`}</span>
<button onClick={onNewAttack} data-testid="new-attack">
New Attack
</button>
Expand Down Expand Up @@ -365,12 +374,21 @@ jest.mock("./components/Scenarios/ScenarioDetail", () => {
};
});

jest.mock("./components/Scenarios/ScenarioRunStarted", () => {
const MockScenarioRunStarted = () => <div data-testid="scenario-run-started" />;
MockScenarioRunStarted.displayName = "MockScenarioRunStarted";
jest.mock("./components/Scenarios/ScenarioRunPage", () => {
const MockScenarioRunPage = () => <div data-testid="scenario-run-page" />;
MockScenarioRunPage.displayName = "MockScenarioRunPage";
return {
__esModule: true,
default: MockScenarioRunPage,
};
});

jest.mock("./components/History/ScenarioHistory", () => {
const MockScenarioHistory = () => <div data-testid="scenario-history" />;
MockScenarioHistory.displayName = "MockScenarioHistory";
return {
__esModule: true,
default: MockScenarioRunStarted,
default: MockScenarioHistory,
};
});

Expand Down Expand Up @@ -464,14 +482,24 @@ 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"
"scenarioHistory"
);
expect(screen.getByTestId("scenario-run-started")).toBeInTheDocument();
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", () => {
Expand All @@ -486,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");

Expand Down Expand Up @@ -937,6 +977,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 () => {
Expand All @@ -956,6 +997,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",
Expand Down Expand Up @@ -985,6 +1089,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");

Expand Down
Loading