paperclip/ui/src/features/connections/ConnectionIntentInteraction...

405 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { act as reactAct, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ConnectionIntentInteraction,
ToolConnection,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
connectedConnectionIntentInteraction,
issueThreadInteractionFixtureMeta,
pendingConnectionIntentInteraction,
retryConnectionIntentInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
const setupOptionsMock = vi.hoisted(() => vi.fn());
const completeMock = vi.hoisted(() => vi.fn());
const declineMock = vi.hoisted(() => vi.fn());
const setPhaseMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/connection-intents", () => ({
connectionIntentsApi: {
setupOptions: (...args: unknown[]) => setupOptionsMock(...args),
complete: (...args: unknown[]) => completeMock(...args),
decline: (...args: unknown[]) => declineMock(...args),
setPhase: (...args: unknown[]) => setPhaseMock(...args),
},
}));
vi.mock("./ConnectionSetupFlow", () => ({
ConnectionSetupFlow: (props: {
requestedAgentId?: string;
existingConnections?: ToolConnection[];
onUseExisting?: (id: string) => Promise<void>;
onComplete?: (completion: { connectionId: string }) => void;
onPhaseChange?: (phase: "needs_retry") => void;
onCancel?: () => void;
}) => (
<div data-testid="shared-connection-setup">
<span data-testid="requested-agent">{props.requestedAgentId}</span>
<span data-testid="existing-count">
{props.existingConnections?.length ?? 0}
</span>
{props.existingConnections?.map((connection) => (
<button
key={connection.id}
onClick={() => void props.onUseExisting?.(connection.id)}
>
Use {connection.name}
</button>
))}
<button
onClick={() => props.onComplete?.({ connectionId: "connection-new" })}
>
Connect new
</button>
<button onClick={() => props.onPhaseChange?.("needs_retry")}>
Simulate retry
</button>
<button onClick={props.onCancel}>Cancel setup</button>
</div>
),
}));
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
let host: HTMLDivElement | null = null;
let queryClient: QueryClient;
async function act(callback: () => void | Promise<void>) {
await reactAct(callback);
}
async function flush() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function terminal(
status: ConnectionIntentInteraction["status"],
outcome: "declined" | "superseded" | "expired",
): ConnectionIntentInteraction {
return {
...pendingConnectionIntentInteraction,
id: `interaction-${outcome}`,
status,
resolvedAt: new Date("2026-08-26T12:00:00.000Z"),
result: { version: 1, outcome },
} as ConnectionIntentInteraction;
}
function renderNode(node: ReactNode) {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
void act(() => {
root?.render(
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>,
);
});
return host;
}
function renderBody(
interaction: ConnectionIntentInteraction = pendingConnectionIntentInteraction,
currentUserId:
string | null = issueThreadInteractionFixtureMeta.currentUserId,
) {
return renderNode(
<ConnectionIntentInteractionBody
interaction={interaction}
currentUserId={currentUserId}
addresseeLabel="Carol"
/>,
);
}
function button(label: string) {
return Array.from(document.body.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.trim() === label || (label === "Connect / Use existing" && candidate.textContent?.trim() === "Connect"),
) as HTMLButtonElement | undefined;
}
beforeEach(() => {
setupOptionsMock.mockReset();
completeMock.mockReset();
declineMock.mockReset();
setPhaseMock.mockReset();
setupOptionsMock.mockResolvedValue({
requestedAgentId:
pendingConnectionIntentInteraction.payload.requestingAgentId,
existingConnections: [],
});
completeMock.mockResolvedValue({});
declineMock.mockResolvedValue({});
setPhaseMock.mockResolvedValue({});
});
afterEach(async () => {
if (root) await act(() => root?.unmount());
host?.remove();
document.body
.querySelectorAll("[data-radix-focus-guard]")
.forEach((node) => node.remove());
root = null;
host = null;
});
describe("ConnectionIntentInteractionBody states and audience", () => {
it.each([
[pendingConnectionIntentInteraction, "Connect"],
[
{
...pendingConnectionIntentInteraction,
payload: {
...pendingConnectionIntentInteraction.payload,
phase: "authorizing",
},
},
"Continue setup",
],
[retryConnectionIntentInteraction, "Try again"],
[connectedConnectionIntentInteraction, "Notion connected"],
[terminal("rejected", "declined"), "Connection declined"],
[terminal("expired", "superseded"), "Request superseded"],
[terminal("expired", "expired"), "Connection request expired"],
] as const)("renders the %s state", (interaction, expected) => {
renderBody(interaction as ConnectionIntentInteraction);
expect(document.body.textContent).toContain(expected);
});
it("shows non-addressees only the waiting state and never loads setup", () => {
renderBody(pendingConnectionIntentInteraction, "other-user");
expect(document.body.textContent).toContain("Waiting for Carol");
expect(button("Connect / Use existing")).toBeUndefined();
expect(button("Not now")).toBeUndefined();
expect(setupOptionsMock).not.toHaveBeenCalled();
expect(document.body.innerHTML).not.toMatch(
/authorizationUrl|bearer|credential/i,
);
});
});
describe("ConnectionIntentInteractionBody dialog behavior", () => {
it("shows loading, then passes existing choices and the locked requesting agent to the shared flow", async () => {
let resolveSetup!: (value: unknown) => void;
setupOptionsMock.mockReturnValue(
new Promise((resolve) => {
resolveSetup = resolve;
}),
);
renderBody();
await act(() => button("Connect / Use existing")?.click());
expect(document.body.textContent).toContain("Loading connection options…");
await act(async () => {
resolveSetup({
requestedAgentId: "agent-requesting",
existingConnections: [
{ id: "connection-one", name: "Carol's Notion" },
{ id: "connection-two", name: "Team Notion" },
],
});
});
await flush();
expect(
document.querySelector('[data-testid="requested-agent"]')?.textContent,
).toBe("agent-requesting");
expect(
document.querySelector('[data-testid="existing-count"]')?.textContent,
).toBe("2");
expect(button("Use Carol's Notion")).toBeDefined();
expect(button("Use Team Notion")).toBeDefined();
expect(button("Connect new")).toBeDefined();
});
it("keeps a load failure open and recovers through the query retry", async () => {
setupOptionsMock.mockRejectedValueOnce(new Error("setup unavailable"));
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
expect(document.body.textContent).toContain(
"Couldn’t load connection setup",
);
expect(document.body.textContent).toContain("setup unavailable");
setupOptionsMock.mockResolvedValueOnce({
requestedAgentId: "agent-requesting",
existingConnections: [],
});
await act(() => button("Try again")?.click());
await flush();
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).not.toBeNull();
});
it("completes an existing connection, closes, restores focus, and invalidates each task query once", async () => {
setupOptionsMock.mockResolvedValue({
requestedAgentId: "agent-requesting",
existingConnections: [{ id: "connection-one", name: "Carol's Notion" }],
});
renderBody();
const trigger = button("Connect / Use existing")!;
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
await act(() => trigger.click());
await flush();
await act(() => button("Use Carol's Notion")?.click());
await flush();
expect(completeMock).toHaveBeenCalledWith(
pendingConnectionIntentInteraction.id,
"connection-one",
);
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).toBeNull();
await waitForAssertion(() =>
expect(document.activeElement).toBe(
document.querySelector(
'[data-testid="connection-intent-focus-target"]',
),
),
);
expect(invalidation).toHaveBeenCalledTimes(2);
expect(invalidation).toHaveBeenCalledWith({
queryKey: ["issues", "interactions"],
});
expect(invalidation).toHaveBeenCalledWith({
queryKey: ["issues", "detail"],
});
});
it("restores focus when completion remounts the intent in a different task host", async () => {
setupOptionsMock.mockResolvedValue({
requestedAgentId: "agent-requesting",
existingConnections: [{ id: "connection-one", name: "Carol's Notion" }],
});
const acceptedInteraction = {
...pendingConnectionIntentInteraction,
status: "accepted",
resolvedAt: new Date("2026-08-26T12:00:00.000Z"),
result: {
version: 1,
outcome: "connected",
connectionId: "connection-one",
},
} as ConnectionIntentInteraction;
let showAcceptedInteraction!: () => void;
function RemountingTaskHost() {
const [interaction, setInteraction] = useState(
pendingConnectionIntentInteraction,
);
showAcceptedInteraction = () => setInteraction(acceptedInteraction);
return (
<ConnectionIntentInteractionBody
key={interaction.status}
interaction={interaction}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
addresseeLabel="Carol"
/>
);
}
completeMock.mockImplementation(async () => {
showAcceptedInteraction();
return acceptedInteraction;
});
renderNode(<RemountingTaskHost />);
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Use Carol's Notion")?.click());
await waitForAssertion(() => {
const target = document.getElementById(
`connection-intent-focus-target-${acceptedInteraction.id}`,
);
expect(document.body.textContent).toContain("Notion connected");
expect(document.activeElement).toBe(target);
});
});
it("keeps the dialog open and surfaces completion failures", async () => {
completeMock.mockRejectedValue(new Error("install commit failed"));
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Connect new")?.click());
await flush();
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).not.toBeNull();
expect(
document.body.querySelector('[role="alert"]')?.textContent,
).toContain("install commit failed");
});
it("declines once with pending controls disabled and invalidates each task query once", async () => {
let finishDecline!: () => void;
declineMock.mockReturnValue(
new Promise<void>((resolve) => {
finishDecline = resolve;
}),
);
renderBody();
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
const decline = button("Not now")!;
await act(() => decline.click());
await flush();
expect(decline.disabled).toBe(true);
decline.click();
expect(declineMock).toHaveBeenCalledTimes(1);
await act(async () => finishDecline());
await flush();
expect(invalidation).toHaveBeenCalledTimes(2);
});
it("turns shared-flow retry signals into the server-authored retry phase", async () => {
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Simulate retry")?.click());
await flush();
expect(setPhaseMock).toHaveBeenCalledWith(
pendingConnectionIntentInteraction.id,
"needs_retry",
);
});
});