import { useState } from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act, 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' type NativeDropPayload = { type: string paths: string[] position: { x: number; y: number } } type NativeDropHandler = (event: { payload: NativeDropPayload }) => void const nativeDropState = vi.hoisted(() => ({ tauriMode: false, handlers: {} as Record, })) vi.mock('../mock-tauri', () => ({ isTauri: () => nativeDropState.tauriMode, })) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ onDragDropEvent: vi.fn((handler: NativeDropHandler) => { nativeDropState.handlers['native-drag-drop'] = handler return Promise.resolve(() => { delete nativeDropState.handlers['native-drag-drop'] }) }), }), })) 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') editor.focus() fireEvent.focus(editor) editor.textContent = text setSelection(editor, text.length) fireEvent.input(editor) } async function waitForCompositionFlush() { await act(async () => { await Promise.resolve() }) } 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: '' }], } } function resetNativeDropState() { nativeDropState.tauriMode = false for (const eventName of Object.keys(nativeDropState.handlers)) { delete nativeDropState.handlers[eventName] } } function mockElementRect(element: HTMLElement) { Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => ({ x: 0, y: 0, left: 0, top: 0, right: 400, bottom: 48, width: 400, height: 48, toJSON: () => ({}), }), }) } function emitNativePathDrop(paths: string[], position = { x: 20, y: 20 }) { const handler = nativeDropState.handlers['native-drag-drop'] if (!handler) throw new Error('No native drop handler registered') handler({ payload: { type: 'drop', paths, position, }, }) } async function waitForNativePathDropListener() { await waitFor(() => { expect(nativeDropState.handlers['native-drag-drop']).toBeDefined() }) } describe('WikilinkChatInput', () => { beforeEach(resetNativeDropState) afterEach(resetNativeDropState) 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 duplicate syllables across consecutive IME compositions', async () => { const onDraftChange = vi.fn() render() const firstEditor = screen.getByTestId('agent-input') as HTMLDivElement firstEditor.focus() fireEvent.compositionStart(firstEditor) firstEditor.appendChild(document.createTextNode('안')) fireEvent.input(firstEditor) fireEvent.compositionEnd(firstEditor) await waitForCompositionFlush() await waitFor(() => { expect(onDraftChange).toHaveBeenLastCalledWith('안') }) await waitFor(() => { expect(screen.getByTestId('agent-input').textContent).toBe('안') }) const secondEditor = screen.getByTestId('agent-input') as HTMLDivElement secondEditor.focus() fireEvent.compositionStart(secondEditor) secondEditor.appendChild(document.createTextNode('녕')) fireEvent.input(secondEditor) fireEvent.compositionEnd(secondEditor) await waitForCompositionFlush() await waitFor(() => { expect(onDraftChange).toHaveBeenLastCalledWith('안녕') }) expect(screen.getByTestId('agent-input').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('lets committed composed characters reach the native input pipeline once', () => { const onDraftChange = vi.fn() render() const editor = screen.getByTestId('agent-input') editor.focus() const portugueseText = 'á é í ç ã õ' const accentedKey = createEvent.keyDown(editor, { key: 'á' }) fireEvent(editor, accentedKey) expect(accentedKey.defaultPrevented).toBe(false) editor.textContent = portugueseText setSelection(editor, portugueseText.length) fireEvent.input(editor) expect(onDraftChange).toHaveBeenLastCalledWith(portugueseText) const updatedEditor = screen.getByTestId('agent-input') const cjkKey = createEvent.keyDown(updatedEditor, { key: '你' }) fireEvent(updatedEditor, cjkKey) expect(cjkKey.defaultPrevented).toBe(false) updatedEditor.textContent = `${portugueseText}你` setSelection(updatedEditor, portugueseText.length + 1) fireEvent.input(updatedEditor) expect(onDraftChange).toHaveBeenLastCalledWith(`${portugueseText}你`) }) it('remounts after native DOM input so React does not diff a mutated contentEditable tree', async () => { const onDraftChange = vi.fn() render() const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement initialEditor.focus() initialEditor.textContent = 'follow up after edit' setSelection(initialEditor, 'follow up after edit'.length) fireEvent.input(initialEditor) await waitFor(() => { expect(onDraftChange).toHaveBeenLastCalledWith('follow up after edit') }) const remountedEditor = screen.getByTestId('agent-input') as HTMLDivElement expect(remountedEditor).not.toBe(initialEditor) expect(remountedEditor.textContent).toBe('follow up after edit') expect(document.activeElement).toBe(remountedEditor) }) 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('handles Command+Backspace as a stable delete-to-line-start edit', () => { const onDraftChange = vi.fn() render() updateEditorText('first line\nsecond line') onDraftChange.mockClear() const editor = screen.getByTestId('agent-input') setSelection(editor, 'first line\nsecond'.length) const commandBackspace = createEvent.keyDown(editor, { key: 'Backspace', metaKey: true }) fireEvent(editor, commandBackspace) expect(commandBackspace.defaultPrevented).toBe(true) expect(onDraftChange).toHaveBeenCalledWith('first line\n line') expect(screen.getByTestId('agent-input')).toHaveFocus() }) it('lets non-Command modified delete shortcuts reach the browser editor pipeline', () => { const onDraftChange = vi.fn() render() updateEditorText('delete the previous words') onDraftChange.mockClear() const editor = screen.getByTestId('agent-input') const optionBackspace = createEvent.keyDown(editor, { key: 'Backspace', altKey: true }) fireEvent(editor, optionBackspace) const controlDelete = createEvent.keyDown(editor, { key: 'Delete', ctrlKey: true }) fireEvent(editor, controlDelete) expect(optionBackspace.defaultPrevented).toBe(false) expect(controlDelete.defaultPrevented).toBe(false) expect(onDraftChange).not.toHaveBeenCalled() }) 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('inserts one controlled newline on Shift+Enter without duplicating the draft', () => { const onDraftChange = vi.fn() render() updateEditorText('first line') fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true }) expect(onDraftChange).toHaveBeenLastCalledWith('first line\n') expect(screen.getByTestId('agent-input').textContent).toBe('first line\n') fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true }) expect(onDraftChange).toHaveBeenLastCalledWith('first line\n\n') expect(screen.getByTestId('agent-input').textContent).toBe('first line\n\n') }) it('inserts one controlled newline from native insertLineBreak beforeinput', () => { const onDraftChange = vi.fn() render() updateEditorText('first line') const editor = screen.getByTestId('agent-input') const beforeInputEvent = new Event('beforeinput', { bubbles: true, cancelable: true, }) Object.defineProperty(beforeInputEvent, 'inputType', { value: 'insertLineBreak', }) fireEvent(editor, beforeInputEvent) expect(beforeInputEvent.defaultPrevented).toBe(true) expect(onDraftChange).toHaveBeenLastCalledWith('first line\n') expect(screen.getByTestId('agent-input').textContent).toBe('first line\n') }) it('submits a multi-line draft with normal Enter after Shift+Enter', () => { const onSend = vi.fn() render() updateEditorText('first line') fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true }) const editor = screen.getByTestId('agent-input') editor.textContent = 'first line\nsecond line' setSelection(editor, 'first line\nsecond line'.length) fireEvent.input(editor) fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) expect(onSend).toHaveBeenCalledWith('first line\nsecond line', []) }) 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('inserts Tauri native folder drops into the AI composer', async () => { nativeDropState.tauriMode = true const onDraftChange = vi.fn() render() const editor = screen.getByTestId('agent-input') as HTMLDivElement mockElementRect(editor) await waitForNativePathDropListener() updateEditorText('Open ') editor.focus() onDraftChange.mockClear() act(() => { emitNativePathDrop(['/Users/test/My Folder']) }) await waitFor(() => { expect(onDraftChange).toHaveBeenCalledWith('Open "/Users/test/My Folder"') }) await waitFor(() => { expect(screen.getByTestId('agent-input').textContent).toContain('/Users/test/My Folder') }) }) it('accepts Tauri native folder drops for the focused AI composer even when native coordinates miss', async () => { nativeDropState.tauriMode = true const onDraftChange = vi.fn() render() const editor = screen.getByTestId('agent-input') as HTMLDivElement mockElementRect(editor) await waitForNativePathDropListener() updateEditorText('Open ') onDraftChange.mockClear() act(() => { emitNativePathDrop(['/Users/test/Projects'], { x: 900, y: 900 }) }) await waitFor(() => { expect(onDraftChange).toHaveBeenCalledWith('Open /Users/test/Projects') }) }) 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: 42 })).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('ignores beforeinput events with non-string inputType instead of calling startsWith', () => { render() const editor = screen.getByTestId('agent-input') const startsWith = vi.fn(() => true) const beforeInputEvent = new Event('beforeinput', { bubbles: true, cancelable: true, }) Object.defineProperty(beforeInputEvent, 'inputType', { value: { startsWith }, }) expect(() => fireEvent(editor, beforeInputEvent)).not.toThrow() expect(startsWith).not.toHaveBeenCalled() 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') }) })