From 6d26628abb25dcdc09e697f0b1f7072e309963d3 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:01:33 -0700 Subject: [PATCH] Fix onboarding guidance for target state Make tour instructions and spotlight targets reflect whether Chat has an active target, with focused unit and browser coverage for both first-use states. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7a2feb74-e66a-427e-8b5c-c503b91d98d8 --- frontend/e2e/onboarding-tour.spec.ts | 100 +++++++++++++++++ frontend/src/App.tsx | 7 +- .../components/Chat/ChatInputArea.test.tsx | 21 +++- .../src/components/Chat/ChatInputArea.tsx | 7 +- frontend/src/components/Chat/ChatWindow.tsx | 2 +- frontend/src/components/Tour/tourSteps.ts | 106 ++++++++++-------- frontend/src/hooks/useTour.test.ts | 24 ++++ frontend/src/hooks/useTour.ts | 21 ++-- 8 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 frontend/e2e/onboarding-tour.spec.ts diff --git a/frontend/e2e/onboarding-tour.spec.ts b/frontend/e2e/onboarding-tour.spec.ts new file mode 100644 index 0000000000..d332c5f357 --- /dev/null +++ b/frontend/e2e/onboarding-tour.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; + +import { makeTarget } from "./_targets"; + +test.describe("Onboarding tour", () => { + test("guides a user with no active target through the visible prerequisite", async ({ + page, + }) => { + await page.goto("/"); + await page.getByRole("button", { name: "Take a tour" }).click(); + + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + + await expect(dialog).toContainText( + "target selection happens in Configuration" + ); + await expect(dialog).toContainText("choose Configure a target"); + await expect(dialog).toContainText("use Set Active there"); + await expect(dialog).not.toContainText("come back here to select it"); + await expect(page.locator('[data-tour="target-card"]')).toBeVisible(); + + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + + await expect(page).toHaveURL(/\/chat$/); + await expect(dialog).toContainText( + "Chat needs an active target before the message composer is available" + ); + await expect(dialog).toContainText( + "After the tour, choose Configure Target" + ); + await expect(dialog).toContainText( + "The message input and converter control appear once a target is active" + ); + await expect( + page.locator('[data-tour="chat-prerequisite"]') + ).toHaveAttribute("data-testid", "no-target-banner"); + await expect(page.locator('[data-tour="converter-toggle"]')).toHaveCount(0); + }); + + test("guides a user with an active target to the visible converter control", async ({ + page, + }) => { + await page.route(/\/api\/targets(?:\?.*)?$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + makeTarget({ + target_registry_name: "tour-target", + target_type: "OpenAIChatTarget", + endpoint: "https://test.com", + model_name: "gpt-4o", + }), + ], + pagination: { + limit: 200, + has_more: false, + next_cursor: null, + prev_cursor: null, + }, + }), + }); + }); + + await page.goto("/"); + await page + .getByRole("button", { name: "Configuration", exact: true }) + .click(); + await expect( + page.getByRole("heading", { name: "Target Configuration" }) + ).toBeVisible(); + await page.getByRole("button", { name: "Set Active", exact: true }).click(); + await page.getByRole("button", { name: "Home", exact: true }).click(); + await expect(page.getByTestId("home-target-active")).toContainText("gpt-4o"); + + await page.getByRole("button", { name: "Take a tour" }).click(); + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + + await expect(dialog).toContainText("target currently active for Chat"); + await expect(dialog).toContainText("use Set Active in Configuration"); + await expect(page.locator('[data-tour="target-card"]')).toBeVisible(); + + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + + await expect(page).toHaveURL(/\/chat$/); + await expect(dialog).toContainText("Chat shows the message composer"); + await expect(dialog).toContainText("Toggle converter panel"); + await expect(page.getByRole("textbox")).toBeVisible(); + await expect(page.locator('[data-tour="converter-toggle"]')).toHaveAttribute( + "aria-label", + "Toggle converter panel" + ); + await expect(page.getByTestId("no-target-banner")).toHaveCount(0); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae9a572dee..93a709bbb8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -345,7 +345,12 @@ function App() { // Onboarding tour — pass handleNavigate so the tour can switch views between steps. // The tour does not auto-start; users launch it from the "Take a tour" button in the top bar. const { resolved } = useTheme() - const { startTour, tourProps } = useTour(handleNavigate, resolved === 'dark', currentView) + const { startTour, tourProps } = useTour( + handleNavigate, + resolved === 'dark', + currentView, + activeTarget !== null, + ) return ( diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index ffae7fc1d9..76ac42719f 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -58,7 +58,10 @@ describe("ChatInputArea", () => { expect(screen.getByRole("textbox")).toBeInTheDocument(); expect(getSendButton()).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /convert/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /convert/i })).toHaveAttribute( + "data-tour", + "converter-toggle" + ); }); it("should call converter panel toggle handler when convert button is clicked", async () => { @@ -79,6 +82,22 @@ describe("ChatInputArea", () => { expect(onToggleConverterPanel).toHaveBeenCalledTimes(1); }); + it("should expose the visible target prerequisite to the tour", () => { + render( + + + + ); + + expect(screen.getByTestId("no-target-banner")).toHaveAttribute( + "data-tour", + "chat-prerequisite" + ); + expect( + screen.queryByRole("button", { name: /toggle converter panel/i }) + ).not.toBeInTheDocument(); + }); + it("should call onSend with input value when send button clicked", async () => { const user = userEvent.setup(); const onSend = jest.fn(); diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index e2c0721b04..2d8d53b5ca 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -34,11 +34,12 @@ interface StatusBannerProps { textClassName: string buttonTestId?: string buttonClassName?: string + tourTarget?: string } -function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testId, className, textClassName, buttonTestId, buttonClassName }: StatusBannerProps) { +function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testId, className, textClassName, buttonTestId, buttonClassName, tourTarget }: StatusBannerProps) { return ( -
+
{icon} {text} @@ -507,6 +508,7 @@ const ChatInputArea = forwardRef(functi testId="no-target-banner" buttonTestId="configure-target-input-btn" buttonClassName={styles.touchTarget} + tourTarget="chat-prerequisite" /> ) : operatorLocked ? ( (functi onClick={onToggleConverterPanel} disabled={disabled} data-testid="toggle-converter-panel-btn" + data-tour="converter-toggle" aria-label="Toggle converter panel" /> diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 844c01d0b9..a6e15308ea 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -632,7 +632,7 @@ export default function ChatWindow({ /> )}
-
+
{activeTarget ? ( diff --git a/frontend/src/components/Tour/tourSteps.ts b/frontend/src/components/Tour/tourSteps.ts index f01f287a23..d4ac770e6a 100644 --- a/frontend/src/components/Tour/tourSteps.ts +++ b/frontend/src/components/Tour/tourSteps.ts @@ -11,50 +11,62 @@ export interface TourStep extends Step { readonly viewRequired: ViewName } -export const TOUR_STEPS: readonly TourStep[] = [ - { - target: '[data-tour="sidebar-nav"]', - content: - 'Ahoy! Welcome to Co-PyRIT! This is your main navigation panel. Home is your dashboard, Chat is where you send prompts, ' + - 'History tracks past attacks, and Configuration is where you set up targets. Feel free to try clicking between these views!', - placement: 'right-start', - skipBeacon: true, - viewRequired: 'home', - }, - { - target: '[data-tour="labels-card"]', - content: - 'Labels like "operator" and "operation" tag every attack you run, making them easy to find later. ' + - 'Update the defaults before you start!', - placement: 'bottom', - skipBeacon: true, - viewRequired: 'home', - }, - { - target: '[data-tour="target-card"]', - content: - 'Targets are the AI endpoints you\'re testing. Head to Configuration to set one up, ' + - 'then come back here to select it before chatting.', - placement: 'bottom', - skipBeacon: true, - viewRequired: 'home', - }, - { - target: '[data-tour="chat-area"]', - content: - 'This is where you send prompts and view assistant responses. Use the converter panel toggle on the ' + - 'left of the message input box to transform your text before sending — like Base64 encoding or translation.', - placement: 'bottom', - skipBeacon: true, - viewRequired: 'chat', - }, - { - target: '[data-tour="history-filters"]', - content: - 'Every attack is logged here. Filter by different criteria like outcome, converter type, or labels to ' + - 'find exactly what you need!', - placement: 'bottom', - skipBeacon: true, - viewRequired: 'history', - }, -] +/** Builds tour guidance for the controls available in the current target state. */ +export function createTourSteps(hasActiveTarget: boolean): TourStep[] { + return [ + { + target: '[data-tour="sidebar-nav"]', + content: + 'Ahoy! Welcome to Co-PyRIT! This is your main navigation panel. Home is your dashboard, Chat is where you send prompts, ' + + 'History tracks past attacks, and Configuration is where you set up targets. Feel free to try clicking between these views!', + placement: 'right-start', + skipBeacon: true, + viewRequired: 'home', + }, + { + target: '[data-tour="labels-card"]', + content: + 'Labels like "operator" and "operation" tag every attack you run, making them easy to find later. ' + + 'Update the defaults before you start!', + placement: 'bottom', + skipBeacon: true, + viewRequired: 'home', + }, + { + target: '[data-tour="target-card"]', + content: hasActiveTarget + ? 'This card shows the target currently active for Chat. To switch targets after the tour, choose Manage targets ' + + 'and use Set Active in Configuration.' + : 'Targets are the AI endpoints you\'re testing. This card only shows the current target; target selection happens ' + + 'in Configuration. After the tour, choose Configure a target, then create or choose one and use Set Active there.', + placement: 'bottom', + skipBeacon: true, + viewRequired: 'home', + }, + { + target: hasActiveTarget + ? '[data-tour="converter-toggle"]' + : '[data-tour="chat-prerequisite"]', + content: hasActiveTarget + ? 'With a target active, Chat shows the message composer. Use this Toggle converter panel button to transform text ' + + 'before sending, such as Base64 encoding or translation.' + : 'Chat needs an active target before the message composer is available. After the tour, choose Configure Target to ' + + 'create or activate one in Configuration, then return to Chat. The message input and converter control appear once ' + + 'a target is active.', + placement: 'bottom', + skipBeacon: true, + viewRequired: 'chat', + }, + { + target: '[data-tour="history-filters"]', + content: + 'Every attack is logged here. Filter by different criteria like outcome, converter type, or labels to ' + + 'find exactly what you need!', + placement: 'bottom', + skipBeacon: true, + viewRequired: 'history', + }, + ] +} + +export const TOUR_STEPS = createTourSteps(false) diff --git a/frontend/src/hooks/useTour.test.ts b/frontend/src/hooks/useTour.test.ts index abe6fbd05b..d371231178 100644 --- a/frontend/src/hooks/useTour.test.ts +++ b/frontend/src/hooks/useTour.test.ts @@ -59,6 +59,30 @@ describe('useTour', () => { }) }) + it('uses visible setup guidance when no target is active', () => { + const { result } = renderHook(() => useTour(onNavigate, true, 'home', false)) + const steps = result.current.tourProps.steps + + expect(steps[2].content).toContain('target selection happens in Configuration') + expect(steps[2].content).toContain('choose Configure a target') + expect(steps[2].content).toContain('use Set Active there') + expect(steps[3].target).toBe('[data-tour="chat-prerequisite"]') + expect(steps[3].content).toContain('before the message composer is available') + expect(steps[3].content).toContain('converter control appear once a target is active') + }) + + it('uses the active target card and visible converter control when a target is active', () => { + const { result } = renderHook(() => useTour(onNavigate, true, 'home', true)) + const steps = result.current.tourProps.steps + + expect(steps[2].content).toContain('target currently active for Chat') + expect(steps[2].content).toContain('after the tour') + expect(steps[2].content).toContain('use Set Active in Configuration') + expect(steps[3].target).toBe('[data-tour="converter-toggle"]') + expect(steps[3].content).toContain('Chat shows the message composer') + expect(steps[3].content).toContain('Toggle converter panel') + }) + it('startTour navigates to home and defers step when on a different view', () => { const { result, rerender } = renderHook( ({ currentView }) => useTour(onNavigate, true, currentView), diff --git a/frontend/src/hooks/useTour.ts b/frontend/src/hooks/useTour.ts index fb4204bbaf..7510d36400 100644 --- a/frontend/src/hooks/useTour.ts +++ b/frontend/src/hooks/useTour.ts @@ -3,13 +3,12 @@ import { useState, useCallback, useRef, useMemo, useEffect, createElement } from import type { EventData } from 'react-joyride' import { ACTIONS, LIFECYCLE, STATUS } from 'react-joyride' -import { TOUR_STEPS } from '../components/Tour/tourSteps' +import { createTourSteps } from '../components/Tour/tourSteps' import TourTooltip from '../components/Tour/TourTooltip' import type { ViewName } from '../components/Sidebar/Navigation' // Static Joyride config — hoisted to module scope so they're created once, // not on every render. Joyride compares these by reference internally. -const JOYRIDE_STEPS = [...TOUR_STEPS] const TOUR_VIEWPORT_PADDING_PX = 12 const JOYRIDE_FLOATING_OPTIONS = { hideArrow: true, @@ -36,9 +35,15 @@ const JOYRIDE_LOCALE = { * * Returns props to spread onto `` plus control functions. */ -export function useTour(onNavigate: (view: ViewName) => void, isDarkMode: boolean, currentView: ViewName) { +export function useTour( + onNavigate: (view: ViewName) => void, + isDarkMode: boolean, + currentView: ViewName, + hasActiveTarget = false, +) { const [run, setRun] = useState(false) const [stepIndex, setStepIndex] = useState(0) + const steps = useMemo(() => createTourSteps(hasActiveTarget), [hasActiveTarget]) // Ref to track whether we're in the middle of a delayed view switch. // Prevents double-advancing if the user clicks rapidly. @@ -127,7 +132,7 @@ export function useTour(onNavigate: (view: ViewName) => void, isDarkMode: boolea const nextIndex = index + (action === ACTIONS.PREV ? -1 : 1) // Past end final index means the tour is complete - if (nextIndex >= TOUR_STEPS.length) { + if (nextIndex >= steps.length) { endTour() return } @@ -137,7 +142,7 @@ export function useTour(onNavigate: (view: ViewName) => void, isDarkMode: boolea return } - const nextStep = TOUR_STEPS[nextIndex] + const nextStep = steps[nextIndex] if (nextStep.viewRequired !== currentViewRef.current) { // The required view differs from the actual current view. @@ -149,7 +154,7 @@ export function useTour(onNavigate: (view: ViewName) => void, isDarkMode: boolea } else { setStepIndex(nextIndex) } - }, [onNavigate, endTour]) + }, [onNavigate, endTour, steps]) // Wrap TourTooltip so it receives isDarkMode via closure. // Uses createElement instead of JSX because this is a .ts file (not .tsx). @@ -164,7 +169,7 @@ export function useTour(onNavigate: (view: ViewName) => void, isDarkMode: boolea // Memoize tourProps so Joyride only receives a new object reference when // something it cares about actually changed (run, stepIndex, callbacks, tooltip). const tourProps = useMemo(() => ({ - steps: JOYRIDE_STEPS, + steps, run, stepIndex, onEvent: handleJoyrideEvent, @@ -175,7 +180,7 @@ export function useTour(onNavigate: (view: ViewName) => void, isDarkMode: boolea floatingOptions: JOYRIDE_FLOATING_OPTIONS, options: JOYRIDE_OPTIONS, locale: JOYRIDE_LOCALE, - }), [run, stepIndex, handleJoyrideEvent, tooltip]) + }), [steps, run, stepIndex, handleJoyrideEvent, tooltip]) return { /** Call to start (or restart) the tour from step 1 on the Home view. */