// @vitest-environment jsdom
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { Agent, MemberWithUser, Squad } from "@multica/core/types";
import enCommon from "../locales/en/common.json";
import enModals from "../locales/en/modals.json";
import enAgents from "../locales/en/agents.json";
import enIssues from "../locales/en/issues.json";
const TEST_RESOURCES = {
en: { common: enCommon, modals: enModals, agents: enAgents, issues: enIssues },
};
const ME = "user-me";
const OTHER = "user-other";
// Hoisted so each test can override mock return values per case.
const mocks = vi.hoisted(() => ({
agents: [] as Agent[],
members: [] as MemberWithUser[],
createSquad: vi.fn(),
addSquadMember: vi.fn(),
navigationPush: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
toastWarning: vi.fn(),
invalidate: vi.fn(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: { queryKey?: unknown[] }) => {
const key = opts.queryKey ?? [];
if (Array.isArray(key) && key.includes("agents")) {
return { data: mocks.agents };
}
if (Array.isArray(key) && key.includes("members")) {
return { data: mocks.members };
}
return { data: [] };
},
useQueryClient: () => ({ invalidateQueries: mocks.invalidate }),
}));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: () => ({ queryKey: ["agents"] }),
memberListOptions: () => ({ queryKey: ["members"] }),
workspaceKeys: { squads: (id: string) => ["squads", id] },
}));
vi.mock("@multica/core/api", () => ({
api: {
createSquad: (...args: unknown[]) => mocks.createSquad(...args),
addSquadMember: (...args: unknown[]) => mocks.addSquadMember(...args),
},
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (selector: (s: { user: { id: string } | null }) => unknown) =>
selector({ user: { id: ME } }),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
squadDetail: (id: string) => `/test-ws/squads/${id}`,
}),
}));
vi.mock("@multica/core/utils", () => ({
isImeComposing: () => false,
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mocks.navigationPush }),
}));
vi.mock("../common/actor-avatar", () => ({
ActorAvatar: ({ actorId }: { actorId: string }) => (
),
}));
vi.mock("../common/avatar-upload-control", () => ({
AvatarUploadControl: ({
value,
onUploaded,
}: {
value: string | null;
onUploaded: (v: string) => void;
}) => (
),
}));
vi.mock("../agents/components/char-counter", () => ({
CharCounter: ({ length, max }: { length: number; max: number }) => (
{length}/{max}
),
}));
// Render Popover/PopoverContent inline so the picker rows are queryable
// without simulating a Base UI portal — we still drive the open prop via
// PopoverTrigger clicks but the content is always in the DOM.
vi.mock("@multica/ui/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) =>
{children}
,
PopoverTrigger: ({
children,
className,
onClick,
render,
}: {
children?: ReactNode;
className?: string;
onClick?: () => void;
render?: ReactNode;
}) => {
// Base UI's `render` prop replaces the trigger with the provided element.
// The mock just renders it as-is so its children stay queryable in tests.
if (render !== undefined) return <>{render}>;
return (
);
},
PopoverContent: ({ children }: { children: ReactNode }) => {children}
,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: ReactNode }) => {children}
,
DialogContent: ({ children }: { children: ReactNode }) => {children}
,
DialogHeader: ({ children }: { children: ReactNode }) => {children}
,
DialogTitle: ({ children }: { children: ReactNode }) => {children}
,
DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
}: {
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}) => (
),
}));
vi.mock("@multica/ui/components/ui/input", () => ({
Input: (props: React.InputHTMLAttributes) => (
),
}));
vi.mock("@multica/ui/components/ui/label", () => ({
Label: ({ children, className }: { children: ReactNode; className?: string }) => (
),
}));
vi.mock("../editor/extensions/pinyin-match", () => ({
matchesPinyin: () => false,
}));
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => mocks.toastSuccess(...args),
error: (...args: unknown[]) => mocks.toastError(...args),
warning: (...args: unknown[]) => mocks.toastWarning(...args),
},
}));
import { CreateSquadModal } from "./create-squad";
function makeAgent(overrides: Partial & { id: string; name: string; owner_id: string | null }): Agent {
return {
workspace_id: "ws-1",
runtime_id: "rt-1",
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: "",
skills: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
function makeMember(user_id: string, name: string): MemberWithUser {
return {
id: `m-${user_id}`,
user_id,
workspace_id: "ws-1",
role: "member",
name,
email: `${user_id}@example.com`,
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
};
}
function makeSquad(overrides: Partial = {}): Squad {
return {
id: "sq-new",
workspace_id: "ws-1",
name: "New Squad",
description: "",
instructions: "",
avatar_url: null,
leader_id: "agent-mine-1",
creator_id: ME,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
// "Create Squad" is both the dialog title and the submit button label. Always
// pick by role so tests don't depend on DOM order between the two.
function getSubmitButton(): HTMLButtonElement {
const btn = screen
.getAllByRole("button")
.find((b) => b.textContent === "Create Squad");
if (!btn) throw new Error("Create Squad submit button not found");
return btn as HTMLButtonElement;
}
// getAllByText is typed to never return undefined slots, but the indexed
// access goes through `noUncheckedIndexedAccess` so we narrow explicitly.
function firstMatch(label: string): HTMLElement {
const matches = screen.getAllByText(label);
if (matches.length === 0) throw new Error(`no match for "${label}"`);
return matches[0]!;
}
function lastMatch(label: string): HTMLElement {
const matches = screen.getAllByText(label);
if (matches.length === 0) throw new Error(`no match for "${label}"`);
return matches[matches.length - 1]!;
}
function renderModal() {
const onClose = vi.fn();
render(
,
);
return { onClose };
}
const myAgent = makeAgent({ id: "agent-mine-1", name: "MineAgentOne", owner_id: ME });
const myAgent2 = makeAgent({ id: "agent-mine-2", name: "MineAgentTwo", owner_id: ME });
const otherAgent = makeAgent({ id: "agent-other-1", name: "OtherAgentOne", owner_id: OTHER });
const wsMember = makeMember(OTHER, "Workspace Pal");
describe("CreateSquadModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.agents = [otherAgent, myAgent, myAgent2];
mocks.members = [wsMember];
});
it("binds the name and description inputs in the identity row", () => {
renderModal();
const name = screen.getByPlaceholderText(/e\.g\. Frontend Team/i) as HTMLInputElement;
fireEvent.change(name, { target: { value: "Platform Team" } });
expect(name.value).toBe("Platform Team");
const desc = screen.getByPlaceholderText(/Describe what this squad/i) as HTMLInputElement;
fireEvent.change(desc, { target: { value: "We own infra" } });
expect(desc.value).toBe("We own infra");
// Char counter reflects the typed length.
expect(screen.getByTestId("char-counter").textContent).toMatch(/^12\//);
});
it("renders the leader picker with 'My Agents' before 'Workspace Agents'", () => {
renderModal();
// The leader group headers come from the LeaderPicker — agent name "MineAgentOne"
// appears under the My Agents section, "OtherAgentOne" under Workspace Agents.
const myGroupLabels = screen.getAllByText("My Agents");
const wsGroupLabels = screen.getAllByText("Workspace Agents");
expect(myGroupLabels.length).toBeGreaterThanOrEqual(1);
expect(wsGroupLabels.length).toBeGreaterThanOrEqual(1);
// Verify ordering: the first occurrence of My Agents appears before the
// first occurrence of Workspace Agents in the DOM (leader picker shows
// both sections; the additional-members picker also has them, but both
// pickers follow the same order so the assertion holds either way).
const all = Array.from(document.querySelectorAll("*"));
const myIdx = all.findIndex((n) => n.textContent === "My Agents");
const wsIdx = all.findIndex((n) => n.textContent === "Workspace Agents");
expect(myIdx).toBeLessThan(wsIdx);
});
it("auto-clears an additional-members entry when the same agent is picked as leader", async () => {
renderModal();
// Find the "MineAgentTwo" row inside the additional-members picker (the
// leader picker also has it; clicking either toggles its respective
// state). We pick it as an additional member via the second occurrence,
// then promote it to leader via the first.
// The additional-members picker's MineAgentTwo row is the LAST occurrence —
// leader picker renders earlier in the tree.
fireEvent.click(lastMatch("MineAgentTwo"));
// Chip should appear (the trigger now lists MineAgentTwo).
await waitFor(() => {
expect(screen.getAllByText("MineAgentTwo").length).toBeGreaterThanOrEqual(2);
});
// Promote MineAgentTwo to leader by clicking the first occurrence.
fireEvent.click(firstMatch("MineAgentTwo"));
// Wire up the rest of the submit path so we can verify the sanitized
// payload sent to addSquadMember (none — leader was the only pick).
mocks.createSquad.mockResolvedValue(makeSquad({ leader_id: "agent-mine-2" }));
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Frontend Team/i), {
target: { value: "Platform" },
});
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(mocks.createSquad).toHaveBeenCalledTimes(1);
});
// addSquadMember must NOT be called for the agent we promoted to leader.
expect(mocks.addSquadMember).not.toHaveBeenCalled();
});
it("removes a member promoted to leader from selectedMembers so switching leader away does not resurrect it", async () => {
renderModal();
// 1. Add MineAgentTwo as an additional member.
fireEvent.click(lastMatch("MineAgentTwo"));
await waitFor(() => {
expect(screen.getAllByText("MineAgentTwo").length).toBeGreaterThanOrEqual(2);
});
// 2. Promote MineAgentTwo to leader (first occurrence is the leader picker row).
fireEvent.click(firstMatch("MineAgentTwo"));
// 3. Switch leader back to MineAgentOne. With MineAgentTwo now the leader,
// the additional-members picker filters it out, so MineAgentOne only
// appears twice (leader picker + members picker) and firstMatch hits
// the leader picker row.
fireEvent.click(firstMatch("MineAgentOne"));
// 4. Submit and assert MineAgentTwo is NOT submitted as a member — the
// promotion must have permanently dropped it from selectedMembers.
mocks.createSquad.mockResolvedValue(makeSquad({ id: "sq-3", leader_id: "agent-mine-1" }));
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Frontend Team/i), {
target: { value: "Swap Squad" },
});
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(mocks.createSquad).toHaveBeenCalledWith({
name: "Swap Squad",
description: undefined,
leader_id: "agent-mine-1",
avatar_url: undefined,
});
});
expect(mocks.addSquadMember).not.toHaveBeenCalled();
});
it("on success with no additional members fires exactly one success toast and navigates", async () => {
renderModal();
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Frontend Team/i), {
target: { value: "Solo Squad" },
});
// Click MineAgentOne in the leader picker (first occurrence is leader picker row).
fireEvent.click(firstMatch("MineAgentOne"));
mocks.createSquad.mockResolvedValue(makeSquad({ id: "sq-1", leader_id: "agent-mine-1" }));
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(mocks.createSquad).toHaveBeenCalledWith({
name: "Solo Squad",
description: undefined,
leader_id: "agent-mine-1",
avatar_url: undefined,
});
});
await waitFor(() => {
expect(mocks.toastSuccess).toHaveBeenCalledTimes(1);
});
expect(mocks.addSquadMember).not.toHaveBeenCalled();
expect(mocks.toastWarning).not.toHaveBeenCalled();
expect(mocks.navigationPush).toHaveBeenCalledWith("/test-ws/squads/sq-1");
});
it("on success with partial member failure shows success + warning toasts and still navigates", async () => {
renderModal();
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Frontend Team/i), {
target: { value: "Mixed Squad" },
});
fireEvent.click(firstMatch("MineAgentOne"));
// Add two additional members: the workspace pal (member) + OtherAgentOne (agent).
// Locate them in the additional-members picker (last occurrence of each).
fireEvent.click(lastMatch("OtherAgentOne"));
fireEvent.click(lastMatch("Workspace Pal"));
mocks.createSquad.mockResolvedValue(makeSquad({ id: "sq-2", leader_id: "agent-mine-1" }));
mocks.addSquadMember
.mockResolvedValueOnce({}) // first call succeeds
.mockRejectedValueOnce(new Error("boom")); // second fails
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(mocks.createSquad).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(mocks.addSquadMember).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(mocks.toastSuccess).toHaveBeenCalledTimes(1);
});
expect(mocks.toastWarning).toHaveBeenCalledTimes(1);
expect(mocks.navigationPush).toHaveBeenCalledWith("/test-ws/squads/sq-2");
});
it("on createSquad failure shows an error toast, does not navigate, and re-enables submit", async () => {
renderModal();
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Frontend Team/i), {
target: { value: "Boom Squad" },
});
fireEvent.click(firstMatch("MineAgentOne"));
mocks.createSquad.mockRejectedValueOnce(new Error("server down"));
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(mocks.toastError).toHaveBeenCalledTimes(1);
});
expect(mocks.navigationPush).not.toHaveBeenCalled();
// Submit button is re-enabled (textContent reads "Create Squad" again,
// not "Creating...").
const button = getSubmitButton();
expect(button.disabled).toBe(false);
});
});