Skip to content

Commit f5e0839

Browse files
FEAT: URL-driven views and shareable deep links in the GUI (#1994)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ba48873 commit f5e0839

16 files changed

Lines changed: 1232 additions & 151 deletions

frontend/e2e/routing.spec.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { test, expect, type Page } from "@playwright/test";
2+
3+
// ---------------------------------------------------------------------------
4+
// Helpers – mock backend API responses so URL-driven navigation can be
5+
// exercised without a real backend. These tests are the only e2e coverage
6+
// that asserts on the browser address bar (deep links, refresh, back/forward).
7+
// ---------------------------------------------------------------------------
8+
9+
/** Attacks the mocked backend "knows about". Unknown ids return 404. */
10+
const KNOWN_ATTACKS: Record<string, { outcome: "success" | "failure" }> = {
11+
"atk-1": { outcome: "success" },
12+
"atk-success": { outcome: "success" },
13+
"atk-failure": { outcome: "failure" },
14+
};
15+
16+
/** Build an attack summary for the single-attack (getAttack) endpoint. */
17+
function makeAttackSummary(attackResultId: string, outcome: "success" | "failure") {
18+
return {
19+
attack_result_id: attackResultId,
20+
conversation_id: `conv-${attackResultId}`,
21+
attack_type: "SingleTurnAttack",
22+
target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" },
23+
converters: [],
24+
outcome,
25+
last_message_preview: null,
26+
message_count: 1,
27+
related_conversation_ids: [],
28+
labels: { operator: "alice" },
29+
created_at: new Date().toISOString(),
30+
updated_at: new Date().toISOString(),
31+
};
32+
}
33+
34+
/** Build a single assistant message whose text identifies its attack. */
35+
function makeMessage(attackResultId: string) {
36+
const text = `Loaded from ${attackResultId}`;
37+
return {
38+
turn_number: 1,
39+
role: "assistant",
40+
created_at: new Date().toISOString(),
41+
message_pieces: [
42+
{
43+
id: `piece-${attackResultId}`,
44+
original_value_data_type: "text",
45+
converted_value_data_type: "text",
46+
original_value: text,
47+
converted_value: text,
48+
scores: [],
49+
response_error: "none",
50+
},
51+
],
52+
};
53+
}
54+
55+
/** A row in the attack history list. */
56+
function makeAttackRow(attackResultId: string, outcome: "success" | "failure") {
57+
return {
58+
attack_result_id: attackResultId,
59+
conversation_id: `conv-${attackResultId}`,
60+
attack_type: "SingleTurnAttack",
61+
target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" },
62+
converters: [],
63+
outcome,
64+
last_message_preview: null,
65+
message_count: 1,
66+
related_conversation_ids: [],
67+
labels: { operator: "alice" },
68+
created_at: new Date().toISOString(),
69+
updated_at: new Date().toISOString(),
70+
};
71+
}
72+
73+
const ATTACK_ROWS = [
74+
makeAttackRow("atk-success", "success"),
75+
makeAttackRow("atk-failure", "failure"),
76+
];
77+
78+
/** Register every API mock the routing tests rely on. */
79+
async function mockRoutingAPIs(page: Page) {
80+
// No active target configured – the chat ribbon shows "No target selected".
81+
await page.route(/\/api\/targets/, async (route) => {
82+
await route.fulfill({
83+
status: 200,
84+
contentType: "application/json",
85+
body: JSON.stringify({ items: [] }),
86+
});
87+
});
88+
89+
await page.route(/\/api\/attacks\/attack-options/, async (route) => {
90+
await route.fulfill({
91+
status: 200,
92+
contentType: "application/json",
93+
body: JSON.stringify({ attack_types: ["SingleTurnAttack"] }),
94+
});
95+
});
96+
97+
await page.route(/\/api\/attacks\/converter-options/, async (route) => {
98+
await route.fulfill({
99+
status: 200,
100+
contentType: "application/json",
101+
body: JSON.stringify({ converter_types: [] }),
102+
});
103+
});
104+
105+
await page.route(/\/api\/labels/, async (route) => {
106+
await route.fulfill({
107+
status: 200,
108+
contentType: "application/json",
109+
body: JSON.stringify({ source: "attacks", labels: { operator: ["alice"], operation: [] } }),
110+
});
111+
});
112+
113+
// Conversation list for the loaded attack's side panel.
114+
await page.route(/\/api\/attacks\/[^/]+\/conversations/, async (route) => {
115+
const attackResultId = new URL(route.request().url()).pathname.split("/")[3] ?? "";
116+
await route.fulfill({
117+
status: 200,
118+
contentType: "application/json",
119+
body: JSON.stringify({
120+
attack_result_id: attackResultId,
121+
main_conversation_id: `conv-${attackResultId}`,
122+
conversations: [],
123+
}),
124+
});
125+
});
126+
127+
// Messages for the loaded conversation – text encodes the attack id so a
128+
// test can prove the URL's attack actually hydrated the chat.
129+
await page.route(/\/api\/attacks\/[^/]+\/messages/, async (route) => {
130+
if (route.request().method() !== "GET") {
131+
await route.continue();
132+
return;
133+
}
134+
const attackResultId = new URL(route.request().url()).pathname.split("/")[3] ?? "";
135+
await route.fulfill({
136+
status: 200,
137+
contentType: "application/json",
138+
body: JSON.stringify({
139+
conversation_id: `conv-${attackResultId}`,
140+
messages: [makeMessage(attackResultId)],
141+
}),
142+
});
143+
});
144+
145+
// Single attack (getAttack). Unknown ids 404 to drive the not-found UX.
146+
await page.route(/\/api\/attacks\/[^/]+$/, async (route) => {
147+
if (route.request().method() !== "GET") {
148+
await route.continue();
149+
return;
150+
}
151+
const attackResultId = new URL(route.request().url()).pathname.split("/")[3] ?? "";
152+
const known = KNOWN_ATTACKS[attackResultId];
153+
if (!known) {
154+
await route.fulfill({
155+
status: 404,
156+
contentType: "application/json",
157+
body: JSON.stringify({ detail: "Attack not found" }),
158+
});
159+
return;
160+
}
161+
await route.fulfill({
162+
status: 200,
163+
contentType: "application/json",
164+
body: JSON.stringify(makeAttackSummary(attackResultId, known.outcome)),
165+
});
166+
});
167+
168+
// Attacks list with outcome filtering (mirrors the real query contract).
169+
await page.route(/\/api\/attacks(?:\?|$)/, async (route) => {
170+
if (route.request().method() !== "GET") {
171+
await route.continue();
172+
return;
173+
}
174+
const outcome = new URL(route.request().url()).searchParams.get("outcome");
175+
const items = outcome ? ATTACK_ROWS.filter((a) => a.outcome === outcome) : ATTACK_ROWS;
176+
await route.fulfill({
177+
status: 200,
178+
contentType: "application/json",
179+
body: JSON.stringify({
180+
items,
181+
pagination: { limit: 25, has_more: false, next_cursor: null, prev_cursor: null },
182+
}),
183+
});
184+
});
185+
}
186+
187+
// ---------------------------------------------------------------------------
188+
// Tests
189+
// ---------------------------------------------------------------------------
190+
191+
test.describe("URL-driven routing", () => {
192+
test.beforeEach(async ({ page }) => {
193+
await mockRoutingAPIs(page);
194+
});
195+
196+
test("history filters round-trip through the URL and survive a reload", async ({ page }) => {
197+
await page.goto("/history");
198+
await expect(page.getByTestId("attacks-table")).toBeVisible({ timeout: 10_000 });
199+
200+
// Select the "Success" outcome filter.
201+
await page.getByTestId("outcome-filter").click();
202+
await page.getByRole("option", { name: "Success" }).click();
203+
204+
// The filter is reflected in the query string and the list narrows.
205+
await expect(page).toHaveURL(/[?&]outcome=success/);
206+
await expect(page.getByTestId("attack-row-atk-success")).toBeVisible();
207+
await expect(page.getByTestId("attack-row-atk-failure")).not.toBeVisible();
208+
209+
// A full page reload restores the filter from the URL alone.
210+
await page.reload();
211+
await expect(page).toHaveURL(/[?&]outcome=success/);
212+
await expect(page.getByTestId("attack-row-atk-success")).toBeVisible({ timeout: 10_000 });
213+
await expect(page.getByTestId("attack-row-atk-failure")).not.toBeVisible();
214+
});
215+
216+
test("deep-links into an attack and hydrates its conversation", async ({ page }) => {
217+
await page.goto("/attacks/atk-1");
218+
219+
// The router keeps the deep link, and the attack named by the URL – not
220+
// some default/empty conversation – drives the chat window.
221+
await expect(page).toHaveURL(/\/attacks\/atk-1$/);
222+
await expect(page.getByText("Loaded from atk-1")).toBeVisible({ timeout: 10_000 });
223+
});
224+
225+
test("shows the not-found screen for an unknown attack id", async ({ page }) => {
226+
await page.goto("/attacks/bogus-id-12345");
227+
228+
await expect(page.getByTestId("attack-not-found")).toBeVisible({ timeout: 10_000 });
229+
});
230+
231+
test("browser back returns from an opened attack to history", async ({ page }) => {
232+
await page.goto("/history");
233+
await expect(page.getByTestId("attacks-table")).toBeVisible({ timeout: 10_000 });
234+
235+
await page.getByTestId("attack-row-atk-success").click();
236+
await expect(page).toHaveURL(/\/attacks\/atk-success$/);
237+
238+
await page.goBack();
239+
await expect(page).toHaveURL(/\/history$/);
240+
await expect(page.getByTestId("attacks-table")).toBeVisible();
241+
});
242+
});

frontend/package-lock.json

Lines changed: 59 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"react": "19.2.7",
3131
"react-dom": "19.2.7",
3232
"react-error-boundary": "6.1.2",
33-
"react-joyride": "^3.1.0"
33+
"react-joyride": "^3.1.0",
34+
"react-router-dom": "7.16.0"
3435
},
3536
"devDependencies": {
3637
"@eslint/js": "10.0.1",

0 commit comments

Comments
 (0)