Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/instructions/frontend-test.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ Files excluded from coverage: `main.tsx`, `vite-env.d.ts`, `services/api.ts` (th

- E2E tests live in `frontend/e2e/`.
- They test full user flows against a running backend.
- Check user-visible failures: missing or clipped content, unreachable controls, broken actions, scrolling, and focus.
- Do not lock tests to exact coordinates, animation frames, or cosmetic sizes. Keep size checks for explicit usability requirements, such as minimum touch targets.
- Do NOT mock APIs in E2E tests — they exercise the real stack.
- Run with `npm run test:e2e` (headless) or `npm run test:e2e:headed`.

Expand Down
26 changes: 0 additions & 26 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,29 +401,3 @@ test.describe("Accessibility", () => {
await expectTourContainedAndActionable(page, "desktop");
});
});

test.describe("Visual Consistency", () => {
test("should render without layout shifts", async ({ page }) => {
await page.goto("/");

// Wait for initial render then navigate to chat to measure the chat ribbon
await expect(page.getByTitle("Technique.execute")).toBeVisible();
await page.getByTitle("Technique.execute").click();
const anchor = page.getByTestId("new-attack-btn");
await expect(anchor).toBeVisible();

// Take measurements
const initialBox = await anchor.boundingBox();

// Wait a moment for any delayed renders
await page.waitForTimeout(500);

// Verify position hasn't changed
const finalBox = await anchor.boundingBox();

if (initialBox && finalBox) {
expect(finalBox.x).toBe(initialBox.x);
expect(finalBox.y).toBe(initialBox.y);
}
});
});
20 changes: 2 additions & 18 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ test.describe("Chat Functionality", () => {
await expect(badge).toContainText(/gpt-4o-mock/);
});

