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
167 changes: 166 additions & 1 deletion frontend/e2e/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, type Page } from "@playwright/test";
import { test, expect, type Locator, type Page } from "@playwright/test";
import { makeTarget, type FlatTarget } from "./_targets";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -32,6 +32,76 @@ const SAMPLE_TARGETS = [
},
];

const LONG_REGISTRY_NAME_A =
"openai-production-eastus2-red-team-evaluation-primary-deployment";
const LONG_REGISTRY_NAME_B =
"openai-production-eastus2-red-team-evaluation-secondary-deployment";

const LONG_NAME_TARGETS: FlatTarget[] = [
{
target_registry_name: LONG_REGISTRY_NAME_A,
target_type: "OpenAIChatTarget",
endpoint: "https://primary.openai.azure.com",
model_name: "gpt-4o-responsive",
},
{
target_registry_name: LONG_REGISTRY_NAME_B,
target_type: "OpenAIChatTarget",
endpoint: "https://secondary.openai.azure.com",
model_name: "gpt-4o-responsive",
},
];

const RESPONSIVE_VIEWPORTS = [
{ name: "mobile", width: 390, height: 844 },
{ name: "desktop", width: 1280, height: 800 },
] as const;

async function routeResponsiveTargetData(
page: Page,
targets: FlatTarget[]
): Promise<void> {
await page.route(/\/api\/targets\/catalog(?:\?.*)?$/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
target_type: "OpenAIChatTarget",
parameters: [],
supported_auth_modes: ["api_key", "identity"],
},
{
target_type: "RoundRobinTarget",
parameters: [],
supported_auth_modes: ["api_key"],
},
],
}),
});
});
await page.route(/\/api\/targets(?:\?.*)?$/, async (route) => {
await route.fulfill(mockTargetsList(targets));
});
}

async function expectWithin(
child: Locator,
container: Locator
): Promise<void> {
const childBox = await child.boundingBox();
const containerBox = await container.boundingBox();
if (!childBox || !containerBox) {
throw new Error("Expected visible child and container bounds");
}

expect(childBox.x).toBeGreaterThanOrEqual(containerBox.x);
expect(childBox.x + childBox.width).toBeLessThanOrEqual(
containerBox.x + containerBox.width
);
}

/** Navigate to the config view. */
async function goToConfig(page: Page) {
await page.goto("/");
Expand Down Expand Up @@ -219,6 +289,101 @@ test.describe("Create Target Dialog", () => {
});
});

test.describe("Responsive Target Configuration", () => {
for (const viewport of RESPONSIVE_VIEWPORTS) {
test(`should contain configuration actions at ${viewport.name} width`, async ({
page,
}) => {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await routeResponsiveTargetData(page, LONG_NAME_TARGETS);
await goToConfig(page);
await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible();

const config = page.getByTestId("target-config");
const newTargetButton = page.getByRole("button", { name: /new target/i });
await expect(newTargetButton).toBeVisible();
await expectWithin(newTargetButton, config);

const configWidth = await config.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(configWidth.scrollWidth).toBeLessThanOrEqual(
configWidth.clientWidth
);

await newTargetButton.click();
await expect(page.getByRole("dialog")).toBeVisible();
});

test(`should contain long Round Robin selections at ${viewport.name} width`, async ({
page,
}) => {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await routeResponsiveTargetData(page, LONG_NAME_TARGETS);
await goToConfig(page);
await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible();

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 dialog.locator("select").first().selectOption("RoundRobinTarget");

const addTargetSelect = dialog.locator("select").nth(1);
await addTargetSelect.selectOption(LONG_REGISTRY_NAME_A);
await addTargetSelect.selectOption(LONG_REGISTRY_NAME_B);

const firstTargetName = dialog.getByLabel(
`Selected target: ${LONG_REGISTRY_NAME_A} (gpt-4o-responsive)`
);
await expect(firstTargetName).toBeVisible();
await firstTargetName.focus();
await expect(page.getByRole("tooltip")).toContainText(
LONG_REGISTRY_NAME_A
);

const form = page.getByTestId("create-target-form");
const formWidths = await form.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(formWidths.scrollWidth).toBeLessThanOrEqual(
formWidths.clientWidth
);
const dialogWidths = await dialog.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(dialogWidths.scrollWidth).toBeLessThanOrEqual(
dialogWidths.clientWidth
);

await expectWithin(
dialog.getByLabel(`Weight for ${LONG_REGISTRY_NAME_A}`),
dialog
);
await expectWithin(
dialog.getByRole("button", {
name: `Remove ${LONG_REGISTRY_NAME_B}`,
}),
dialog
);
});
}
});

