Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a478646
FEAT: Add scenario run history
Copilot Aug 12, 2026
f004bee
FEAT: Add FIFO scenario scheduling
Copilot Aug 12, 2026
8461771
FIX: Align FIFO scheduling with history contracts
Copilot Aug 21, 2026
f36d604
TEST: Mock auth config in scenario E2E
romanlutz Aug 25, 2026
5338137
FEAT: Add scenario configuration sizing
Copilot Aug 12, 2026
e0797eb
FIX: Cache conditional scenario estimates
Copilot Aug 21, 2026
0bdd7c2
FIX: Preserve rich scenario sizing after restack
Copilot Aug 21, 2026
7a70111
FIX: Clarify conditional scenario estimates
Copilot Aug 21, 2026
1c482f4
TEST: Keep auth bootstrap responsive in slow API E2E
romanlutz Aug 25, 2026
25c0f70
STYLE: Remove stale jailbreak test import
romanlutz Aug 25, 2026
1cacfc9
FIX: Restore scenario dataset sizing contract
Sep 2, 2026
918592a
MERGE: Update FIFO scenario scheduling with main
romanlutz Sep 11, 2026
b33c393
MERGE: Update FIFO scenario scheduling with current main
romanlutz Sep 12, 2026
6452761
Merge parent updates into scenario sizing
romanlutz Sep 12, 2026
8077fa1
TEST: Configure seeded scorer target
romanlutz Sep 12, 2026
5b356d5
TEST: Stabilize FIFO scheduler CI
romanlutz Sep 12, 2026
c513eab
Merge corrected #2376 parent into #2377
romanlutz Sep 12, 2026
3ae1ba7
TEST: Stabilize catalog timeout on Windows
romanlutz Sep 12, 2026
20c624c
TEST: Ignore concurrent catalog call order
romanlutz Sep 12, 2026
aab7b57
TEST: Make completed scheduler task explicit
romanlutz Sep 14, 2026
437fed4
MERGE: Update FIFO scheduling with main
romanlutz Sep 15, 2026
49dbc7e
MERGE: Include latest main test coverage
romanlutz Sep 15, 2026
5201e03
FIX: Address FIFO scheduler review feedback
romanlutz Sep 18, 2026
d6390b7
MERGE: Update FIFO scheduling with latest main
romanlutz Sep 18, 2026
641397a
Merge corrected #2376 parent into #2377
romanlutz Sep 18, 2026
e649a46
TEST: Add queue fixture to theme E2E
romanlutz Sep 18, 2026
b5134fe
Initialize memory in scenario sizing test
romanlutz Sep 18, 2026
b9cc21e
TEST: Bound scheduler shutdown wait
romanlutz Sep 18, 2026
6c1ce22
TEST: Release scheduler preparation once
romanlutz Sep 18, 2026
d2523e5
Merge final #2376 parent into #2377
romanlutz Sep 18, 2026
9fadd98
MERGE: Update FIFO scheduling with current deployment split
romanlutz Sep 18, 2026
ae392e6
Merge latest #2376 parent into #2377
romanlutz Sep 18, 2026
0285314
MERGE: Update FIFO scheduling with current main
romanlutz Sep 18, 2026
68fc64a
Merge repaired #2376 parent into #2377
romanlutz Sep 18, 2026
a9d116a
FIX: Refresh history after queue updates
romanlutz Sep 18, 2026
e07691f
MERGE: Update FIFO scheduler with current main
romanlutz Sep 19, 2026
0856515
Merge final PR #2376 parent into scenario sizing layer
romanlutz Sep 19, 2026
091fe07
Merge latest main into scenario sizing layer
romanlutz Sep 19, 2026
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
111 changes: 68 additions & 43 deletions frontend/e2e/api.spec.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import { test, expect } from "@playwright/test";
import type { APIRequestContext } from "@playwright/test";

// API tests go through the Vite dev server proxy (/api -> configured backend)
// rather than hitting the backend directly, so they work as soon as
// Playwright's webServer is ready.

