Files
PaperClipAI/tests/runner-e2e/product-journeys/cloud-onboarding.test.ts
T
DottaandPaperclip 970127a022 test: retain onboarding Connect failure evidence (#17529)
## Thinking Path

> - Paperclip manages agents and their work through normal company
controls.
> - Product E2E journeys check these controls through the browser and
public API.
> - Cloud onboarding uses one Connect action to validate the environment
and hire the first agent.
> - A failed Connect action previously left only a general journey error
in the report.
> - This pull request retains a closed diagnostic receipt for that
action.
> - The report can show the observed failure boundary without retaining
private response text.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Cloud onboarding Product E2E failure evidence and report detail.

**Current behavior**

A validation or hire failure can reach the journey as a general
operation failure. The report does not retain which Connect response
failed.

**Proposed behavior**

Observe only the same-origin, same-company validation and hire POST
requests made during one Connect action. Retain a closed receipt with
the checkpoint, response state, bounded HTTP status, and allowlisted
adapter, outcome, check code, level, and server code. The Get started
control and saved-agent readback remain required.

**Reason and benefit**

The next failure can be attributed to the exact observed browser/API
boundary. This change does not claim the cause of a historical failure.

**Breaking changes**

The new receipt is optional. Historical results remain valid. Explicit
validation and hire rejections now stop the journey with a precise
closed error code. This changes only E2E source; it does not change
product behavior or qualification gates.

## What Changed

- Add a bounded Connect observer with exact request matching and
listener cleanup.
- Retain known server codes from `details.code` or `code` without
response messages, hints, headers, request bodies, provider text, or
credentials.
- Validate the receipt as a closed schema and render its allowlisted
fields in existing report detail.
- Include the new helper in the journey definition hash.
- Add route, privacy, malformed-body, deadline, late-response,
delayed-failure, success, and historical compatibility tests. Document
the evidence boundary.

## Verification

- Focused Product E2E tests passed: 3 files, 58 tests on the review-fix
head.
- E2E typecheck, workspace typecheck, and full build passed on the
preceding implementation head. The review fix changes only report
labels, checkpoint timing, and the matching tests.
- The local full `pnpm test:run` command stopped progressing at the
general-server start header. Its owned session was interrupted. This is
not recorded as a pass.
- All 58 remote checks completed on the final review-fix head: 55
passed, one neutral, and two expected skips. Both Greptile and Paperclip
reviews are 5/5 with no unresolved review threads.
- No live campaign was run with this patch. The observed historical
failure is preserved.

## Risks

- Unknown or missing responses remain explicit diagnostic states. They
do not establish a product root cause.
- Response body reads stop after five seconds or the action deadline and
accept at most 64 KiB of JSON. Checks are capped at 32 retained entries.
- New server or check codes appear as `unrecognized` until the closed
allowlist is updated.
- The production launch classifier, native gateway qualification, and
Get started requirement are unchanged.

## Model Used

OpenAI Codex (GPT-6 family), with code execution and tool use. The host
does not expose the exact model ID or context window to this run; these
values are unavailable.

## 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-10-24 21:32:42 -07:00

163 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { cloudOnboarding, type CloudOnboardingInput } from "./cloud-onboarding.js";
import { onboardingFirstTask } from "./onboarding-first-task.js";
import { retainOnboardingArtifact } from "./artifact-evidence.js";
vi.mock("@playwright/test", () => ({ expect: (locator: { isVisible(): Promise<boolean> }) => ({
toBeVisible: async () => { expect(await locator.isVisible()).toBe(true); },
not: { toBeVisible: async () => { expect(await locator.isVisible()).toBe(false); } },
}) }));
vi.mock("./onboarding-first-task.js", async () => {
const { retainOnboardingArtifact } = await import("./artifact-evidence.js");
return { onboardingFirstTask: vi.fn(async ({ proof, onArtifact }) => {
const artifact = retainOnboardingArtifact({ id: "artifact", originatingRunId: "artifact-run", bytes: Buffer.from(JSON.stringify(proof.expected)), proof });
onArtifact?.(artifact);
return { artifact };
}) };
});
type Entry = "first-run" | "portfolio" | "create-form";
type Control = { role: string; name: string; scope?: string; fill?(value: string): void; click?(): void; select?(value: string): void };
/** Visible control contracts only; no browser, Cloud API, or provider is started. */
function fixture(entry: Entry, options: { intentFailure?: boolean; qualificationFailure?: boolean; traceFailure?: boolean } = {}) {
let stage: string = entry, name = "", apiMode = false, source = false, advanced = false, runnerOpen = false;
let modelsOpen = false, model = "", runner = "", saved = false, hired = false;
const fields: Record<string, string> = {}, events: string[] = [];
const gateway = { baseUrl: "https://owned-gateway.example.test/v1", model: "gpt-5.4-mini", apiKey: "synthetic-test-key" };
const connection = { id: "connection", name: "My owned-gateway.example.test", provider: "openai", method: "api_key", status: "connected" };
const create = () => {
expect(events).toContain("intent:workspace");
expect(name).toBe("Journey QA campaign");
events.push("create:workspace"); stage = "agent";
};
const controls = (): Control[] => {
if (stage === "portfolio") return [{ role: "link", name: "Create a new organization", click() { stage = "create-form"; } }];
if (stage === "first-run" || stage === "create-form") return [
{ role: "textbox", name: stage === "first-run" ? "Name" : "Organization name", fill(value) { name = value; } },
{ role: "button", name: stage === "first-run" ? "Next" : "Create organization", click: create },
];
if (stage === "agent") return [
{ role: "heading", name: "Create your first agent" },
{ role: "textbox", name: "Agent name", fill(value) { name = value; } },
{ role: "button", name: "Next", click() { stage = "connect"; } },
];
if (stage === "started") return [];
if (hired) return [{ role: "button", name: "Get started", click() { stage = "started"; } }];
const result: Control[] = [
{ role: "button", name: apiMode ? "Use subscription instead" : "Use API key instead", click() { apiMode = !apiMode; } },
{ role: "radio", name: apiMode ? "OpenAI API key" : "OpenAI Subscription", click() { source = true; } },
];
if (source && apiMode) result.push({ role: "button", name: "Use a custom gateway", click() { stage = "gateway"; } });
if (stage === "gateway") {
result.push({ role: "dialog", name: "Custom gateway connection" });
for (const field of ["Provider URL", "API key", ...(modelsOpen ? ["Model IDs"] : [])]) result.push({
role: "textbox", name: field, scope: "gateway", fill(value) { fields[field] = value; },
});
result.push({ role: "button", name: "Change", scope: "gateway", click() { modelsOpen = true; } },
{ role: "button", name: "Save connection", scope: "gateway", click() {
expect(fields).toEqual({ "Provider URL": gateway.baseUrl, "API key": gateway.apiKey, "Model IDs": gateway.model });
expect(events).toContain("intent:connection"); events.push("save:connection"); saved = true; stage = "connect";
} });
}
if (saved) result.push({ role: "combobox", name: "Model", select(value) { expect(value).toBe(gateway.model); model = value; } });
if (source) result.push({ role: "summary", name: "Advanced", click() { advanced = true; } });
if (advanced) result.push({ role: "button", name: "Runner", click() { runnerOpen = true; } });
if (runnerOpen) result.push({ role: "option", name: "Paperclip Runner (default)", click() { runner = "paperclip"; runnerOpen = false; } });
if (saved && model && runner) result.push({ role: "button", name: "Connect", click() {
expect(events).toContain("qualified"); events.push("paid:connect"); hired = true;
} });
return result;
};
const match = (value: string, expected: string | RegExp) => typeof expected === "string" ? value === expected : expected.test(value);
const locator = (resolve: () => Control[]): any => ({
first() { return locator(() => resolve().slice(0, 1)); },
or(other: { resolve(): Control[] }) { return locator(() => [...resolve(), ...other.resolve()]); },
resolve,
async isVisible() { return resolve().length > 0; },
async fill(value: string) { const control = resolve()[0]; if (!control?.fill) throw new Error("No visible text field"); control.fill(value); },
async click() { const control = resolve()[0]; if (!control?.click) throw new Error("No visible action"); events.push(`click:${control.name}`); control.click(); },
async selectOption(value: string) { const control = resolve()[0]; if (!control?.select) throw new Error("No visible native model picker"); control.select(value); },
async waitFor() { expect(resolve().length).toBeGreaterThan(0); },
filter({ hasText }: { hasText: string }) { return locator(() => resolve().filter(control => control.name.includes(hasText))); },
getByLabel(label: string) { return locator(() => controls().filter(control => control.scope === "gateway" && control.name === label)); },
getByRole(role: string, { name: label }: { name: string | RegExp }) {
return locator(() => controls().filter(control => control.scope === "gateway" && control.role === role && match(control.name, label)));
},
});
const page = {
on() { return this; },
off() { return this; },
getByLabel(label: string) { return locator(() => controls().filter(control => control.name === label && control.role === "textbox")); },
getByRole(role: string, { name: label }: { name: string | RegExp }) {
return locator(() => controls().filter(control => control.role === role && match(control.name, label)));
},
locator(selector: string) { expect(selector).toBe("summary"); return locator(() => controls().filter(control => control.role === "summary")); },
};
const context = { page, company: { id: "company" }, identity: { kind: "cloud" }, startingState: "recipe", api: {
origin: "https://tenant.example.test",
async get(route: string) {
if (route.endsWith("/ai-connections")) return { connections: saved ? [connection] : [] };
if (route.endsWith("/agents")) return hired ? [{ id: "agent", name, adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: gateway.model } }] : [];
throw new Error("Unexpected API read");
},
} };
const input = { page, campaignId: "campaign", deadlineAt: Date.now() + 60_000, gateway, maxRuns: 8, evidence: { checks: [] },
async beforeWorkspace() { if (options.intentFailure) throw new Error("ownership unavailable"); events.push("intent:workspace"); },
async workspaceCreated() { expect(stage).toBe("agent"); return context; },
async own(resource: { kind: string }) { events.push(`intent:${resource.kind}`); },
async phase() {}, async observeRun() {},
async qualify() { if (options.qualificationFailure) throw new Error("admission denied"); events.push("qualified"); },
async verifyExecution(_context: unknown, runIds: string[]) {
expect(runIds).toEqual(["artifact-run"]);
if (options.traceFailure) throw new Error("trace verification failed");
events.push("native:verified");
},
} as unknown as CloudOnboardingInput;
return { input, events };
}
describe("Cloud onboarding visible controls", () => {
it.each(["first-run", "portfolio", "create-form"] as const)("creates from %s with intent before dispatch and qualification before paid Connect", async entry => {
const f = fixture(entry);
const result = await cloudOnboarding(f.input);
expect(result.startingState).toBe("onboarding");
expect(f.events.indexOf("intent:workspace")).toBeLessThan(f.events.indexOf("create:workspace"));
expect(f.events.indexOf("intent:connection")).toBeLessThan(f.events.indexOf("save:connection"));
expect(f.events.indexOf("qualified")).toBeLessThan(f.events.indexOf("paid:connect"));
expect(f.events).toContain("native:verified");
});
it("does not dispatch workspace creation without durable ownership", async () => {
const f = fixture("first-run", { intentFailure: true });
await expect(cloudOnboarding(f.input)).rejects.toThrow("ownership unavailable");
expect(f.events).not.toContain("create:workspace");
});
it("never presses paid Connect when route admission fails", async () => {
const f = fixture("portfolio", { qualificationFailure: true });
const previous = vi.mocked(onboardingFirstTask).mock.calls.length;
await expect(cloudOnboarding(f.input)).rejects.toThrow("admission denied");
expect(f.events).not.toContain("paid:connect");
expect(vi.mocked(onboardingFirstTask).mock.calls).toHaveLength(previous);
});
it("keeps verified artifact bytes and the independent oracle when later trace verification fails", async () => {
const f = fixture("portfolio", { traceFailure: true });
await expect(cloudOnboarding(f.input)).rejects.toThrow("trace verification failed");
const retained = JSON.parse(JSON.stringify(f.input.evidence)).artifact;
expect(JSON.parse(Buffer.from(retained.contentBase64, "base64").toString("utf8"))).toEqual(retained.expected);
expect(retained.originatingRunId).toBe("artifact-run");
expect(f.input.evidence.checks).not.toContainEqual({ id: "native-trace-verified", passed: true });
});
it("retains the artifact but fails the journey without pass markers when first-task readback fails", async () => {
const f = fixture("portfolio");
vi.mocked(onboardingFirstTask).mockImplementationOnce(async ({ proof, onArtifact }) => {
onArtifact?.(retainOnboardingArtifact({ id: "artifact", originatingRunId: "artifact-run", bytes: Buffer.from(JSON.stringify(proof.expected)), proof }));
throw new Error("first-task readback failed");
});
await expect(cloudOnboarding(f.input)).rejects.toThrow("first-task readback failed");
const retained = JSON.parse(JSON.stringify(f.input.evidence)).artifact;
expect(JSON.parse(Buffer.from(retained.contentBase64, "base64").toString("utf8"))).toEqual(retained.expected);
expect(f.input.evidence.checks.map(check => check.id)).toEqual(["fresh-workspace", "ui-connection-and-native-agent"]);
expect(f.events).not.toContain("native:verified");
});
});