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
13 changes: 10 additions & 3 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,13 @@ test.describe("Accessibility", () => {
});

test("should be navigable with keyboard", async ({ page }) => {
// Tab to the first interactive element
await page.keyboard.press("Tab");
// Wait for the sidebar to render so there is a focusable element for Tab
// to land on, and dispatch the Tab through `body` (rather than the bare
// keyboard) to guarantee the document has focus when the keystroke fires.
// Without both, Chromium sometimes leaves `:focus` empty under parallel
// worker load.
await expect(page.getByTitle("Home")).toBeVisible();
await page.locator("body").press("Tab");
const focused = page.locator(":focus");
await expect(focused).toBeVisible();

Expand Down Expand Up @@ -145,7 +150,9 @@ test.describe("Visual Consistency", () => {
test("should render without layout shifts", async ({ page }) => {
await page.goto("/");

// Wait for initial render
// Wait for initial render then navigate to chat to measure the chat ribbon
await expect(page.getByTitle("Chat")).toBeVisible();
await page.getByTitle("Chat").click();
await expect(page.getByText("PyRIT Attack")).toBeVisible();

// Take measurements
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.getByTitle("Chat")).toBeVisible({ timeout: 10000 });
});
});
7 changes: 6 additions & 1 deletion frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,22 +154,26 @@ test.describe("Application Smoke Tests", () => {
});

test("should display PyRIT header", async ({ page }) => {
await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000 });
await page.getByTitle("Chat").click();
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 });
});

test("should have New Attack button", async ({ page }) => {
await page.getByTitle("Chat").click();
await expect(page.getByRole("button", { name: /new attack/i })).toBeVisible();
});

