From 2c05c2c0ac2387243ea58fc8bb0fe1a20c53d2bc Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 08:00:02 -0500 Subject: [PATCH] test: harden onboarding route coverage --- tests/e2e/onboarding.spec.ts | 42 +------------- tests/e2e/playwright.config.ts | 2 +- ui/src/App.tsx | 50 +++++++--------- ui/src/components/OnboardingWizard.tsx | 63 ++++++++++++++------ ui/src/lib/onboarding-route.test.ts | 80 ++++++++++++++++++++++++++ ui/src/lib/onboarding-route.ts | 51 ++++++++++++++++ 6 files changed, 203 insertions(+), 85 deletions(-) create mode 100644 ui/src/lib/onboarding-route.test.ts create mode 100644 ui/src/lib/onboarding-route.ts diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index f1dbd0f5..f5f32be8 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -22,14 +22,11 @@ const TASK_TITLE = "E2E test task"; test.describe("Onboarding wizard", () => { test("completes full wizard flow", async ({ page }) => { - // Navigate to root — should auto-open onboarding when no companies exist await page.goto("/"); - // If the wizard didn't auto-open (company already exists), click the button const wizardHeading = page.locator("h3", { hasText: "Name your company" }); const newCompanyBtn = page.getByRole("button", { name: "New Company" }); - // Wait for either the wizard or the start page await expect( wizardHeading.or(newCompanyBtn) ).toBeVisible({ timeout: 15_000 }); @@ -38,40 +35,28 @@ test.describe("Onboarding wizard", () => { await newCompanyBtn.click(); } - // ----------------------------------------------------------- - // Step 1: Name your company - // ----------------------------------------------------------- await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); - await expect(page.locator("text=Step 1 of 4")).toBeVisible(); const companyNameInput = page.locator('input[placeholder="Acme Corp"]'); await companyNameInput.fill(COMPANY_NAME); - // Click Next const nextButton = page.getByRole("button", { name: "Next" }); await nextButton.click(); - // ----------------------------------------------------------- - // Step 2: Create your first agent - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Create your first agent" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 2 of 4")).toBeVisible(); - // Agent name should default to "CEO" const agentNameInput = page.locator('input[placeholder="CEO"]'); await expect(agentNameInput).toHaveValue(AGENT_NAME); - // Claude Code adapter should be selected by default await expect( page.locator("button", { hasText: "Claude Code" }).locator("..") ).toBeVisible(); - // Select the "Process" adapter to avoid needing a real CLI tool installed - await page.locator("button", { hasText: "Process" }).click(); + await page.getByRole("button", { name: "More Agent Adapter Types" }).click(); + await page.getByRole("button", { name: "Process" }).click(); - // Fill in process adapter fields const commandInput = page.locator('input[placeholder="e.g. node, python"]'); await commandInput.fill("echo"); const argsInput = page.locator( @@ -79,52 +64,34 @@ test.describe("Onboarding wizard", () => { ); await argsInput.fill("hello"); - // Click Next (process adapter skips environment test) await page.getByRole("button", { name: "Next" }).click(); - // ----------------------------------------------------------- - // Step 3: Give it something to do - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Give it something to do" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 3 of 4")).toBeVisible(); - // Clear default title and set our test title const taskTitleInput = page.locator( 'input[placeholder="e.g. Research competitor pricing"]' ); await taskTitleInput.clear(); await taskTitleInput.fill(TASK_TITLE); - // Click Next await page.getByRole("button", { name: "Next" }).click(); - // ----------------------------------------------------------- - // Step 4: Ready to launch - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Ready to launch" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 4 of 4")).toBeVisible(); - // Verify summary displays our created entities await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible(); await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); await expect(page.locator("text=" + TASK_TITLE)).toBeVisible(); - // Click "Open Issue" - await page.getByRole("button", { name: "Open Issue" }).click(); + await page.getByRole("button", { name: "Create & Open Issue" }).click(); - // Should navigate to the issue page await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); - // ----------------------------------------------------------- - // Verify via API that entities were created - // ----------------------------------------------------------- const baseUrl = page.url().split("/").slice(0, 3).join("/"); - // List companies and find ours const companiesRes = await page.request.get(`${baseUrl}/api/companies`); expect(companiesRes.ok()).toBe(true); const companies = await companiesRes.json(); @@ -133,7 +100,6 @@ test.describe("Onboarding wizard", () => { ); expect(company).toBeTruthy(); - // List agents for our company const agentsRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/agents` ); @@ -146,7 +112,6 @@ test.describe("Onboarding wizard", () => { expect(ceoAgent.role).toBe("ceo"); expect(ceoAgent.adapterType).toBe("process"); - // List issues for our company const issuesRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/issues` ); @@ -159,7 +124,6 @@ test.describe("Onboarding wizard", () => { expect(task.assigneeAgentId).toBe(ceoAgent.id); if (!SKIP_LLM) { - // LLM-dependent: wait for the heartbeat to transition the issue await expect(async () => { const res = await page.request.get( `${baseUrl}/api/issues/${task.id}` diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 5ae1b677..fd7e8b59 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ // The webServer directive starts `paperclipai run` before tests. // Expects `pnpm paperclipai` to be runnable from repo root. webServer: { - command: `pnpm paperclipai run --yes`, + command: `pnpm paperclipai run`, url: `${BASE_URL}/api/health`, reuseExistingServer: !!process.env.CI, timeout: 120_000, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ff1daecf..05aa5381 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef } from "react"; import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -40,6 +39,7 @@ import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; +import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return ( @@ -175,24 +175,13 @@ function LegacySettingsRedirect() { } function OnboardingRoutePage() { - const { companies, loading } = useCompany(); - const { onboardingOpen, openOnboarding } = useDialog(); + const { companies } = useCompany(); + const { openOnboarding } = useDialog(); const { companyPrefix } = useParams<{ companyPrefix?: string }>(); - const opened = useRef(false); const matchedCompany = companyPrefix ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null : null; - useEffect(() => { - if (loading || opened.current || onboardingOpen) return; - opened.current = true; - if (matchedCompany) { - openOnboarding({ initialStep: 2, companyId: matchedCompany.id }); - return; - } - openOnboarding(); - }, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]); - const title = matchedCompany ? `Add another agent to ${matchedCompany.name}` : companies.length > 0 @@ -227,19 +216,22 @@ function OnboardingRoutePage() { function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); - const { onboardingOpen } = useDialog(); + const location = useLocation(); if (loading) { return
Loading...
; } - // Keep the first-run onboarding mounted until it completes. - if (onboardingOpen) { - return ; - } - const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { + if ( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: location.pathname, + hasCompanies: false, + }) + ) { + return ; + } return ; } @@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() { const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { + if ( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: location.pathname, + hasCompanies: false, + }) + ) { + return ; + } return ; } @@ -267,16 +267,8 @@ function UnprefixedBoardRedirect() { ); } -function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) { +function NoCompaniesStartPage() { const { openOnboarding } = useDialog(); - const opened = useRef(false); - - useEffect(() => { - if (!autoOpen) return; - if (opened.current) return; - opened.current = true; - openOnboarding(); - }, [autoOpen, openOnboarding]); return (
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 88e16d09..dbce861e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; +import { useLocation, useNavigate, useParams } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { companiesApi } from "../api/companies"; @@ -30,6 +30,7 @@ import { } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { resolveRouteOnboardingOptions } from "../lib/onboarding-route"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { ChoosePathButton } from "./PathInstructionsModal"; import { HintIcon } from "./agent-config-primitives"; @@ -75,12 +76,29 @@ After that, hire yourself a Founding Engineer agent and then plan the roadmap an export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); - const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany(); + const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const location = useLocation(); + const { companyPrefix } = useParams<{ companyPrefix?: string }>(); + const [routeDismissed, setRouteDismissed] = useState(false); - const initialStep = onboardingOptions.initialStep ?? 1; - const existingCompanyId = onboardingOptions.companyId; + const routeOnboardingOptions = + companyPrefix && companiesLoading + ? null + : resolveRouteOnboardingOptions({ + pathname: location.pathname, + companyPrefix, + companies, + }); + const effectiveOnboardingOpen = + onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed); + const effectiveOnboardingOptions = onboardingOpen + ? onboardingOptions + : routeOnboardingOptions ?? {}; + + const initialStep = effectiveOnboardingOptions.initialStep ?? 1; + const existingCompanyId = effectiveOnboardingOptions.companyId; const [step, setStep] = useState(initialStep); const [loading, setLoading] = useState(false); @@ -134,27 +152,31 @@ export function OnboardingWizard() { const [createdAgentId, setCreatedAgentId] = useState(null); const [createdIssueRef, setCreatedIssueRef] = useState(null); + useEffect(() => { + setRouteDismissed(false); + }, [location.pathname]); + // Sync step and company when onboarding opens with options. // Keep this independent from company-list refreshes so Step 1 completion // doesn't get reset after creating a company. useEffect(() => { - if (!onboardingOpen) return; - const cId = onboardingOptions.companyId ?? null; - setStep(onboardingOptions.initialStep ?? 1); + if (!effectiveOnboardingOpen) return; + const cId = effectiveOnboardingOptions.companyId ?? null; + setStep(effectiveOnboardingOptions.initialStep ?? 1); setCreatedCompanyId(cId); setCreatedCompanyPrefix(null); }, [ - onboardingOpen, - onboardingOptions.companyId, - onboardingOptions.initialStep + effectiveOnboardingOpen, + effectiveOnboardingOptions.companyId, + effectiveOnboardingOptions.initialStep ]); // Backfill issue prefix for an existing company once companies are loaded. useEffect(() => { - if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return; + if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return; const company = companies.find((c) => c.id === createdCompanyId); if (company) setCreatedCompanyPrefix(company.issuePrefix); - }, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]); + }, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]); // Resize textarea when step 3 is shown or description changes useEffect(() => { @@ -171,7 +193,7 @@ export function OnboardingWizard() { ? queryKeys.agents.adapterModels(createdCompanyId, adapterType) : ["agents", "none", "adapter-models", adapterType], queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), - enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2 }); const isLocalAdapter = adapterType === "claude_local" || @@ -546,13 +568,16 @@ export function OnboardingWizard() { } } - if (!onboardingOpen) return null; + if (!effectiveOnboardingOpen) return null; return ( { - if (!open) handleClose(); + if (!open) { + setRouteDismissed(true); + handleClose(); + } }} > @@ -762,6 +787,12 @@ export function OnboardingWizard() { icon: Gem, desc: "Local Gemini agent" }, + { + value: "process" as const, + label: "Process", + icon: Terminal, + desc: "Run a local command" + }, { value: "opencode_local" as const, label: "OpenCode", diff --git a/ui/src/lib/onboarding-route.test.ts b/ui/src/lib/onboarding-route.test.ts new file mode 100644 index 00000000..2d2b25c2 --- /dev/null +++ b/ui/src/lib/onboarding-route.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + isOnboardingPath, + resolveRouteOnboardingOptions, + shouldRedirectCompanylessRouteToOnboarding, +} from "./onboarding-route"; + +describe("isOnboardingPath", () => { + it("matches the global onboarding route", () => { + expect(isOnboardingPath("/onboarding")).toBe(true); + }); + + it("matches a company-prefixed onboarding route", () => { + expect(isOnboardingPath("/pap/onboarding")).toBe(true); + }); + + it("ignores non-onboarding routes", () => { + expect(isOnboardingPath("/pap/dashboard")).toBe(false); + }); +}); + +describe("resolveRouteOnboardingOptions", () => { + it("opens company creation for the global onboarding route", () => { + expect( + resolveRouteOnboardingOptions({ + pathname: "/onboarding", + companies: [], + }), + ).toEqual({ initialStep: 1 }); + }); + + it("opens agent creation when the prefixed company exists", () => { + expect( + resolveRouteOnboardingOptions({ + pathname: "/pap/onboarding", + companyPrefix: "pap", + companies: [{ id: "company-1", issuePrefix: "PAP" }], + }), + ).toEqual({ initialStep: 2, companyId: "company-1" }); + }); + + it("falls back to company creation when the prefixed company is missing", () => { + expect( + resolveRouteOnboardingOptions({ + pathname: "/pap/onboarding", + companyPrefix: "pap", + companies: [], + }), + ).toEqual({ initialStep: 1 }); + }); +}); + +describe("shouldRedirectCompanylessRouteToOnboarding", () => { + it("redirects companyless entry routes into onboarding", () => { + expect( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: "/", + hasCompanies: false, + }), + ).toBe(true); + }); + + it("does not redirect when already on onboarding", () => { + expect( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: "/onboarding", + hasCompanies: false, + }), + ).toBe(false); + }); + + it("does not redirect when companies exist", () => { + expect( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: "/issues", + hasCompanies: true, + }), + ).toBe(false); + }); +}); diff --git a/ui/src/lib/onboarding-route.ts b/ui/src/lib/onboarding-route.ts new file mode 100644 index 00000000..425d1fb6 --- /dev/null +++ b/ui/src/lib/onboarding-route.ts @@ -0,0 +1,51 @@ +type OnboardingRouteCompany = { + id: string; + issuePrefix: string; +}; + +export function isOnboardingPath(pathname: string): boolean { + const segments = pathname.split("/").filter(Boolean); + + if (segments.length === 1) { + return segments[0]?.toLowerCase() === "onboarding"; + } + + if (segments.length === 2) { + return segments[1]?.toLowerCase() === "onboarding"; + } + + return false; +} + +export function resolveRouteOnboardingOptions(params: { + pathname: string; + companyPrefix?: string; + companies: OnboardingRouteCompany[]; +}): { initialStep: 1 | 2; companyId?: string } | null { + const { pathname, companyPrefix, companies } = params; + + if (!isOnboardingPath(pathname)) return null; + + if (!companyPrefix) { + return { initialStep: 1 }; + } + + const matchedCompany = + companies.find( + (company) => + company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(), + ) ?? null; + + if (!matchedCompany) { + return { initialStep: 1 }; + } + + return { initialStep: 2, companyId: matchedCompany.id }; +} + +export function shouldRedirectCompanylessRouteToOnboarding(params: { + pathname: string; + hasCompanies: boolean; +}): boolean { + return !params.hasCompanies && !isOnboardingPath(params.pathname); +}