Skip to content

Commit 6877c88

Browse files
romanlutzCopilot
andauthored
FEAT [GUI] Add Home landing page and fix empty-chat message (#1750)
Co-authored-by: romanlutz <romanlutz@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 575380f commit 6877c88

14 files changed

Lines changed: 820 additions & 19 deletions

frontend/e2e/accessibility.spec.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,13 @@ test.describe("Accessibility", () => {
6161
});
6262

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

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

148-
// Wait for initial render
153+
// Wait for initial render then navigate to chat to measure the chat ribbon
154+
await expect(page.getByTitle("Chat")).toBeVisible();
155+
await page.getByTitle("Chat").click();
149156
await expect(page.getByText("PyRIT Attack")).toBeVisible();
150157

151158
// Take measurements

frontend/e2e/api.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,6 @@ test.describe("Error Handling", () => {
141141
await page.goto("/");
142142

143143
// UI should be responsive even while APIs are delayed
144-
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 });
144+
await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000 });
145145
});
146146
});

frontend/e2e/chat.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,22 +154,26 @@ test.describe("Application Smoke Tests", () => {
154154
});
155155

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

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

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

169173
test.describe("Theme Toggle", () => {
170174
test("should toggle dark/light theme", async ({ page }) => {
171175
await page.goto("/");
172-
await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 });
176+
await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000 });
173177