test("should show 'no target' hint when no target is active", async ({ page }) => {
await page.getByTitle("Chat").click();
await expect(page.getByTestId("no-target-banner")).toBeVisible();
});
});

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.getByTitle("Chat")).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 @@ -293,6 +297,7 @@ test.describe("Multiple Messages", () => {
test.describe("Chat without target", () => {
test("should disable input when no target is active", async ({ page }) => {
await page.goto("/");
await page.getByTitle("Chat").click();

// The no-target-banner should be visible because no target is active
await expect(page.getByTestId("no-target-banner")).toBeVisible();
Expand Down
1 change: 1 addition & 0 deletions frontend/e2e/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ test.describe("Target Config ↔ Chat Navigation", () => {

// Start in chat — no-target-banner should be visible
await page.goto("/");
await page.getByTitle("Chat").click();
await expect(page.getByTestId("no-target-banner")).toBeVisible();

// Go to config, set a target
Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/errors.spec.ts
Original file line number Diff line number Diff line change
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.getByTitle("Chat")).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.getByTitle("Chat")).toBeVisible({
timeout: 10000,
});

Expand Down
106 changes: 99 additions & 7 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jest.mock("./components/Layout/MainLayout", () => {
<button onClick={onToggleTheme} data-testid="toggle-theme">
Toggle Theme
</button>
<button onClick={() => onNavigate("home")} data-testid="nav-home">
Home
</button>
<button onClick={() => onNavigate("config")} data-testid="nav-config">
Config
</button>
Expand Down Expand Up @@ -188,6 +191,41 @@ jest.mock("./components/History/AttackHistory", () => {
};
});

jest.mock("./components/Home/Home", () => {
const MockHome = ({
activeTarget,
onNavigate,
onOpenAttack,
labels,
}: {
activeTarget: unknown;
onNavigate: (view: string) => void;
onOpenAttack: (attackResultId: string) => void;
labels: Record<string, string>;
}) => {
return (
<div data-testid="home-view">
<span data-testid="home-has-target">{activeTarget ? "yes" : "no"}</span>
<span data-testid="home-labels-json">{JSON.stringify(labels)}</span>
<button onClick={() => onNavigate("config")} data-testid="home-go-config">
Go to config
</button>
<button
onClick={() => onOpenAttack("ar-home-attack")}
data-testid="home-open-attack"
>
Open Home Attack
</button>
</div>
);
};
MockHome.displayName = "MockHome";
return {
__esModule: true,
default: MockHome,
};
});

describe("App", () => {
beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -197,7 +235,7 @@ describe("App", () => {
it("renders with FluentProvider and MainLayout", () => {
render(<App />);
expect(screen.getByTestId("main-layout")).toBeInTheDocument();
expect(screen.getByTestId("chat-window")).toBeInTheDocument();
expect(screen.getByTestId("home-view")).toBeInTheDocument();
});

it("starts in dark mode", () => {
Expand Down Expand Up @@ -229,9 +267,21 @@ describe("App", () => {
);
});

it("starts in chat view", () => {
it("starts in home view", () => {
render(<App />);

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"home"
);
expect(screen.getByTestId("home-view")).toBeInTheDocument();
});

it("switches to chat view", () => {
render(<App />);

fireEvent.click(screen.getByTestId("nav-chat"));

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"chat"
Expand Down Expand Up @@ -264,6 +314,7 @@ describe("App", () => {
it("sets conversationId from chat window", () => {
render(<App />);

fireEvent.click(screen.getByTestId("nav-chat"));
expect(screen.getByTestId("conversation-id")).toHaveTextContent("none");

fireEvent.click(screen.getByTestId("set-conversation"));
Expand All @@ -273,6 +324,7 @@ describe("App", () => {
it("clears conversationId on new attack", () => {
render(<App />);

fireEvent.click(screen.getByTestId("nav-chat"));
fireEvent.click(screen.getByTestId("set-conversation"));
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");

Expand All @@ -283,7 +335,8 @@ describe("App", () => {
it("sets active target from config page and passes to chat", () => {
render(<App />);

// No target initially
// Switch to chat and confirm no target initially
fireEvent.click(screen.getByTestId("nav-chat"));
expect(screen.getByTestId("has-target")).toHaveTextContent("no");

// Switch to config and set target
Expand Down Expand Up @@ -322,6 +375,36 @@ describe("App", () => {
await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("attack-conv-1"));
});

it("opens attack from home and switches to chat", async () => {
mockGetAttack.mockResolvedValue({
attack_result_id: "ar-home-attack",
conversation_id: "home-conv-1",
labels: { operator: "roakey" },
});
render(<App />);

fireEvent.click(screen.getByTestId("home-open-attack"));

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"chat"
);
await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-home-attack"));
await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("home-conv-1"));
});

it("navigates to config from the home view", () => {
render(<App />);

fireEvent.click(screen.getByTestId("home-go-config"));

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"config"
);
expect(screen.getByTestId("target-config")).toBeInTheDocument();
});

it("handles failed attack open gracefully", async () => {
mockGetAttack.mockRejectedValue(new Error("Not found"));
render(<App />);
Expand Down Expand Up @@ -349,6 +432,9 @@ describe("App", () => {
expect(mockedVersionApi.getVersion).toHaveBeenCalled();
});

// Switch to chat to inspect labels
fireEvent.click(screen.getByTestId("nav-chat"));

await waitFor(() => {
expect(screen.getByTestId("labels-operator")).toHaveTextContent("default_user");
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
Expand All @@ -364,9 +450,12 @@ describe("App", () => {

render(<App />);

// Home receives the same labels prop — assert there to avoid racing the
// async initLabels effect against a view-change re-render.
await waitFor(() => {
expect(screen.getByTestId("labels-operator")).toHaveTextContent("test.user");
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
const labels = screen.getByTestId("home-labels-json").textContent ?? "";
expect(labels).toContain('"operator":"test.user"');
expect(labels).toContain('"custom":"value"');
});
});

Expand All @@ -380,8 +469,9 @@ describe("App", () => {
render(<App />);

await waitFor(() => {
expect(screen.getByTestId("labels-operator")).toHaveTextContent("override_user");
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
const labels = screen.getByTestId("home-labels-json").textContent ?? "";
expect(labels).toContain('"operator":"override_user"');
expect(labels).toContain('"custom":"value"');
});
});

Expand All @@ -401,6 +491,8 @@ describe("App", () => {
it("sets active conversation when onSelectConversation is called", () => {
render(<App />);

fireEvent.click(screen.getByTestId("nav-chat"));

// First create a conversation to have an attack
fireEvent.click(screen.getByTestId("set-conversation"));
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-com
import { useMsal } from '@azure/msal-react'
import MainLayout from './components/Layout/MainLayout'
import ChatWindow from './components/Chat/ChatWindow'
import Home from './components/Home/Home'
import TargetConfig from './components/Config/TargetConfig'
import AttackHistory from './components/History/AttackHistory'
import { DEFAULT_HISTORY_FILTERS } from './components/History/historyFilters'
Expand Down Expand Up @@ -39,7 +40,7 @@ function ConnectionBannerContainer() {
function App() {
const { instance } = useMsal()
const [isDarkMode, setIsDarkMode] = useState(true)
const [currentView, setCurrentView] = useState<ViewName>('chat')
const [currentView, setCurrentView] = useState<ViewName>('home')
const [activeTarget, setActiveTarget] = useState<TargetInstance | null>(null)
const [globalLabels, setGlobalLabels] = useState<Record<string, string>>({ ...DEFAULT_GLOBAL_LABELS })
/** True while loading a historical attack from the history view */
Expand Down Expand Up @@ -175,6 +176,15 @@ function App() {
onToggleTheme={toggleTheme}
isDarkMode={isDarkMode}
>
{currentView === 'home' && (
<Home
labels={globalLabels}
onLabelsChange={setGlobalLabels}
activeTarget={activeTarget}
onNavigate={setCurrentView}
onOpenAttack={handleOpenAttack}
/>
)}
{currentView === 'chat' && (
<ChatWindow
onNewAttack={handleNewAttack}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/Chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ describe("MessageList", () => {
</TestWrapper>
);

expect(document.body).toBeTruthy();
expect(
screen.getByText("There are no messages in this conversation yet.")
).toBeInTheDocument();
});

it("should render all messages", () => {
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,8 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
if (messages.length === 0) {
return (
<div className={styles.emptyState}>
<Text size={500} weight="semibold">Welcome to PyRIT</Text>
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
Start a conversation to test AI safety and robustness
There are no messages in this conversation yet.
</Text>
</div>
)
Expand Down
Loading
Loading