Skip to content
Open
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
8 changes: 4 additions & 4 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,17 @@ test.describe("Visual Consistency", () => {
await page.goto("/");

// Wait for initial render
await expect(page.getByText("PyRIT Attack")).toBeVisible();
const anchor = page.getByTestId("new-attack-btn");
await expect(anchor).toBeVisible();

// Take measurements
const header = page.getByText("PyRIT Attack");
const initialBox = await header.boundingBox();
const initialBox = await anchor.boundingBox();

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

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

if (initialBox && finalBox) {
expect(finalBox.x).toBe(initialBox.x);
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,6 @@ test.describe("Error Handling", () => {
await page.goto("/");

// UI should be responsive even while APIs are delayed
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 10000 });
});
});
24 changes: 16 additions & 8 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async function activateMockTarget(page: Page) {

// Return to Chat view
await page.getByTitle("Chat").click();
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5000 });
}

// ---------------------------------------------------------------------------
Expand All @@ -153,8 +153,8 @@ test.describe("Application Smoke Tests", () => {
await expect(page.locator("body")).toBeVisible();
});

test("should display PyRIT header", async ({ page }) => {
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 });
test("should display chat ribbon", async ({ page }) => {
await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 10000 });
});

test("should have New Attack button", async ({ page }) => {
Expand All @@ -169,7 +169,7 @@ test.describe("Application Smoke Tests", () => {
test.describe("Theme Toggle", () => {
test("should toggle dark/light theme", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 10000 });

// The app defaults to dark mode, so the toggle button title should say "Light Mode"
const themeBtn = page.getByTitle("Light Mode");
Expand Down Expand Up @@ -197,8 +197,12 @@ test.describe("Chat Functionality", () => {
});

test("should display target info after activation", async ({ page }) => {
await expect(page.getByText("OpenAIChatTarget")).toBeVisible();
await expect(page.getByText(/gpt-4o-mock/)).toBeVisible();
// Scope queries to the badge so we don't also match the (hidden)
// copy of the target text that Fluent's Tooltip renders into the DOM.
const badge = page.getByTestId("target-badge");
await expect(badge).toBeVisible();
await expect(badge).toContainText("OpenAIChatTarget");
await expect(badge).toContainText(/gpt-4o-mock/);
});

test("should send a message and receive backend response", async ({ page }) => {
Expand Down Expand Up @@ -721,7 +725,11 @@ test.describe("Target type scenarios", () => {

// Navigate to chat
await page.getByTitle("Chat").click();
await expect(page.getByText("OpenAIImageTarget")).toBeVisible();
await expect(page.getByText(/dall-e-3/)).toBeVisible();
// Scope queries to the badge so we don't also match the (hidden)
// copy of the target text that Fluent's Tooltip renders into the DOM.
const badge = page.getByTestId("target-badge");
await expect(badge).toBeVisible();
await expect(badge).toContainText("OpenAIImageTarget");
await expect(badge).toContainText(/dall-e-3/);
});
});
13 changes: 8 additions & 5 deletions frontend/e2e/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,14 @@ test.describe("Target Config ↔ Chat Navigation", () => {

// Navigate back to chat
await page.getByTitle("Chat").click();
await expect(page.getByText("PyRIT Attack")).toBeVisible();

// Chat should show the active target type
await expect(page.getByText("OpenAIChatTarget")).toBeVisible();
await expect(page.getByText(/gpt-4o/)).toBeVisible();
await expect(page.getByTestId("new-attack-btn")).toBeVisible();

// Chat should show the active target type. Scope to the badge to
// avoid matching the (hidden) tooltip copy of the same text.
const badge = page.getByTestId("target-badge");
await expect(badge).toBeVisible();
await expect(badge).toContainText("OpenAIChatTarget");
await expect(badge).toContainText(/gpt-4o/);
});

test("should enable chat input after a target is set", async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/converters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ async function activateMockTarget(page: Page) {
await setActiveBtn.click();

await page.getByTitle("Chat", { exact: true }).click();
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5000 });
}

/** Open converter panel and select a converter by name. */
Expand Down
6 changes: 3 additions & 3 deletions frontend/e2e/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ async function activateMockTarget(page: Page) {
await expect(setActiveBtn).toBeVisible({ timeout: 5000 });
await setActiveBtn.click();
await page.getByTitle("Chat").click();
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5000 });
}

/** Send a message and wait for the response. */
Expand Down Expand Up @@ -324,7 +324,7 @@ test.describe("Error: connection banner on health failure", () => {
}) => {
// Let the page load normally first
await page.goto("/");
await expect(page.getByText("PyRIT Attack")).toBeVisible({
await expect(page.getByTestId("new-attack-btn")).toBeVisible({
timeout: 10000,
});

Expand Down Expand Up @@ -372,7 +372,7 @@ test.describe("Error: connection banner recovery", () => {
});

await page.goto("/");
await expect(page.getByText("PyRIT Attack")).toBeVisible({
await expect(page.getByTestId("new-attack-btn")).toBeVisible({
timeout: 10000,
});

Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ async function activateTarget(page: Page, targetType: string): Promise<void> {
const row = page.locator("tr", { has: page.getByText(targetType, { exact: true }) }).first();
await row.getByRole("button", { name: /set active/i }).click();
await page.getByTitle("Chat").click();
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5_000 });
}

/** Navigate to an attack by opening the History view and clicking its row. */
Expand Down
18 changes: 13 additions & 5 deletions frontend/src/components/Chat/ChatWindow.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,27 @@ export const useChatWindowStyles = makeStyles({
gap: tokens.spacingHorizontalS,
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase300,
},
targetInfo: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
flex: '1 1 auto',
minWidth: 0,
overflow: 'hidden',
},
noTarget: {
color: tokens.colorNeutralForeground3,
fontStyle: 'italic',
flexShrink: 0,
},
ribbonActions: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalS,
flexShrink: 0,
},
newAttackButton: {
flexShrink: 0,
},
newAttackLabel: {
'@media (max-width: 600px)': {
display: 'none',
},
},
})
21 changes: 15 additions & 6 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,11 @@ describe("ChatWindow Integration", () => {
</TestWrapper>
);

expect(screen.getByText("PyRIT Attack")).toBeInTheDocument();
expect(screen.getByText("New Attack")).toBeInTheDocument();
// The ribbon no longer shows the "PyRIT Attack" prefix; the target
// badge stands on its own as the leftmost element.
expect(screen.queryByText("PyRIT Attack")).not.toBeInTheDocument();
expect(screen.getByTestId("target-badge")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new attack/i })).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});

