// @vitest-environment jsdom
import { flushSync } from "react-dom";
import 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 { ToolCatalogEntry } from "@paperclipai/shared";
import { ActionTestDialog, errorHints } from "./ActionTestDialog";
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const runTestCallMock = vi.hoisted(() => vi.fn());
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
const declineActionRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) => getTestAgentAccessMock(connectionId, agentId),
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
getTestCallStatus: (connectionId: string, requestId: string) => getTestCallStatusMock(connectionId, requestId),
declineActionRequest: (companyId: string, requestId: string) => declineActionRequestMock(companyId, requestId),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => {children},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const entry = {
id: "asana-list-projects",
companyId: "company-1",
applicationId: "app-asana",
connectionId: "conn-1",
entryKind: "tool",
toolName: "list_projects",
title: "List projects",
description: "Find Asana projects.",
inputSchema: { type: "object", properties: {} },
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-09-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-09-01T00:00:00Z"),
lastSeenAt: new Date("2026-10-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-09-01T00:00:00Z"),
updatedAt: new Date("2026-10-01T00:00:00Z"),
} as ToolCatalogEntry;
const askFirstEntry = {
...entry,
id: "asana-create-task",
toolName: "create_task",
title: "Create task",
riskLevel: "write",
isReadOnly: false,
isWrite: true,
} as ToolCatalogEntry;
let container: HTMLDivElement;
let root: ReturnType;
let client: QueryClient;
async function act(callback: () => void | Promise) {
let result: void | Promise | undefined;
flushSync(() => { result = callback(); });
await result;
}
async function flushReact() {
for (let i = 0; i < 4; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
async function renderDialog(testEntry = entry) {
await act(() => root.render(
undefined} />
,
));
await flushReact();
}
async function openAndRun(testEntry = entry) {
await renderDialog(testEntry);
const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === "Run");
expect(button).toBeTruthy();
await act(() => button!.click());
await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 260)); });
await flushReact();
}
function allowAskFirst() {
getTestAgentAccessMock.mockResolvedValue({
access: {
connectionId: "conn-1", toolCount: 1, allowedCount: 0, askFirstCount: 1, offCount: 0,
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
tools: [{ toolName: "create_task", gatewayToolName: "asana__create_task", displayName: "Create task", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] }],
},
});
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "ask-invocation", actionRequestId: "request-1" });
}
function requestStatus(phase: "waiting" | "running" | "done" | "denied") {
return {
actionRequestId: "request-1", invocationId: "ask-invocation", phase,
parameters: {}, requestedAt: "2026-10-01T00:00:00.000Z",
resolvedAt: phase === "waiting" ? null : "2026-10-01T00:00:05.000Z",
durationMs: phase === "done" ? 1200 : null,
};
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listTestAgentsMock.mockReset().mockResolvedValue({
agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }],
});
getTestAgentAccessMock.mockReset().mockResolvedValue({
access: {
connectionId: "conn-1", toolCount: 1, allowedCount: 1, askFirstCount: 0, offCount: 0,
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
tools: [{ toolName: "list_projects", gatewayToolName: "asana__list_projects", displayName: "List projects", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] }],
},
});
runTestCallMock.mockReset();
getTestCallStatusMock.mockReset().mockResolvedValue(requestStatus("waiting"));
declineActionRequestMock.mockReset();
window.sessionStorage.clear();
});
afterEach(async () => {
await act(() => root.unmount());
container.remove();
window.sessionStorage.clear();
});
describe("Permissions action Test dialog", () => {
it("forwards an explicitly cleared optional text field", async () => {
const textEntry = {
...entry,
inputSchema: { type: "object", properties: { update_text: { type: "string" } } },
} as ToolCatalogEntry;
runTestCallMock.mockResolvedValue({ decision: "allowed", invocationId: "empty-text" });
await renderDialog(textEntry);
const moreOptions = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "More options");
expect(moreOptions).toBeTruthy();
await act(() => moreOptions!.click());
const input = document.body.querySelector('input[aria-label="Update Text"]');
expect(input).toBeTruthy();
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(() => {
setValue!.call(input, "draft");
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(() => {
setValue!.call(input, "");
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
const runButton = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Run");
await act(() => runButton!.click());
await flushReact();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
agentId: "agent-ceo",
toolName: "list_projects",
parameters: { update_text: "" },
});
});
it("forwards false, zero, and an explicit object default without normalization", async () => {
const defaultsEntry = {
...entry,
inputSchema: {
type: "object",
properties: {
enabled: { type: "boolean", default: false },
count: { type: "integer", default: 0 },
options: {
type: "object",
default: { enabled: false, count: 0 },
required: ["enabled", "count"],
properties: {
enabled: { type: "boolean" },
count: { type: "integer" },
},
},
},
},
} as ToolCatalogEntry;
runTestCallMock.mockResolvedValue({ decision: "allowed", invocationId: "defaults" });
await renderDialog(defaultsEntry);
const runButton = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Run");
await act(() => runButton!.click());
await flushReact();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
agentId: "agent-ceo",
toolName: "list_projects",
parameters: { enabled: false, count: 0, options: { enabled: false, count: 0 } },
});
});
it("rejects an explicitly supplied empty optional object with required children", async () => {
const objectEntry = {
...entry,
inputSchema: {
type: "object",
properties: {
options: {
type: "object",
default: {},
required: ["name"],
properties: { name: { type: "string" } },
},
},
},
} as ToolCatalogEntry;
await renderDialog(objectEntry);
const runButton = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Run");
await act(() => runButton!.click());
await flushReact();
expect(document.body.textContent).toContain("This field is required");
expect(runTestCallMock).not.toHaveBeenCalled();
});
it("runs the actual ActionTester with Firecrawl's minimal URL input after opening More options", async () => {
const firecrawlEntry = {
...entry,
id: "firecrawl-scrape",
toolName: "firecrawl_scrape",
title: "Firecrawl scrape",
inputSchema: {
type: "object",
properties: {
url: { type: "string" },
profile: { type: "object", required: ["name"], properties: { name: { type: "string" } } },
queryOptions: {
type: "object",
required: ["prompt"],
properties: {
mode: { type: "string", enum: ["directQuote", "freeform"], default: "freeform" },
prompt: { type: "string" },
},
},
screenshotOptions: {
type: "object",
properties: {
viewport: {
type: "object",
required: ["width", "height"],
properties: { width: { type: "number" }, height: { type: "number" } },
},
},
},
},
},
} as ToolCatalogEntry;
getTestAgentAccessMock.mockResolvedValue({
access: {
connectionId: "conn-1", toolCount: 1, allowedCount: 1, askFirstCount: 0, offCount: 0,
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
tools: [{ toolName: "firecrawl_scrape", gatewayToolName: "firecrawl__firecrawl_scrape", displayName: "Firecrawl scrape", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] }],
},
});
runTestCallMock.mockResolvedValue({ decision: "allowed", invocationId: "firecrawl-minimal" });
await renderDialog(firecrawlEntry);
const moreOptions = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "More options");
expect(moreOptions).toBeTruthy();
await act(() => moreOptions!.click());
const urlInput = document.body.querySelector('input[aria-label="Url"]');
expect(urlInput, document.body.textContent).toBeTruthy();
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(() => {
setValue!.call(urlInput, "https://paperclip.ing");
urlInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
const runButton = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Run");
expect(runButton).toBeTruthy();
await act(() => runButton!.click());
await flushReact();
expect(document.body.textContent).not.toContain("This field is required");
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
agentId: "agent-ceo",
toolName: "firecrawl_scrape",
parameters: { url: "https://paperclip.ing" },
});
});
it("renders structured MCP rows and keeps the full response behind the raw disclosure", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "structured",
result: {
content: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}',
data: {
content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}' }],
structuredContent: { data: [{ name: "Q4 launch" }, { name: "Customer onboarding" }] },
isError: false, transport: "mcp_http",
},
},
});
await openAndRun();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", { agentId: "agent-ceo", toolName: "list_projects", parameters: {} });
expect(document.body.textContent).toContain("Worked. 2 rows came back.");
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
expect(document.body.textContent).not.toContain("structuredContent");
const raw = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Show raw response");
await act(() => raw!.click());
expect(document.body.textContent).toContain("structuredContent");
});
it("parses JSON text when structured content is absent", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "text",
result: { content: '{"data":[{"name":"Q4 launch"}]}', data: { content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"}]}' }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
});
it("uses structured content when an MCP result has no content blocks", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "structured-only",
result: { content: "", data: { structuredContent: { results: [{ name: "Northstar launch plan" }] }, isError: false } },
});
await openAndRun();
expect(document.body.textContent).toContain("Worked. 1 row came back.");
expect(document.body.querySelector("table")?.textContent).toContain("Northstar launch plan");
expect(document.body.textContent).not.toContain("structuredContent");
});
it("shows wide search results as readable cards with a safe page link", async () => {
const rows = [{
id: "11111111-2222-4333-8444-555555555555",
title: "Northstar launch plan",
url: "https://www.notion.so/11111111222243338444555555555555",
type: "page",
highlight: "The launch plan covers milestones, owners, and a detailed summary that should not stretch a table row in the dialog.",
timestamp: "2026-09-10T14:30:00.000Z",
}];
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "notion-wide",
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.querySelector("table")).toBeNull();
expect(document.body.textContent).toContain("Northstar launch plan");
expect(document.body.textContent).toContain("The launch plan covers milestones");
expect(document.body.querySelector('a[href="https://www.notion.so/11111111222243338444555555555555"]')?.textContent).toBe("Open link");
});
it("shows a non-link URL as a field in a result card", async () => {
const rows = [{ title: "Northstar launch plan", url: "notion://workspace/launch", description: "A long description that keeps the card layout useful even when the provider supplies a URL that cannot open in a browser." }];
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "non-link-url",
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], isError: false } },
});
await openAndRun();
expect(document.body.querySelector('a[href="notion://workspace/launch"]')).toBeNull();
expect(document.body.textContent).toContain("notion://workspace/launch");
});
it("keeps an ask-first request visible after reopening the dialog", async () => {
allowAskFirst();
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Sent for your OK.");
expect(document.body.textContent).toContain("Cancel this request");
expect(document.body.querySelector('a[href="/apps/conn-1/review"]')).toBeTruthy();
await act(() => root.unmount());
root = createRoot(container);
await renderDialog(askFirstEntry);
expect(runTestCallMock).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Sent for your OK.");
});
it("shows an approved request running and then the completed result", async () => {
allowAskFirst();
getTestCallStatusMock.mockResolvedValue(requestStatus("running"));
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Approved · running");
expect(document.body.textContent).not.toContain("Cancel this request");
getTestCallStatusMock.mockResolvedValue({ ...requestStatus("done"), result: [{ name: "Northstar launch plan" }] });
await act(async () => { await client.invalidateQueries({ queryKey: ["tools"] }); });
await flushReact();
expect(document.body.textContent).toContain("Worked.");
expect(document.body.textContent).toContain("Northstar launch plan");
expect(runTestCallMock).toHaveBeenCalledTimes(1);
});
it("shows a denied request without offering cancellation", async () => {
allowAskFirst();
getTestCallStatusMock.mockResolvedValue(requestStatus("denied"));
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Denied — see Review for why");
expect(document.body.textContent).not.toContain("Cancel this request");
});
it("shows a tool error and opens its raw response", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "error",
result: { content: "Asana denied access to this workspace.", data: { content: [{ type: "text", text: "Asana denied access to this workspace." }], isError: true }, error: "MCP tool returned an error result" },
});
await openAndRun();
expect(document.body.textContent).toContain("It didn't work.");
expect(document.body.textContent).toContain("Asana denied access to this workspace.");
expect(document.body.textContent).toContain("Hide raw response");
});
it("opens the raw response when an MCP block has an unexpected shape", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "malformed",
result: { content: "", data: { content: [{ type: "text", payload: "missing text" }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.textContent).toContain("Hide raw response");
expect(document.body.textContent).toContain("missing text");
});
it("keeps useful and generic error hints", () => {
expect(errorHints("Not found", "NOT_FOUND").join(" ")).toMatch(/check|verify|ID/i);
expect(errorHints("Unexpected provider failure", null).length).toBeGreaterThan(0);
});
});