test.describe("Target Config ↔ Chat Navigation", () => {
test("should display active target info in chat after setting it", async ({ page }) => {
await page.route(/\/api\/targets/, async (route) => {
Expand Down
99 changes: 96 additions & 3 deletions frontend/src/components/Config/CreateTargetDialog.styles.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useCreateTargetDialogStyles = makeStyles({
dialogSurface: {
width: '100%',
minWidth: 0,
maxWidth: '37.5rem',
'@media (max-width: 600px)': {
maxWidth: `calc(100vw - ${tokens.spacingHorizontalXXL} - ${tokens.spacingHorizontalXXL})`,
},
},
dialogContent: {
minWidth: 0,
},
form: {
display: 'flex',
flexDirection: 'column',
width: '100%',
minWidth: 0,
maxWidth: '100%',
gap: tokens.spacingVerticalL,
},
formField: {
minWidth: 0,
maxWidth: '100%',
},
fullWidthSelect: {
width: '100%',
minWidth: 0,
maxWidth: '100%',
'& select': {
width: '100%',
minWidth: 0,
maxWidth: '100%',
},
},
warningMessage: {
width: '100%',
},
Expand All @@ -14,19 +42,84 @@ export const useCreateTargetDialogStyles = makeStyles({
overflowWrap: 'anywhere',
wordBreak: 'break-word',
},
/** Container for the list of selected inner targets in the RoundRobin form. */
selectedTargetsSection: {
minWidth: 0,
maxWidth: '100%',
},
selectedTargetsLabel: {
display: 'block',
marginBottom: tokens.spacingVerticalXS,
},
selectedTargetsList: {
display: 'flex',
flexDirection: 'column',
minWidth: 0,
maxWidth: '100%',
gap: tokens.spacingVerticalXS,
},
/** A single row in the selected targets list: target name + weight + remove button. */
selectedTargetRow: {
display: 'flex',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
alignItems: 'center',
minWidth: 0,
maxWidth: '100%',
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusSmall,
'@media (max-width: 600px)': {
gridTemplateColumns: 'minmax(0, 1fr)',
alignItems: 'stretch',
},
},
selectedTargetName: {
display: 'block',
minWidth: 0,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'help',
'@media (max-width: 600px)': {
overflow: 'visible',
overflowWrap: 'anywhere',
textOverflow: 'clip',
whiteSpace: 'normal',
cursor: 'text',
},
},
targetNameTooltip: {
overflowWrap: 'anywhere',
wordBreak: 'break-word',
},
selectedTargetControlGroup: {
display: 'flex',
flexDirection: 'column',
minWidth: 0,
},
selectedTargetControls: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexWrap: 'wrap',
minWidth: 0,
gap: tokens.spacingHorizontalXS,
'@media (max-width: 600px)': {
justifyContent: 'flex-start',
},
},
weightInput: {
width: '5rem',
minWidth: '5rem',
},
weightError: {
alignSelf: 'flex-end',
marginTop: tokens.spacingVerticalXXS,
color: tokens.colorPaletteRedForeground1,
textAlign: 'right',
'@media (max-width: 600px)': {
alignSelf: 'flex-start',
textAlign: 'left',
},
},
})
54 changes: 54 additions & 0 deletions frontend/src/components/Config/CreateTargetDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,60 @@ describe("CreateTargetDialog", () => {
expect(createButton).toBeDisabled();
});

it("should keep full long registry names accessible after selecting RoundRobin targets", async () => {
const user = userEvent.setup();
const firstRegistryName =
"openai-production-eastus2-red-team-evaluation-primary-deployment";
const secondRegistryName =
"openai-production-eastus2-red-team-evaluation-secondary-deployment";

render(
<TestWrapper>
<CreateTargetDialog
{...defaultProps}
existingTargets={[
makeTarget({
target_registry_name: firstRegistryName,
target_type: "OpenAIChatTarget",
model_name: "gpt-4o",
identifier_hash: "long-hash-a",
}),
makeTarget({
target_registry_name: secondRegistryName,
target_type: "OpenAIChatTarget",
model_name: "gpt-4o",
identifier_hash: "long-hash-b",
}),
]}
/>
</TestWrapper>
);

await selectTargetType(user, "RoundRobinTarget");
const select = screen.getByText("Select a target to add...").closest("select");
expect(select).not.toBeNull();
if (!select) {
throw new Error("Round Robin target selector was not rendered");
}

await user.selectOptions(select, firstRegistryName);
await user.selectOptions(select, secondRegistryName);

const selectedName = screen.getByLabelText(
`Selected target: ${firstRegistryName} (gpt-4o)`
);
expect(selectedName).toHaveAttribute("tabindex", "0");
await user.click(selectedName);
expect(selectedName).toHaveFocus();

expect(
screen.getByRole("button", { name: `Remove ${firstRegistryName}` })
).toBeInTheDocument();
expect(
screen.getByLabelText(`Weight for ${secondRegistryName}`)
).toBeInTheDocument();
});

it("filters duplicate-by-identifier-hash targets out of the picker once one is selected", async () => {
const user = userEvent.setup();

Expand Down
Loading