test("should overlay conversations without shrinking mobile chat and restore focus", async ({ page }) => {
test("should open mobile conversations and restore focus on close", async ({ page }) => {
await page.route(/\/api\/attacks\/[^/]+\/conversations/, async (route) => {
await route.fulfill({
status: 200,
Expand All @@ -252,30 +252,14 @@ test.describe("Chat Functionality", () => {
await expect(getMessageByText(page, "Start a mobile conversation")).toBeVisible();

await page.setViewportSize({ width: 390, height: 844 });
const chatArea = page.getByTestId("chat-area");
const toggleButton = page.getByRole("button", { name: "Toggle conversations panel" });
await expect(toggleButton).toBeEnabled();
const beforeOpen = await chatArea.boundingBox();

await toggleButton.click();
const drawer = page.getByRole("dialog", { name: "Attack Conversations" });
await expect(drawer).toBeVisible();

const afterOpen = await chatArea.boundingBox();
const viewport = page.viewportSize();
if (!beforeOpen || !afterOpen || !viewport) {
throw new Error("Expected chat and drawer layout bounds");
}

expect(Math.abs(afterOpen.width - beforeOpen.width)).toBeLessThanOrEqual(1);
await expect.poll(async () => {
const drawerBounds = await drawer.boundingBox();
return drawerBounds ? drawerBounds.x : -1;
}).toBeGreaterThanOrEqual(0);
await expect.poll(async () => {
const drawerBounds = await drawer.boundingBox();
return drawerBounds ? drawerBounds.x + drawerBounds.width : Number.POSITIVE_INFINITY;
}).toBeLessThanOrEqual(viewport.width);
await expect(drawer).toBeInViewport({ ratio: 1 });

await page.keyboard.press("Escape");
await expect(drawer).not.toBeVisible();
Expand Down
32 changes: 6 additions & 26 deletions frontend/e2e/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,13 +560,7 @@ test.describe("Responsive Target Registry", () => {

await page.getByRole("button", { name: /new target/i }).click();
const dialog = page.getByRole("dialog");
const dialogBox = await dialog.boundingBox();
if (!dialogBox) {
throw new Error("Expected the Create Target dialog to be visible");
}
if (viewport.name === "desktop") {
expect(dialogBox.width).toBeLessThanOrEqual(640);
}
await expect(dialog).toBeVisible();
await selectTargetType(page, dialog, "RoundRobinTarget");

const addTargetSelect = dialog.locator("select");
Expand Down Expand Up @@ -610,7 +604,7 @@ test.describe("Responsive Target Registry", () => {
);
});

test(`should fit and align filter selections at ${viewport.name} width`, async ({
test(`should fit and reset filter selections at ${viewport.name} width`, async ({
page,
}) => {
await page.setViewportSize({
Expand Down Expand Up @@ -654,25 +648,11 @@ test.describe("Responsive Target Registry", () => {
expect(widths.scrollWidth).toBeLessThanOrEqual(widths.clientWidth);
}

// Wrapped dropdowns start under the first one, and the reset button centers on the first row.
const filterBoxes = await page
.getByTestId("target-filters")
.getByRole("combobox")
.evaluateAll((filters) =>
filters.map((filter) => {
const box = filter.parentElement?.getBoundingClientRect();
return { left: box?.left ?? 0, centerY: box ? box.top + box.height / 2 : 0 };
})
);
expect(filterBoxes).toHaveLength(4);
for (const { left } of filterBoxes) {
expect(left).toBeGreaterThanOrEqual(filterBoxes[0].left);
}
const resetBox = await page.getByTestId("target-reset-filters-btn").boundingBox();
if (!resetBox) {
throw new Error("Expected a visible reset button");
await page.getByRole("button", { name: "Reset all filters", exact: true }).click();
for (const { filterName, summary } of selections) {
await expect(page.getByRole("combobox", { name: filterName, exact: true })).not.toHaveValue(summary);
}
expect(Math.abs(resetBox.y + resetBox.height / 2 - filterBoxes[0].centerY)).toBeLessThanOrEqual(1);
await expect(page.getByTestId("target-row-filter-chat")).toBeVisible();

const config = page.getByTestId("target-config");
const configWidth = await config.evaluate((element) => ({
Expand Down
20 changes: 2 additions & 18 deletions frontend/e2e/datasets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,7 @@ async function expectLongLabelStaysInsideCard(page: Page): Promise<void> {
range.selectNodeContents(textHost);
const text = range.getBoundingClientRect();
const cardBox = article.getBoundingClientRect();
const badgeBox = textHost.getBoundingClientRect();
return {
cardWidth: cardBox.width,
cardRight: cardBox.right,
cardLeft: cardBox.left,
cardTop: cardBox.top,
Expand All @@ -102,18 +100,15 @@ async function expectLongLabelStaysInsideCard(page: Page): Promise<void> {
textRight: text.right,
textTop: text.top,
textBottom: text.bottom,
badgeHeight: badgeBox.height,
scrollWidth: textHost.scrollWidth,
clientWidth: textHost.clientWidth,
};
}, LONG_HARM_CATEGORY);

expect(metrics.cardWidth).toBeLessThanOrEqual(360);
expect(metrics.textLeft).toBeGreaterThanOrEqual(metrics.cardLeft - 1);
expect(metrics.textRight).toBeLessThanOrEqual(metrics.cardRight + 1);
expect(metrics.textTop).toBeGreaterThanOrEqual(metrics.cardTop - 1);
expect(metrics.textBottom).toBeLessThanOrEqual(metrics.cardBottom + 1);
expect(metrics.badgeHeight).toBeGreaterThan(20);
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
}

Expand All @@ -131,11 +126,10 @@ test.describe("dataset catalog layout", () => {
await expect(cards).toHaveCount(2);
const [first, second] = await cards.evaluateAll((articles: Element[]) =>
articles.map((article: Element) => {
const { left, right, top } = article.getBoundingClientRect();
return { left, right, top };
const { left, right } = article.getBoundingClientRect();
return { left, right };
}),
);
expect(first.top).toBe(second.top);
expect(second.left).toBeGreaterThan(first.right);
});

Expand Down Expand Up @@ -454,7 +448,6 @@ test.describe("dataset example table", () => {
range.selectNodeContents(textHost);
const text = range.getBoundingClientRect();
const cellBox = host.getBoundingClientRect();
const badgeBox = textHost.getBoundingClientRect();
return {
textLeft: text.left,
textRight: text.right,
Expand All @@ -464,7 +457,6 @@ test.describe("dataset example table", () => {
cellRight: cellBox.right,
cellTop: cellBox.top,
cellBottom: cellBox.bottom,
badgeHeight: badgeBox.height,
scrollWidth: textHost.scrollWidth,
clientWidth: textHost.clientWidth,
};
Expand All @@ -474,14 +466,11 @@ test.describe("dataset example table", () => {
expect(metrics.textRight).toBeLessThanOrEqual(metrics.cellRight + 1);
expect(metrics.textTop).toBeGreaterThanOrEqual(metrics.cellTop - 1);
expect(metrics.textBottom).toBeLessThanOrEqual(metrics.cellBottom + 1);
expect(metrics.badgeHeight).toBeGreaterThan(20);
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
const tableLayout = await table.evaluate((host) => ({
columnWidth: host.querySelector("th")?.getBoundingClientRect().width ?? 0,
wrapperWidth: host.parentElement?.clientWidth ?? 0,
scrollWidth: host.parentElement?.scrollWidth ?? 0,
}));
expect(tableLayout.columnWidth).toBeGreaterThanOrEqual(140);
expect(tableLayout.scrollWidth).toBeGreaterThan(tableLayout.wrapperWidth);
await table.getByRole("button", { name: "Show alpha" }).click();
await expect(page.getByRole("region", { name: "Example detail" })).toBeVisible();
Expand All @@ -501,18 +490,13 @@ test.describe("dataset example table", () => {
const termBox = term.getBoundingClientRect();
const valueBox = value.getBoundingClientRect();
return {
termLeft: termBox.left,
termBottom: termBox.bottom,
valueLeft: valueBox.left,
valueTop: valueBox.top,
valueWidth: valueBox.width,
clientWidth: value.clientWidth,
scrollWidth: value.scrollWidth,
};
});
expect(dimensions.valueTop).toBeGreaterThanOrEqual(dimensions.termBottom);
expect(dimensions.valueLeft).toBeCloseTo(dimensions.termLeft, 0);
expect(dimensions.valueWidth).toBeGreaterThanOrEqual(250);
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
Expand Down
35 changes: 2 additions & 33 deletions frontend/e2e/labels-operation-picker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { READY_RUNTIME, waitForAppReady } from "./_runtime";
// These tests measure the rendered box in a real browser.
// ---------------------------------------------------------------------------

const LIST_MAX_HEIGHT = 240;
const LONG_OPERATION = "op_2026_08_a_very_long_operation_name_that_would_be_clipped";

function operations(count: number): string[] {
Expand Down Expand Up @@ -194,29 +193,6 @@ test.describe("operation picker placement", () => {
await expect(popover.getByRole("button", { name: "Add", exact: true })).toBeVisible();
});

test("caps the list height and anchors it to the input", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await setupMocks(page, operations(60));
const listbox = await openOperationPicker(page);

const box = (await listbox.boundingBox())!;
const input = (await page
.getByTestId("edit-label-operation")
.boundingBox())!;

expect(box.height).toBeLessThanOrEqual(LIST_MAX_HEIGHT);
// Opens below the input and stays attached to it.
expect(box.y).toBeGreaterThanOrEqual(input.y + input.height);
expect(box.y - (input.y + input.height)).toBeLessThan(16);

// The options that do not fit are reachable by scrolling, not lost.
const scroll = await listbox.evaluate((el) => ({
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
}));
expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight);
});

test("keeps the list on screen when it opens above the input", async ({
page,
}) => {
Expand All @@ -239,11 +215,8 @@ test.describe("operation picker placement", () => {
.boundingBox())!;
const viewport = page.viewportSize()!;

expect(input.y).toBeGreaterThanOrEqual(LIST_MAX_HEIGHT);
expect(viewport.height - (input.y + input.height)).toBeLessThan(LIST_MAX_HEIGHT);
expect(box.y).toBeLessThan(input.y);
expect(box.y + box.height).toBeLessThanOrEqual(input.y);
expect(input.y - (box.y + box.height)).toBeLessThan(16);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
});
Expand Down Expand Up @@ -290,17 +263,15 @@ test.describe("operation picker placement", () => {
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
});

test("shrinks below the cap when the window is too short for it", async ({
test("keeps the list on screen in a short window", async ({
page,
}) => {
// Shorter than the 240px cap, so a flat cap would hang off the screen.
const viewportHeight = 200;
await page.setViewportSize({ width: 1280, height: viewportHeight });
await setupMocks(page, operations(60));
const listbox = await openOperationPicker(page);

const box = (await listbox.boundingBox())!;
expect(box.height).toBeLessThan(LIST_MAX_HEIGHT);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.y + box.height).toBeLessThanOrEqual(viewportHeight);
});
Expand Down Expand Up @@ -593,7 +564,7 @@ test.describe("finishing an edit another way", () => {
await expect(page.getByTestId("edit-label-operator")).toHaveValue("alice");
});

test("keeps compact metadata inputs visible and creation first on mobile", async ({
test("keeps metadata inputs visible and creation first on mobile", async ({
page,
}) => {
await page.setViewportSize({ width: 360, height: 800 });
Expand All @@ -604,8 +575,6 @@ test.describe("finishing an edit another way", () => {
const operation = page.getByRole("combobox", { name: "Operation" });
await expect(operator).toBeVisible();
await expect(operation).toBeVisible();
expect(await operator.evaluate(input => input.parentElement?.getBoundingClientRect().width)).toBeLessThanOrEqual(100);
expect(await operation.evaluate(input => input.parentElement?.getBoundingClientRect().width)).toBeLessThanOrEqual(140);
await operation.click();
await expect(page.getByRole("option").first()).toHaveText("New operation…");
await operation.fill("missing");
Expand Down
57 changes: 4 additions & 53 deletions frontend/e2e/registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,16 +499,6 @@ test.describe("Converter Registry", () => {
await expect(dialog.getByLabel("Registry name")).toHaveValue("");
});

test("uses the available viewport height for the converter type list", async ({ page }) => {
await page.getByRole("button", { name: "New Converter" }).click();
await page.getByRole("combobox", { name: "Converter type" }).click();

const listbox = page.getByRole("listbox");
await expect(listbox).toBeVisible();
const bounds = await listbox.boundingBox();
expect(bounds?.height).toBeGreaterThan(300);
});

for (const { name, viewport, deviceScaleFactor } of [
{ name: "wide short screen", viewport: { width: 1920, height: 600 }, deviceScaleFactor: 1 },
{ name: "scaled laptop", viewport: { width: 1280, height: 640 }, deviceScaleFactor: 1.5 },
Expand All @@ -517,56 +507,17 @@ test.describe("Converter Registry", () => {
test.describe(name, () => {
test.use({ viewport, deviceScaleFactor });

test("keeps a long converter list vertical, stable, and scrollable", async ({ page }) => {
test("reaches and selects the last converter in a long list", async ({ page }) => {
await page.getByRole("button", { name: "New Converter" }).click();
const trigger = page.getByRole("combobox", { name: "Converter type" });
await trigger.click();

const listbox = page.getByRole("listbox");
await expect(listbox).toBeVisible();
const anchor = await trigger.evaluate((button) => {
const { x, y, width, height } = button.parentElement!.getBoundingClientRect();
return { x, y, width, height };
});
await expect.poll(async () => {
const box = (await listbox.boundingBox())!;
return Math.abs(box.x - anchor.x);
}).toBeLessThanOrEqual(1);

// Sample successive frames: a single bounding box can miss position oscillation.
const samples = await listbox.evaluate(async (element) => {
const bounds = [];
for (let frame = 0; frame < 60; frame++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const { x, y, width, height } = element.getBoundingClientRect();
bounds.push({ x, y, width, height });
}
return bounds;
});

for (const box of samples) {
expect(Math.abs(box.x - anchor.x)).toBeLessThanOrEqual(1);
expect(Math.abs(box.width - anchor.width)).toBeLessThanOrEqual(1);
expect(box.height).toBeGreaterThan(0);
expect(box.x).toBeGreaterThanOrEqual(-1);
expect(box.y).toBeGreaterThanOrEqual(-1);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 1);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 1);
expect(
box.y >= anchor.y + anchor.height - 1
|| box.y + box.height <= anchor.y + 1,
).toBe(true);
expect(Math.abs(box.y - samples[0].y)).toBeLessThanOrEqual(1);
expect(Math.abs(box.height - samples[0].height)).toBeLessThanOrEqual(1);
}

const scroll = await listbox.evaluate((element) => ({
height: element.clientHeight,
contentHeight: element.scrollHeight,
}));
expect(scroll.contentHeight).toBeGreaterThan(scroll.height);
await expect(listbox).toBeInViewport({ ratio: 1 });
const lastOption = listbox.getByRole("option").last();
await lastOption.scrollIntoViewIfNeeded();
await expect(lastOption).not.toBeInViewport();
await trigger.press("End");
Comment thread
richlundeen marked this conversation as resolved.
await expect(lastOption).toBeInViewport();
await lastOption.click();
await expect(listbox).toBeHidden();
Expand Down
Loading
Loading