ece8a51e22
## 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>
414 lines
14 KiB
TypeScript
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",
|
|
};
|
|
}
|