test.describe("API Health Check", () => {
// The backend may still be starting when Vite is already up.
// Poll the health endpoint through the proxy until the backend is ready.
test.beforeAll(async ({ request }) => {
const maxWait = 30_000;
const interval = 1_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
try {
const resp = await request.get("/api/health", { timeout: 2_000 });
if (resp.ok()) return;
} catch {
// Backend not ready yet
async function waitForBackend(request: APIRequestContext): Promise<void> {
const maxWait = 30_000;
const interval = 1_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
try {
const response = await request.get("/api/health", { timeout: 2_000 });
if (response.ok()) {
return;
}
await new Promise((r) => setTimeout(r, interval));
} catch {
// Backend not ready yet
}
throw new Error("Backend did not become healthy within 30 seconds");
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error("Backend did not become healthy within 30 seconds");
}

test.describe("API Health Check", () => {
test.beforeAll(async ({ request }) => {
await waitForBackend(request);
});

test("should have healthy backend API @seeded", async ({ request }) => {
Expand All @@ -38,24 +43,12 @@ test.describe("API Health Check", () => {
const data = await response.json();
expect(data).toBeDefined();
});

});

test.describe("Targets API", () => {
test.beforeAll(async ({ request }) => {
// Wait for backend readiness
const maxWait = 30_000;
const interval = 1_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
try {
const resp = await request.get("/api/health", { timeout: 2_000 });
if (resp.ok()) return;
} catch {
// Backend not ready yet
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error("Backend did not become healthy within 30 seconds");
await waitForBackend(request);
});

test("should list targets @seeded", async ({ request }) => {
Expand Down Expand Up @@ -103,19 +96,7 @@ test.describe("Targets API", () => {

test.describe("Attacks API", () => {
test.beforeAll(async ({ request }) => {
const maxWait = 30_000;
const interval = 1_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
try {
const resp = await request.get("/api/health", { timeout: 2_000 });
if (resp.ok()) return;
} catch {
// Backend not ready yet
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error("Backend did not become healthy within 30 seconds");
await waitForBackend(request);
});

test("should list attacks @seeded", async ({ request }) => {
Expand All @@ -124,10 +105,54 @@ test.describe("Attacks API", () => {
});
});

test.describe("Scenarios API", () => {
test.beforeAll(async ({ request }) => {
await waitForBackend(request);
});

test("should expose scenario catalog details and queue state @seeded", async ({ request }) => {
test.setTimeout(90_000);
const catalogResponse = await request.get("/api/scenarios/catalog?limit=200");
expect(catalogResponse.ok()).toBe(true);
const catalog = await catalogResponse.json();
expect(catalog.items.length).toBeGreaterThan(0);

const scenarioName = catalog.items[0].scenario_name as string;
const detailResponse = await request.get(`/api/scenarios/catalog/${encodeURIComponent(scenarioName)}`);
expect(detailResponse.ok()).toBe(true);
const detail = await detailResponse.json();
expect(detail.scenario_name).toBe(scenarioName);
expect(detail.dataset_size_limit).toEqual(expect.objectContaining({
default_scope: expect.any(String),
override_scope: expect.any(String),
}));

const queueResponse = await request.get("/api/scenarios/runs/queue");
expect(queueResponse.ok()).toBe(true);
await expect(queueResponse.json()).resolves.toEqual(expect.objectContaining({
revision: expect.any(Number),
queued: expect.any(Array),
}));
});
});

test.describe("Error Handling", () => {
test("should display UI when backend is slow", async ({ page }) => {
// Intercept and delay API calls
// Auth configuration must resolve before the application can render.
await page.route("**/api/**", async (route) => {
if (new URL(route.request().url()).pathname === "/api/auth/config") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
clientId: "",
tenantId: "",
allowedGroupIds: "",
}),
});
return;
}

await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
Expand Down
120 changes: 98 additions & 22 deletions frontend/e2e/scenario-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,26 @@ const datasetSummary = {
};

const configuredEstimate = {
estimated_attack_count: 8,
minimum_attack_count: null,
maximum_attack_count: null,
version: 1,
status: "exact",
total_attack_count: 8,
minimum_attack_count: 8,
maximum_attack_count: 8,
condition: null,
components: [{
label: "Prompt sending",
count: 8,
is_baseline: false,
note: null,
}],
datasets: [datasetSummary],
adaptive_details: null,
effective_parameters: {
num_jailbreaks: 2,
num_jailbreak_attempts: 1,
},
note: "The backend total is authoritative.",
retries_included: false,
};

const catalogScenario = {
Expand Down Expand Up @@ -82,7 +87,11 @@ const catalogScenario = {
},
],
default_datasets: ["harmbench"],
default_dataset_summaries: [datasetSummary],
dataset_size_limit: {
default_scope: "per_dataset",
default_count: 4,
override_scope: "per_dataset",
},
baseline_policy: "enabled",
include_baseline_by_default: false,
supported_parameters: [
Expand Down Expand Up @@ -115,21 +124,26 @@ const catalogScenario = {
},
],
default_run_size: {
estimated_attack_count: 16,
minimum_attack_count: null,
maximum_attack_count: null,
version: 1,
status: "exact",
total_attack_count: 16,
minimum_attack_count: 16,
maximum_attack_count: 16,
condition: null,
components: [{
label: "Default attacks",
count: 16,
is_baseline: false,
note: null,
}],
datasets: [datasetSummary],
adaptive_details: null,
effective_parameters: {
num_jailbreaks: 2,
num_jailbreak_attempts: 1,
},
note: "Retries and internal turns are excluded.",
retries_included: false,
},
};

Expand Down Expand Up @@ -214,6 +228,8 @@ const progressAttempt = {
timestamp: "2026-08-07T00:00:30Z",
total_retries: 1,
retries: [],
result_kind: "attack",
technique_name: "prompt_sending",
};

interface ScenarioMocks {
Expand Down Expand Up @@ -277,6 +293,14 @@ async function mockScenarioAPIs(page: Page): Promise<ScenarioMocks> {
});
});

await page.route(/\/api\/datasets(?:\?|$)/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [{ name: "harmbench" }] }),
});
});

await page.route(new RegExp(`/api/scenarios/catalog/${SCENARIO_NAME.replace(".", "\\.")}/estimate$`), async (route) => {
const request = route.request().postDataJSON() as Record<string, unknown>;
estimateRequests.push(request);
Expand Down Expand Up @@ -496,16 +520,20 @@ async function mockScenarioAPIs(page: Page): Promise<ScenarioMocks> {

async function configurePromptSendingRun(page: Page): Promise<void> {
await expect(page.getByTestId("scenario-target-select")).toHaveValue("test-target");
await page.getByTestId("technique-prompt_sending").check();
await page.getByTestId("technique-jailbreak_system_prompt").uncheck();
await page.getByTestId("technique-mode-custom").click();
await expect(page.getByTestId("technique-prompt_sending")).toBeChecked();
await expect(page.getByTestId("technique-jailbreak_system_prompt")).toBeChecked();
await page.getByTestId("technique-jailbreak_system_prompt").click();
await page.getByTestId("scenario-param-num_jailbreaks").fill("2");
await page.getByTestId("scenario-param-num_jailbreak_attempts").fill("1");
await expect(page.getByTestId("baseline-checkbox")).not.toBeChecked();
await expect(page.getByTestId("run-estimate").getByText("8", { exact: true })).toBeVisible();
await expect(page.getByRole("group", {
name: "8 planned attacks.",
})).toBeVisible();
}

test.describe("Scenario catalog, history, and live run routing", () => {
test("renders the semantic catalog, full metadata, safe MyST, and both sidebar destinations", async ({ page }) => {
test("opens the Configure page from the semantic launch index with complete safe metadata", async ({ page }) => {
await mockScenarioAPIs(page);
await page.goto("/scanner");

Expand All @@ -524,17 +552,52 @@ test.describe("Scenario catalog, history, and live run routing", () => {
]);
await expect(page.getByTitle("Scanner")).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("table", { name: "Registered scenarios" })).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Default run size" })).toBeVisible();

await expect(page.getByRole("columnheader")).toHaveText([
"Scenario / purpose",
"Configure",
"Default datasets",
"Default techniques",
"Default run size",
]);
const row = page.getByTestId(`scenario-card-${SCENARIO_NAME}`);
await row.getByRole("link", { name: SCENARIO_NAME }).click();
const cells = row.getByRole("cell");
await expect(cells).toHaveCount(5);
const configureButton = cells.nth(1).getByRole("button", { name: "Configure run" });
await expect(configureButton).toBeVisible();
const [scenarioCellBox, configureCellBox, datasetCellBox] = await Promise.all([
cells.nth(0).boundingBox(),
cells.nth(1).boundingBox(),
cells.nth(2).boundingBox(),
]);
expect(scenarioCellBox).not.toBeNull();
expect(configureCellBox).not.toBeNull();
expect(datasetCellBox).not.toBeNull();
expect(configureCellBox!.x).toBeGreaterThan(scenarioCellBox!.x);
expect(configureCellBox!.x).toBeLessThan(datasetCellBox!.x);
await expect(page.getByRole("button", { name: /show details|hide details/i })).toHaveCount(0);

await configureButton.click();
await expect(page).toHaveURL(`/scanner/${SCENARIO_NAME}`);
await expect(page.getByRole("heading", { name: SCENARIO_NAME, level: 1 })).toBeVisible();
await expect(page.getByText("Jailbreak · v4")).toBeVisible();
const description = page.getByTestId("scenario-detail-description");
await expect(description.getByText("dataset")).toHaveCSS("font-weight", /^(600|700)$/);
await expect(description.locator("code").filter({ hasText: "num_jailbreaks" })).toBeVisible();
await expect(description.locator("img")).toHaveCount(0);
await expect(description).toContainText(RAW_IMAGE_HTML);
await expect(page.getByRole("radio", { name: /Recommended \(default\).*2 techniques/ })).toBeChecked();
await expect(page.getByRole("radio", { name: /Easy.*1 technique/ })).toBeVisible();
await expect(page.getByRole("radio", { name: "Custom" })).toBeVisible();
const members = page.getByTestId("selected-technique-set-members");
await expect(members.getByText("prompt_sending")).toBeVisible();
await expect(members.getByText("jailbreak_system_prompt")).toBeVisible();
const preview = page.getByRole("complementary", { name: "Run preview" });
await expect(preview.getByText("Jailbreak templates: 2")).toBeVisible();
await expect(preview.getByRole("group", {
name: "16 planned attacks.",
})).toBeVisible();
await expect(page.getByText("Include direct baseline comparison")).toBeVisible();
await expect(page.getByText(/Also send each selected objective directly/)).toBeVisible();

await page.getByTitle("History").click();
await expect(page).toHaveURL("/history/attacks");
Expand All @@ -551,7 +614,7 @@ test.describe("Scenario catalog, history, and live run routing", () => {
await page.goto(`/scanner/${SCENARIO_NAME}`);

const form = page.getByRole("form", { name: "Scenario run configuration" });
const preview = page.getByTestId("run-estimate");
const preview = page.getByRole("complementary", { name: "Run preview" });
await expect(form).toBeVisible();
await expect(preview).toBeVisible();

Expand All @@ -570,12 +633,12 @@ test.describe("Scenario catalog, history, and live run routing", () => {
const requests = mocks.getEstimateRequests();
return requests[requests.length - 1];
}).toEqual(expectedEstimateRequest);
await expect(preview.getByText("8", { exact: true })).toBeVisible();
await expect(preview.getByRole("group", {
name: "8 planned attacks.",
})).toBeVisible();
await expect(preview).not.toContainText("context_compliance");

await page.getByTestId("launch-scenario-btn").click();
await expect(page.getByRole("dialog", { name: "Run preview" })).toBeVisible();
await page.getByTestId("confirm-launch-scenario-btn").click();
const expectedLaunchRequest = {
scenario_name: SCENARIO_NAME,
target_name: "test-target",
Expand Down Expand Up @@ -609,15 +672,28 @@ test.describe("Scenario catalog, history, and live run routing", () => {
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/scanner/${SCENARIO_NAME}`);
await page.goto("/scanner");
const catalogRow = page.getByTestId(`scenario-card-${SCENARIO_NAME}`);
const configureButton = catalogRow.getByRole("button", { name: "Configure run" });
await expect(catalogRow).toBeVisible();
expect(await catalogRow.getByRole("cell").allInnerTexts()).toEqual([
expect.stringContaining("Scenario / purpose"),
expect.stringContaining("Configure"),
expect.stringContaining("Default datasets"),
expect.stringContaining("Default techniques"),
expect.stringContaining("Default run size"),
]);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
expect((await configureButton.boundingBox())?.height).toBeGreaterThanOrEqual(44);
await configureButton.press("Enter");
await expect(page).toHaveURL(`/scanner/${SCENARIO_NAME}`);
await configurePromptSendingRun(page);

const formBox = await page.getByRole("form", { name: "Scenario run configuration" }).boundingBox();
const previewBox = await page.getByTestId("run-estimate").boundingBox();
const previewBox = await page.getByRole("complementary", { name: "Run preview" }).boundingBox();
expect(formBox).not.toBeNull();
expect(previewBox).not.toBeNull();
expect(previewBox!.y).toBeGreaterThan(formBox!.y);
expect(previewBox!.y + previewBox!.height).toBeLessThanOrEqual(formBox!.y + formBox!.height);
expect(previewBox!.y).toBeGreaterThanOrEqual(formBox!.y + formBox!.height);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);

for (const control of [
Expand Down
Loading
Loading