import { useState } from 'react' import { describe, it, expect, vi } from 'vitest' import { createEvent, fireEvent, render, screen, waitFor, } from '@testing-library/react' import { WikilinkChatInput } from './WikilinkChatInput' import { extractDroppedPathText } from './inlineWikilinkDropText' import { UNSUPPORTED_INLINE_PASTE_MESSAGE, } from './InlineWikilinkInput' import { isInsertBeforeInput } from './inlineWikilinkBeforeInput' import type { VaultEntry } from '../types' const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/vault/note/test.md', filename: 'test.md', title: 'Test Note', isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], ...overrides, }) const entries: VaultEntry[] = [ makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }), makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person', aliases: ['BLT'] }), makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }), ] function Controlled({ onSend, onUnsupportedPaste, disabled = false, placeholder, onDraftChange, }: { onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void onUnsupportedPaste?: (message: string) => void disabled?: boolean placeholder?: string onDraftChange?: (value: string) => void }) { const [value, setValue] = useState('') const handleChange = (nextValue: string) => { onDraftChange?.(nextValue) setValue(nextValue) } return ( ) } function setSelection(editor: HTMLElement, offset: number) { const selection = window.getSelection() if (!selection) return const targetNode = editor.firstChild ?? editor const safeOffset = targetNode.nodeType === Node.TEXT_NODE ? Math.min(offset, targetNode.textContent?.length ?? 0) : Math.min(offset, targetNode.childNodes.length) const range = document.createRange() range.setStart(targetNode, safeOffset) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } function updateEditorText(text: string) { const editor = screen.getByTestId('agent-input') fireEvent.focus(editor) editor.textContent = text setSelection(editor, text.length) fireEvent.input(editor) } function clickFirstSuggestion() { const rows = screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]') expect(rows.length).toBeGreaterThan(0) fireEvent.click(rows[0]) } function fireComposingKeyDown(editor: HTMLElement, key: string) { const event = createEvent.keyDown(editor, { key, keyCode: 229, which: 229, }) Object.defineProperty(event, 'isComposing', { configurable: true, value: true, }) fireEvent(editor, event) } function createFileLikeDataTransfer({ plainText = '', uriList = '', }: { plainText?: string uriList?: string }) { return { getData: vi.fn((type: string) => { if (type === 'text/plain') return plainText if (type === 'text/uri-list') return uriList return '' }), files: [new File(['folder'], 'Projects')], items: [{ kind: 'file', type: '' }], } } describe('WikilinkChatInput', () => { it('renders the placeholder overlay for an empty draft', () => { render() expect(screen.getByText('Ask something...')).toBeInTheDocument() expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask something...') }) it('calls onChange when the draft changes', () => { const onChange = vi.fn() render( , ) updateEditorText('hello') expect(onChange).toHaveBeenCalledWith('hello') }) it('shows wikilink suggestions after typing [[', () => { render() updateEditorText('[[a') const menu = screen.getByTestId('wikilink-menu') expect(menu.textContent).toContain('Alpha') }) it('matches suggestions by alias', () => { render() updateEditorText('[[BLT') expect(screen.getByTestId('wikilink-menu').textContent).toContain('Beta') }) it('renders selected wikilinks inline instead of in a separate pill strip', () => { render() updateEditorText('edit my [[alp') clickFirstSuggestion() expect(screen.queryByTestId('reference-pill')).toBeNull() expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument() expect(screen.getByTestId('agent-input').textContent).toContain('Alpha') }) it('selects a suggestion with Enter before sending the draft', () => { const onSend = vi.fn() render() updateEditorText('edit my [[alp') fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument() expect(onSend).not.toHaveBeenCalled() }) it('does not hijack Enter while IME composition is active', async () => { const onDraftChange = vi.fn() const onSend = vi.fn() render() const editor = screen.getByTestId('agent-input') fireEvent.focus(editor) fireEvent.compositionStart(editor) editor.textContent = 'ni' setSelection(editor, 2) fireEvent.input(editor) expect(onDraftChange).not.toHaveBeenCalled() fireComposingKeyDown(editor, 'Enter') expect(onSend).not.toHaveBeenCalled() editor.textContent = '你' setSelection(editor, 1) fireEvent.compositionEnd(editor) await waitFor(() => { expect(onDraftChange).toHaveBeenCalledWith('你') }) expect(editor.textContent).toContain('你') }) it('does not select wikilink suggestions while IME composition is active', () => { render() updateEditorText('[[a') const editor = screen.getByTestId('agent-input') fireEvent.compositionStart(editor) fireComposingKeyDown(editor, 'Enter') expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull() expect(screen.getByTestId('wikilink-menu').textContent).toContain('Alpha') }) it('clears IME-injected stray text nodes after composition ends', async () => { const onDraftChange = vi.fn() render() const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement initialEditor.focus() fireEvent.compositionStart(initialEditor) initialEditor.appendChild(document.createTextNode('你')) fireEvent.input(initialEditor) fireEvent.compositionEnd(initialEditor) await waitFor(() => { expect(onDraftChange).toHaveBeenCalledWith('你') }) await waitFor(() => { const editor = screen.getByTestId('agent-input') as HTMLDivElement expect(editor.textContent).toBe('你') }) }) it('does not steal focus back if it was moved elsewhere during composition end', async () => { const onDraftChange = vi.fn() render( <> , ) const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement const otherTarget = screen.getByTestId('other-target') as HTMLButtonElement initialEditor.focus() fireEvent.compositionStart(initialEditor) initialEditor.appendChild(document.createTextNode('你')) fireEvent.input(initialEditor) otherTarget.focus() fireEvent.compositionEnd(initialEditor) await waitFor(() => { expect(onDraftChange).toHaveBeenCalledWith('你') }) expect(document.activeElement).toBe(otherTarget) }) it('does not reset the DOM selection while IME composition is active', () => { const removeAllRanges = vi.spyOn(Selection.prototype, 'removeAllRanges') render() const editor = screen.getByTestId('agent-input') as HTMLDivElement editor.focus() fireEvent.compositionStart(editor) editor.appendChild(document.createTextNode('ni')) setSelection(editor, 2) removeAllRanges.mockClear() fireEvent.keyUp(editor) fireEvent.click(editor) fireEvent.mouseUp(editor) expect(removeAllRanges).not.toHaveBeenCalled() expect(editor.textContent).toContain('ni') removeAllRanges.mockRestore() }) it('deletes an inline chip with a single Backspace', () => { render() updateEditorText('edit my [[alp') clickFirstSuggestion() const editor = screen.getByTestId('agent-input') fireEvent.keyDown(editor, { key: 'Backspace' }) expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull() }) it('submits serialized wikilink text and resolved references', () => { const onSend = vi.fn() render() updateEditorText('edit my [[alpha]] essay') fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) expect(onSend).toHaveBeenCalledWith('edit my [[alpha]] essay', [ { title: 'Alpha', path: '/vault/alpha.md', type: 'Project' }, ]) }) it('does not send on Shift+Enter', () => { const onSend = vi.fn() render() updateEditorText('hello') fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true }) expect(onSend).not.toHaveBeenCalled() }) it('marks the editor disabled when disabled is true', () => { render() const editor = screen.getByTestId('agent-input') expect(editor).toHaveAttribute('contenteditable', 'false') expect(editor).toHaveAttribute('aria-disabled', 'true') }) it('rejects pasted images without freezing the editor', () => { const onUnsupportedPaste = vi.fn() render() const editor = screen.getByTestId('agent-input') const clipboardData = { getData: vi.fn(() => ''), files: [new File(['image'], 'paste.png', { type: 'image/png' })], items: [{ kind: 'file', type: 'image/png' }], } fireEvent.paste(editor, { clipboardData }) expect(onUnsupportedPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE) updateEditorText('still works') expect(editor.textContent).toContain('still works') }) it('extracts dropped folder paths from text/plain payloads', () => { expect(extractDroppedPathText( createFileLikeDataTransfer({ plainText: '/Users/test/Projects', }) as DataTransfer, )).toBe('/Users/test/Projects') }) it('falls back to file URLs exposed through uri lists', () => { expect(extractDroppedPathText( createFileLikeDataTransfer({ uriList: 'file:///Users/test/My%20Folder', }) as DataTransfer, )).toBe('"/Users/test/My Folder"') }) it('treats missing inputType as a non-insert beforeinput event', () => { expect(() => isInsertBeforeInput({} as InputEvent)).not.toThrow() expect(isInsertBeforeInput({} as InputEvent)).toBe(false) expect(isInsertBeforeInput({ inputType: 'insertFromPaste' } as InputEvent)).toBe(true) }) it('ignores beforeinput events without inputType instead of crashing', () => { render() const editor = screen.getByTestId('agent-input') const beforeInputEvent = new Event('beforeinput', { bubbles: true, cancelable: true, }) expect(() => fireEvent(editor, beforeInputEvent)).not.toThrow() updateEditorText('still works') expect(editor.textContent).toContain('still works') }) it('recovers if unsupported media lands in the editor DOM', async () => { const onUnsupportedPaste = vi.fn() render() const editor = screen.getByTestId('agent-input') editor.innerHTML = 'paste' fireEvent.input(editor) expect(onUnsupportedPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE) await waitFor(() => { expect(screen.getByTestId('agent-input').querySelector('img')).toBeNull() }) updateEditorText('still works') expect(screen.getByTestId('agent-input').textContent).toContain('still works') }) })