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
189 changes: 189 additions & 0 deletions frontend/e2e/onboarding-tour.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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(page.locator('[data-tour="target-card"]')).toBeVisible();

await page
.getByRole("button", { name: "Configure a target", exact: true })
.click();
await expect(page).toHaveURL(/\/config$/);
await expect(
page.getByRole("heading", { name: "Target Configuration" })
).toBeVisible();
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(
"target selection happens in Configuration"
);

await dialog.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/$/);
await expect(dialog).toContainText(
"Labels like \"operator\" and \"operation\""
);

await dialog.getByRole("button", { name: "Next", exact: true }).click();
await page
.getByRole("button", { name: "Configure a target", exact: true })
.click();
await expect(page).toHaveURL(/\/config$/);
await expect(dialog).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 a 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 page
.getByRole("button", { name: "Manage targets", exact: true })
.click();
await expect(page).toHaveURL(/\/config$/);
await expect(
page.getByRole("heading", { name: "Target Configuration" })
).toBeVisible();
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("target currently active for Chat");

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);
});

test("adapts step 4 when a target is activated during step 3", 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: "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 page
.getByRole("button", { name: "Configure a target", exact: true })
.click();

await expect(page).toHaveURL(/\/config$/);
await expect(dialog).toBeVisible();
await page.getByRole("button", { name: "Set Active", exact: true }).click();
await expect(page.getByText("Active", { exact: true }).first()).toBeVisible();
await expect(dialog).toContainText("target currently active for Chat");

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);
});
});
3 changes: 1 addition & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,9 @@ jest.mock("./hooks/useTour", () => ({
onEvent: jest.fn(),
continuous: true,
showSkipButton: true,
spotlightClicks: false,
tooltipComponent: () => null,
floatingOptions: { hideArrow: true },
options: { closeButtonAction: "skip", overlayClickAction: false },
options: { blockTargetInteraction: false, closeButtonAction: "skip", overlayClickAction: false },
locale: { back: "Back", close: "Close", last: "Let's go!", next: "Next", skip: "Skip tour" },
},
}),
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ErrorBoundary>
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/components/Chat/ChatInputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -79,6 +82,22 @@ describe("ChatInputArea", () => {
expect(onToggleConverterPanel).toHaveBeenCalledTimes(1);
});

it("should expose the visible target prerequisite to the tour", () => {
render(
<TestWrapper>
<ChatInputArea {...defaultProps} noTargetSelected />
</TestWrapper>
);

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();
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={className} data-testid={testId}>
<div className={className} data-testid={testId} data-tour={tourTarget}>
{icon}
<Text className={textClassName} size={300}>
{text}
Expand Down Expand Up @@ -507,6 +508,7 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
testId="no-target-banner"
buttonTestId="configure-target-input-btn"
buttonClassName={styles.touchTarget}
tourTarget="chat-prerequisite"
/>
) : operatorLocked ? (
<StatusBanner
Expand Down Expand Up @@ -585,6 +587,7 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
onClick={onToggleConverterPanel}
disabled={disabled}
data-testid="toggle-converter-panel-btn"
data-tour="converter-toggle"
aria-label="Toggle converter panel"
/>
</Tooltip>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ export default function ChatWindow({
/>
)}
<div className={styles.chatArea} data-testid="chat-area">
<div className={styles.ribbon} data-tour="chat-area">
<div className={styles.ribbon}>
<div className={styles.conversationInfo}>
{activeTarget ? (
<TargetBadge target={activeTarget} />
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/Tour/TourTooltip.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { makeStyles, tokens } from '@fluentui/react-components'
import { mobileTouchTarget } from '../../styles/touchTargets'

export const useTourTooltipStyles = makeStyles({
// Outer wrapper: provides space for the mascot to overflow below the card
// Include the mascot's overhang in the floating bounds so Joyride can keep it in the viewport.
wrapper: {
display: 'flex',
flexDirection: 'column',
width: '420px',
maxWidth: `calc(100vw - ${tokens.spacingHorizontalM} - ${tokens.spacingHorizontalM})`,
paddingBottom: `calc(${tokens.spacingVerticalXXL} + ${tokens.spacingVerticalL})`,
position: 'relative',
},
container: {
Expand All @@ -25,7 +26,7 @@ export const useTourTooltipStyles = makeStyles({
// Mascot positioned at bottom-left, overlapping the card edge
mascot: {
position: 'absolute',
bottom: '-40px',
bottom: 0,
left: '-20px',
width: '90px',
height: '90px',
Expand Down
Loading