Files
multica/packages/views/agents/components/create-agent-dialog.test.tsx

412 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { configStore } from "@multica/core/config";
import { COMPOSIO_MCP_APPS_FLAG } from "@multica/core/feature-flags";
import { NavigationProvider, type NavigationAdapter } from "../../navigation";
import enCommon from "../../locales/en/common.json";
import enAgents from "../../locales/en/agents.json";
const navigationStub: NavigationAdapter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/",
searchParams: new URLSearchParams(),
getShareableUrl: (path: string) => path,
};
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// ModelDropdown talks to the api; the create dialog only needs it as a
// stand-in here, so swap it out.
vi.mock("./model-dropdown", () => ({
ModelDropdown: () => null,
}));
// Provider logos don't matter for these assertions but they pull in SVGs.
vi.mock("../../runtimes/components/provider-logo", () => ({
ProviderLogo: () => null,
}));
// Avatars hit the api for member metadata.
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: () => null,
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
import { CreateAgentDialog } from "./create-agent-dialog";
const ME = "user-me";
const OTHER = "user-other";
const members: MemberWithUser[] = [
{
id: "m-me",
user_id: ME,
workspace_id: "ws-1",
role: "member",
name: "Me",
email: "me@example.com",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
},
{
id: "m-other",
user_id: OTHER,
workspace_id: "ws-1",
role: "member",
name: "Other",
email: "other@example.com",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
},
];
function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
return {
id: "rt",
workspace_id: "ws-1",
daemon_id: null,
name: "Test Runtime",
runtime_mode: "local",
provider: "claude",
launch_header: "",
status: "online",
device_info: "host.local",
metadata: {},
owner_id: ME,
visibility: "private",
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
...overrides,
};
}
function makeTemplate(runtimeId: string): Agent {
return {
id: "agent-template",
workspace_id: "ws-1",
runtime_id: runtimeId,
name: "Template Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_args: [],
visibility: "private",
permission_mode: "private",
invocation_targets: [],
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: ME,
skills: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
};
}
function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const onCreate = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<WorkspaceSlugProvider slug="test-ws">
<NavigationProvider value={navigationStub}>
<CreateAgentDialog
runtimes={runtimes}
members={members}
currentUserId={ME}
template={template}
onClose={onClose}
onCreate={onCreate}
/>
</NavigationProvider>
</WorkspaceSlugProvider>
</QueryClientProvider>
</I18nProvider>,
);
return { onCreate, onClose };
}
describe("CreateAgentDialog runtime visibility gate", () => {
beforeEach(() => vi.clearAllMocks());
// Base UI Dialog renders into a portal on document.body and leaves
// focus-guard / inert wrapper divs around after the React tree unmounts.
// The auto-cleanup from @testing-library/react drops the container but
// not the portal residue, so two-tests-in-a-row queries see double
// matches ("All", "My Runtime"). Force cleanup + wipe body between tests.
afterEach(() => {
cleanup();
document.body.innerHTML = "";
});
it("disables another member's private runtime in the picker", () => {
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
owner_id: OTHER,
visibility: "private",
});
renderDialog([mine, othersPrivate]);
// Flip to "All" so other-owned runtimes show.
fireEvent.click(screen.getByText("All"));
// Open the picker.
fireEvent.click(
screen.getByText("My Runtime", { selector: "span.truncate" }),
);
const disabledRow = screen
.getByText("Others Private")
.closest("button") as HTMLButtonElement;
expect(disabledRow).not.toBeNull();
expect(disabledRow.disabled).toBe(true);
expect(disabledRow.title).toMatch(/Private runtime/i);
});
it("lets a plain member pick another member's public runtime", () => {
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
const othersPublic = makeRuntime({
id: "rt-others-public",
name: "Others Public",
owner_id: OTHER,
visibility: "public",
});
renderDialog([mine, othersPublic]);
fireEvent.click(screen.getByText("All"));
fireEvent.click(
screen.getByText("My Runtime", { selector: "span.truncate" }),
);
const publicRow = screen
.getByText("Others Public")
.closest("button") as HTMLButtonElement;
expect(publicRow).not.toBeNull();
expect(publicRow.disabled).toBe(false);
});
it("defaults the selected runtime to a usable one, not a locked private", () => {
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
owner_id: OTHER,
visibility: "private",
});
const mine = makeRuntime({
id: "rt-mine",
name: "My Runtime",
owner_id: ME,
visibility: "private",
});
renderDialog([othersPrivate, mine]);
// The trigger label shows the selected runtime name. The picker must
// not seed with the other-owned private runtime even if it sorted
// first in the input list.
expect(screen.queryByText("Others Private", { selector: "span.truncate" })).toBeNull();
expect(screen.getByText("My Runtime", { selector: "span.truncate" })).toBeInTheDocument();
});
it("in duplicate mode, does not pre-fill the template's runtime when it's now locked", async () => {
// Template runtime is owned by someone else and now private — the
// duplicate flow used to seed with it anyway, leaving the user with
// a Create button that 403s server-side. Now we fall back to the
// first usable runtime instead.
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
owner_id: OTHER,
visibility: "private",
});
const mine = makeRuntime({
id: "rt-mine",
name: "My Runtime",
owner_id: ME,
visibility: "private",
});
const template = makeTemplate("rt-others-private");
const { onCreate } = renderDialog([othersPrivate, mine], template);
expect(
screen.getByText("My Runtime", { selector: "span.truncate" }),
).toBeInTheDocument();
expect(
screen.queryByText("Others Private", { selector: "span.truncate" }),
).toBeNull();
// Sanity check: with a usable selection seeded, Create should submit.
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
});
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
// Edge case: template points at a locked runtime AND the workspace
// has no usable alternatives in scope. The defense-in-depth gate on
// the Create button must keep the user from submitting a 403.
const onlyOthersPrivate = makeRuntime({
id: "rt-only-others-private",
name: "Only Others Private",
owner_id: OTHER,
visibility: "private",
});
// Flip the picker to "All" so the locked runtime is at least
// visible — that's the scope where the selected-but-locked state
// can persist after the initial seed search returns nothing.
const template = makeTemplate("rt-only-others-private");
renderDialog([onlyOthersPrivate], template);
// The Create button is rendered by lucide-free CTA text "Create".
const createBtn = screen
.getAllByRole("button")
.find((b) => b.textContent === "Create");
expect(createBtn).toBeDefined();
expect((createBtn as HTMLButtonElement).disabled).toBe(true);
});
});
describe("CreateAgentDialog access picker (MUL-4010, feature-flag gated)", () => {
beforeEach(() => {
vi.clearAllMocks();
// The dialog's default (workspace) still needs to be usable by ME:
// reset flags before every test so a stray "on" state in one test
// can't bleed into the next.
configStore.getState().setFeatureFlags({});
});
afterEach(() => {
cleanup();
document.body.innerHTML = "";
configStore.getState().setFeatureFlags({});
});
it("keeps the legacy Workspace/Personal toggle when the flag is OFF", async () => {
configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: false });
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME });
const { onCreate } = renderDialog([mine]);
// Legacy copy is rendered — matches VISIBILITY_DESCRIPTION.
expect(screen.getByText(/All members can assign/i)).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("e.g. Deep Research Agent"), {
target: { value: "Legacy Agent" },
});
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
const payload = onCreate.mock.calls[0]?.[0];
expect(payload).toBeDefined();
// Legacy path submits visibility, NOT permission_mode/invocation_targets.
expect(payload.visibility).toBe("workspace");
expect(payload.permission_mode).toBeUndefined();
expect(payload.invocation_targets).toBeUndefined();
});
it("submits permission_mode=public_to + workspace target when the flag is ON (default)", async () => {
configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: true });
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME });
const { onCreate } = renderDialog([mine]);
// New copy replaces the old one.
expect(screen.getByText("Only you can run this agent")).toBeInTheDocument();
expect(screen.getByText("Entire workspace")).toBeInTheDocument();
expect(screen.getByText("Specific people")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("e.g. Deep Research Agent"), {
target: { value: "Access Agent" },
});
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
const payload = onCreate.mock.calls[0]?.[0];
expect(payload).toBeDefined();
// MUL-3963 payload shape.
expect(payload.visibility).toBeUndefined();
expect(payload.permission_mode).toBe("public_to");
expect(payload.invocation_targets).toEqual([
{ target_type: "workspace" },
]);
});
it("submits permission_mode=private with empty targets when Private is chosen", async () => {
configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: true });
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME });
const { onCreate } = renderDialog([mine]);
fireEvent.change(screen.getByPlaceholderText("e.g. Deep Research Agent"), {
target: { value: "Private Agent" },
});
// Click the Private card. The Private description doubles as a stable
// click target inside the button.
fireEvent.click(screen.getByText("Only you can run this agent"));
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
const payload = onCreate.mock.calls[0]?.[0];
expect(payload).toBeDefined();
expect(payload.permission_mode).toBe("private");
expect(payload.invocation_targets).toEqual([]);
});
it("does not create when specific-people scope has no member", async () => {
configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: true });
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME });
const { onCreate } = renderDialog([mine]);
fireEvent.change(screen.getByPlaceholderText("e.g. Deep Research Agent"), {
target: { value: "Empty Public Agent" },
});
fireEvent.click(screen.getByRole("radio", { name: /^Specific people/i }));
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
expect(onCreate).not.toHaveBeenCalled();
});
it("includes ticked members in the invocation_targets payload", async () => {
configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: true });
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME });
const { onCreate } = renderDialog([mine]);
fireEvent.change(screen.getByPlaceholderText("e.g. Deep Research Agent"), {
target: { value: "Shared Agent" },
});
fireEvent.click(screen.getByRole("radio", { name: /^Specific people/i }));
fireEvent.click(screen.getByRole("checkbox", { name: /^Other/ }));
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
const payload = onCreate.mock.calls[0]?.[0];
expect(payload).toBeDefined();
expect(payload.permission_mode).toBe("public_to");
expect(payload.invocation_targets).toEqual([
{ target_type: "member", target_id: OTHER },
]);
});
});