Files
paperclip/ui/src/pages/CloudUpstream.test.tsx
T
Dotta ece8a51e22 [codex] Bundle local branch fixes from PAP-10032 (#6604)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - This branch accumulated multiple already-tested control-plane,
adapter runtime, invite, workspace, plugin, and UI quality fixes on the
primary Paperclip checkout.
> - `origin/master` advanced while those commits were still local, so
the branch needed to be preserved and reconciled before review.
> - Splitting the branch commit-by-commit against the new base produced
overlapping conflicts with recently merged upstream PRs.
> - This pull request keeps the remaining branch as one standalone PR
because the final diff is 38 files after removing screenshot artifacts,
under Greptile's 100-file cap, and can be merged independently after
review.
> - The benefit is that none of the local work is lost, the branch is
now based on current `origin/master`, and reviewers can evaluate the
reconciled changes in one place.

## What Changed

- Merged the local accumulated branch with current `origin/master` and
resolved the invite-flow overlaps from the newer upstream companies
query helper.
- Preserved the local fixes for invite existing-member behavior, invite
link copy fallback, reusable workspace selection, worktree auth, static
SPA fallback, markdown wrapping, plugin slot registration, cloud
upstream UX/server polish, project sorting, and related tests.
- Removed screenshot artifacts from the PR per review request.
- Kept the PR under the requested file limit: 38 files changed, with no
`pnpm-lock.yaml` or `.github/workflows/*` changes.

## Verification

- `NODE_ENV=test pnpm exec vitest run
ui/src/pages/CompanyInvites.test.tsx ui/src/pages/InviteLanding.test.tsx
ui/src/pages/Projects.test.tsx ui/src/plugins/slots.test.ts
ui/src/components/MarkdownBody.test.tsx
server/src/__tests__/invite-accept-existing-member.test.ts
server/src/__tests__/static-index-html.test.ts
server/src/__tests__/execution-workspaces-service.test.ts
server/src/__tests__/better-auth.test.ts
server/src/__tests__/worktree-config.test.ts`
- `NODE_ENV=test pnpm --filter @paperclipai/ui typecheck`
- `NODE_ENV=test pnpm --filter @paperclipai/server typecheck`
- Confirmed `git diff --name-only origin/master...HEAD | wc -l` is `38`.
- Confirmed no PR diff entries match `pnpm-lock.yaml`,
`.github/workflows/*`, or `screenshots/*`.

## Risks

- Medium review risk because this is a bundled rescue PR rather than
several narrow feature PRs.
- Invite flow and company cache behavior overlapped with newer upstream
changes; the merge resolution intentionally keeps the shared
`companiesListQueryOptions` helper while preserving local
existing-member invite behavior.
- Visual review evidence is no longer attached in-repo because
screenshots were removed from this PR per review request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, with repository tool access,
terminal execution, and git/GitHub CLI operations.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] UI screenshots were intentionally removed from this PR per review
request
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: CodexCoder <codexcoder@paperclip.local>
2026-05-25 07:25:26 -05:00

