test: harden onboarding route coverage
This commit is contained in:
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <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;
|
||||
if (!targetCompany) {
|
||||
if (
|
||||
shouldRedirectCompanylessRouteToOnboarding({
|
||||
pathname: location.pathname,
|
||||
hasCompanies: false,
|
||||
})
|
||||
) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
return <NoCompaniesStartPage />;
|
||||
}
|
||||
|
||||
@@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() {
|
||||
|
||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||
if (!targetCompany) {
|
||||
if (
|
||||
shouldRedirectCompanylessRouteToOnboarding({
|
||||
pathname: location.pathname,
|
||||
hasCompanies: false,
|
||||
})
|
||||
) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
return <NoCompaniesStartPage />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
|
||||
@@ -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<Step>(initialStep);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -134,27 +152,31 @@ export function OnboardingWizard() {
|
||||
const [createdAgentId, setCreatedAgentId] = 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.
|
||||
// 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 (
|
||||
<Dialog
|
||||
open={onboardingOpen}
|
||||
open={effectiveOnboardingOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
if (!open) {
|
||||
setRouteDismissed(true);
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogPortal>
|
||||
@@ -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",
|
||||
|
||||
80
ui/src/lib/onboarding-route.test.ts
Normal file
80
ui/src/lib/onboarding-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
ui/src/lib/onboarding-route.ts
Normal file
51
ui/src/lib/onboarding-route.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user