// @vitest-environment happy-dom import { Editor } from "@tiptap/core"; import { describe, expect, it } from "vitest"; import { canSubmitComposerContent, canRemoveVoicePreview, compactComposerModelName, compactComposerReasoningEffortLabel, composerModelCostTier, createTiptapComposerExtensions, displayableComposerModeMessage, getComposerSendTooltipKey, getComposerSubmitIntentForEnterKey, getComposerPopoverPosition, getComposerReasoningEffortOptions, getOversizedDocumentAttachmentError, handleComposerFileDrop, insertComposerHardBreakAndScrollIntoView, isOpenAiModelProviderGroup, isComposerEditorUsable, formatVoiceTranscriptForComposer, hasConfiguredCloudProvider, MODEL_SELECTOR_POPOVER_STYLE, resolveContextChipBackspaceAction, resolveComposerPrimaryAction, shouldRenderModelSelector, shouldShowModelSelectorSkeleton, shouldShowOnlyConnectPath, } from "./TiptapComposer.js"; describe("createTiptapComposerExtensions", () => { it("refreshes the rendered placeholder after a locale change", () => { let placeholder = "Ask the agent..."; const element = document.createElement("div"); const editor = new Editor({ element, extensions: createTiptapComposerExtensions(() => placeholder), }); expect( element .querySelector(".is-editor-empty") ?.getAttribute("data-placeholder"), ).toBe("Ask the agent..."); placeholder = "Frag den Agenten..."; editor.view.dispatch(editor.state.tr.setSelection(editor.state.selection)); expect( element .querySelector(".is-editor-empty") ?.getAttribute("data-placeholder"), ).toBe("Frag den Agenten..."); editor.destroy(); }); it("rejects a truthy editor after BFCache/remount destruction", () => { const editor = new Editor({ element: document.createElement("div"), extensions: createTiptapComposerExtensions(() => "Message agent..."), }); expect(isComposerEditorUsable(editor)).toBe(true); editor.destroy(); expect(editor).toBeTruthy(); expect(editor.isDestroyed).toBe(true); expect(isComposerEditorUsable(editor)).toBe(false); expect(() => { if (isComposerEditorUsable(editor)) editor.commands.clearContent(); }).not.toThrow(); }); it("offers explicit effort levels without legacy Auto", () => { expect(getComposerReasoningEffortOptions("auto")).toEqual([ "low", "medium", "high", "xhigh", "max", ]); expect(getComposerReasoningEffortOptions("claude-sonnet-5")).not.toContain( "auto", ); }); it("uses compact GPT-5.6 model and effort names in the collapsed trigger", () => { expect(compactComposerModelName("gpt-5.6-sol")).toBe("GPT-5.6 Sol"); expect(compactComposerModelName("gpt-5-6-terra")).toBe("GPT-5.6 Terra"); expect(compactComposerModelName("openai/gpt-5.6-luna")).toBe( "GPT-5.6 Luna", ); expect(compactComposerModelName("claude-sonnet-5")).toBe("Sonnet 5"); expect(compactComposerModelName("codex-cli")).toBe("Codex"); expect(compactComposerReasoningEffortLabel("medium")).toBe("Med"); expect(compactComposerReasoningEffortLabel("minimal")).toBe("Min"); expect(compactComposerReasoningEffortLabel("xhigh")).toBe("XHigh"); const translate = (key: string, options?: Record) => key === "agentChat.composer.defaultModel" ? "Standardmodell" : key === "agentChat.composer.reasoningMediumShort" ? "Mittel" : String(options?.defaultValue ?? key); expect(compactComposerModelName("auto", translate)).toBe("Standardmodell"); expect(compactComposerReasoningEffortLabel("medium", translate)).toBe( "Mittel", ); }); it("limits Codex to OpenAI model providers", () => { expect( isOpenAiModelProviderGroup({ engine: "builder", label: "OpenAI", models: ["gpt-5.6-luna"], }), ).toBe(true); expect( isOpenAiModelProviderGroup({ engine: "ai-sdk:google", label: "Gemini", models: ["gemini-3.5-flash"], }), ).toBe(false); expect( isOpenAiModelProviderGroup({ engine: "custom-gateway", label: "Custom", models: ["gpt-5.6-luna"], }), ).toBe(true); expect( isOpenAiModelProviderGroup({ engine: "ai-sdk:openrouter", label: "OpenRouter", models: ["openai/gpt-5.6-luna"], }), ).toBe(true); expect( isOpenAiModelProviderGroup({ engine: "codex-cli", label: "OpenAI", models: ["gpt-5.6-luna"], }), ).toBe(true); expect( isOpenAiModelProviderGroup({ engine: "codex-cli", label: "OpenAI", models: ["codex-cli"], }), ).toBe(false); }); it("keeps the prompt composer schema minimal and restores legacy draft HTML", () => { const editor = new Editor({ element: document.createElement("div"), extensions: createTiptapComposerExtensions(() => "Message agent..."), }); expect(Object.keys(editor.schema.marks)).toEqual([]); expect(Object.keys(editor.schema.nodes).sort()).toEqual([ "doc", "fileReference", "hardBreak", "mentionReference", "paragraph", "skillReference", "text", ]); expect(() => { editor.commands.setContent(`