414 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CloudUpstream, buildActivationRows } from "./CloudUpstream";
const mockCloudUpstreamsApi = vi.hoisted(() => ({
list: vi.fn(),
startConnect: vi.fn(),
finishConnect: vi.fn(),
preview: vi.fn(),
createRun: vi.fn(),
getRun: vi.fn(),
cancelRun: vi.fn(),
activateEntities: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockCompanyState = vi.hoisted(() => ({
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" } as
| { id: string; name: string; issuePrefix: string | null }
| null,
selectedCompanyId: "company-1" as string | null,
}));
const mockLocationState = vi.hoisted(() => ({
pathname: "/PAP/company/settings/cloud-upstream",
search: "",
}));
vi.mock("@/api/cloudUpstreams", () => ({
cloudUpstreamsApi: mockCloudUpstreamsApi,
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: mockCompanyState.selectedCompany,
selectedCompanyId: mockCompanyState.selectedCompanyId,
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, className }: { children: React.ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>
{children}
</a>
),
useLocation: () => ({ pathname: mockLocationState.pathname, search: mockLocationState.search }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
await callback();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("CloudUpstream", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockCompanyState.selectedCompany = { id: "company-1", name: "Paperclip", issuePrefix: "PAP" };
mockCompanyState.selectedCompanyId = "company-1";
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
mockLocationState.search = "";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCloudSync: true });
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "succeeded" })));
mockCloudUpstreamsApi.activateEntities.mockImplementation((_connectionId, _runId, input) =>
Promise.resolve(buildRun({
status: "succeeded",
report: {
activationChecklist: {
[input.entityType]: {
entityType: input.entityType,
count: input.entityType === "agents" ? 2 : 1,
status: "activated",
activatedAt: "2026-05-18T19:00:00.000Z",
},
},
},
})),
);
mockCloudUpstreamsApi.createRun.mockResolvedValue(buildRun({ status: "running" }));
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("binds the succeeded run activation checklist to imported category counts", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Re-run");
expect(container.textContent).not.toContain("Retry");
expect(container.textContent).toContain("Activation checklist");
expect(container.textContent).toContain("2 paused");
expect(container.textContent).toContain("1 paused");
expect(container.textContent).toContain("0 imported monitors in this run.");
expect(container.textContent).toContain("Keep paused");
const activateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Activate") as HTMLButtonElement | undefined;
expect(activateButton).toBeTruthy();
await act(async () => {
activateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockCloudUpstreamsApi.activateEntities).toHaveBeenCalledWith(
"connection-1",
"run-1",
{ companyId: "company-1", entityType: "agents" },
);
await act(async () => {
root.unmount();
});
});
it("sends a company-prefixed redirectUri when starting Connect", async () => {
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
mockCloudUpstreamsApi.startConnect.mockResolvedValue({
pendingConnectionId: "pending-1",
authorizationUrl: "https://cloud.example/upstream-consent?state=abc",
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const input = container.querySelector<HTMLInputElement>("input[aria-label='Paperclip Cloud stack URL']");
expect(input).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input!, "https://cloud.example/PAP/dashboard");
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
const connectButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Connect") as HTMLButtonElement | undefined;
expect(connectButton).toBeTruthy();
await act(async () => {
connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockCloudUpstreamsApi.startConnect).toHaveBeenCalledWith({
companyId: "company-1",
remoteUrl: "https://cloud.example/PAP/dashboard",
redirectUri: `${window.location.origin}/PAP/company/settings/cloud-upstream`,
});
await act(async () => {
root.unmount();
});
});
it("uses the URL pathname prefix when cleaning up the callback URL with no company context", async () => {
mockCompanyState.selectedCompany = null;
mockCompanyState.selectedCompanyId = null;
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
mockLocationState.search = "?code=cb-code&state=cb-state";
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
mockCloudUpstreamsApi.finishConnect.mockResolvedValue({
id: "connection-1",
companyId: "company-1",
remoteUrl: "https://cloud.example/PAP",
target: {
stackId: "stack-1",
stackSlug: "stack",
stackDisplayName: "Paperclip Cloud",
companyId: "cloud-company-1",
primaryHost: "cloud.example",
origin: "https://cloud.example",
product: "Paperclip Cloud",
schemaMajor: 1,
maxChunkBytes: 1024,
},
tokenStatus: "connected",
scopes: ["upstream_import:write"],
authorizedGlobalUserId: "user-1",
expiresAt: null,
createdAt: "2026-05-18T18:00:00.000Z",
updatedAt: "2026-05-18T18:00:00.000Z",
lastRunId: null,
});
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
try {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledWith({
pendingConnectionId: "pending-1",
code: "cb-code",
state: "cb-state",
});
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/PAP/company/settings/cloud-upstream");
await act(async () => {
root.unmount();
});
} finally {
replaceStateSpy.mockRestore();
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
}
});
it("does not retry the OAuth callback finish mutation after an error", async () => {
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
mockLocationState.search = "?code=cb-code&state=cb-state";
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
mockCloudUpstreamsApi.finishConnect.mockRejectedValue(new Error("state expired"));
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
try {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("state expired");
await act(async () => {
root.unmount();
});
} finally {
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
}
});
it("keeps retry only for failed or cancelled runs", async () => {
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "failed" })));
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Retry");
expect(container.textContent).not.toContain("Re-run");
expect(container.textContent).not.toContain("Activation checklist");
await act(async () => {
root.unmount();
});
});
});
describe("buildActivationRows", () => {
it("reads activation decisions from the run report", () => {
const rows = buildActivationRows(buildRun({
status: "succeeded",
report: {
activationChecklist: {
agents: {
entityType: "agents",
count: 2,
status: "activated",
activatedAt: "2026-05-18T19:00:00.000Z",
},
},
},
}));
expect(rows[0]).toMatchObject({ key: "agents", count: 2, status: "activated", statusLabel: "2 activated" });
expect(rows[2]).toMatchObject({ key: "monitors", count: 0, status: "paused", statusLabel: "0 imported" });
});
});
function stateWithRun(run: CloudUpstreamRun): CloudUpstreamsState {
return {
connections: [
{
id: "connection-1",
companyId: "company-1",
remoteUrl: "https://paperclip.example/PAP",
target: {
stackId: "stack-1",
stackSlug: "stack",
stackDisplayName: "Paperclip Cloud",
companyId: "cloud-company-1",
primaryHost: "paperclip.example",
origin: "https://paperclip.example",
product: "Paperclip Cloud",
schemaMajor: 1,
maxChunkBytes: 1024,
},
tokenStatus: "connected",
scopes: ["upstream_import:write"],
authorizedGlobalUserId: "user-1",
expiresAt: null,
createdAt: "2026-05-18T18:00:00.000Z",
updatedAt: "2026-05-18T18:00:00.000Z",
lastRunId: run.id,
},
],
runs: [run],
};
}
function buildRun(input: {
status: CloudUpstreamRun["status"];
report?: Record<string, unknown>;
}): CloudUpstreamRun {
return {
id: "run-1",
connectionId: "connection-1",
companyId: "company-1",
status: input.status,
activeStep: input.status === "succeeded" ? "activate" : "push",
progressPercent: input.status === "running" ? 70 : 100,
dryRun: false,
summary: [
{ key: "agents", label: "Agents", count: 2 },
{ key: "routines", label: "Routines", count: 1 },
{ key: "issues", label: "Issues", count: 7 },
],
warnings: [],
conflicts: [],
events: [
{
id: "event-1",
at: "2026-05-18T18:30:00.000Z",
phase: input.status === "succeeded" ? "activate" : "push",
type: input.status === "failed" ? "failed" : "completed",
message: input.status === "failed" ? "Push failed." : "Activation checklist is ready.",
},
],
targetUrl: "https://paperclip.example",
report: input.report ?? {},
retryOfRunId: null,
createdAt: "2026-05-18T18:00:00.000Z",
updatedAt: "2026-05-18T18:30:00.000Z",
completedAt: input.status === "running" ? null : "2026-05-18T18:30:00.000Z",
};
}