Files
PaperClipAI/ui/src/App.test.tsx
T
DottaandPaperclip 61b3fd57a6 fix(ui): recover gracefully during server restarts (#14560)
Show a clear reconnecting state during server restarts and retry safe access
checks every five seconds. Preserve open drafts, wait for initial startup
readiness, and keep authentication failures separate from temporary outages.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-29 08:31:05 -05:00

366 lines
15 KiB
TypeScript

// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { queryKeys } from "./lib/queryKeys";
import appSource from "./App.tsx?raw";
const mockHealthApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
getCurrentBoardAccess: vi.fn(),
claimBootstrapAdmin: vi.fn(),
}));
const beginCloudSignInMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/cloud-sign-in", () => ({
beginCloudSignIn: (url: string) => beginCloudSignInMock(url),
clearCloudSignInAttempt: vi.fn(),
}));
vi.mock("./api/health", () => ({
healthApi: mockHealthApi,
}));
vi.mock("./api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("./api/access", () => ({
accessApi: mockAccessApi,
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children?: ReactNode }) => <a href={to}>{children}</a>,
Navigate: ({ to }: { to: string }) => <div>Navigate:{to}</div>,
Outlet: () => <div>Outlet content</div>,
Route: ({ children }: { children?: ReactNode }) => <>{children}</>,
Routes: ({ children }: { children?: ReactNode }) => <>{children}</>,
useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }),
useParams: () => ({}),
}));
/**
* Waits on the condition, not on a fixed number of turns. A hand-rolled retry
* loop is ample on an idle machine and not when the suite runs many workers in
* parallel: it gives up after N turns and reports a failure on behaviour that
* works. `vi.waitFor` retries against a time budget, so a loaded worker gets
* more turns instead.
*
* Same replacement as #11499 and #11521, which fixed the shorter-budget
* instances of this in the routing tests.
*/
async function waitForText(container: HTMLElement, text: string) {
await vi.waitFor(() => expect(container.textContent).toContain(text));
}
function renderGate(container: HTMLElement, allowMembershipRequest = false, client?: QueryClient) {
const root = createRoot(container);
const queryClient = client ?? new QueryClient({
defaultOptions: { queries: { retry: false } },
});
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudAccessGate allowMembershipRequest={allowMembershipRequest} />
</QueryClientProvider>,
);
});
return root;
}
function unmountRoot(root: ReturnType<typeof createRoot>) {
flushSync(() => {
root.unmount();
});
}
describe("CloudAccessGate", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "ready",
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renews a missing Cloud instance session without opening local auth", async () => {
mockHealthApi.get.mockResolvedValue({ deploymentMode: "authenticated", cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: "https://my-staging.paperclip.app", stackSlug: "team" } });
mockAuthApi.getSession.mockResolvedValue(null);
beginCloudSignInMock.mockReturnValue(true);
const root = renderGate(container);
await vi.waitFor(() => expect(beginCloudSignInMock).toHaveBeenCalledTimes(1));
expect(beginCloudSignInMock).toHaveBeenCalledWith("https://my-staging.paperclip.app/v1/stacks/team/entry-redirect?returnTo=%2Finstance%2Fsettings%2Fgeneral");
expect(container.textContent).not.toContain("Navigate:/auth");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
it("does not mistake a session service failure for a signed-out user", async () => {
mockAuthApi.getSession.mockRejectedValue(new Error("Session service unavailable"));
const root = renderGate(container);
await waitForText(container, "Unable to load Paperclip");
expect(container.querySelector("button")?.textContent).toBe("Try again");
expect(container.textContent).not.toContain("Outlet content");
expect(container.textContent).not.toContain("Navigate:/auth");
expect(beginCloudSignInMock).not.toHaveBeenCalled();
unmountRoot(root);
});
it.each([undefined, { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: "https://my.paperclip.app", stackSlug: "team" }])(
"does not require a session in local trusted mode, including with Cloud metadata %j", async (cloud) => {
mockHealthApi.get.mockResolvedValue({ deploymentMode: "local_trusted", cloud });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await client.fetchQuery({
queryKey: queryKeys.auth.session,
queryFn: () => Promise.reject(new Error("Session service unavailable")),
}).catch(() => {});
const root = renderGate(container, false, client);
await waitForText(container, "Outlet content");
expect(beginCloudSignInMock).not.toHaveBeenCalled();
unmountRoot(root);
});
it("shows a no-access message for signed-in users without org access", async () => {
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
source: "session",
keyId: null,
});
const root = renderGate(container);
await waitForText(container, "No organization access");
expect(container.textContent).toContain("No organization access");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
it("admits signed-in nonmembers only for the private invitation landing page", async () => {
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ isInstanceAdmin: false, companyIds: [] });
const root = renderGate(container, true);
await waitForText(container, "Outlet content");
unmountRoot(root);
});
it("still requires sign-in for the invitation landing page", async () => {
mockAuthApi.getSession.mockResolvedValue(null);
const root = renderGate(container, true);
await waitForText(container, "Navigate:/auth?next=");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
it("still blocks invitation pages while cloud bootstrap is pending", async () => {
mockHealthApi.get.mockResolvedValue({ deploymentMode: "authenticated", deploymentExposure: "public", bootstrapStatus: "bootstrap_pending" });
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
const root = renderGate(container, true);
await waitForText(container, "This Paperclip is waiting on its first admin");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
it("keeps invitation pages closed when cloud access checks fail", async () => {
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
mockAccessApi.getCurrentBoardAccess.mockRejectedValueOnce(new Error("Access check unavailable"));
const root = renderGate(container, true);
await waitForText(container, "Unable to load Paperclip");
expect(container.querySelector("button")?.textContent).toBe("Try again");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
it("allows authenticated users with company access through to the board", async () => {
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
userId: "user-1",
isInstanceAdmin: false,
companyIds: ["company-1"],
source: "session",
keyId: null,
});
const root = renderGate(container);
await waitForText(container, "Outlet content");
expect(container.textContent).toContain("Outlet content");
expect(container.textContent).not.toContain("No organization access");
unmountRoot(root);
});
it("shows browser sign-in setup for signed-out private bootstrap-pending instances", async () => {
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "bootstrap_pending",
bootstrapInviteActive: false,
});
mockAuthApi.getSession.mockResolvedValue(null);
const root = renderGate(container);
await waitForText(container, "Finish setting up this Paperclip");
expect(container.textContent).toContain("Finish setting up this Paperclip");
expect(container.textContent).toContain("Sign in / Create account");
expect(container.textContent).toContain("npx paperclipai auth bootstrap-ceo");
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
unmountRoot(root);
});
it("shows the claim action for signed-in private bootstrap-pending instances", async () => {
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "bootstrap_pending",
bootstrapInviteActive: false,
});
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.claimBootstrapAdmin.mockResolvedValue({ claimed: true, userId: "user-1" });
const root = renderGate(container);
await waitForText(container, "Claim this instance");
expect(container.textContent).toContain("Claim this instance");
expect(container.textContent).toContain("Signed in as user@example.com");
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes("Claim this instance"),
);
expect(button).toBeTruthy();
flushSync(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await waitForText(container, "You're the instance admin");
expect(mockAccessApi.claimBootstrapAdmin).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("You're the instance admin");
expect(container.textContent).toContain("Continue to dashboard");
unmountRoot(root);
});
it("keeps public bootstrap-pending instances invite-only", async () => {
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
bootstrapStatus: "bootstrap_pending",
bootstrapInviteActive: true,
});
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
const root = renderGate(container);
await waitForText(container, "This Paperclip is waiting on its first admin");
expect(container.textContent).toContain("This Paperclip is waiting on its first admin");
expect(container.textContent).toContain("invite-only mode");
expect(container.textContent).not.toContain("Claim this instance");
expect(container.textContent).not.toContain("Sign in / Create account");
expect(mockAccessApi.claimBootstrapAdmin).not.toHaveBeenCalled();
unmountRoot(root);
});
});
describe("Skill Studio routes", () => {
it("registers create mode before the skillId route in prefixed and unprefixed routing", () => {
const createRoute = 'path="skills/studio/new"';
const detailRoute = 'path="skills/studio/:skillId"';
const createIndexes = [...appSource.matchAll(new RegExp(createRoute, "g"))].map((match) => match.index ?? -1);
const detailIndexes = [...appSource.matchAll(new RegExp(detailRoute, "g"))].map((match) => match.index ?? -1);
expect(createIndexes).toHaveLength(2);
expect(detailIndexes).toHaveLength(2);
expect(createIndexes[0]).toBeLessThan(detailIndexes[0]!);
expect(createIndexes[1]).toBeLessThan(detailIndexes[1]!);
});
});
describe("Apps routes", () => {
it("uses one connector landing page and redirects retired browse, connections, and audit URLs", () => {
expect(appSource).toContain('<Route path="apps" element={<Browse />} />');
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connections" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />');
expect(appSource).not.toContain('import { Connections }');
expect(appSource).toContain('<Route path="apps/byo" element={<AppsConnect byoOnly />} />');
expect(appSource).toContain('path="apps/vercel-connect"');
expect(appSource).toContain('<AppsConnectEntryRoute credentialSource="vercel_connect" />');
expect(appSource).toContain('<AppsConnect credentialSource={credentialSource} />');
expect(appSource).toContain('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/advanced/gateways" element={<GatewaysList />} />');
});
it("redirects legacy Rules and Health links to the remaining developer surfaces", () => {
expect(appSource).toContain('if (tab === "runtime" || tab === "audit") return "/apps";');
expect(appSource).toContain('if (tab === "policies") return "/apps/advanced/profiles";');
});
});
describe("Retired settings routes", () => {
it("redirects the removed heartbeats page to the settings root instead of dropping the route", () => {
expect(appSource).toContain(
'<Route path="company/settings/instance/heartbeats" element={<Navigate to="/company/settings" replace />} />',
);
expect(appSource).not.toContain("<InstanceSettings");
expect(appSource).not.toContain('"./pages/InstanceSettings"');
});
});
describe("Decisions routes", () => {
it("does not register decision-training views", () => {
expect(appSource).not.toContain('path="decisions/training"');
expect(appSource).not.toContain('path="decisions/training/:id"');
expect(appSource).not.toContain('path="training"');
expect(appSource).not.toContain('path="training/:id"');
});
});