Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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