174178
// The app defaults to dark mode, so the toggle button title should say "Light Mode"
175179
const themeBtn = page.getByTitle("Light Mode");
@@ -293,6 +297,7 @@ test.describe("Multiple Messages", () => {
293297
test.describe("Chat without target", () => {
294298
test("should disable input when no target is active", async ({ page }) => {
295299
await page.goto("/");
300+
await page.getByTitle("Chat").click();
296301

297302
// The no-target-banner should be visible because no target is active
298303
await expect(page.getByTestId("no-target-banner")).toBeVisible();

frontend/e2e/config.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ test.describe("Target Config ↔ Chat Navigation", () => {
246246

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

251252
// Go to config, set a target

frontend/e2e/errors.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ test.describe("Error: connection banner on health failure", () => {
324324
}) => {
325325
// Let the page load normally first
326326
await page.goto("/");
327-
await expect(page.getByText("PyRIT Attack")).toBeVisible({
327+
await expect(page.getByTitle("Chat")).toBeVisible({
328328
timeout: 10000,
329329
});
330330

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

374374
await page.goto("/");
375-
await expect(page.getByText("PyRIT Attack")).toBeVisible({
375+
await expect(page.getByTitle("Chat")).toBeVisible({
376376
timeout: 10000,
377377
});
378378

frontend/src/App.test.tsx

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ jest.mock("./components/Layout/MainLayout", () => {
6161
<button onClick={onToggleTheme} data-testid="toggle-theme">
6262
Toggle Theme
6363
</button>
64+
<button onClick={() => onNavigate("home")} data-testid="nav-home">
65+
Home
66+
</button>
6467
<button onClick={() => onNavigate("config")} data-testid="nav-config">
6568
Config
6669
</button>
@@ -188,6 +191,41 @@ jest.mock("./components/History/AttackHistory", () => {
188191
};
189192
});
190193

194+
jest.mock("./components/Home/Home", () => {
195+
const MockHome = ({
196+
activeTarget,
197+
onNavigate,
198+
onOpenAttack,
199+
labels,
200+
}: {
201+
activeTarget: unknown;
202+
onNavigate: (view: string) => void;
203+
onOpenAttack: (attackResultId: string) => void;
204+
labels: Record<string, string>;
205+
}) => {
206+
return (
207+
<div data-testid="home-view">
208+
<span data-testid="home-has-target">{activeTarget ? "yes" : "no"}</span>
209+
<span data-testid="home-labels-json">{JSON.stringify(labels)}</span>
210+
<button onClick={() => onNavigate("config")} data-testid="home-go-config">
211+
Go to config
212+
</button>
213+
<button
214+
onClick={() => onOpenAttack("ar-home-attack")}
215+
data-testid="home-open-attack"
216+
>
217+
Open Home Attack
218+
</button>
219+
</div>
220+
);
221+
};
222+
MockHome.displayName = "MockHome";
223+
return {
224+
__esModule: true,
225+
default: MockHome,
226+
};
227+
});
228+
191229
describe("App", () => {
192230
beforeEach(() => {
193231
jest.clearAllMocks();
@@ -197,7 +235,7 @@ describe("App", () => {
197235
it("renders with FluentProvider and MainLayout", () => {
198236
render(<App />);
199237
expect(screen.getByTestId("main-layout")).toBeInTheDocument();
200-
expect(screen.getByTestId("chat-window")).toBeInTheDocument();
238+
expect(screen.getByTestId("home-view")).toBeInTheDocument();
201239
});
202240

203241
it("starts in dark mode", () => {
@@ -229,9 +267,21 @@ describe("App", () => {
229267
);
230268
});
231269

232-
it("starts in chat view", () => {
270+
it("starts in home view", () => {
233271
render(<App />);
234272

273+
expect(screen.getByTestId("main-layout")).toHaveAttribute(
274+
"data-current-view",
275+
"home"
276+
);
277+
expect(screen.getByTestId("home-view")).toBeInTheDocument();
278+
});
279+
280+
it("switches to chat view", () => {
281+
render(<App />);
282+
283+
fireEvent.click(screen.getByTestId("nav-chat"));
284+
235285
expect(screen.getByTestId("main-layout")).toHaveAttribute(
236286
"data-current-view",
237287
"chat"
@@ -264,6 +314,7 @@ describe("App", () => {
264314
it("sets conversationId from chat window", () => {
265315
render(<App />);
266316

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

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

327+
fireEvent.click(screen.getByTestId("nav-chat"));
276328
fireEvent.click(screen.getByTestId("set-conversation"));
277329
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");
278330

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

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

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

378+
it("opens attack from home and switches to chat", async () => {
379+
mockGetAttack.mockResolvedValue({
380+
attack_result_id: "ar-home-attack",
381+
conversation_id: "home-conv-1",
382+
labels: { operator: "roakey" },
383+
});
384+
render(<App />);
385+
386+
fireEvent.click(screen.getByTestId("home-open-attack"));
387+
388+
expect(screen.getByTestId("main-layout")).toHaveAttribute(
389+
"data-current-view",
390+
"chat"
391+
);
392+
await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-home-attack"));
393+
await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("home-conv-1"));
394+
});
395+
396+
it("navigates to config from the home view", () => {
397+
render(<App />);
398+
399+
fireEvent.click(screen.getByTestId("home-go-config"));
400+
401+
expect(screen.getByTestId("main-layout")).toHaveAttribute(
402+
"data-current-view",
403+
"config"
404+
);
405+
expect(screen.getByTestId("target-config")).toBeInTheDocument();
406+
});
407+
325408
it("handles failed attack open gracefully", async () => {
326409
mockGetAttack.mockRejectedValue(new Error("Not found"));
327410
render(<App />);
@@ -349,6 +432,9 @@ describe("App", () => {
349432
expect(mockedVersionApi.getVersion).toHaveBeenCalled();
350433
});
351434

435+
// Switch to chat to inspect labels
436+
fireEvent.click(screen.getByTestId("nav-chat"));
437+
352438
await waitFor(() => {
353439
expect(screen.getByTestId("labels-operator")).toHaveTextContent("default_user");
354440
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
@@ -364,9 +450,12 @@ describe("App", () => {
364450

365451
render(<App />);
366452

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

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

382471
await waitFor(() => {
383-
expect(screen.getByTestId("labels-operator")).toHaveTextContent("override_user");
384-
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
472+
const labels = screen.getByTestId("home-labels-json").textContent ?? "";
473+
expect(labels).toContain('"operator":"override_user"');
474+
expect(labels).toContain('"custom":"value"');
385475
});
386476
});
387477

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

494+
fireEvent.click(screen.getByTestId("nav-chat"));
495+
404496
// First create a conversation to have an attack
405497
fireEvent.click(screen.getByTestId("set-conversation"));
406498
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");

frontend/src/App.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-com
33
import { useMsal } from '@azure/msal-react'
44
import MainLayout from './components/Layout/MainLayout'
55
import ChatWindow from './components/Chat/ChatWindow'
6+
import Home from './components/Home/Home'
67
import TargetConfig from './components/Config/TargetConfig'
78
import AttackHistory from './components/History/AttackHistory'
89
import { DEFAULT_HISTORY_FILTERS } from './components/History/historyFilters'
@@ -39,7 +40,7 @@ function ConnectionBannerContainer() {
3940
function App() {
4041
const { instance } = useMsal()
4142
const [isDarkMode, setIsDarkMode] = useState(true)
42-
const [currentView, setCurrentView] = useState<ViewName>('chat')
43+
const [currentView, setCurrentView] = useState<ViewName>('home')
4344
const [activeTarget, setActiveTarget] = useState<TargetInstance | null>(null)
4445
const [globalLabels, setGlobalLabels] = useState<Record<string, string>>({ ...DEFAULT_GLOBAL_LABELS })
4546
/** True while loading a historical attack from the history view */
@@ -175,6 +176,15 @@ function App() {
175176
onToggleTheme={toggleTheme}
176177
isDarkMode={isDarkMode}
177178
>
179+
{currentView === 'home' && (
180+
<Home
181+
labels={globalLabels}
182+
onLabelsChange={setGlobalLabels}
183+
activeTarget={activeTarget}
184+
onNavigate={setCurrentView}
185+
onOpenAttack={handleOpenAttack}
186+
/>
187+
)}
178188
{currentView === 'chat' && (
179189
<ChatWindow
180190
onNewAttack={handleNewAttack}

frontend/src/components/Chat/MessageList.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ describe("MessageList", () => {
3434
</TestWrapper>
3535
);
3636

37-
expect(document.body).toBeTruthy();
37+
expect(
38+
screen.getByText("There are no messages in this conversation yet.")
39+
).toBeInTheDocument();
3840
});
3941

4042
it("should render all messages", () => {

frontend/src/components/Chat/MessageList.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,8 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
144144
if (messages.length === 0) {
145145
return (
146146
<div className={styles.emptyState}>
147-
<Text size={500} weight="semibold">Welcome to PyRIT</Text>
148147
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
149-
Start a conversation to test AI safety and robustness
148+
There are no messages in this conversation yet.
150149
</Text>
151150
</div>
152151
)

0 commit comments

Comments
 (0)