Legacy heading

Legacy link

`); }).not.toThrow(); expect(editor.getText()).toContain("Legacy heading"); expect(editor.getText()).toContain("Legacy list item"); expect(editor.getText()).toContain("Legacy link"); expect(editor.getHTML()).toContain('data-type="file-reference"'); editor.destroy(); }); it.each([ [ "text", { type: "text", text: "PK", backgroundColor: "#4f46e5", }, ], [ "image", { type: "image", src: "/agents/property.png", fit: "cover", backgroundColor: "#ffffff", }, ], ["none", { type: "none" }], ] as const)( "preserves %s mention media through HTML drafts", (_type, media) => { const first = new Editor({ element: document.createElement("div"), extensions: createTiptapComposerExtensions(() => "Message agent..."), content: { type: "doc", content: [ { type: "paragraph", content: [ { type: "mentionReference", attrs: { label: "Property agent", media, }, }, ], }, ], }, }); const html = first.getHTML(); first.destroy(); expect(html).toContain("data-media="); expect(html).not.toContain("[object Object]"); const restored = new Editor({ element: document.createElement("div"), extensions: createTiptapComposerExtensions(() => "Message agent..."), content: html, }); const mentionNode = restored.getJSON().content?.[0]?.content?.[0] as | { attrs?: Record } | undefined; restored.destroy(); expect(mentionNode?.attrs?.media).toEqual(media); }, ); it("allows sending an attachment-only prompt", () => { expect( canSubmitComposerContent({ hasEditorContent: false, attachmentCount: 1, }), ).toBe(true); expect( canSubmitComposerContent({ hasEditorContent: false, attachmentCount: 1, disabled: true, }), ).toBe(false); }); it("uses one primary action while a response is running", () => { expect( resolveComposerPrimaryAction({ canSubmit: false, hasStopButton: true, }), ).toBe("stop"); expect( resolveComposerPrimaryAction({ canSubmit: true, hasStopButton: true, }), ).toBe("send"); expect( resolveComposerPrimaryAction({ canSubmit: false, hasStopButton: false, }), ).toBe("send"); }); it("uses the queue tooltip when the submit will wait", () => { expect(getComposerSendTooltipKey(true)).toBe("composer.queueMessage"); expect(getComposerSendTooltipKey(false)).toBe("composer.sendMessage"); }); it("selects and removes context chips one Backspace at a time", () => { let contextItemKeys = ["dashboard", "panel"]; let selectedKey: string | null = null; const selectPanel = resolveContextChipBackspaceAction({ contextItemKeys, selectedKey, cursorAtStart: true, }); expect(selectPanel).toEqual({ type: "select", key: "panel" }); selectedKey = selectPanel?.key ?? null; const removePanel = resolveContextChipBackspaceAction({ contextItemKeys, selectedKey, cursorAtStart: true, }); expect(removePanel).toEqual({ type: "remove", key: "panel" }); contextItemKeys = contextItemKeys.filter((key) => key !== removePanel?.key); selectedKey = null; const selectDashboard = resolveContextChipBackspaceAction({ contextItemKeys, selectedKey, cursorAtStart: true, }); expect(selectDashboard).toEqual({ type: "select", key: "dashboard" }); selectedKey = selectDashboard?.key ?? null; expect( resolveContextChipBackspaceAction({ contextItemKeys, selectedKey, cursorAtStart: true, }), ).toEqual({ type: "remove", key: "dashboard" }); }); it("leaves context chips alone when the caret is not at the start", () => { expect( resolveContextChipBackspaceAction({ contextItemKeys: ["dashboard"], selectedKey: null, cursorAtStart: false, }), ).toBeNull(); }); it("uses a visible fallback for attachment-only composer mode prompts", () => { expect( displayableComposerModeMessage({ messagePrefix: "Create an extension: ", trimmedText: "", attachmentCount: 1, }), ).toBe("Create an extension: Use the attached context."); expect( displayableComposerModeMessage({ messagePrefix: "Erstelle eine Erweiterung: ", trimmedText: "", attachmentCount: 1, attachedContextFallback: "Verwende den angehängten Kontext.", }), ).toBe("Erstelle eine Erweiterung: Verwende den angehängten Kontext."); }); it("detects oversized PDF attachments before submit", () => { const file = new File([new Uint8Array(4 * 1024 * 1024 + 1)], "large.pdf", { type: "application/pdf", }); expect( getOversizedDocumentAttachmentError([ { type: "document", name: "large.pdf", contentType: "application/pdf", file, }, ]), ).toContain('"large.pdf" is 4.0 MB. PDFs are capped at 4 MB'); expect( getOversizedDocumentAttachmentError([ { type: "image", name: "large.png", contentType: "image/png", file, }, ]), ).toBeNull(); }); it("allows hosts to use a larger multipart document cap", () => { const file = new File( [new Uint8Array(4 * 1024 * 1024 + 1)], "reference.pdf", { type: "application/pdf" }, ); expect( getOversizedDocumentAttachmentError( [ { type: "document", name: "reference.pdf", contentType: "application/pdf", file, }, ], { maxBytes: 50 * 1024 * 1024, label: "Slides reference files", }, ), ).toBeNull(); }); it("localizes a custom multipart document cap", () => { const file = new File( [new Uint8Array(4 * 1024 * 1024 + 1)], "reference.pdf", { type: "application/pdf" }, ); let translatedOptions: Record | undefined; const error = getOversizedDocumentAttachmentError( [ { type: "document", name: "reference.pdf", contentType: "application/pdf", file, }, ], { maxBytes: 4 * 1024 * 1024, label: "Präsentationsdateien", translate: (key, options) => { expect(key).toBe("agentChat.composer.documentTooLarge"); translatedOptions = options; return `„${String(options?.name)}“ ist ${String(options?.size)} MB groß. ${String(options?.label)} sind auf ${String(options?.maxSize)} MB begrenzt.`; }, }, ); expect(error).toBe( "„reference.pdf“ ist 4.0 MB groß. Präsentationsdateien sind auf 4 MB begrenzt.", ); expect(translatedOptions).toEqual( expect.objectContaining({ name: "reference.pdf", size: "4.0", label: "Präsentationsdateien", maxSize: "4", }), ); }); it("maps Enter keybindings to immediate and queued submit intents", () => { const enter = { key: "Enter", shiftKey: false, metaKey: false, ctrlKey: false, }; expect(getComposerSubmitIntentForEnterKey(enter, true)).toBe("immediate"); expect(getComposerSubmitIntentForEnterKey(enter, false)).toBe("immediate"); expect( getComposerSubmitIntentForEnterKey({ ...enter, metaKey: true }, true), ).toBe("queued"); expect( getComposerSubmitIntentForEnterKey({ ...enter, ctrlKey: true }, false), ).toBe("queued"); expect( getComposerSubmitIntentForEnterKey( { ...enter, shiftKey: true, metaKey: true }, true, ), ).toBeNull(); expect( getComposerSubmitIntentForEnterKey({ ...enter, ctrlKey: true }, true), ).toBeNull(); expect( getComposerSubmitIntentForEnterKey({ ...enter, metaKey: true }, false), ).toBeNull(); }); it("scrolls the composer caret into view for Shift+Enter line breaks", () => { const editor = new Editor({ element: document.createElement("div"), extensions: createTiptapComposerExtensions(() => "Message agent..."), content: "

Hello

", }); editor.commands.setTextSelection(editor.state.doc.content.size); const view = editor.view; const scrolledTransactions: boolean[] = []; const dispatch = view.dispatch.bind(view); view.dispatch = (transaction) => { scrolledTransactions.push(transaction.scrolledIntoView); dispatch(transaction); }; expect(insertComposerHardBreakAndScrollIntoView(view)).toBe(true); expect(scrolledTransactions).toEqual([true]); expect(editor.getText()).toBe("Hello\n"); editor.destroy(); }); it("guards popover positioning when the editor cannot resolve coordinates", () => { expect( getComposerPopoverPosition( { coordsAtPos: () => ({ top: 12, bottom: 20, left: 34, right: 34 }), }, 1, ), ).toEqual({ top: 12, left: 34 }); expect( getComposerPopoverPosition( { coordsAtPos: () => { throw new TypeError("node.getBoundingClientRect is not a function"); }, }, 1, ), ).toBeNull(); expect( getComposerPopoverPosition( { coordsAtPos: () => ({ top: Number.NaN, bottom: 20, left: 34, right: 34, }), }, 1, ), ).toBeNull(); }); it("consumes composer file drops so parent drop targets do not attach duplicates", () => { const file = new File(["fake"], "image.png", { type: "image/png" }); const added: File[] = []; let prevented = false; let stopped = false; const handled = handleComposerFileDrop({ event: { dataTransfer: { files: [file] }, preventDefault: () => { prevented = true; }, stopPropagation: () => { stopped = true; }, } as unknown as DragEvent, addAttachment: async (attachment) => { added.push(attachment); }, }); expect(handled).toBe(true); expect(prevented).toBe(true); expect(stopped).toBe(true); expect(added).toHaveLength(1); expect(added[0]?.name).toMatch(/^\d+-[a-z0-9]+-image\.png$/); }); it("caps the model picker height without forcing empty vertical space", () => { expect(MODEL_SELECTOR_POPOVER_STYLE).toMatchObject({ fontSize: 13, maxHeight: "min(500px, var(--radix-popover-content-available-height, 500px))", }); expect(MODEL_SELECTOR_POPOVER_STYLE).not.toHaveProperty("height"); }); it("shows the model picker skeleton only while the initial list is loading", () => { expect(shouldShowModelSelectorSkeleton(true, 0)).toBe(true); expect(shouldShowModelSelectorSkeleton(true, 2)).toBe(false); expect(shouldShowModelSelectorSkeleton(false, 0)).toBe(false); }); it("replaces the model list with connect CTAs only when nothing is configured", () => { const unconfigured = [{ configured: false }, { configured: false }]; expect(shouldShowOnlyConnectPath(true, unconfigured)).toBe(true); expect( shouldShowOnlyConnectPath(true, [ { configured: true }, { configured: false }, ]), ).toBe(false); // No CTA to fall back on — keep the list rather than empty the popover. expect(shouldShowOnlyConnectPath(false, unconfigured)).toBe(false); }); it("keeps cloud setup readiness separate from local runtime readiness", () => { expect( hasConfiguredCloudProvider([ { engine: "pi-cli", configured: true }, { engine: "opencode-cli", configured: true }, ]), ).toBe(false); expect( hasConfiguredCloudProvider([ { engine: "pi-cli", configured: true }, { engine: "builder", configured: true }, ]), ).toBe(true); expect( hasConfiguredCloudProvider([{ engine: "builder", configured: false }]), ).toBe(false); }); it("still renders the picker when nothing is configured, even though that leaves selectedModel empty", () => { // Nothing routable yet means useChatModels() resolves selectedModel to // "" (never null/undefined) so it can't be pre-selected — that must not // read as "no picker to show": the connect-provider CTAs still live // inside the picker itself. const unconfigured = [ { engine: "openai", label: "OpenAI", models: ["gpt-5"], configured: false, }, ]; expect(shouldRenderModelSelector(unconfigured, () => {})).toBe(true); // Genuinely nothing to show: no engines, or no way to change the model. expect(shouldRenderModelSelector([], () => {})).toBe(false); expect(shouldRenderModelSelector(unconfigured, undefined)).toBe(false); expect(shouldRenderModelSelector(undefined, () => {})).toBe(false); }); }); describe("composerModelCostTier", () => { it("tiers each provider's entry, mid, and flagship models", () => { expect(composerModelCostTier("gpt-5-6-luna")).toBe(1); expect(composerModelCostTier("gpt-5.6-terra")).toBe(2); expect(composerModelCostTier("openai/gpt-5.6-sol")).toBe(3); expect(composerModelCostTier("claude-haiku-4-5")).toBe(1); expect(composerModelCostTier("claude-sonnet-5")).toBe(2); expect(composerModelCostTier("anthropic/claude-opus-4.8")).toBe(3); expect(composerModelCostTier("claude-fable-5")).toBe(3); expect(composerModelCostTier("gemini-3-1-flash-lite")).toBe(1); expect(composerModelCostTier("gemini-3-1-pro")).toBe(3); }); it("returns undefined for unmapped models so no cost label renders", () => { // A guessed tier is worse than none — these render without a `$` label. expect(composerModelCostTier("auto")).toBeUndefined(); expect(composerModelCostTier("z-ai/glm-5.2")).toBeUndefined(); expect(composerModelCostTier("kimi-k2-5")).toBeUndefined(); expect(composerModelCostTier("")).toBeUndefined(); }); }); describe("voice composer insertion", () => { it("adds sentence punctuation and a trailing separator to dictated text", () => { expect(formatVoiceTranscriptForComposer(" First sentence ")).toBe( "First sentence. ", ); expect(formatVoiceTranscriptForComposer("Already done? ")).toBe( "Already done? ", ); expect(formatVoiceTranscriptForComposer(" ")).toBe(""); }); it("only removes a live preview when its range still contains the preview", () => { expect( canRemoveVoicePreview({ documentSize: 20, anchor: 4, previewText: "draft", currentText: "draft", }), ).toBe(true); expect( canRemoveVoicePreview({ documentSize: 6, anchor: 4, previewText: "draft", currentText: "", }), ).toBe(false); expect( canRemoveVoicePreview({ documentSize: 20, anchor: 4, previewText: "draft", currentText: "sent", }), ).toBe(false); }); });