Files
paperclip/ui/src/components/InstanceSidebar.test.tsx
T
Aron Prins 4811d8dd33 Fix wrapped company issue prefix conflicts (#6423)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Company creation is the first control-plane object operators create,
and the generated issue prefix becomes part of task identity.
> - The company service already retries when a generated issue prefix
collides with the `companies_issue_prefix_idx` unique constraint.
> - Drizzle 0.45.x wraps PostgreSQL errors in `DrizzleQueryError`,
leaving the real `23505` constraint error on the `.cause` chain.
> - The existing retry detector only inspected the top-level error, so
wrapped prefix collisions surfaced as 500s instead of retrying.
> - This pull request walks the error cause chain for the exact prefix
constraint and verifies the retry path against embedded Postgres.
> - The benefit is company creation no longer fails when generated
prefixes collide under Drizzle 0.45.x wrappers.

## What Changed

- Walk the error `.cause` chain when detecting
`companies_issue_prefix_idx` unique violations, with a cycle guard and
support for `constraint` / `constraint_name` fields.
- Added an embedded Postgres regression test that seeds `ARO`, creates
`Aron & Sharon`, and verifies the retry produces `AROA`.
- Stabilized existing async tests touched by full verification: instance
sidebar plugin rendering now waits for React Query results, and
Tailscale-unavailable CLI tests explicitly hide host `tailscale`
detection.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/companies-service.test.ts`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-stale-queue-invalidation.test.ts`
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/InstanceSidebar.test.tsx`
- `pnpm --filter paperclipai exec vitest run
src/__tests__/network-bind.test.ts src/__tests__/onboard.test.ts`
- `pnpm test:run`
- `pnpm -r typecheck`
- `pnpm build`

## Risks

- Low runtime risk: the retry behavior only expands detection for the
existing exact company issue-prefix unique constraint.
- The cause-chain walk is bounded by visited objects to avoid cycles.
- The sidebar and CLI changes are test-only stabilization and do not
change production behavior.

> 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 coding agent in Codex desktop, with local
shell/tool execution.

## 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] If this change affects the UI, I have included before/after
screenshots (N/A: no UI behavior change)
- [x] I have updated relevant documentation to reflect my changes (N/A:
bug fix with no user-facing docs change)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Closes #6350
2026-05-22 15:27:54 -05:00

281 lines
8.6 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRecord } from "@paperclipai/shared";
const mockPluginsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("@/api/plugins", () => ({
pluginsApi: mockPluginsApi,
}));
vi.mock("@/lib/router", () => ({
NavLink: ({
children,
to,
className,
}: {
children: ReactNode | ((arg: { isActive: boolean }) => ReactNode);
to: string;
state?: unknown;
end?: boolean;
onClick?: () => void;
className?: string | ((arg: { isActive: boolean }) => string);
}) => {
const resolvedClass =
typeof className === "function" ? className({ isActive: false }) : className;
const content = typeof children === "function" ? children({ isActive: false }) : children;
return (
<a href={to} className={resolvedClass}>
{content}
</a>
);
},
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
import { InstanceSidebar } from "./InstanceSidebar";
function makePlugin(overrides: Partial<PluginRecord> & { manifestJson: PluginRecord["manifestJson"] }): PluginRecord {
return {
id: overrides.id ?? "plugin-id",
pluginKey: overrides.pluginKey ?? "plugin-key",
packageName: overrides.packageName ?? "@scope/pkg",
version: overrides.version ?? "1.0.0",
apiVersion: overrides.apiVersion ?? 1,
categories: overrides.categories ?? [],
manifestJson: overrides.manifestJson,
status: overrides.status ?? "ready",
installOrder: overrides.installOrder ?? 0,
packagePath: overrides.packagePath ?? null,
lastError: overrides.lastError ?? null,
installedAt: overrides.installedAt ?? new Date(0),
updatedAt: overrides.updatedAt ?? new Date(0),
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function findPluginLinks(container: HTMLElement, expectedCount: number) {
await act(async () => {
await vi.waitFor(() => {
expect(container.querySelectorAll('a[href^="/instance/settings/plugins/"]')).toHaveLength(expectedCount);
});
});
return Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/plugins/"]'));
}
function renderSidebar(container: HTMLElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<InstanceSidebar />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
describe("InstanceSidebar", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = null;
mockPluginsApi.list.mockReset();
});
afterEach(async () => {
if (root) {
const currentRoot = root;
await act(async () => {
currentRoot.unmount();
});
}
queryClient?.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("filters out sandbox-provider-only plugins from the sidebar", async () => {
const sandboxPlugin = makePlugin({
id: "e2b",
packageName: "@paperclipai/plugin-e2b",
manifestJson: {
id: "e2b",
name: "E2B Sandbox Provider",
displayName: "E2B Sandbox Provider",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
});
const regularPlugin = makePlugin({
id: "linear",
packageName: "@paperclipai/plugin-linear",
manifestJson: {
id: "linear",
name: "Linear",
displayName: "Linear",
version: "1.0.0",
apiVersion: 1,
} as unknown as PluginRecord["manifestJson"],
});
mockPluginsApi.list.mockResolvedValue([sandboxPlugin, regularPlugin]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
const pluginLinks = await findPluginLinks(container, 1);
expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/linear");
expect(pluginLinks[0]?.textContent).toBe("Linear");
});
it("keeps plugins that mix sandbox-provider with other contributions", async () => {
const hybridPlugin = makePlugin({
id: "hybrid",
packageName: "@example/plugin-hybrid",
manifestJson: {
id: "hybrid",
name: "Hybrid",
displayName: "Hybrid",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "sb",
kind: "sandbox_provider",
displayName: "SB",
configSchema: { type: "object" },
},
{
driverKey: "env",
kind: "environment_driver",
displayName: "Env",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
});
mockPluginsApi.list.mockResolvedValue([hybridPlugin]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
const pluginLinks = await findPluginLinks(container, 1);
expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/hybrid");
});
it("renders the indented plugin list between the Plugins and Adapters rows", async () => {
mockPluginsApi.list.mockResolvedValue([
makePlugin({
id: "linear",
packageName: "@paperclipai/plugin-linear",
manifestJson: {
id: "linear",
name: "Linear",
displayName: "Linear",
version: "1.0.0",
apiVersion: 1,
} as unknown as PluginRecord["manifestJson"],
}),
]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
await findPluginLinks(container, 1);
await vi.waitFor(() => {
const links = Array.from(
container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/"]'),
);
expect(links.some((a) => a.getAttribute("href") === "/instance/settings/plugins/linear")).toBe(true);
});
const topLevelLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/"]'));
const hrefs = topLevelLinks.map((a) => a.getAttribute("href"));
const pluginsIndex = hrefs.indexOf("/instance/settings/plugins");
const adaptersIndex = hrefs.indexOf("/instance/settings/adapters");
const linearIndex = hrefs.indexOf("/instance/settings/plugins/linear");
expect(pluginsIndex).toBeGreaterThanOrEqual(0);
expect(adaptersIndex).toBeGreaterThan(pluginsIndex);
expect(linearIndex).toBeGreaterThan(pluginsIndex);
expect(linearIndex).toBeLessThan(adaptersIndex);
});
it("does not render the indented group when every plugin is filtered out", async () => {
mockPluginsApi.list.mockResolvedValue([
makePlugin({
id: "e2b",
packageName: "@paperclipai/plugin-e2b",
manifestJson: {
id: "e2b",
name: "E2B",
displayName: "E2B",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
}),
]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
await vi.waitFor(() => {
expect(mockPluginsApi.list).toHaveBeenCalled();
});
const pluginLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]'));
expect(pluginLinks).toHaveLength(0);
});
});