mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Use the shared animated prompt-copy control across setup, invitations, webhooks, and task handoffs. Preserve first-click copying, clipboard recovery, and logo continuity. Add Storybook coverage and restore mention icon masks for the current Lucide data shape. Co-Authored-By: Paperclip <noreply@paperclip.ing>
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
import { NewAgentDialog } from "./NewAgentDialog";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
vi.mock("@/api/instanceSettings", () => ({
|
|
instanceSettingsApi: {
|
|
getExperimental: async () => ({ enableNativeRunner: true }),
|
|
},
|
|
}));
|
|
const invites = vi.hoisted(() => ({ createCompanyInvite: vi.fn(), getInviteOnboarding: vi.fn(), copy: vi.fn() }));
|
|
vi.mock("../api/access", () => ({ accessApi: invites }));
|
|
vi.mock("../lib/clipboard", () => ({ copyTextToClipboard: invites.copy }));
|
|
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) }));
|
|
const state = vi.hoisted(() => ({
|
|
adapters: [] as object[],
|
|
navigate: vi.fn(),
|
|
close: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/router", () => ({ useNavigate: () => state.navigate }));
|
|
vi.mock("../context/DialogContext", () => ({
|
|
useDialog: () => ({ newAgentOpen: true, closeNewAgent: state.close }),
|
|
}));
|
|
vi.mock("@/api/adapters", () => ({
|
|
adaptersApi: { list: async () => state.adapters },
|
|
}));
|
|
vi.mock("./onboarding/PillGuy", () => ({ PillGuy: () => null }));
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
let root: Root;
|
|
let container: HTMLDivElement;
|
|
let cache: QueryClient;
|
|
async function click(label: string) {
|
|
const button = [...document.querySelectorAll("button")].find(
|
|
(b) => b.textContent?.trim() === label,
|
|
);
|
|
expect(button).toBeTruthy();
|
|
await act(async () => button!.click());
|
|
}
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
invites.createCompanyInvite.mockResolvedValue({ token: "one-time-token", onboardingTextPath: "/api/invites/one-time-token/onboarding.txt" });
|
|
invites.getInviteOnboarding.mockResolvedValue({ onboarding: { connectivity: {} } });
|
|
invites.copy.mockResolvedValue(undefined);
|
|
state.adapters = [
|
|
{ type: "codex_local", loaded: true },
|
|
{ type: "paperclip_runner", loaded: true },
|
|
{ type: "claude_local", loaded: true, disabled: true },
|
|
];
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
cache = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await act(async () =>
|
|
root.render(
|
|
<QueryClientProvider client={cache}>
|
|
<NewAgentDialog />
|
|
</QueryClientProvider>,
|
|
),
|
|
);
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
});
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
cache.clear();
|
|
container.remove();
|
|
});
|
|
async function name() {
|
|
const input = document.querySelector("input")!;
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(
|
|
HTMLInputElement.prototype,
|
|
"value",
|
|
)!.set!.call(input, "Ada & Co");
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await click("Choose adapter");
|
|
}
|
|
it("requires a name and an enabled adapter before navigating", async () => {
|
|
await click("Choose adapter");
|
|
expect(document.body.textContent).toContain("Agent name");
|
|
await name();
|
|
expect(document.querySelector('input[value="claude_local"]')).toBeNull();
|
|
await click("Configure agent");
|
|
expect(state.navigate).not.toHaveBeenCalled();
|
|
await act(async () =>
|
|
(
|
|
document.querySelector('input[value="codex_local"]') as HTMLInputElement
|
|
).click(),
|
|
);
|
|
await click("Configure agent");
|
|
expect(state.close).toHaveBeenCalledTimes(1);
|
|
const query = new URL(state.navigate.mock.calls[0][0], "http://local")
|
|
.searchParams;
|
|
expect(query.get("name")).toBe("Ada & Co");
|
|
expect(query.get("adapterType")).toBe("codex_local");
|
|
});
|
|
it("offers native Codex, Claude ACPX, and OpenCode runners", async () => {
|
|
await name();
|
|
await act(async () =>
|
|
(
|
|
document.querySelector(
|
|
'input[value="paperclip_runner"]',
|
|
) as HTMLInputElement
|
|
).click(),
|
|
);
|
|
const options = [...document.querySelectorAll("option")].map(
|
|
(option) => option.textContent,
|
|
);
|
|
expect(options).toContain("Codex (app server)");
|
|
expect(options).toContain("Claude (ACPX)");
|
|
expect(options).toContain("OpenCode");
|
|
expect(options.join(" ")).not.toContain("ACPX Codex");
|
|
});
|
|
|
|
it.each([false, undefined])(
|
|
"hides the runner unless explicitly enabled (%s)",
|
|
async (enableNativeRunner) => {
|
|
await act(async () => {
|
|
cache.setQueryData(queryKeys.instance.experimentalSettings, {
|
|
enableNativeRunner,
|
|
});
|
|
});
|
|
await name();
|
|
expect(
|
|
document.querySelector('input[value="paperclip_runner"]'),
|
|
).toBeNull();
|
|
expect(document.querySelector('input[value="codex_local"]')).not.toBeNull();
|
|
},
|
|
);
|
|
|
|
it.each([true, false, undefined])("gates the Cloud native runner on explicit enablement (%s)", async (enableNativeRunner) => {
|
|
await act(async () => {
|
|
cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner });
|
|
cache.setQueryData(queryKeys.health, {
|
|
status: "ok",
|
|
cloud: { managed: true },
|
|
});
|
|
cache.setQueryData(
|
|
queryKeys.adapters.all,
|
|
[
|
|
"claude_local",
|
|
"codex_local",
|
|
"opencode_local",
|
|
"cursor",
|
|
"cursor_cloud",
|
|
"gemini_local",
|
|
"grok_local",
|
|
"kimi_local",
|
|
"pi_local",
|
|
"hermes_local",
|
|
"paperclip_runner",
|
|
].map((type) => ({ type, loaded: true })),
|
|
);
|
|
});
|
|
await name();
|
|
expect(
|
|
[...document.querySelectorAll<HTMLInputElement>('input[type="radio"]')].map(
|
|
(input) => input.value,
|
|
),
|
|
).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local", ...(enableNativeRunner ? ["paperclip_runner"] : [])]);
|
|
await act(async () =>
|
|
document.querySelector<HTMLInputElement>('input[value="grok_local"]')!.click(),
|
|
);
|
|
await click("Configure agent");
|
|
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
|
|
expect(query.get("adapterType")).toBe("grok_local");
|
|
expect(query.get("name")).toBe("Ada & Co");
|
|
});
|
|
|
|
it("keeps agent-only invitations reachable from the new-agent flow", async () => {
|
|
await click("Invite an external agent");
|
|
const message = document.querySelector("textarea")!;
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(message, "Help with research");
|
|
message.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await click("Generate onboarding prompt");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledWith("company-1", {
|
|
allowedJoinTypes: "agent", humanRole: null, agentMessage: "Help with research",
|
|
});
|
|
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
|
|
expect(invites.copy).toHaveBeenCalledTimes(1);
|
|
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("true");
|
|
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Copy onboarding prompt"]')!.click());
|
|
expect(invites.copy).toHaveBeenCalledTimes(2);
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
|
|
expect(document.querySelector('pre[aria-label="Setup prompt"]')?.textContent).toBe(invites.copy.mock.calls[0][0]);
|
|
expect(state.navigate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the generated invitation readable when clipboard access fails", async () => {
|
|
invites.copy.mockRejectedValue(new Error("Clipboard unavailable"));
|
|
invites.getInviteOnboarding.mockRejectedValue(new Error("Manifest unavailable"));
|
|
await click("Invite an external agent");
|
|
await click("Generate onboarding prompt");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.body.textContent).toContain("Copy the prompt manually");
|
|
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
|
|
});
|
|
|
|
it("clears the invitation copy failure after retrying without generating another invite", async () => {
|
|
invites.copy.mockRejectedValueOnce(new Error("Clipboard unavailable"));
|
|
await click("Invite an external agent");
|
|
await click("Generate onboarding prompt");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.body.textContent).toContain("Clipboard unavailable");
|
|
await click("Copy onboarding prompt");
|
|
expect(invites.copy).toHaveBeenCalledTimes(2);
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
|
|
expect(document.body.textContent).not.toContain("Clipboard unavailable");
|
|
expect(document.body.textContent).toContain("Copied to clipboard");
|
|
});
|
|
|
|
it("shows the generated invitation while automatic clipboard access is pending", async () => {
|
|
let finishCopy!: () => void;
|
|
invites.copy.mockImplementationOnce(() => new Promise<void>((resolve) => { finishCopy = resolve; }));
|
|
await click("Invite an external agent");
|
|
await click("Generate onboarding prompt");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
|
|
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("false");
|
|
await act(async () => finishCopy());
|
|
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("true");
|
|
});
|
|
|
|
it("keeps a newer user copy result when the automatic invitation copy fails", async () => {
|
|
let failAutomaticCopy!: (error: Error) => void;
|
|
let finishUserCopy!: () => void;
|
|
invites.copy
|
|
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => { failAutomaticCopy = reject; }))
|
|
.mockImplementationOnce(() => new Promise<void>((resolve) => { finishUserCopy = resolve; }));
|
|
await click("Invite an external agent");
|
|
await click("Generate onboarding prompt");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
await click("Copy onboarding prompt");
|
|
await act(async () => failAutomaticCopy(new Error("Automatic copy failed")));
|
|
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copying…");
|
|
await act(async () => finishUserCopy());
|
|
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
|
|
expect(document.body.textContent).not.toContain("Clipboard unavailable");
|
|
expect(invites.copy).toHaveBeenCalledTimes(2);
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
|
|
});
|