From b4ee9b47f53972900e6837c1d1199a294c115293 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 14:03:35 -0500 Subject: [PATCH] MAESTRO: Build guided Wizard flow for first-time users with 5-step onboarding Add Wizard component with steps: configure LLM endpoint, paste sample text, write a prompt, run experiment and view result, congratulations with sweep CTA. Dashboard empty state updated with "Get Started" wizard link. 22 tests added, all 456 tests pass. --- Auto Run Docs/02b-frontend-dashboard.md | 6 +- frontend/src/App.tsx | 2 + frontend/src/components/Wizard.test.tsx | 712 +++++++++++++++++++++ frontend/src/components/Wizard.tsx | 724 ++++++++++++++++++++++ frontend/src/pages/DashboardPage.test.tsx | 28 +- frontend/src/pages/DashboardPage.tsx | 29 +- frontend/src/pages/WizardPage.tsx | 22 + 7 files changed, 1510 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/Wizard.test.tsx create mode 100644 frontend/src/components/Wizard.tsx create mode 100644 frontend/src/pages/WizardPage.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 9d86b00..f9b980f 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -53,6 +53,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil - [x] Build the WebSocket hook (frontend/src/hooks/useExperimentWS.ts). Custom React hook that manages WebSocket connection to /ws/experiments/{id}. Handles connect/disconnect/reconnect, parses incoming events, exposes typed event stream, and provides connection status. Reconnect with exponential backoff on disconnect. -- [ ] Style pass — go through every page and component ensuring consistent Tailwind usage, proper dark mode support (use Tailwind dark: prefix), responsive layout (works on tablet+), smooth transitions on state changes, and accessible form inputs. The UI should feel alive and dynamic, not static. Use subtle animations for new data arriving. +- [x] Style pass — go through every page and component ensuring consistent Tailwind usage, proper dark mode support (use Tailwind dark: prefix), responsive layout (works on tablet+), smooth transitions on state changes, and accessible form inputs. The UI should feel alive and dynamic, not static. Use subtle animations for new data arriving. + -- [ ] Build a "Wizard" flow for first-time users (frontend/src/components/Wizard.tsx). A guided multi-step flow: (1) Configure your first LLM endpoint, (2) Paste some sample text, (3) Write a prompt, (4) Run it and see the result, (5) Congratulations, now try a sweep! This should be accessible from the Dashboard for new users. +- [x] Build a "Wizard" flow for first-time users (frontend/src/components/Wizard.tsx). A guided multi-step flow: (1) Configure your first LLM endpoint, (2) Paste some sample text, (3) Write a prompt, (4) Run it and see the result, (5) Congratulations, now try a sweep! This should be accessible from the Dashboard for new users. + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3eb7eba..6cea163 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import ExperimentPage from "./pages/ExperimentPage"; import LivePage from "./pages/LivePage"; import ComparePage from "./pages/ComparePage"; import AdminPage from "./pages/AdminPage"; +import WizardPage from "./pages/WizardPage"; function AuthenticatedRoutes() { return ( @@ -20,6 +21,7 @@ function AuthenticatedRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/Wizard.test.tsx b/frontend/src/components/Wizard.test.tsx new file mode 100644 index 0000000..ec5e3b5 --- /dev/null +++ b/frontend/src/components/Wizard.test.tsx @@ -0,0 +1,712 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import Wizard from "./Wizard"; +import * as client from "../api/client"; + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +const MOCK_ENDPOINT: client.EndpointResponse = { + id: "ep1", + name: "Test LLM", + url: "http://localhost:11434/v1", + default_model: "llama3", +}; + +const MOCK_PROJECT: client.ProjectResponse = { + id: "proj1", + name: "Quick Start", + description: "Created by the Getting Started wizard", + owner_id: "u1", + created_at: "2026-04-07T10:00:00Z", + updated_at: "2026-04-07T10:00:00Z", +}; + +const MOCK_EXPERIMENT: client.ExperimentResponse = { + id: "exp1", + project_id: "proj1", + name: "Wizard Run", + description: "First experiment from the wizard", + sample_data: { input_data: "Hello world" }, + pipeline_stages: null, + scoring_config: null, + parameter_space: null, + status: "draft", + created_at: "2026-04-07T10:00:00Z", + updated_at: "2026-04-07T10:00:00Z", +}; + +const MOCK_RUN: client.RunResponse = { + id: "run1", + experiment_id: "exp1", + config_hash: "abc123", + config: { model: "llama3" }, + status: "running", + started_at: "2026-04-07T10:00:00Z", + completed_at: null, + duration_ms: null, + tokens_in: null, + tokens_out: null, + cost_estimate: null, +}; + +const MOCK_RUN_DETAIL: client.RunDetailResponse = { + ...MOCK_RUN, + status: "completed", + completed_at: "2026-04-07T10:01:00Z", + duration_ms: 2500, + tokens_in: 100, + tokens_out: 50, + cost_estimate: 0.001, + stage_results: [ + { + id: "sr1", + run_id: "run1", + stage_index: 0, + prompt_sent: "Summarize: Hello world", + response_raw: "This is a greeting text.", + model_used: "llama3", + parameters: null, + tokens_in: 100, + tokens_out: 50, + latency_ms: 2500, + }, + ], + scores: [], +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const mockedNavigate = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockedNavigate, + }; +}); + +function renderWizard(onDismiss?: () => void) { + return render( + + + , + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Wizard", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockedNavigate.mockClear(); + }); + + // -- Rendering & navigation ----------------------------------------------- + + it("renders the wizard container and step indicator", () => { + renderWizard(); + expect(screen.getByTestId("wizard")).toBeInTheDocument(); + expect(screen.getByTestId("step-indicator")).toBeInTheDocument(); + }); + + it("starts on step 1 — endpoint configuration", () => { + renderWizard(); + expect( + screen.getByText("Connect your first LLM endpoint"), + ).toBeInTheDocument(); + expect(screen.getByTestId("wiz-ep-url")).toBeInTheDocument(); + }); + + it("shows back button disabled on first step", () => { + renderWizard(); + const back = screen.getByTestId("wiz-back"); + expect(back).toBeDisabled(); + }); + + it("calls onDismiss when skip wizard is clicked", async () => { + const dismiss = vi.fn(); + const user = userEvent.setup(); + renderWizard(dismiss); + + await user.click(screen.getByTestId("wiz-skip")); + expect(dismiss).toHaveBeenCalledOnce(); + }); + + // -- Step 1: Endpoint ---------------------------------------------------- + + it("shows validation error when URL is whitespace only", async () => { + const user = userEvent.setup(); + renderWizard(); + + // Type whitespace-only URL (bypasses HTML required but fails our trim check) + const urlInput = screen.getByTestId("wiz-ep-url"); + await user.type(urlInput, " "); + await user.click(screen.getByTestId("wiz-ep-submit")); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Endpoint URL is required.", + ); + }); + + it("creates endpoint and advances to step 2", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + await user.clear(screen.getByTestId("wiz-ep-url")); + await user.type( + screen.getByTestId("wiz-ep-url"), + "http://localhost:11434/v1", + ); + await user.click(screen.getByTestId("wiz-ep-submit")); + + await waitFor(() => { + expect( + screen.getByText("Paste some sample text"), + ).toBeInTheDocument(); + }); + }); + + it("shows API error when endpoint creation fails", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockRejectedValue( + new client.ApiError(422, "Unprocessable", {}), + ); + + renderWizard(); + + await user.type( + screen.getByTestId("wiz-ep-url"), + "http://bad", + ); + await user.click(screen.getByTestId("wiz-ep-submit")); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to create endpoint (422).", + ); + }); + }); + + it("shows network error on non-API failure", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockRejectedValue( + new Error("fetch failed"), + ); + + renderWizard(); + + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Network error. Is the server running?", + ); + }); + }); + + it("shows submitting state on endpoint form", async () => { + const user = userEvent.setup(); + // Never resolving promise to keep submitting state + vi.spyOn(client.endpoints, "create").mockReturnValue( + new Promise(() => {}), + ); + + renderWizard(); + + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + expect(screen.getByTestId("wiz-ep-submit")).toHaveTextContent( + "Connecting...", + ); + expect(screen.getByTestId("wiz-ep-submit")).toBeDisabled(); + }); + + // -- Step 2: Sample data ------------------------------------------------- + + it("disables continue when sample text is empty", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + // Advance to step 2 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-next")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("wiz-sample-next")).toBeDisabled(); + }); + + it("advances to step 3 when sample text is provided", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + // Step 1 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + // Step 2 + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello world"); + await user.click(screen.getByTestId("wiz-sample-next")); + + await waitFor(() => { + expect(screen.getByText("Write a prompt")).toBeInTheDocument(); + }); + }); + + // -- Step 3: Prompt ------------------------------------------------------- + + it("disables continue when prompt text is empty", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + // Step 1 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + // Step 2 + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + + // Step 3 + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-next")).toBeInTheDocument(); + }); + expect(screen.getByTestId("wiz-prompt-next")).toBeDisabled(); + }); + + it("advances to step 4 when prompt is provided", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + // Step 1 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + // Step 2 + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + + // Step 3 + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type( + screen.getByTestId("wiz-prompt-text"), + "Summarize this", + ); + await user.click(screen.getByTestId("wiz-prompt-next")); + + await waitFor(() => { + expect(screen.getByText("Run it!")).toBeInTheDocument(); + }); + }); + + // -- Step 4: Run ---------------------------------------------------------- + + it("shows run button on step 4 with endpoint name", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + // Navigate to step 4 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize"); + await user.click(screen.getByTestId("wiz-prompt-next")); + + await waitFor(() => { + expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument(); + }); + expect(screen.getByText("Test LLM")).toBeInTheDocument(); + }); + + it("runs experiment and shows result then advances to congrats", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT); + vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN); + vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL); + + renderWizard(); + + // Navigate to step 4 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize"); + await user.click(screen.getByTestId("wiz-prompt-next")); + + await waitFor(() => { + expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument(); + }); + + // Click run + await user.click(screen.getByTestId("wiz-run-btn")); + + // Should show running state + await waitFor(() => { + expect(screen.getByTestId("wiz-running")).toBeInTheDocument(); + }); + + // Should eventually show result and advance to congrats + await waitFor( + () => { + expect( + screen.getByText("You're all set!"), + ).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + }); + + it("shows error when run fails", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + vi.spyOn(client.projects, "create").mockRejectedValue( + new client.ApiError(500, "Server Error", {}), + ); + + renderWizard(); + + // Navigate to step 4 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize"); + await user.click(screen.getByTestId("wiz-prompt-next")); + + await waitFor(() => { + expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("wiz-run-btn")); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Request failed (500).", + ); + }); + }); + + // -- Step 5: Congrats ----------------------------------------------------- + + it("shows try a sweep and dashboard buttons on congrats step", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT); + vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN); + vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL); + + renderWizard(); + + // Navigate through all steps + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize"); + await user.click(screen.getByTestId("wiz-prompt-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("wiz-run-btn")); + + await waitFor( + () => { + expect(screen.getByTestId("wiz-try-sweep")).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + expect(screen.getByTestId("wiz-go-dashboard")).toBeInTheDocument(); + }); + + it("try sweep navigates to experiment page", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT); + vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN); + vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL); + + renderWizard(); + + // Navigate through all steps + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize"); + await user.click(screen.getByTestId("wiz-prompt-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("wiz-run-btn")); + + await waitFor( + () => { + expect(screen.getByTestId("wiz-try-sweep")).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + await user.click(screen.getByTestId("wiz-try-sweep")); + expect(mockedNavigate).toHaveBeenCalledWith("/experiments/exp1"); + }); + + it("go to dashboard navigates to root and calls onDismiss", async () => { + const dismiss = vi.fn(); + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT); + vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN); + vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL); + + renderWizard(dismiss); + + // Navigate through all steps + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize"); + await user.click(screen.getByTestId("wiz-prompt-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("wiz-run-btn")); + + await waitFor( + () => { + expect(screen.getByTestId("wiz-go-dashboard")).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + await user.click(screen.getByTestId("wiz-go-dashboard")); + expect(dismiss).toHaveBeenCalledOnce(); + expect(mockedNavigate).toHaveBeenCalledWith("/"); + }); + + // -- Back navigation ------------------------------------------------------ + + it("back button navigates to previous step", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + // Advance to step 2 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + await waitFor(() => { + expect( + screen.getByText("Paste some sample text"), + ).toBeInTheDocument(); + }); + + // Go back to step 1 + await user.click(screen.getByTestId("wiz-back")); + + await waitFor(() => { + expect( + screen.getByText("Connect your first LLM endpoint"), + ).toBeInTheDocument(); + }); + }); + + // -- Step indicator ------------------------------------------------------- + + it("shows correct step labels in indicator", () => { + renderWizard(); + const indicator = screen.getByTestId("step-indicator"); + expect(indicator).toBeInTheDocument(); + // Step 1 is active + expect(screen.getByText("Connect LLM")).toBeInTheDocument(); + }); + + // -- Congrats hides back/skip ------------------------------------------------- + + it("hides back and skip buttons on congrats step", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT); + vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT); + vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN); + vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL); + + renderWizard(); + + // Navigate through all steps to congrats + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize"); + await user.click(screen.getByTestId("wiz-prompt-next")); + await waitFor(() => { + expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("wiz-run-btn")); + + await waitFor( + () => { + expect( + screen.getByText("You're all set!"), + ).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + expect(screen.queryByTestId("wiz-back")).not.toBeInTheDocument(); + expect(screen.queryByTestId("wiz-skip")).not.toBeInTheDocument(); + }); + + // -- Endpoint form fields ------------------------------------------------- + + it("renders all endpoint form fields", () => { + renderWizard(); + expect(screen.getByTestId("wiz-ep-name")).toBeInTheDocument(); + expect(screen.getByTestId("wiz-ep-url")).toBeInTheDocument(); + expect(screen.getByTestId("wiz-ep-key")).toBeInTheDocument(); + expect(screen.getByTestId("wiz-ep-model")).toBeInTheDocument(); + }); + + it("sends correct data when creating endpoint", async () => { + const user = userEvent.setup(); + const createSpy = vi + .spyOn(client.endpoints, "create") + .mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + await user.clear(screen.getByTestId("wiz-ep-name")); + await user.type(screen.getByTestId("wiz-ep-name"), "Custom Name"); + await user.type( + screen.getByTestId("wiz-ep-url"), + "http://localhost:8080/v1", + ); + await user.type(screen.getByTestId("wiz-ep-key"), "sk-test123"); + await user.type(screen.getByTestId("wiz-ep-model"), "gpt-4o"); + await user.click(screen.getByTestId("wiz-ep-submit")); + + await waitFor(() => { + expect(createSpy).toHaveBeenCalledWith({ + name: "Custom Name", + url: "http://localhost:8080/v1", + api_key: "sk-test123", + default_model: "gpt-4o", + }); + }); + }); + + // -- Prompt template hint ------------------------------------------------- + + it("shows template variable hint on prompt step", async () => { + const user = userEvent.setup(); + vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT); + + renderWizard(); + + // Navigate to step 3 + await user.type(screen.getByTestId("wiz-ep-url"), "http://x"); + await user.click(screen.getByTestId("wiz-ep-submit")); + await waitFor(() => { + expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument(); + }); + await user.type(screen.getByTestId("wiz-sample-text"), "Hello"); + await user.click(screen.getByTestId("wiz-sample-next")); + + await waitFor(() => { + expect(screen.getByText("{{ input_data }}")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/Wizard.tsx b/frontend/src/components/Wizard.tsx new file mode 100644 index 0000000..c185f76 --- /dev/null +++ b/frontend/src/components/Wizard.tsx @@ -0,0 +1,724 @@ +import { useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + endpoints as endpointsApi, + experiments, + projects, + runs, + ApiError, +} from "../api/client"; +import type { + EndpointCreate, + EndpointResponse, + ProjectResponse, + ExperimentResponse, + RunDetailResponse, +} from "../api/client"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface WizardProps { + /** Called when the wizard is dismissed (skip or finish). */ + onDismiss?: () => void; +} + +type StepId = + | "endpoint" + | "sample" + | "prompt" + | "run" + | "congrats"; + +const STEPS: { id: StepId; label: string }[] = [ + { id: "endpoint", label: "Connect LLM" }, + { id: "sample", label: "Sample Data" }, + { id: "prompt", label: "Write Prompt" }, + { id: "run", label: "Run It" }, + { id: "congrats", label: "Done!" }, +]; + +// --------------------------------------------------------------------------- +// Step indicator +// --------------------------------------------------------------------------- + +function StepIndicator({ + steps, + currentIndex, +}: { + steps: typeof STEPS; + currentIndex: number; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Step 1 — Configure Endpoint +// --------------------------------------------------------------------------- + +function EndpointStep({ + onComplete, + error, + setError, + submitting, + setSubmitting, +}: { + onComplete: (ep: EndpointResponse) => void; + error: string | null; + setError: (e: string | null) => void; + submitting: boolean; + setSubmitting: (s: boolean) => void; +}) { + const [name, setName] = useState("My LLM"); + const [url, setUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!url.trim()) { + setError("Endpoint URL is required."); + return; + } + + setSubmitting(true); + try { + const payload: EndpointCreate = { + name: name.trim() || "My LLM", + url: url.trim(), + api_key: apiKey.trim() || null, + default_model: model.trim() || null, + }; + const created = await endpointsApi.create(payload); + onComplete(created); + } catch (err: unknown) { + if (err instanceof ApiError) { + setError(`Failed to create endpoint (${err.status}).`); + } else { + setError("Network error. Is the server running?"); + } + } finally { + setSubmitting(false); + } + } + + return ( +
+

+ Connect your first LLM endpoint +

+

+ PromptLooper talks to any OpenAI-compatible API. Point it at OpenAI, + Ollama, vLLM, or any other provider. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} + placeholder="My LLM" + data-testid="wiz-ep-name" + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-sm text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + /> +
+ +
+ + setUrl(e.target.value)} + placeholder="http://localhost:11434/v1" + required + data-testid="wiz-ep-url" + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-sm text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + /> +
+ +
+ + setApiKey(e.target.value)} + placeholder="sk-..." + data-testid="wiz-ep-key" + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-sm text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + /> +
+ +
+ + setModel(e.target.value)} + placeholder="gpt-4o, llama3, mistral..." + data-testid="wiz-ep-model" + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-sm text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + /> +
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Step 2 — Sample Data +// --------------------------------------------------------------------------- + +function SampleStep({ + sampleText, + setSampleText, + onNext, +}: { + sampleText: string; + setSampleText: (s: string) => void; + onNext: () => void; +}) { + return ( +
+

+ Paste some sample text +

+

+ This is the input your prompt will process. It can be anything — an + article, a support ticket, raw data, a question. We'll use it to + test your prompt in the next step. +

+ +