import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; const mockQuickCreateIssue = vi.hoisted(() => vi.fn()); const mockSetLastActor = vi.hoisted(() => vi.fn()); const mockSetLastProjectId = vi.hoisted(() => vi.fn()); const mockSetQuickCreateFieldVisible = vi.hoisted(() => vi.fn()); const mockSetPrompt = vi.hoisted(() => vi.fn()); const mockClearPrompt = vi.hoisted(() => vi.fn()); const mockSetKeepOpen = vi.hoisted(() => vi.fn()); const mockSetLastMode = vi.hoisted(() => vi.fn()); const mockToastSuccess = vi.hoisted(() => vi.fn()); const mockUploadWithToast = vi.hoisted(() => vi.fn()); const mockNavigationPush = vi.hoisted(() => vi.fn()); const mockSetIssueDraft = vi.hoisted(() => vi.fn()); const mockIssueDraftStore = { draft: { title: "", description: "", status: "todo" as const, priority: "none" as const, assigneeType: undefined as "agent" | "squad" | "member" | undefined, assigneeId: undefined as string | undefined, projectId: undefined as string | undefined, startDate: null as string | null, dueDate: null as string | null, labelIds: [] as string[], propertyValues: {} as Record, attachments: [], }, setDraft: mockSetIssueDraft, }; const mockQuickCreateStore = { lastActorType: null as "agent" | "squad" | null, lastActorId: null as string | null, setLastActor: mockSetLastActor, lastProjectId: null as string | null, setLastProjectId: mockSetLastProjectId, prompt: "Persisted draft prompt", setPrompt: mockSetPrompt, clearPrompt: mockClearPrompt, keepOpen: false, setKeepOpen: mockSetKeepOpen, }; const mockCreateSettingsStore = { quickCreateFields: ["project"] as Array<"project" | "priority" | "due_date">, setQuickCreateFieldVisible: mockSetQuickCreateFieldVisible, }; // Per-test override for the projects query, so tests can swap between // "loaded as empty" (the deleted-project case) and "still loading" without // re-mocking the whole module. const mockProjectsQuery = vi.hoisted(() => ({ data: [] as Array<{ id: string; title: string; icon: string | null }>, isSuccess: true, })); // Per-test override for the squads list so we can flip between "squads // exist and one's leader is reachable" and "no squads" cases without // re-mocking the whole module. const mockSquadsData = vi.hoisted( () => ({ list: [] as Array<{ id: string; name: string; leader_id: string; archived_at: string | null }> }), ); vi.mock("@tanstack/react-query", () => ({ useQuery: ({ queryKey }: { queryKey: string[] }) => { // Workspace-scoped query keys carry the wsId as `queryKey[1]`; the // discriminator is at `queryKey[2]` (e.g. ["workspaces", wsId, "squads"]). if (queryKey[0] === "workspaces" && queryKey[2] === "squads") { return { data: mockSquadsData.list }; } switch (queryKey[0]) { case "members": return { data: [{ user_id: "user-1", role: "admin" }] }; case "agents": return { data: [{ id: "agent-1", name: "Bohan", archived_at: null, runtime_id: "runtime-1" }], }; case "runtimes": return { data: [{ id: "runtime-1", metadata: { cli_version: "1.2.3" } }] }; case "projects": return mockProjectsQuery; default: return { data: [] }; } }, })); vi.mock("@multica/core/api", () => ({ api: { quickCreateIssue: mockQuickCreateIssue, }, ApiError: class ApiError extends Error { body?: unknown; }, })); vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test", })); vi.mock("@multica/core/paths", () => ({ useCurrentWorkspace: () => ({ name: "Test Workspace" }), useWorkspacePaths: () => ({ settings: () => "/ws-test/settings", }), })); vi.mock("../navigation", () => ({ useNavigation: () => ({ push: mockNavigationPush }), })); vi.mock("@multica/core/workspace/queries", () => ({ agentListOptions: () => ({ queryKey: ["agents"] }), memberListOptions: () => ({ queryKey: ["members"] }), squadListOptions: (wsId: string) => ({ queryKey: ["workspaces", wsId, "squads"], }), })); vi.mock("@multica/core/projects/queries", () => ({ projectListOptions: () => ({ queryKey: ["projects"] }), })); vi.mock("@multica/core/issues/stores/quick-create-store", () => ({ useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) => (selector ? selector(mockQuickCreateStore) : mockQuickCreateStore), })); vi.mock("@multica/core/issues/stores/draft-store", () => ({ useIssueDraftStore: Object.assign( (selector?: (state: typeof mockIssueDraftStore) => unknown) => (selector ? selector(mockIssueDraftStore) : mockIssueDraftStore), { getState: () => mockIssueDraftStore }, ), })); vi.mock("@multica/core/issues/stores/issue-create-settings-store", () => ({ useIssueCreateSettingsStore: ( selector?: (state: typeof mockCreateSettingsStore) => unknown, ) => (selector ? selector(mockCreateSettingsStore) : mockCreateSettingsStore), })); vi.mock("@multica/core/issues/stores/create-mode-store", () => ({ useCreateModeStore: (selector?: (state: { setLastMode: typeof mockSetLastMode }) => unknown) => (selector ? selector({ setLastMode: mockSetLastMode }) : { setLastMode: mockSetLastMode }), })); vi.mock("@multica/core/auth", () => ({ useAuthStore: (selector?: (state: { user: { id: string } }) => unknown) => (selector ? selector({ user: { id: "user-1" } }) : { user: { id: "user-1" } }), })); vi.mock("@multica/core/runtimes", () => ({ runtimeListOptions: () => ({ queryKey: ["runtimes"] }), checkQuickCreateCliVersion: () => ({ state: "ok", min: "1.0.0" }), checkQuickCreateFieldsCliVersion: () => ({ state: "ok", min: "1.0.0" }), readRuntimeCliVersion: () => "1.2.3", MIN_QUICK_CREATE_CLI_VERSION: "1.0.0", })); vi.mock("@multica/core/hooks/use-file-upload", () => ({ useFileUpload: () => ({ uploadWithToast: mockUploadWithToast, uploading: false }), })); vi.mock("../issues/components/pickers/assignee-picker", () => ({ canAssignAgent: () => true, })); vi.mock("../common/actor-avatar", () => ({ ActorAvatar: () => , })); vi.mock("../issues/components", () => ({ PriorityIcon: ({ priority }: { priority: string }) => {priority}, PriorityPicker: ({ priority, onUpdate }: any) => ( ), DueDatePicker: ({ dueDate, onUpdate }: any) => ( ), })); vi.mock("../projects/components/project-picker", () => ({ ProjectPicker: ({ projectId, onUpdate }: any) => ( ), })); vi.mock("../common/pill-button", () => ({ PillButton: ({ children, ...props }: any) => , })); vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({ DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, DropdownMenuTrigger: ({ render }: { render: ReactNode }) => <>{render}, DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, DropdownMenuItem: ({ children, onClick }: any) => ( ), DropdownMenuSeparator: () => null, })); vi.mock("@multica/ui/lib/utils", () => ({ cn: (...values: Array) => values.filter(Boolean).join(" "), })); vi.mock("../editor", async () => { // Real submit gate (pure React) driven by the mock editor's // `hasActiveUploads` / `onUploadingChange`. const uploadGate = await vi.importActual( "../editor/use-upload-gate", ); const ContentEditor = forwardRef(({ defaultValue, onUpdate, onSubmit, onUploadFile, onUploadingChange, placeholder }: any, ref: any) => { const valueRef = useRef(defaultValue || ""); const [value, setValue] = useState(defaultValue || ""); // Mirrors the real editor's `uploading` node attrs: the placeholder sits // in the doc from before the await until the upload settles, which is what // `hasActiveUploads` reports and `onUploadingChange` publishes. const inFlightRef = useRef(0); const runUpload = async (file: File) => { inFlightRef.current += 1; if (inFlightRef.current === 1) onUploadingChange?.(true); try { return await onUploadFile?.(file); } finally { inFlightRef.current -= 1; if (inFlightRef.current === 0) onUploadingChange?.(false); } }; useImperativeHandle(ref, () => ({ getMarkdown: () => valueRef.current, clearContent: () => { valueRef.current = ""; setValue(""); }, uploadFile: runUpload, focus: vi.fn(), hasActiveUploads: () => inFlightRef.current > 0, })); return ( <>