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"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@multica/core/i18n/react"; import enCommon from "../locales/en/common.json"; import enModals from "../locales/en/modals.json"; import enEditor from "../locales/en/editor.json"; const TEST_RESOURCES = { // `editor` carries the shared upload-gate copy ("Uploading…"). en: { common: enCommon, modals: enModals, editor: enEditor }, }; function I18nWrapper({ children }: { children: ReactNode }) { return ( {children} ); } const mockPush = vi.hoisted(() => vi.fn()); const mockCreateIssue = vi.hoisted(() => vi.fn()); const mockAttachLabel = vi.hoisted(() => vi.fn()); const mockListProperties = vi.hoisted(() => vi.fn()); const mockSetIssueProperty = vi.hoisted(() => vi.fn()); const mockSetDraft = vi.hoisted(() => vi.fn()); const mockClearDraft = vi.hoisted(() => vi.fn()); const mockSetLastAssignee = vi.hoisted(() => vi.fn()); const mockSetKeepOpen = vi.hoisted(() => vi.fn()); const mockToastCustom = vi.hoisted(() => vi.fn()); const mockToastDismiss = vi.hoisted(() => vi.fn()); const mockToastError = vi.hoisted(() => vi.fn()); const mockUploadWithToast = vi.hoisted(() => vi.fn()); const mockDraftStore = { 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, dueDate: null, labelIds: [] as string[], propertyValues: {} as Record, attachments: [] as Array<{ id: string; workspace_id: string; issue_id: string | null; comment_id: string | null; chat_session_id: string | null; chat_message_id: string | null; uploader_type: string; uploader_id: string; filename: string; url: string; download_url: string; markdown_url: string; content_type: string; size_bytes: number; created_at: string; }>, }, lastAssigneeType: undefined, lastAssigneeId: undefined, setDraft: mockSetDraft, clearDraft: mockClearDraft, setLastAssignee: mockSetLastAssignee, }; const mockQuickCreateStore = { keepOpen: false, setKeepOpen: mockSetKeepOpen, }; type ManualCreateField = | "status" | "priority" | "assignee" | "labels" | "project" | "due_date" | "start_date"; const DEFAULT_MANUAL_FIELDS: ManualCreateField[] = [ "status", "priority", "assignee", "labels", "project", ]; const mockCreateSettingsStore = { manualCreateFields: DEFAULT_MANUAL_FIELDS as ManualCreateField[], }; vi.mock("../navigation", () => ({ useNavigation: () => ({ push: mockPush }), })); vi.mock("@multica/core/paths", () => ({ useCurrentWorkspace: () => ({ name: "Test Workspace" }), useWorkspacePaths: () => ({ issueDetail: (id: string) => `/ws-test/issues/${id}`, settings: () => "/ws-test/settings", }), })); vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test", })); vi.mock("@multica/core/issues/queries", () => ({ issueDetailOptions: (wsId: string, id: string) => ({ queryKey: ["issues", wsId, "detail", id], queryFn: () => Promise.resolve(null), }), childIssuesOptions: (wsId: string, id: string) => ({ queryKey: ["issues", wsId, "children", id], queryFn: () => Promise.resolve([]), }), })); // CreateRunHint's pre-trigger preview + actor-name lookup are exercised in // their own suites; here we only need the create form to render without query // infra, so stub them to the inert "no run will start" state. vi.mock("../issues/hooks/use-issue-trigger-preview", () => ({ useIssueTriggerPreview: () => ({ triggers: [], totalCount: 0, isLoading: false, handoffSupported: false, }), })); vi.mock("@multica/core/workspace/hooks", () => ({ useActorName: () => ({ getActorName: () => "Agent" }), })); // CreateRunHint now renders an ActorAvatar for agent/squad assignees. This // suite is about the create form, not the avatar (whose own workspace/presence/ // navigation hook tree is exercised elsewhere), so stub it inert. vi.mock("../common/actor-avatar", () => ({ ActorAvatar: () => null, })); vi.mock("@multica/core/issues/stores/draft-store", () => ({ useIssueDraftStore: Object.assign( (selector?: (state: typeof mockDraftStore) => unknown) => (selector ? selector(mockDraftStore) : mockDraftStore), { getState: () => mockDraftStore }, ), })); 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/issue-create-settings-store", () => ({ useIssueCreateSettingsStore: ( selector?: (state: typeof mockCreateSettingsStore) => unknown, ) => (selector ? selector(mockCreateSettingsStore) : mockCreateSettingsStore), })); vi.mock("@multica/core/issues/mutations", () => ({ useCreateIssue: () => ({ mutateAsync: mockCreateIssue }), useUpdateIssue: () => ({ mutate: vi.fn() }), })); vi.mock("@multica/core/labels", () => ({ useAttachLabelToIssue: () => ({ mutateAsync: mockAttachLabel }), })); vi.mock("@multica/core/properties", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useSetIssueProperty: () => ({ mutateAsync: ({ issueId, propertyId, value }: { issueId: string; propertyId: string; value: string | number | boolean | string[]; }) => mockSetIssueProperty(issueId, propertyId, value), }), }; }); vi.mock("@multica/core/hooks/use-file-upload", () => ({ useFileUpload: () => ({ uploadWithToast: mockUploadWithToast }), })); // Hoisted ApiError class so both the vi.mock factory and the tests below // can construct/instanceof-check the same identity. vi.mock is hoisted, so // a normal `class` declaration above it would still be in the TDZ at mock // evaluation time. const { ApiError } = vi.hoisted(() => { class ApiErrorImpl extends Error { readonly status: number; readonly statusText: string; readonly body?: unknown; constructor(message: string, status: number, statusText: string, body?: unknown) { super(message); this.name = "ApiError"; this.status = status; this.statusText = statusText; this.body = body; } } return { ApiError: ApiErrorImpl }; }); vi.mock("@multica/core/api", async () => { // Pull real `parseWithFallback` + `DuplicateIssueErrorBodySchema` from the // schema modules so the drift-fallback branch in create-issue.tsx runs the // actual validation logic (not a stub). Only `ApiError` is local — the // component imports it from this module and the cross-realm `instanceof` // check requires a single class identity. const { parseWithFallback } = await vi.importActual( "@multica/core/api/schema", ); const { DuplicateIssueErrorBodySchema } = await vi.importActual< typeof import("@multica/core/api/schemas") >("@multica/core/api/schemas"); return { api: { listProperties: mockListProperties, setIssueProperty: mockSetIssueProperty, }, ApiError, parseWithFallback, DuplicateIssueErrorBodySchema, }; }); 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, attachments }: any, ref: any) => { const valueRef = useRef(defaultValue || ""); const [value, setValue] = useState(defaultValue || ""); // Mirrors the real editor's `uploading` node attrs: the placeholder is in // the doc from before the await until the upload settles, and the host // hears about it through onUploadingChange. const inFlightRef = useRef(0); useImperativeHandle(ref, () => ({ getMarkdown: () => valueRef.current, clearContent: () => { valueRef.current = ""; setValue(""); }, uploadFile: 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); } }, hasActiveUploads: () => inFlightRef.current > 0, })); return ( <>