test: harden onboarding route coverage

This commit is contained in:
dotta
2026-03-18 08:00:02 -05:00
parent cc1620e4fe
commit 2c05c2c0ac
6 changed files with 203 additions and 85 deletions

View File

@@ -22,14 +22,11 @@ const TASK_TITLE = "E2E test task";
test.describe("Onboarding wizard", () => { test.describe("Onboarding wizard", () => {
test("completes full wizard flow", async ({ page }) => { test("completes full wizard flow", async ({ page }) => {
// Navigate to root — should auto-open onboarding when no companies exist
await page.goto("/"); 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 wizardHeading = page.locator("h3", { hasText: "Name your company" });
const newCompanyBtn = page.getByRole("button", { name: "New Company" }); const newCompanyBtn = page.getByRole("button", { name: "New Company" });
// Wait for either the wizard or the start page
await expect( await expect(
wizardHeading.or(newCompanyBtn) wizardHeading.or(newCompanyBtn)
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
@@ -38,40 +35,28 @@ test.describe("Onboarding wizard", () => {
await newCompanyBtn.click(); await newCompanyBtn.click();
} }
// -----------------------------------------------------------
// Step 1: Name your company
// -----------------------------------------------------------
await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); 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"]'); const companyNameInput = page.locator('input[placeholder="Acme Corp"]');
await companyNameInput.fill(COMPANY_NAME); await companyNameInput.fill(COMPANY_NAME);
// Click Next
const nextButton = page.getByRole("button", { name: "Next" }); const nextButton = page.getByRole("button", { name: "Next" });
await nextButton.click(); await nextButton.click();
// -----------------------------------------------------------
// Step 2: Create your first agent
// -----------------------------------------------------------
await expect( await expect(
page.locator("h3", { hasText: "Create your first agent" }) page.locator("h3", { hasText: "Create your first agent" })
).toBeVisible({ timeout: 10_000 }); ).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"]'); const agentNameInput = page.locator('input[placeholder="CEO"]');
await expect(agentNameInput).toHaveValue(AGENT_NAME); await expect(agentNameInput).toHaveValue(AGENT_NAME);
// Claude Code adapter should be selected by default
await expect( await expect(
page.locator("button", { hasText: "Claude Code" }).locator("..") page.locator("button", { hasText: "Claude Code" }).locator("..")
).toBeVisible(); ).toBeVisible();
// Select the "Process" adapter to avoid needing a real CLI tool installed await page.getByRole("button", { name: "More Agent Adapter Types" }).click();
await page.locator("button", { hasText: "Process" }).click(); await page.getByRole("button", { name: "Process" }).click();
// Fill in process adapter fields
const commandInput = page.locator('input[placeholder="e.g. node, python"]'); const commandInput = page.locator('input[placeholder="e.g. node, python"]');
await commandInput.fill("echo"); await commandInput.fill("echo");
const argsInput = page.locator( const argsInput = page.locator(
@@ -79,52 +64,34 @@ test.describe("Onboarding wizard", () => {
); );
await argsInput.fill("hello"); await argsInput.fill("hello");
// Click Next (process adapter skips environment test)
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
// -----------------------------------------------------------
// Step 3: Give it something to do
// -----------------------------------------------------------
await expect( await expect(
page.locator("h3", { hasText: "Give it something to do" }) page.locator("h3", { hasText: "Give it something to do" })
).toBeVisible({ timeout: 10_000 }); ).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( const taskTitleInput = page.locator(
'input[placeholder="e.g. Research competitor pricing"]' 'input[placeholder="e.g. Research competitor pricing"]'
); );
await taskTitleInput.clear(); await taskTitleInput.clear();
await taskTitleInput.fill(TASK_TITLE); await taskTitleInput.fill(TASK_TITLE);
// Click Next
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
// -----------------------------------------------------------
// Step 4: Ready to launch
// -----------------------------------------------------------
await expect( await expect(
page.locator("h3", { hasText: "Ready to launch" }) page.locator("h3", { hasText: "Ready to launch" })
).toBeVisible({ timeout: 10_000 }); ).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=" + COMPANY_NAME)).toBeVisible();
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible(); await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
// Click "Open Issue" await page.getByRole("button", { name: "Create & Open Issue" }).click();
await page.getByRole("button", { name: "Open Issue" }).click();
// Should navigate to the issue page
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
// -----------------------------------------------------------
// Verify via API that entities were created
// -----------------------------------------------------------
const baseUrl = page.url().split("/").slice(0, 3).join("/"); const baseUrl = page.url().split("/").slice(0, 3).join("/");
// List companies and find ours
const companiesRes = await page.request.get(`${baseUrl}/api/companies`); const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesRes.ok()).toBe(true); expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json(); const companies = await companiesRes.json();
@@ -133,7 +100,6 @@ test.describe("Onboarding wizard", () => {
); );
expect(company).toBeTruthy(); expect(company).toBeTruthy();
// List agents for our company
const agentsRes = await page.request.get( const agentsRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/agents` `${baseUrl}/api/companies/${company.id}/agents`
); );
@@ -146,7 +112,6 @@ test.describe("Onboarding wizard", () => {
expect(ceoAgent.role).toBe("ceo"); expect(ceoAgent.role).toBe("ceo");
expect(ceoAgent.adapterType).toBe("process"); expect(ceoAgent.adapterType).toBe("process");
// List issues for our company
const issuesRes = await page.request.get( const issuesRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/issues` `${baseUrl}/api/companies/${company.id}/issues`
); );
@@ -159,7 +124,6 @@ test.describe("Onboarding wizard", () => {
expect(task.assigneeAgentId).toBe(ceoAgent.id); expect(task.assigneeAgentId).toBe(ceoAgent.id);
if (!SKIP_LLM) { if (!SKIP_LLM) {
// LLM-dependent: wait for the heartbeat to transition the issue
await expect(async () => { await expect(async () => {
const res = await page.request.get( const res = await page.request.get(
`${baseUrl}/api/issues/${task.id}` `${baseUrl}/api/issues/${task.id}`

View File

@@ -23,7 +23,7 @@ export default defineConfig({
// The webServer directive starts `paperclipai run` before tests. // The webServer directive starts `paperclipai run` before tests.
// Expects `pnpm paperclipai` to be runnable from repo root. // Expects `pnpm paperclipai` to be runnable from repo root.
webServer: { webServer: {
command: `pnpm paperclipai run --yes`, command: `pnpm paperclipai run`,
url: `${BASE_URL}/api/health`, url: `${BASE_URL}/api/health`,
reuseExistingServer: !!process.env.CI, reuseExistingServer: !!process.env.CI,
timeout: 120_000, timeout: 120_000,

View File

@@ -1,4 +1,3 @@
import { useEffect, useRef } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -40,6 +39,7 @@ import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext"; import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext"; import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox"; import { loadLastInboxTab } from "./lib/inbox";
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return ( return (
@@ -175,24 +175,13 @@ function LegacySettingsRedirect() {
} }
function OnboardingRoutePage() { function OnboardingRoutePage() {
const { companies, loading } = useCompany(); const { companies } = useCompany();
const { onboardingOpen, openOnboarding } = useDialog(); const { openOnboarding } = useDialog();
const { companyPrefix } = useParams<{ companyPrefix?: string }>(); const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const opened = useRef(false);
const matchedCompany = companyPrefix const matchedCompany = companyPrefix
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
: 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 const title = matchedCompany
? `Add another agent to ${matchedCompany.name}` ? `Add another agent to ${matchedCompany.name}`
: companies.length > 0 : companies.length > 0
@@ -227,19 +216,22 @@ function OnboardingRoutePage() {
function CompanyRootRedirect() { function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany(); const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog(); const location = useLocation();
if (loading) { if (loading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>; return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
} }
// Keep the first-run onboarding mounted until it completes.
if (onboardingOpen) {
return <NoCompaniesStartPage autoOpen={false} />;
}
const targetCompany = selectedCompany ?? companies[0] ?? null; const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) { if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />; return <NoCompaniesStartPage />;
} }
@@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() {
const targetCompany = selectedCompany ?? companies[0] ?? null; const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) { if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />; return <NoCompaniesStartPage />;
} }
@@ -267,16 +267,8 @@ function UnprefixedBoardRedirect() {
); );
} }
function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) { function NoCompaniesStartPage() {
const { openOnboarding } = useDialog(); const { openOnboarding } = useDialog();
const opened = useRef(false);
useEffect(() => {
if (!autoOpen) return;
if (opened.current) return;
opened.current = true;
openOnboarding();
}, [autoOpen, openOnboarding]);
return ( return (
<div className="mx-auto max-w-xl py-10"> <div className="mx-auto max-w-xl py-10">

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from "react"; import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { useLocation, useNavigate, useParams } from "@/lib/router";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { companiesApi } from "../api/companies"; import { companiesApi } from "../api/companies";
@@ -30,6 +30,7 @@ import {
} from "@paperclipai/adapter-codex-local"; } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal"; import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives"; 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() { export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany(); const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const [routeDismissed, setRouteDismissed] = useState(false);
const initialStep = onboardingOptions.initialStep ?? 1; const routeOnboardingOptions =
const existingCompanyId = onboardingOptions.companyId; 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<Step>(initialStep); const [step, setStep] = useState<Step>(initialStep);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -134,27 +152,31 @@ export function OnboardingWizard() {
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null); const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null); const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
useEffect(() => {
setRouteDismissed(false);
}, [location.pathname]);
// Sync step and company when onboarding opens with options. // Sync step and company when onboarding opens with options.
// Keep this independent from company-list refreshes so Step 1 completion // Keep this independent from company-list refreshes so Step 1 completion
// doesn't get reset after creating a company. // doesn't get reset after creating a company.
useEffect(() => { useEffect(() => {
if (!onboardingOpen) return; if (!effectiveOnboardingOpen) return;
const cId = onboardingOptions.companyId ?? null; const cId = effectiveOnboardingOptions.companyId ?? null;
setStep(onboardingOptions.initialStep ?? 1); setStep(effectiveOnboardingOptions.initialStep ?? 1);
setCreatedCompanyId(cId); setCreatedCompanyId(cId);
setCreatedCompanyPrefix(null); setCreatedCompanyPrefix(null);
}, [ }, [
onboardingOpen, effectiveOnboardingOpen,
onboardingOptions.companyId, effectiveOnboardingOptions.companyId,
onboardingOptions.initialStep effectiveOnboardingOptions.initialStep
]); ]);
// Backfill issue prefix for an existing company once companies are loaded. // Backfill issue prefix for an existing company once companies are loaded.
useEffect(() => { useEffect(() => {
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return; if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
const company = companies.find((c) => c.id === createdCompanyId); const company = companies.find((c) => c.id === createdCompanyId);
if (company) setCreatedCompanyPrefix(company.issuePrefix); if (company) setCreatedCompanyPrefix(company.issuePrefix);
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]); }, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
// Resize textarea when step 3 is shown or description changes // Resize textarea when step 3 is shown or description changes
useEffect(() => { useEffect(() => {
@@ -171,7 +193,7 @@ export function OnboardingWizard() {
? queryKeys.agents.adapterModels(createdCompanyId, adapterType) ? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType], : ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
}); });
const isLocalAdapter = const isLocalAdapter =
adapterType === "claude_local" || adapterType === "claude_local" ||
@@ -546,13 +568,16 @@ export function OnboardingWizard() {
} }
} }
if (!onboardingOpen) return null; if (!effectiveOnboardingOpen) return null;
return ( return (
<Dialog <Dialog
open={onboardingOpen} open={effectiveOnboardingOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) handleClose(); if (!open) {
setRouteDismissed(true);
handleClose();
}
}} }}
> >
<DialogPortal> <DialogPortal>
@@ -762,6 +787,12 @@ export function OnboardingWizard() {
icon: Gem, icon: Gem,
desc: "Local Gemini agent" desc: "Local Gemini agent"
}, },
{
value: "process" as const,
label: "Process",
icon: Terminal,
desc: "Run a local command"
},
{ {
value: "opencode_local" as const, value: "opencode_local" as const,
label: "OpenCode", label: "OpenCode",

View File

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

View File

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