mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source control plane for agent companies. > - People choose a harness when creating an agent. > - The server should resolve that harness to a qualified runner. > - Codex is the first harness in the staged default rollout. > - Saved agents must retain their execution choices. > - New Codex agents therefore use Paperclip Runner on qualified macOS/Linux targets, with an explicit Legacy override. ## Linked Issues or Issue Description Refs #16028, #15555, #15673, #15422, #15631, #15926, #15932, #16149. The runtime/install prerequisite and release repair are merged. This slice owns Codex creation, setup, configuration and preservation. Other harness defaults remain unchanged. It adds no database migration; incoming master migrations, required maps and regression coverage are retained. ## What Changed - Resolve `runner: auto | paperclip | legacy` at the common creation boundary across onboarding, API/CLI, hires, built-ins, plugins and imports. Freeze pending hires and export resolved execution choices. - Preserve existing runners on upgrades, unrelated edits and import updates, including compatible accounts, workspace settings, Codex effort and intentional clears. - Use the underlying harness picker and branding; put the runner override in Advanced. Use the shared dropdown and update production-component stories and creation contracts. - Support qualified Apple Silicon, Intel Mac, Linux x64 and Linux ARM64. Linux requires GNU libc 2.39+. Disabled, external, unsupported and unqualified combinations retain their applicable Legacy execution. - Test the selected runtime/account/model/environment. Ordinary Connect probes eligible immutable managed targets and refreshes scoped discovery. Creation/discovery do not provision. Auth, dependency, incompatible-setting and cleanup failures remain actionable without changing runners. - Keep setup probes read-only, preserve local/SSH isolation and resolve packaged assets before staging credentials. Bound cleanup reads by the remaining absolute deadline; physically close every owned ingress socket before endpoint release. Unconfirmed cleanup retains recovery state. - Preserve validated completed-onboarding callbacks through startup recovery. First file-producing work belongs to the child task and uses the existing dependency/block handoff and task-scoped registration. - Reuse existing E2E fixtures, artifact checks, reporting and controls. Keep Docker/Cargo/native compilation remote. Paperclip tarballs contain no Codex payloads; official optional npm platform dependencies remain. ## Verification Current tested head: `1c03a8815672d82c264a9b08d4ea1984e8fae107`. **Merge-ready: required CI, fresh review, package qualification, Mac core acceptance and Test task/follow-up acceptance pass.** The existing attachment and native-continuity oracles independently verify both Test downloads, run attribution and unchanged native/provider/workspace identities. The maintainer authorized a code-owner-review exception for this PR only. The first Test setup failure and normal Board completion-review friction are retained below. Actual post-merge packages and private Cloud distribution remain separate qualification gates. - [Current CI](https://github.com/paperclipai/paperclip/actions/runs/38564241625): all 48 ordinary jobs and required verify/E2E pass. Attempt 1 retained six simultaneous host-shutdown cancellations. One failed-only retry passes those six lanes and two aggregates, preserving 40 successes. Actual integration checkout `53104c1` combines this head with master `c80`; it is not relabeled as the branch tree. Fresh exact-head Greptile 5/5, 30 resolved threads, no actionable findings. - [Exact-source package qualification](https://github.com/paperclipai/paperclip/actions/runs/38565287239): nine applicable jobs pass, nine conditional jobs skip. Four remote native producers; Linux x64 clean install; Intel Mac, Apple Silicon and Linux ARM64 native startup/physical cleanup controls. All 18 tarballs/four daemon source/hash/manifest bindings and 343 migrations through 0344 verify. No Codex payload is packed. These hosted tarballs are not a published npm canary. - Normal Apple Silicon installation and credential-free native startup/cleanup pass using the packaged daemon, with no source build or provider turn. Fresh installed DB initialization passes. Upgrade 651a→1c03 preserves all three complete execution configs, accounts, ten recorded run profiles, paused budgets, active incidents, cost summaries and two historical unpriced events. Historical recovery state is retained. - Cleanup regressions: four delayed-read baseline failures become 15 passing deadline/authority controls. No-acknowledgement and prior-error socket ownership regressions become 12 passing controls. Direct runtime/server types, maps: 35 areas / 176 IDs, diff checks and independent reviews pass. Permission/auth/reconnect policy and the 10-second setup budget remain intact. - Existing source coverage includes creation/selection/imports, pending approvals, recovery, account/lifecycle handling, mounted forms, dropdowns and E2E support. Full typechecks/tests/build/token and applicable Storybook checks pass in the recorded current CI; no duplicate suite or weakened grader. - Literal 9546 live Mac proof: ordinary Auto Codex onboarding, useful document, real completed-child callback/reply, seven successful native runs, manual follow-up and independently downloaded1,101-byte child file. Existing Legacy title edit/reload preserves execution; two real Legacy runs produce independently checked15/34-byte files. These remain historical source records. - Current literal 1c03 Mac core acceptance passes independent checks: ordinary Auto native Codex onboarding without runner override; only timeout 180 seconds/concurrency 1 saved; six successful native runs and six serialized turns (maximum overlap 1); both same-task continuity checks and five mechanical callback checks; actual rendered parent reply and exact child downloads. The useful handoff advances from revision 1 to 2. Both files are 48 bytes, SHA-256 `8b742bd4e8123a7c078f0a8d9f3449fbfa8a320d7d90be641a2818db3c53d92c` and `f42db9774fa06e804ab7d596fc8b922a2b638921c418b87d0111151d290a6afe`; exactly one byte changes and old history remains. The agent is paused with zero active runs/errors. Both artifact work products have `isPrimary=false`, and descriptive titles fail a stricter title-helper assumption; these retained results are scoped editorial follow-up, not new merge gates. Six source-bound screenshots are uploaded and verified in the private primary QA report. - [Literal 9546 managed proof](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html): original 19 assertions and completed judge pass, four native turns, child-bound240-byte file and session/workspace continuity. Checkout reports a verified generated target-lock mutation; full final porcelain was not retained. Signed execution-image identity remains separate from Core source. - Actual 651a Test deployment succeeds on one owned/pinned target. One ordinary Auto API-key Connect then fails native cleanup before hire. Controller turn/drain/suspend completion and service-recorded environment cleanup are observed; they do not certify physical deletion or native authority retirement. Failure/recovery/unknown holds are retained. No Legacy switch or unchanged paid retry. The two source repairs above require actual treatment. - [Current immutable Core preview](https://github.com/paperclipai/paperclip/actions/runs/38570365725) and matching DB/shared artifacts pass. One owned, pinned Test target serves exact `1c03`; identical migration history correctly skips the migrator. Ordinary Auto Codex Connect succeeds and hires native; saved native selection and QA bounds survive reload. The initial child preparation attempt fails before provider work, with actual stdout identifying `renewal_deadline`; its trigger remains unknown and the failure is retained. Normal Board **Try again** and **Resume** recover the child to Done, producing a browser-verified 44-byte file, SHA-256 `0a8458b1efcc2d7a0d815689458c4caab039a46c7c7a3e5805de6bc8b1a96ecc`. The actual parent callback reports the completed child document/file. The existing validator requires a parent-owned attachment, so recovery enters normal Review; after byte checking, normal **Approve completion** reaches parent Done. No forced status or duplicate parent file is used. The validator is unchanged from integrated and current master. Autonomous initial callback-to-Done is not proven. The same-child follow-up completes document revision 2 and publishes a second independently downloaded 44-byte file, SHA-256 `22022257300fae762784632d08d3bbe0bab61e2ce81f91864a14dcd7aa464591`; only the revision digit changes. The unchanged existing native-continuity and attachment oracles pass. All ten recorded runs are terminal: nine native successes and the retained pre-provider setup failure. The QA agent is paused with zero active runs. The pre-existing parent evidence scope mismatch is a separate follow-up, with ordinary completion review exercised rather than a forced status or relaxed validation. This unsigned Core preview remains distinct from post-merge private Cloud composition. ## Risks - Saved agents are not migrated. Explicit runner changes use normal configuration/session rules; historical runs retain their recorded execution. - Native readiness depends on verified assets and server-owned target qualification. A Legacy probe, HTTP200 or successful account save cannot prove native readiness. - Recovery exceptions remain restricted to validated completed-onboarding reports; process-loss, ownership and terminal-state safeguards remain active. Unexecuted live journeys stay open. - Linux ARM paid tasks, Windows, Alpine and 32-bit ARM remain deferred. Cold Cargo cache misses are recorded; no warm-cache speedup is claimed. - Historical invoices/physical cleanup remain unconfirmed and held. After merge, exact published npm canary, signed standard image/migrator, private composition and serving/live acceptance still require qualification. Production deployment/default promotion is not authorized. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tools, code execution and parallel agents. Exact serving snapshot and context-window size are not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with available 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 linked public issues or PRs above - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run the stated focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green on the current candidate - [x] Greptile is 5/5 with no open findings on the current candidate - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
632 lines
25 KiB
TypeScript
632 lines
25 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection";
|
|
|
|
/**
|
|
* 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));
|
|
|
|
// `--data-dir` starts a new server, not a new browser profile. Keep a
|
|
// resumable draft here so this ordinary browser condition is covered when
|
|
// the company-list invalidation runs after the create request.
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("paperclip-onboarding-state", JSON.stringify({
|
|
step: 1,
|
|
companyName: "",
|
|
createdCompanyId: null,
|
|
}));
|
|
});
|
|
let delayCompanyListRefetch = false;
|
|
await page.route("**/api/companies", async (route) => {
|
|
if (delayCompanyListRefetch && route.request().method() === "GET") {
|
|
// Keep the invalidation observable: a background fetch must not reset
|
|
// the in-progress wizard.
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
await route.continue();
|
|
});
|
|
|
|
// 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);
|
|
delayCompanyListRefetch = true;
|
|
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("connect step starts the sign-in when the source is chosen, rather than hiring, when the signal reports no credential", 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" }),
|
|
}),
|
|
);
|
|
|
|
// The sign-in Connect now starts. Stubbed so the card is deterministic:
|
|
// the session start answers, and the guarded prompt read hands back an
|
|
// authorization URL for the card's link row.
|
|
await page.route("**/setup-token-login-sessions", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
sessionId: "e2e-setup-token-session",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
}),
|
|
}),
|
|
);
|
|
await page.route("**/setup-token-login-sessions/*/prompt", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
authorizationUrl: "https://claude.ai/oauth/authorize?code=true&client=e2e",
|
|
transportAdvisory: null,
|
|
}),
|
|
}),
|
|
);
|
|
await page.route("**/setup-token-login-sessions/e2e-setup-token-session", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
sessionId: "e2e-setup-token-session",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// 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). By role rather than by label, because which
|
|
// adapters the row offers depends on the registry this environment reports.
|
|
const source = page.getByRole("radio").first();
|
|
await source.waitFor({ timeout: 30_000 });
|
|
|
|
// Nothing before the row is answered: the card is the answer to the tile,
|
|
// and the button has nothing to do until there is a source to do it with.
|
|
const cardInstruction = page.getByText("then come back and enter authorization code");
|
|
await expect(cardInstruction).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled();
|
|
|
|
// Answering the row is what starts the sign-in — this is the ordering the
|
|
// step exists to enforce, since a hire here would file an agent with no
|
|
// credential to run on. It is also the whole of the interaction: there is
|
|
// no second press between choosing a source and being signed in.
|
|
await source.click();
|
|
|
|
await expect(cardInstruction).toBeVisible({ timeout: 30_000 });
|
|
// One destination, two ways to it: the card's own link for anyone
|
|
// finishing in another browser, and the step's button for the flow.
|
|
const cardLink = page.getByRole("link", { name: /^Sign in to / });
|
|
await expect(cardLink).toBeVisible({ timeout: 15_000 });
|
|
await expect(cardLink).toHaveAttribute("href", /claude\.ai\/oauth\/authorize/);
|
|
await expect(
|
|
page.getByRole("button", { name: /^Sign in to / }),
|
|
).toBeEnabled({ timeout: 15_000 });
|
|
// No "Use saved login" here: the hire step applies a stored login itself.
|
|
await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0);
|
|
expect(hireCalled).toBe(false);
|
|
|
|
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
|
});
|
|
test("a reload during a login resumes the same session", async ({ page }) => {
|
|
const pageErrors: string[] = [];
|
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
const loginSessionReads: string[] = [];
|
|
page.on("request", (request) => {
|
|
const match = new URL(request.url()).pathname.match(/\/setup-token-login-sessions\/([^/]+)(?:\/prompt)?$/);
|
|
if (match && match[1] !== "active") loginSessionReads.push(match[1]);
|
|
});
|
|
let hireCalls = 0;
|
|
await page.route("**/agent-hires", (route) => {
|
|
hireCalls += 1;
|
|
return route.fulfill({ status: 400, json: { error: "Unexpected hire while login is pending." } });
|
|
});
|
|
|
|
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
|
|
data: { enableConferenceRoomChat: true },
|
|
});
|
|
expect(flagRes.ok()).toBe(true);
|
|
|
|
const FAKE_SANDBOX_ENVIRONMENT_ID = "a6d2e530-1e8b-4f0b-9c94-42f1d23e8750";
|
|
const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider-reload";
|
|
|
|
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 });
|
|
});
|
|
|
|
await page.route("**/adapters/*/auth-signal*", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ status: "absent" }),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/adapters?**", async (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (url.searchParams.get("environmentId") !== FAKE_SANDBOX_ENVIRONMENT_ID) return route.continue();
|
|
url.searchParams.delete("environmentId");
|
|
const response = await route.fetch({ url: url.toString() });
|
|
expect(response.ok()).toBe(true);
|
|
await route.fulfill({ response });
|
|
});
|
|
|
|
const SESSION_ID = "e2e-reload-setup-token-session";
|
|
const AUTHORIZATION_URL = "https://claude.ai/oauth/authorize?code=true&client=e2e-reload";
|
|
let startCalls = 0;
|
|
// Flips once the login actually starts, so the owner-scoped resume read
|
|
// below answers "no active session" until then — matching the real
|
|
// route, and keeping the pre-Connect part of this test the same as the
|
|
// ordinary sign-in test above.
|
|
let sessionStarted = false;
|
|
const resumedCompanyIds: string[] = [];
|
|
let aiConnection: Record<string, unknown> | undefined;
|
|
await page.route("**/setup-token-login-sessions", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
startCalls += 1;
|
|
sessionStarted = true;
|
|
aiConnection = route.request().postDataJSON().aiConnection;
|
|
}
|
|
return route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
sessionId: SESSION_ID,
|
|
environmentId: FAKE_SANDBOX_ENVIRONMENT_ID,
|
|
aiConnection,
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
}),
|
|
});
|
|
});
|
|
await page.route(`**/setup-token-login-sessions/${SESSION_ID}/prompt`, (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
authorizationUrl: AUTHORIZATION_URL,
|
|
transportAdvisory: null,
|
|
}),
|
|
}),
|
|
);
|
|
await page.route(`**/setup-token-login-sessions/${SESSION_ID}`, (route) => {
|
|
if (route.request().method() !== "GET") return route.continue();
|
|
return route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
sessionId: SESSION_ID,
|
|
environmentId: FAKE_SANDBOX_ENVIRONMENT_ID,
|
|
aiConnection,
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
}),
|
|
});
|
|
});
|
|
// The owner-scoped resume read. Both the wizard's own step-restore effect
|
|
// and the panel's own resume-on-mount read use this, with no session id
|
|
// in the URL — the caller rediscovers its own session.
|
|
await page.route("**/setup-token-login-sessions/active", (route) => {
|
|
if (!sessionStarted) {
|
|
return route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Setup-token login session not found." }),
|
|
});
|
|
}
|
|
resumedCompanyIds.push(new URL(route.request().url()).pathname.split("/")[3]);
|
|
return route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
sessionId: SESSION_ID,
|
|
environmentId: FAKE_SANDBOX_ENVIRONMENT_ID,
|
|
aiConnection,
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
panelMode: "submitted_browser_code",
|
|
prompt: { authorizationUrl: AUTHORIZATION_URL, transportAdvisory: null },
|
|
}),
|
|
});
|
|
});
|
|
|
|
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}-reload`);
|
|
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();
|
|
|
|
const source = page.getByRole("radio").first();
|
|
await source.waitFor({ timeout: 30_000 });
|
|
|
|
// Answering the row is what starts the sign-in now — there is no separate
|
|
// Connect press between choosing a source and being signed in.
|
|
await source.click();
|
|
|
|
const cardInstruction = page.getByText("then come back and enter authorization code");
|
|
const authorizationLink = page.getByRole("link", { name: /^Sign in to / });
|
|
|
|
await expect(cardInstruction).toBeVisible({ timeout: 30_000 });
|
|
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
|
|
await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL);
|
|
expect(startCalls).toBe(1);
|
|
|
|
const companiesRes = await page.request.get("/api/companies");
|
|
expect(companiesRes.ok()).toBe(true);
|
|
const companies = await companiesRes.json();
|
|
const company = companies.find(
|
|
(c: { name: string }) => c.name === `${COMPANY_NAME}-reload`,
|
|
);
|
|
expect(company, "the created company should exist").toBeTruthy();
|
|
|
|
const savedSelection = {
|
|
step: 4,
|
|
companyName: `${COMPANY_NAME}-reload`,
|
|
agentName: "Ada",
|
|
adapterType: "claude_local",
|
|
runner: "auto",
|
|
credentialMode: "subscription",
|
|
credentialModeChoice: null,
|
|
createdCompanyId: company.id,
|
|
createdCompanyPrefix: company.issuePrefix,
|
|
createdAgentId: null,
|
|
};
|
|
const readDraft = () => page.evaluate(() => JSON.parse(localStorage.getItem("paperclip-onboarding-state") ?? "null"));
|
|
await expect.poll(readDraft).toMatchObject(savedSelection);
|
|
const assertResumedLogin = async () => {
|
|
await expect(page.getByRole("heading", { name: "Connect a model", exact: true })).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.getByText("Ada", { exact: true })).toBeVisible();
|
|
await expect(page.getByRole("radio", { name: "Claude Subscription", exact: true })).toBeChecked();
|
|
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
|
|
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
|
|
await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL);
|
|
await expect.poll(readDraft).toMatchObject(savedSelection);
|
|
expect(startCalls, "reopening must resume the session without a second source choice").toBe(1);
|
|
expect(hireCalls, "a pending login must not hire an agent").toBe(0);
|
|
expect(loginSessionReads.length).toBeGreaterThan(0);
|
|
expect(new Set(loginSessionReads)).toEqual(new Set([SESSION_ID]));
|
|
expect(new Set(resumedCompanyIds)).toEqual(new Set([company.id]));
|
|
};
|
|
|
|
const beforeReopenReads = resumedCompanyIds.length;
|
|
await page.goto(`/${company.issuePrefix}/onboarding`);
|
|
await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReopenReads);
|
|
await assertResumedLogin();
|
|
|
|
const beforeReloadReads = resumedCompanyIds.length;
|
|
await page.reload();
|
|
await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReloadReads);
|
|
await assertResumedLogin();
|
|
|
|
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
|
});
|
|
|
|
test("connect step blocks the hire when the environment probe fails after account connection", async ({
|
|
page,
|
|
}) => {
|
|
// A successful local account connection still requires a passing
|
|
// environment probe before the wizard may hire the agent.
|
|
await mockOnboardingLocalAiConnection(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);
|
|
|
|
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}-probe-gate`);
|
|
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();
|
|
|
|
const source = page.getByRole("radio").first();
|
|
await source.waitFor({ timeout: 30_000 });
|
|
await source.click();
|
|
|
|
const connect = page.getByRole("button", { name: "Connect", exact: true });
|
|
await expect(connect).toBeEnabled({ timeout: 30_000 });
|
|
await connect.click();
|
|
|
|
// The failed probe 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);
|
|
});
|
|
});
|