Files
PaperClipAI/tests/e2e/onboarding.spec.ts
T
Nicky LeachandPaperclip d9449e636e feat(onboarding): sign in to an agent provider during onboarding (#12440)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - New organizations create their first agent through the onboarding
wizard
> - The wizard does not show provider sign-in when a host credential is
absent or unknown
> - The create step also gives unclear feedback when the provider needs
authentication
> - This pull request adds a safe auth signal and a provider sign-in
step for sandbox drivers
> - The benefit is a clearer onboarding path with no token or account
data in the signal

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (server API, shared types, and UI)

**Problem or motivation**

The onboarding wizard can fail when the selected provider needs
authentication. It does not tell the person how to complete sign-in.

**Proposed solution**

Add a status-only provider auth signal. Show the sign-in panel for
sandbox drivers when the signal says `absent` or `unknown`. Apply a
stored Claude login to the new agent and block creation when the adapter
test reports missing authentication.

**Alternatives considered**

The wizard could hide the sign-in panel when the signal read fails. This
would hide a needed action, so this pull request shows the panel when
the signal is unknown.

**Roadmap alignment**

The change supports the roadmap goal for scoped and audited credential
bindings.

**Additional context**

The auth signal returns only `present`, `absent`, or `unknown`. It never
returns a token, identifier, or account name.

## What Changed

- Add `GET /api/companies/:companyId/adapters/:type/auth-signal` with
company and permission checks.
- Add shared auth-signal types and the UI query path.
- Apply a stored Claude login by reference without reading its token.
- Show the provider sign-in panel only for sandbox drivers with
interactive terminal support.
- Block agent creation when the provider test reports missing
authentication.
- Add route, wizard, and end-to-end test coverage.

## Verification

- `pnpm --filter @paperclipai/server test adapter-auth-signal-routes`
passes 50 tests.
- `pnpm --filter @paperclipai/ui test OnboardingWizard` passes 69 tests.
- `pnpm --filter @paperclipai/ui exec tsc --noEmit` exits with code 0.
- The `e2e_shards` lane runs `tests/e2e/onboarding.spec.ts`.

## Risks

The route reads a host-local readiness signal. It returns `unknown` on
read errors and never exposes credential data. The UI may add a sign-in
step when the signal is unavailable.

## Model Used

OpenAI Codex, GPT-5, extended reasoning, tool use, and code execution.
The exact context window was not provided.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-28 10:20:37 -07:00

241 lines
9.4 KiB
TypeScript

import { test, expect } from "@playwright/test";
/**
* E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard).
*
* The wizard now opens on a front door (path picker) and the "Create a new
* company" path runs:
* Step 0 — Front door (Create a new company / Level up existing)
* Step 1a — Name your organization (creates the company)
* Step 2 — Hire your team lead (adapter picker)
* Step 3+ — Launch celebration → CEO chat → hiring plan → orientation
*
* This test covers the deterministic, LLM-free core: it drives the front door
* through company naming (which creates the company) and verifies the wizard
* advances to the team-lead step without asking for a mission.
*
* The tail (CEO chat at step 4, hiring-plan generation at step 5, final
* landing) depends on a live LLM and is verified separately during manual /
* LLM-backed QA — see PAP-50. Surface-level rendering of every step is
* snapshotted by nux-phase4-screenshots.spec.ts.
*/
const COMPANY_NAME = `E2E-Test-${Date.now()}`;
test.describe("Onboarding wizard", () => {
test("create-company path: naming creates the company, and no goal is invented", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
// experimental flag on for this throwaway instance before driving them.
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
expect(flagRes.ok()).toBe(true);
await page.goto("/onboarding");
// The wizard may open on a launcher card or directly on the capsule
// wizard; the front door (step 0) requires a click into the create path.
const startBtn = page.getByRole("button", {
name: /Start Onboarding|New Organization|Add Agent/,
});
if (await startBtn.count()) {
await startBtn.first().click();
}
const createCard = page.getByRole("button", { name: /Build a new organization/ });
if (await createCard.count()) {
await createCard.first().click();
}
// Step 1 — Name your organization.
await expect(
page.getByRole("heading", { name: "What is the name of your organization?" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("e.g. Northwind Labs").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Continue/ }).click();
// Step 1's "Next" now creates the company and goes straight to the agent.
// The mission step used to sit between them and do the creating; onboarding
// no longer asks for the mission, which is collected later in the app.
await page.waitForSelector("#onboarding-agent-name", {
timeout: 30_000,
});
// Verify the company + company-level goal were persisted.
const baseUrl = page.url().split("/").slice(0, 3).join("/");
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
const company = companies.find(
(c: { name: string }) => c.name === COMPANY_NAME,
);
expect(company, `company ${COMPANY_NAME} should exist`).toBeTruthy();
// And no company-level goal, which is the point rather than an omission.
// Onboarding no longer asks for a mission, so writing one here would mean
// inventing a goal the customer never chose. The mission is collected later
// in the app, and the absence is what leaves room for it.
const goalsRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/goals`,
);
expect(goalsRes.ok()).toBe(true);
const goals = await goalsRes.json();
const companyGoal = (Array.isArray(goals) ? goals : []).find(
(g: { level?: string }) => g.level === "company",
);
expect(
companyGoal,
"onboarding must not invent a mission the customer never gave",
).toBeFalsy();
// The expanded wizard must not crash the app (Rules-of-Hooks regression).
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
test("adapter step shows the login panel from the cheap auth signal, and blocks the hire on a failed test", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
expect(flagRes.ok()).toBe(true);
// The login panel's capability gate requires a sandbox environment with a
// login-capable provider, and this throwaway instance has neither: it
// only auto-creates the local environment. Add one fake sandbox
// environment to the real list, make it the instance default, and declare
// its provider's login pseudo-terminal capability — this reproduces the
// gate a real sandbox-backed instance would already pass, without
// changing any other field the rest of the page depends on.
const FAKE_SANDBOX_ENVIRONMENT_ID = "e2e-fake-sandbox-environment";
const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider";
await page.route("**/environments", async (route) => {
const response = await route.fetch();
const environments = await response.json();
environments.push({
id: FAKE_SANDBOX_ENVIRONMENT_ID,
name: "E2E fake sandbox",
description: null,
driver: "sandbox",
status: "active",
config: { provider: FAKE_SANDBOX_PROVIDER },
envVars: {},
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await route.fulfill({ response, json: environments });
});
await page.route("**/environments/capabilities", async (route) => {
const response = await route.fetch();
const capabilities = await response.json();
capabilities.sandboxProviders[FAKE_SANDBOX_PROVIDER] = {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: false,
supportsInteractiveSetup: false,
interactiveSetupConnectionTypes: [],
supportsTemplateCapture: false,
supportsTemplateDelete: false,
supportsLoginPty: true,
source: "plugin",
};
await route.fulfill({ response, json: capabilities });
});
await page.route("**/instance/settings", async (route) => {
const response = await route.fetch();
const settings = await response.json();
settings.defaultEnvironmentId = FAKE_SANDBOX_ENVIRONMENT_ID;
await route.fulfill({ response, json: settings });
});
// Report no ready credential, so the wizard shows the login panel right
// after the adapter is picked, before it ever runs an adapter test.
await page.route("**/adapters/*/auth-signal*", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "absent" }),
}),
);
// Fail the adapter test the "Connect" button runs, so the hire gate
// blocks the create and this test can prove no agent is hired.
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
adapterType: "claude_local",
status: "fail",
checks: [
{
code: "claude_cli_not_found",
level: "fail",
message: "The claude CLI was not found on this host.",
},
],
testedAt: new Date().toISOString(),
}),
}),
);
let hireCalled = false;
await page.route("**/agent-hires", (route) => {
hireCalled = true;
return route.continue();
});
await page.goto("/onboarding");
const startBtn = page.getByRole("button", {
name: /Start Onboarding|New Organization|Add Agent/,
});
if (await startBtn.count()) {
await startBtn.first().click();
}
const createCard = page.getByRole("button", { name: /Build a new organization/ });
if (await createCard.count()) {
await createCard.first().click();
}
await expect(
page.getByRole("heading", { name: "What is the name of your organization?" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("e.g. Northwind Labs").fill(`${COMPANY_NAME}-auth-signal`);
await page.getByRole("button", { name: /^Continue/ }).click();
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
await page.locator("#onboarding-agent-name").fill("Ada");
await page.getByRole("button", { name: "Next" }).click();
// Step 4 (Connect a model): the default adapter is claude_local, and the
// signal above reports no ready credential, so the login panel must show
// with no button to reuse a saved login.
await expect(page.getByText("Sign in to the environment")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0);
await page.getByRole("button", { name: /^Connect/ }).click();
// The failed test blocks the hire and shows its own checks.
await expect(page.getByText("The claude CLI was not found on this host.")).toBeVisible({
timeout: 15_000,
});
expect(hireCalled).toBe(false);
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
});