mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
The remaining instances of the pattern #11499 and #11521 replaced in the routing tests, found by grepping `attempt < N` across the suite. Budgets of 20, 25 and 30 turns rather than 3 and 5, which is why they surfaced far less often - SkillStudio was among the failures seen while verifying the earlier PRs. Three of the four were reimplementations of `vi.waitFor` down to rethrowing the last error, differing only in bounding on turns rather than on time. The fourth was the text variant. Two `flushReact` helpers became dead with the loops that used them and are removed. Verified on the mechanism, since budgets this large pass until the machine is loaded and so prove nothing by passing: a throwaway probe drove a 30-turn loop and `vi.waitFor` against a value landing at turn 60. The loop throws, `vi.waitFor` reaches it. Not a lateral move between arbitrary bounds. This closes one spelling of the pattern, not the class, and the sweep that found these was too narrow. Two other shapes do the same thing and a grep for `attempt < N` cannot see either: a fixed-cycle helper, `flushReact(cycles = 4)` in AgentToolsTab.test.tsx, and fixed-duration sleeps in AgentToolsTab, CompanyContext, AgentConfigForm.render, Artifacts, Search and ImportFromVaultDialog. `AgentToolsTab > autosaves installed apps for the current agent` failed one of three full-suite runs here, holds both shapes, and is untouched by this change. Refs #11484. ui typecheck clean; the four files pass; full suite passes two of three runs, the third failing only on that pre-existing instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
265 lines
9.4 KiB
TypeScript
265 lines
9.4 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 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(),
|
|
}));
|
|
|
|
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) {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<CloudAccessGate />
|
|
</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("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 company access");
|
|
|
|
expect(container.textContent).toContain("No company access");
|
|
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 company 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 browse as the Apps landing page and gives connections a canonical URL", () => {
|
|
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={<Connections />} />');
|
|
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 />} />');
|
|
});
|
|
});
|
|
|
|
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"');
|
|
});
|
|
});
|