// @vitest-environment jsdom import { act, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MarkdownEditor } from "../MarkdownEditor"; import { IssueAttachmentsSection } from "../IssueAttachmentsSection"; import { ArtifactDropzone } from "../artifacts/ArtifactDropzone"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; import { ComposerFileDropTarget } from "./ComposerFileDropTarget"; let host: HTMLDivElement; let root: Root; const file = new File(["image"], "screenshot.png", { type: "image/png" }); function render(content: ReactElement) { act(() => root.render(content)); } function drag(type: string, target: EventTarget = document.body, types = ["Files"], relatedTarget: EventTarget | null = null) { const event = new MouseEvent(type, { bubbles: true, cancelable: true }); Object.defineProperties(event, { dataTransfer: { value: { types, files: types.includes("Files") ? [file] : [], items: types.includes("Files") ? [{ kind: "file", type: file.type, getAsFile: () => file }] : [], dropEffect: "none" } }, relatedTarget: { value: relatedTarget }, }); act(() => { target.dispatchEvent(event); }); return event; } const overlay = () => document.querySelector('[data-testid="composer-file-drop-overlay"]'); beforeEach(() => { vi.stubGlobal("DragEvent", MouseEvent); host = document.createElement("div"); document.body.append(host); root = createRoot(host); }); afterEach(() => { act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); }); describe("composer screen file drops", () => { it("accepts a drop outside the composer exactly once and clears the overlay", () => { const onFiles = vi.fn(); render(); expect(drag("dragenter").defaultPrevented).toBe(true); expect(overlay()?.textContent).toContain("Drop files to attach"); expect(drag("dragover").defaultPrevented).toBe(true); const downstream = vi.fn(); document.body.addEventListener("drop", downstream, { once: true }); expect(drag("drop").defaultPrevented).toBe(true); expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); expect(downstream).not.toHaveBeenCalled(); document.body.removeEventListener("drop", downstream); expect(overlay()).toBeNull(); }); it("keeps the overlay while moving between children, then clears on exit or cancellation", () => { render(); drag("dragenter"); drag("dragenter", host); drag("dragleave", document.body, ["Files"], host); expect(overlay()).not.toBeNull(); drag("dragleave", host); expect(overlay()).toBeNull(); for (const cancel of ["dragend", "blur"]) { drag("dragenter"); act(() => { window.dispatchEvent(new Event(cancel)); }); expect(overlay()).toBeNull(); } }); it("clears a nested drag when the pointer leaves the window", () => { render(); drag("dragenter"); drag("dragenter", host); drag("dragleave", document.documentElement); expect(overlay()).toBeNull(); }); it("preserves description editor file uploads and clears both overlays", async () => { const onFiles = vi.fn(); const onDropFile = vi.fn().mockResolvedValue(undefined); render(<> ); const editor = host.querySelector('[contenteditable="true"]')!; expect(editor).not.toBeNull(); drag("dragenter"); expect(overlay()).not.toBeNull(); drag("dragenter", editor); drag("dragover", editor); expect(overlay()).toBeNull(); expect(host.textContent).toContain("Drop file to upload"); const attachment = new File(["notes"], "notes.txt", { type: "text/plain" }); const event = new MouseEvent("drop", { bubbles: true, cancelable: true }); Object.defineProperty(event, "dataTransfer", { value: { types: ["Files"], files: [attachment] } }); await act(async () => { editor.dispatchEvent(event); }); expect(onDropFile).toHaveBeenCalledExactlyOnceWith(attachment); expect(onFiles).not.toHaveBeenCalled(); expect(host.textContent).not.toContain("Drop file to upload"); expect(overlay()).toBeNull(); drag("drop"); expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); }); it("keeps image drops in the description image plugin", async () => { const onFiles = vi.fn(); const uploadImage = vi.fn().mockResolvedValue("/description-image.png"); render(<> ); const editor = host.querySelector('[contenteditable="true"]')!; drag("dragenter", editor); expect(overlay()).toBeNull(); expect(host.textContent).toContain("Drop image to upload"); await act(async () => { drag("drop", editor); }); expect(uploadImage).toHaveBeenCalledExactlyOnceWith(file); expect(onFiles).not.toHaveBeenCalled(); expect(host.textContent).not.toContain("Drop image to upload"); }); it.each(["parent", "readOnly"])("leaves %s editor drops with the composer", (mode) => { const onFiles = vi.fn(); const onDropFile = vi.fn(); render(<> ); drag("drop", host.querySelector(".paperclip-mdxeditor-scope")!); expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); expect(onDropFile).not.toHaveBeenCalled(); }); it("preserves the task attachments section drop handler", () => { const onFiles = vi.fn(); const onDrop = vi.fn(); render(<> ); const label = Array.from(host.querySelectorAll("h3")).find(item => item.textContent === "Attachments")!; drag("drop", label); expect(onDrop).toHaveBeenCalledOnce(); expect(onFiles).not.toHaveBeenCalled(); }); it("ignores text and link drags", () => { const onFiles = vi.fn(); render(); expect(drag("dragenter", host, ["text/plain", "text/uri-list"]).defaultPrevented).toBe(false); expect(drag("drop", host, ["text/plain"]).defaultPrevented).toBe(false); expect(overlay()).toBeNull(); expect(onFiles).not.toHaveBeenCalled(); }); it("gives the open dialog exclusive ownership and restores the task on close", () => { const taskFiles = vi.fn(); const newFiles = vi.fn(); const content = (open: boolean, enabled = true) => <> {open ? New task : null} ; render(content(true)); drag("dragenter"); expect(overlay()?.textContent).toContain("your new task"); drag("drop"); expect(newFiles).toHaveBeenCalledExactlyOnceWith([file]); expect(taskFiles).not.toHaveBeenCalled(); render(content(true, false)); drag("dragenter"); drag("drop"); expect(overlay()).toBeNull(); expect(taskFiles).not.toHaveBeenCalled(); render(content(false)); drag("drop"); expect(taskFiles).toHaveBeenCalledExactlyOnceWith([file]); }); it("does not send files to the underlying task while another dialog is open", () => { const onFiles = vi.fn(); render(<>Settings); drag("dragenter"); drag("drop"); expect(overlay()).toBeNull(); expect(onFiles).not.toHaveBeenCalled(); }); it.each([false, true])("preserves artifact uploads with an external target: %s", (external) => { const onFiles = vi.fn(); const onUpload = vi.fn(); const frame = document.createElement("div"); const header = document.createElement("button"); frame.append(header); document.body.append(frame); try { render(<> ); const destination = external ? header : host.querySelector("[data-artifact-file]")!; drag("dragenter"); expect(overlay()).not.toBeNull(); drag("dragenter", destination); drag("dragover", destination); expect(overlay()).toBeNull(); expect(document.querySelector("[data-artifact-drop-overlay]")).not.toBeNull(); expect(drag("drop", destination).defaultPrevented).toBe(true); expect(onUpload).toHaveBeenCalledExactlyOnceWith([file]); expect(onFiles).not.toHaveBeenCalled(); drag("dragenter"); expect(overlay()).not.toBeNull(); drag("drop"); expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); render(
); expect(frame.hasAttribute("data-file-drop-target")).toBe(false); } finally { frame.remove(); } }); it("keeps composer drops available while a portaled picker is open", () => { const onFiles = vi.fn(); render(<> ModelChoose model); const picker = document.querySelector('[data-slot="popover-content"]')!; expect(picker.getAttribute("role")).toBe("dialog"); expect(host.contains(picker)).toBe(false); drag("dragenter"); expect(overlay()).not.toBeNull(); expect(drag("drop", picker).defaultPrevented).toBe(true); expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); }); it("prevents browser navigation while the new task is read-only", () => { const onFiles = vi.fn(); render(New task ); expect(drag("dragenter").defaultPrevented).toBe(true); expect(drag("dragover").defaultPrevented).toBe(true); expect(drag("drop").defaultPrevented).toBe(true); expect(overlay()).toBeNull(); expect(onFiles).not.toHaveBeenCalled(); }); it("does not accept drops for a concealed composer", () => { const onFiles = vi.fn(); render(
); drag("dragenter"); drag("drop"); expect(overlay()).toBeNull(); expect(onFiles).not.toHaveBeenCalled(); }); it("uses the latest callback and clears on disable and unmount", () => { const previous = vi.fn(); const next = vi.fn(); render(); drag("dragenter"); render(); expect(overlay()).toBeNull(); drag("drop"); render(); drag("drop"); expect(next).toHaveBeenCalledExactlyOnceWith([file]); expect(previous).not.toHaveBeenCalled(); drag("dragenter"); render(
); expect(overlay()).toBeNull(); expect(drag("drop").defaultPrevented).toBe(false); }); });