mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - A release is gated by the release smoke: it installs the published
`paperclipai` artifact into a Docker container and drives the sign-in →
onboarding → first-agent path with Playwright
> - That suite runs only from the release pipeline, never on a pull
request, so it sees the UI only after the UI has already changed
> - The onboarding wizard was rebuilt into the agent arc. The "Name your
organization" step, the "Start Onboarding" launcher, and the agent role
picker are all gone
> - The spec still waited for those, so it failed on its first assertion
and blocked every nightly and beta release
> - The failure was also hard to read. The workflow uploaded no
container logs, because it learned the container's name only after the
harness succeeded, and the harness ran the container with `--rm` and
deleted it before anything read it
> - This pull request rewrites the spec to follow the current arc, and
repairs the log capture at both ends
> - The benefit is that nightly and beta releases are unblocked, and the
next failure arrives with the logs attached
## Linked Issues or Issue Description
No existing issue. Describing it inline, following
`.github/ISSUE_TEMPLATE/bug_report.yml`.
Refs #12274 (removed the company-naming step from the wizard).
Refs #12135 (the previous alignment of this spec, before #12274).
Refs #12316 (open; also edits `scripts/docker-onboard-smoke.sh`, in the
bootstrap helpers rather than the container lifecycle, so the two
changes do
not overlap. Whichever lands second should rebase and re-run).
**What happened?**
The release smoke fails.
`tests/release-smoke/docker-auth-onboarding.spec.ts`
never gets past its first wait:
```
✘ tests/release-smoke/docker-auth-onboarding.spec.ts:43:3 › Docker authenticated onboarding smoke › logs in, completes onboarding, and hires the lead agent
Error: expect(locator).toBeVisible() failed — element(s) not found (timeout 20000ms)
> 33 | await expect(wizardHeading.or(startButton)).toBeVisible({ timeout: 20_000 });
```
The spec waits for an `h3` reading "Name your organization" or a
"Start Onboarding" button. Neither exists. #12274 removed the
company-naming
step; the string now survives only in a code comment and in
`ui/src/components/OnboardingWizard.step.test.tsx`, which asserts it is
*absent*. The steps after the first wait are stale too: the CTA on step
1 is
"Continue" and not "Next", the organization input's placeholder changed,
and
the agent step's `#onboarding-agent-role` picker is gone, so every
onboarding
hire is filed under the neutral `general` role.
The suite runs only from the release pipeline, so nothing on a pull
request
saw the drift. Both `smoke_nightly` and `smoke_beta` call the same
reusable
workflow, so every nightly and every beta was blocked.
The failure also arrived without diagnostics. The job's "Capture Docker
logs"
step is `if: always()`, but it is guarded on `SMOKE_CONTAINER_NAME`,
which the
"Launch Docker smoke harness" step writes to `$GITHUB_ENV` only *after*
the
harness returns. On any failure before that the guard is false, the step
does
nothing, and the upload reports "No files were found". Below that,
`scripts/docker-onboard-smoke.sh` starts the container with
`docker run -d --rm`, so the `docker stop` in its EXIT trap deletes the
container and its logs together — and a container that crashes on its
own is
removed the instant its process exits.
**Expected behavior**
The spec walks the onboarding arc the app actually presents, and proves
the
company is created, the lead agent is hired, and the first task is
seeded and
dispatched. When the smoke fails, the run's artifact carries the
container's
logs.
**Steps to reproduce**
1. Run the Release Smoke workflow against a published artifact that
carries
#12274, or run it locally:
`PAPERCLIPAI_VERSION=2026.828.0-canary.3 SMOKE_DETACH=true
./scripts/docker-onboard-smoke.sh`
2. Run `pnpm run test:release-smoke` against that container.
3. The single spec fails at `openOnboarding()` after 20 seconds.
4. In CI, open the run's `release-smoke` artifact. It has no
`docker-onboard-smoke.log`.
**Paperclip version or commit**
`2026.828.0-canary.3` (commit 8316ceb0b).
**Deployment mode**
Docker.
**Installation method**
npm / pnpm global install (the container runs `npx
paperclipai@<version>`).
**Node.js version**
v24.20.0 inside the container.
**Relevant logs or output**
```
Running 1 test using 1 worker
✓ 1 [chromium] › tests/release-smoke/docker-auth-onboarding.spec.ts:76:3 › Docker authenticated onboarding smoke › logs in, completes onboarding, and hires the lead agent (7.0s)
1 passed (8.7s)
```
That is the result after this change. Before it, the same command failed
at
the first wait, as quoted above.
## What Changed
- `tests/release-smoke/docker-auth-onboarding.spec.ts` now follows the
current
arc. It signs in, opens `/onboarding`, names the organization and
presses
"Continue" (which creates the company and routes straight to the agent
step,
because onboarding no longer asks for a mission), names the lead and
presses
"Next", presses "Connect" on the default adapter to hire, then presses
"Get started" to launch.
- The spec addresses controls by role and accessible name, or by id
where one
exists (`#onboarding-agent-name`). Step 1's field has no id and no
associated
label, so it is found as the wizard's only text box rather than by its
placeholder copy.
- The spec asserts the hired agent's role is `general`, which is what
the arc
files every onboarding hire under. Every other API assertion is
unchanged.
- The spec navigates to `/onboarding` explicitly and drops any saved
onboarding
draft first, so it can run twice against one instance. The suite retries
once
in CI. It still asserts that a company-less board routes sign-in into
onboarding, guarded on the board actually being empty.
- `scripts/docker-onboard-smoke.sh` accepts `SMOKE_CONTAINER_NAME`,
drops
`--rm`, removes the container itself, and dumps `docker logs` to
`SMOKE_LOG_FILE` before the teardown.
- `.github/workflows/release-smoke.yml` pins the container name in the
job's
`env`, so every `always()` step has it before anything runs. The capture
step
refreshes the log from a live container when there is one, keeps the
harness's dump when there is not, and writes a one-line explanation when
there is neither. The upload's paths are literals, and
`if-no-files-found: error` makes a broken diagnostics path fail rather
than
warn.
- `scripts/docker-onboard-smoke.test.mjs` pins that wiring. It is added
to
`test:release-registry`, which runs on every pull request.
- `doc/DOCKER.md` documents `SMOKE_CONTAINER_NAME` and `SMOKE_LOG_FILE`.
## Verification
The spec was run against a real container built from the published
`2026.828.0-canary.3` artifact, exactly as the workflow runs it.
```sh
SMOKE_CONTAINER_NAME=release-smoke-onboard \
HOST_PORT=3232 DATA_DIR=<tmp>/smoke-data \
PAPERCLIPAI_VERSION=2026.828.0-canary.3 \
SMOKE_READY_TIMEOUT_SECONDS=420 SMOKE_DETACH=true \
SMOKE_METADATA_FILE=<tmp>/release-smoke.env \
SMOKE_LOG_FILE=<tmp>/docker-onboard-smoke.log \
./scripts/docker-onboard-smoke.sh
PAPERCLIP_RELEASE_SMOKE_BASE_URL=http://localhost:3232 \
PAPERCLIP_RELEASE_SMOKE_EMAIL=smoke-admin@paperclip.local \
PAPERCLIP_RELEASE_SMOKE_PASSWORD=paperclip-smoke-password \
PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome \
pnpm run test:release-smoke
```
```
Running 1 test using 1 worker
✓ 1 [chromium] › tests/release-smoke/docker-auth-onboarding.spec.ts:76:3 › Docker authenticated onboarding smoke › logs in, completes onboarding, and hires the lead agent (7.0s)
1 passed (8.7s)
```
The same command was run a second time against the same, now non-empty,
instance. That covers the retry path, and it also passes.
The log capture was verified by making the container die during startup:
```sh
PAPERCLIPAI_VERSION=0.0.0-no-such-version \
SMOKE_CONTAINER_NAME=release-smoke-onboard SMOKE_LOG_FILE=<tmp>/fail.log \
./scripts/docker-onboard-smoke.sh
```
`<tmp>/fail.log` was written and carried the cause:
```
npm error code ETARGET
npm error notarget No matching version found for paperclipai@0.0.0-no-such-version.
```
The container was removed afterwards. On `master` this file is never
written,
because `--rm` deletes the container the moment its process exits.
The workflow's capture step was run by hand against three states: a live
container (258 lines), a removed container with the harness's dump
already on
disk (258 lines kept), and neither (a one-line explanation).
Unit coverage:
```sh
pnpm run test:release-registry # 93 tests, 93 pass
```
Nothing under `ui/` changed, so `pnpm --filter @paperclipai/ui
typecheck` was
not required. `tests/release-smoke` is outside the TypeScript project
references; Playwright compiles it at run time, which the runs above did
three
times.
## Risks
Low risk. Nothing ships to users. The change touches one Playwright
spec, one
smoke script, and one workflow.
Points worth a reviewer's attention:
- **This suite gates every nightly and beta, and it runs only
post-merge.**
`smoke_nightly` and `smoke_beta` both call `release-smoke.yml`, and no
pull
request runs it. Drift between the wizard and this spec is therefore
invisible until a release is already blocked, which is how this bug
reached
a release train. I think the arc deserves an earlier check. The cheapest
version is the one added here: `scripts/docker-onboard-smoke.test.mjs`
runs
on every pull request and pins the harness wiring. The full container
smoke
is too slow for the pull request path, but a UI-level test of the arc's
step
sequence would catch exactly this class of drift, and
`ui/src/components/OnboardingWizard.step.test.tsx` is already the right
home for it. I did not add it here, to keep this change to the repair.
- **Dropping `--rm`.** The container is now removed by the script's
cleanup
instead of by Docker. The script already ran `docker rm -f` before
starting,
and the workflow's final step removes it too, so a leaked container is
cleaned up on the next run either way. A developer who kills the script
with
`SIGKILL` will leave a stopped container behind, where previously they
would
not.
- **`if-no-files-found: error` on the upload.** The capture step now
always
writes the log file, so the upload always has at least one path to
match. If
that ever stops being true, the job fails instead of warning. That is
deliberate.
- **The spec drops the saved onboarding draft before it walks.** A stale
draft
makes step 1 skip company creation and hire into the previous run's
company.
That state only exists when the spec runs twice against one instance. A
fresh
release-smoke container never has it.
## Model Used
Claude (Anthropic), Claude Opus, 1M context, extended thinking, agentic
tool
use via Claude Code. The container, the Playwright runs, and the failure
injection were driven as real commands on a local Docker host.
## 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
209 lines
8.5 KiB
TypeScript
209 lines
8.5 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const ADMIN_EMAIL =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_EMAIL ??
|
|
process.env.SMOKE_ADMIN_EMAIL ??
|
|
"smoke-admin@paperclip.local";
|
|
const ADMIN_PASSWORD =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_PASSWORD ??
|
|
process.env.SMOKE_ADMIN_PASSWORD ??
|
|
"paperclip-smoke-password";
|
|
|
|
const COMPANY_NAME = `Release-Smoke-${Date.now()}`;
|
|
const AGENT_NAME = "Release Smoke Lead";
|
|
// The arc asks for a name, not a role, so every onboarding hire is filed under
|
|
// the neutral role (DEFAULT_AGENT_ROLE in ui/src/lib/onboarding-agent-role.ts).
|
|
const AGENT_ROLE = "general";
|
|
// Seeded by the wizard's launch step (DEFAULT_TASK_TITLE in
|
|
// ui/src/components/OnboardingWizard.tsx).
|
|
const FIRST_TASK_TITLE = "Paperclip onboarding";
|
|
|
|
async function signIn(page: Page) {
|
|
await page.goto("/");
|
|
await expect(page).toHaveURL(/\/auth/);
|
|
|
|
await page.locator('input[type="email"]').fill(ADMIN_EMAIL);
|
|
await page.locator('input[type="password"]').fill(ADMIN_PASSWORD);
|
|
await page.getByRole("button", { name: "Sign In" }).click();
|
|
|
|
await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 });
|
|
}
|
|
|
|
async function getJson<T>(page: Page, url: string): Promise<T> {
|
|
const response = await page.request.get(url);
|
|
expect(response.ok()).toBe(true);
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
// ONBOARDING_STORAGE_KEY in ui/src/components/OnboardingWizard.tsx.
|
|
const ONBOARDING_DRAFT_STORAGE_KEY = "paperclip-onboarding-state";
|
|
|
|
/**
|
|
* Open the wizard on its first step and hand back the organization-name field.
|
|
*
|
|
* `/onboarding` resolves to `{ initialStep: 1 }` on a self-hosted instance
|
|
* (`resolveRouteOnboardingOptions`) and the route keeps the wizard open, so
|
|
* this lands on "name your organization" whether or not the instance already
|
|
* holds a company. Navigating explicitly is what keeps the spec re-runnable:
|
|
* the release-smoke config retries once in CI, and by the second attempt the
|
|
* instance is no longer company-less, so sign-in lands on a dashboard instead.
|
|
*
|
|
* The saved draft is dropped first. Sign-in on an instance that already holds
|
|
* an agentless company redirects into *that* company's onboarding, which
|
|
* persists its id into the draft; the restored draft then makes step 1 skip
|
|
* creating a company and hire into the old one instead. That is an artifact of
|
|
* re-running against a re-used instance, not behaviour this spec is asserting,
|
|
* and a fresh release-smoke container never has it.
|
|
*
|
|
* The field is located by role. Step 1 has no id and its `<label>` is not
|
|
* associated with the input, so the alternative is its placeholder copy — the
|
|
* exact coupling that let this spec drift. The wizard's first screen has
|
|
* exactly one text box, and a second one appearing there would fail Playwright's
|
|
* strict mode loudly rather than silently matching the wrong control.
|
|
*/
|
|
async function openOnboarding(page: Page) {
|
|
await page.evaluate((key) => {
|
|
window.localStorage.removeItem(key);
|
|
}, ONBOARDING_DRAFT_STORAGE_KEY);
|
|
await page.goto("/onboarding");
|
|
|
|
const orgNameField = page.getByRole("textbox");
|
|
await expect(orgNameField).toBeVisible({ timeout: 20_000 });
|
|
return orgNameField;
|
|
}
|
|
|
|
test.describe("Docker authenticated onboarding smoke", () => {
|
|
test("logs in, completes onboarding, and hires the lead agent", async ({
|
|
page,
|
|
}) => {
|
|
await signIn(page);
|
|
|
|
const baseUrl = new URL(page.url()).origin;
|
|
|
|
// A board with no company routes sign-in straight into onboarding rather
|
|
// than a dashboard — the first-run experience this suite exists to guard.
|
|
// Asserted only when the instance really is company-less, because a retry
|
|
// (or a re-used smoke container) runs against one that is not.
|
|
const companiesBeforeOnboarding = await getJson<Array<{ id: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies`
|
|
);
|
|
if (companiesBeforeOnboarding.length === 0) {
|
|
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 20_000 });
|
|
}
|
|
|
|
// Step 1: name the organization. "Continue" creates the company itself and
|
|
// routes straight to the agent step — onboarding no longer asks for the
|
|
// mission (it is collected later, in the app), so step 2 is skipped.
|
|
const orgNameField = await openOnboarding(page);
|
|
await orgNameField.fill(COMPANY_NAME);
|
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
|
|
|
// Step 3: name the team lead. The name is the step's only question and it
|
|
// gates the CTA; the role picker is gone, so the hire is filed as `general`.
|
|
const agentNameField = page.locator("#onboarding-agent-name");
|
|
await expect(agentNameField).toBeVisible({ timeout: 20_000 });
|
|
await agentNameField.fill(AGENT_NAME);
|
|
|
|
const nextButton = page.getByRole("button", { name: "Next", exact: true });
|
|
await expect(nextButton).toBeEnabled({ timeout: 10_000 });
|
|
await nextButton.click();
|
|
|
|
// Step 4: keep the default adapter and connect (hire) the lead. Connect
|
|
// probes the adapter environment first and blocks the hire on a `fail`. In
|
|
// the smoke container no agent CLI is installed, which the probe reports as
|
|
// a warning rather than an error, so the hire proceeds — a genuine failure
|
|
// here means the published artifact cannot hire on a clean machine. Allow
|
|
// generous time for the probe + hire + auto-approval.
|
|
const connectButton = page.getByRole("button", {
|
|
name: "Connect",
|
|
exact: true,
|
|
});
|
|
await expect(connectButton).toBeVisible({ timeout: 10_000 });
|
|
await expect(connectButton).toBeEnabled({ timeout: 30_000 });
|
|
await connectButton.click();
|
|
|
|
// Step 5: review, then launch. "Get started" provisions the onboarding
|
|
// project and first task and, only on success, drops the user into the
|
|
// seeded first task's thread (not the dashboard).
|
|
const getStartedButton = page.getByRole("button", {
|
|
name: "Get started",
|
|
exact: true,
|
|
});
|
|
await expect(getStartedButton).toBeVisible({ timeout: 60_000 });
|
|
await expect(getStartedButton).toBeEnabled({ timeout: 10_000 });
|
|
await getStartedButton.click();
|
|
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
|
|
|
|
const companies = await getJson<Array<{ id: string; name: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies`
|
|
);
|
|
const company = companies.find((entry) => entry.name === COMPANY_NAME);
|
|
expect(company).toBeTruthy();
|
|
|
|
const agents = await getJson<
|
|
Array<{ id: string; name: string; role: string; adapterType: string }>
|
|
>(page, `${baseUrl}/api/companies/${company!.id}/agents`);
|
|
const leadAgent = agents.find((entry) => entry.name === AGENT_NAME);
|
|
expect(leadAgent).toBeTruthy();
|
|
expect(leadAgent!.role).toBe(AGENT_ROLE);
|
|
expect(leadAgent!.adapterType).not.toBe("process");
|
|
|
|
// Onboarding deliberately writes no goal: the mission is collected later in
|
|
// the app, so a fresh company must come out of the wizard with an empty
|
|
// goal list rather than an unchosen one.
|
|
const goals = await getJson<Array<{ id: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies/${company!.id}/goals`
|
|
);
|
|
expect(goals).toEqual([]);
|
|
|
|
const issues = await getJson<
|
|
Array<{
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
assigneeAgentId: string | null;
|
|
}>
|
|
>(page, `${baseUrl}/api/companies/${company!.id}/issues`);
|
|
const seededIssue = issues.find((entry) => entry.title === FIRST_TASK_TITLE);
|
|
expect(seededIssue).toBeTruthy();
|
|
expect(seededIssue!.assigneeAgentId).toBe(leadAgent!.id);
|
|
|
|
// The launch must have landed on the seeded task itself, not merely on
|
|
// some issue route.
|
|
const seededRef = seededIssue!.identifier ?? seededIssue!.id;
|
|
expect(new URL(page.url()).pathname.endsWith(`/issues/${seededRef}`)).toBe(
|
|
true
|
|
);
|
|
|
|
await expect.poll(
|
|
async () => {
|
|
const runs = await getJson<
|
|
Array<{ agentId: string; invocationSource: string; status: string }>
|
|
>(
|
|
page,
|
|
`${baseUrl}/api/companies/${company!.id}/heartbeat-runs?agentId=${leadAgent!.id}`
|
|
);
|
|
const latestRun = runs.find((entry) => entry.agentId === leadAgent!.id);
|
|
return latestRun
|
|
? {
|
|
invocationSource: latestRun.invocationSource,
|
|
status: latestRun.status,
|
|
}
|
|
: null;
|
|
},
|
|
{
|
|
timeout: 30_000,
|
|
intervals: [1_000, 2_000, 5_000],
|
|
}
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
invocationSource: "assignment",
|
|
status: expect.stringMatching(/^(queued|running|succeeded|failed)$/),
|
|
})
|
|
);
|
|
});
|
|
});
|