Expand Down Expand Up @@ -321,8 +324,13 @@ describe("ChatWindow Integration", () => {
</TestWrapper>
);

expect(screen.getByText(/OpenAIChatTarget/)).toBeInTheDocument();
expect(screen.getByText(/gpt-4/)).toBeInTheDocument();
// The target badge is the leftmost element. Its visible label
// includes the type and model. The same strings also appear in the
// tooltip body, so we query the badge specifically.
const badge = screen.getByTestId("target-badge");
expect(badge).toHaveTextContent(/OpenAIChatTarget/);
expect(badge).toHaveTextContent(/gpt-4/);
expect(badge).toHaveAttribute("aria-label", expect.stringContaining(mockTarget.target_registry_name));
});

it("should show no-target message when target is null", () => {
Expand Down Expand Up @@ -389,8 +397,9 @@ describe("ChatWindow Integration", () => {
</TestWrapper>
);

expect(screen.getByText(/OpenAIChatTarget/)).toBeInTheDocument();
expect(screen.queryByText(/gpt/)).not.toBeInTheDocument();
const badge = screen.getByTestId("target-badge");
expect(badge).toHaveTextContent(/OpenAIChatTarget/);
expect(badge).not.toHaveTextContent(/gpt/);
});

// -----------------------------------------------------------------------
Expand Down
35 changes: 15 additions & 20 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import {
Button,
Text,
Badge,
Tooltip,
} from '@fluentui/react-components'
import { AddRegular, PanelRightRegular } from '@fluentui/react-icons'
import MessageList from './MessageList'
import ChatInputArea from './ChatInputArea'
import ConversationPanel from './ConversationPanel'
import ConverterPanel from './ConverterPanel'
import TargetBadge from './TargetBadge'
import type { PieceConversion } from './converterTypes'
import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes'
import LabelsBar from '../Labels/LabelsBar'
Expand Down Expand Up @@ -513,17 +513,8 @@ export default function ChatWindow({
<div className={styles.chatArea}>
<div className={styles.ribbon}>
<div className={styles.conversationInfo}>
<Text>PyRIT Attack</Text>
{activeTarget ? (
<div className={styles.targetInfo}>
<Text size={200}>→</Text>
<Tooltip content={activeTarget.target_registry_name} relationship="label">
<Badge appearance="outline" size="medium">
{activeTarget.target_type}
{activeTarget.model_name ? ` (${activeTarget.model_name})` : ''}
</Badge>
</Tooltip>
</div>
<TargetBadge target={activeTarget} />
) : (
<Text size={200} className={styles.noTarget}>
No target selected
Expand All @@ -544,15 +535,19 @@ export default function ChatWindow({
aria-label="Toggle conversations panel"
/>
</Tooltip>
<Button
appearance="primary"
icon={<AddRegular />}
onClick={() => { setIsPanelOpen(false); onNewAttack() }}
disabled={!attackResultId}
data-testid="new-attack-btn"
>
New Attack
</Button>
<Tooltip content="New Attack" relationship="label">
<Button
appearance="primary"
icon={<AddRegular />}
onClick={() => { setIsPanelOpen(false); onNewAttack() }}
disabled={!attackResultId}
data-testid="new-attack-btn"
aria-label="New Attack"
className={styles.newAttackButton}
>
<span className={styles.newAttackLabel}>New Attack</span>
</Button>
</Tooltip>
</div>
</div>
<MessageList
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/components/Chat/TargetBadge.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useTargetBadgeStyles = makeStyles({
badge: {
display: 'inline-flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
padding: `2px ${tokens.spacingHorizontalS}`,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground1,
cursor: 'help',
minWidth: 0,
maxWidth: '100%',
},
badgeText: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
},
// Applied to the Fluent Tooltip's `content` slot (the actual surface
// that renders the white/dark popover with the arrow). Fluent caps
// surface max-width at 240px by default, which truncates anything
// wider than a short label. We override here so the surface grows
// with its content, capped only by the viewport.
tooltipSurface: {
maxWidth: 'min(800px, calc(100vw - 64px))',
width: 'max-content',
minWidth: '420px',
},
tooltipBody: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalS,
width: '100%',
boxSizing: 'border-box',
},
tooltipHeader: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXXS,
},
tooltipSection: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXXS,
minWidth: 0,
},
sectionLabel: {
fontSize: tokens.fontSizeBase100,
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground3,
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
endpointText: {
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
overflowWrap: 'anywhere',
},
flagsRow: {
display: 'flex',
flexWrap: 'wrap',
gap: tokens.spacingHorizontalXS,
},
paramsBlock: {
margin: 0,
padding: tokens.spacingHorizontalXS,
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusSmall,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
whiteSpace: 'pre-wrap',
wordBreak: 'normal',
overflowWrap: 'anywhere',
maxHeight: '200px',
maxWidth: '100%',
overflowY: 'auto',
overflowX: 'auto',
boxSizing: 'border-box',
},
})
Loading
Loading