diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.test.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.test.tsx new file mode 100644 index 00000000000..dbfca1bc4da --- /dev/null +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { GHCommentComposer } from '@/components/github-item-dialog/discuss-item/gh-comment-composer' +import type { AppState } from '@/store/types' +import type { GitHubCommentResult, PRComment } from '../../../../shared/github/comment-types' +import { GitHubMarkdownComposer } from './GitHubMarkdownComposer' + +type ComposerStoreState = { settings: Partial } + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: ComposerStoreState) => unknown) => selector({ settings: {} }) +})) +vi.mock('@/components/editor/RichMarkdownToolbar', () => ({ + RichMarkdownToolbar: () => null +})) +vi.mock('@/components/editor/RichMarkdownTableControls', () => ({ + RichMarkdownTableControls: () => null +})) +vi.mock('@/components/github/github-markdown-composer-preview-pane', () => ({ + GitHubMarkdownComposerPreviewPane: () => null +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +const addIssueComment = vi.fn() +const comment: PRComment = { + id: 29533, + author: 'test-author', + authorAvatarUrl: '', + body: 'Submitted comment', + createdAt: '2026-10-23T12:00:00Z', + url: '' +} + +beforeEach(() => { + addIssueComment.mockReset() + vi.stubGlobal('api', { gh: { addIssueComment } }) +}) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +function getComposerEditor(container: HTMLElement): HTMLElement { + const editor = container.querySelector('.tiptap') + if (!editor) { + throw new Error('Composer editor did not mount') + } + return editor +} + +async function enterComment(container: HTMLElement): Promise { + await waitFor(() => expect(container.querySelector('.tiptap')).not.toBeNull()) + fireEvent.input(getComposerEditor(container), { + target: { innerHTML: '

Submitted comment

' } + }) +} + +function renderCommentSubmission() { + const onCommentAdded = vi.fn() + const view = render( + + + + ) + return { ...view, onCommentAdded } +} + +describe('GitHubMarkdownComposer submission', () => { + it('clears the real submission owner and editor after a successful request', async () => { + const request = Promise.withResolvers() + addIssueComment.mockReturnValue(request.promise) + const view = renderCommentSubmission() + const send = view.getByRole('button', { name: 'Send comment' }) + await enterComment(view.container) + await waitFor(() => expect(send.hasAttribute('disabled')).toBe(false)) + fireEvent.click(send) + await waitFor(() => + expect(getComposerEditor(view.container).getAttribute('contenteditable')).toBe('false') + ) + expect(send.hasAttribute('disabled')).toBe(true) + expect(addIssueComment).toHaveBeenCalledOnce() + expect(addIssueComment.mock.calls[0]?.[0].body).toBe('Submitted comment') + + await act(async () => request.resolve({ ok: true, comment })) + + await waitFor(() => { + expect(getComposerEditor(view.container).textContent).toBe('') + expect(getComposerEditor(view.container).getAttribute('contenteditable')).toBe('true') + expect(send.hasAttribute('disabled')).toBe(true) + }) + expect(view.onCommentAdded).toHaveBeenCalledExactlyOnceWith(comment) + }) + + it.each(['result-error', 'rejection'])( + 'preserves a failed draft for retry (%s)', + async (kind) => { + const request = Promise.withResolvers() + addIssueComment.mockReturnValueOnce(request.promise) + const view = renderCommentSubmission() + const send = view.getByRole('button', { name: 'Send comment' }) + await enterComment(view.container) + await waitFor(() => expect(send.hasAttribute('disabled')).toBe(false)) + fireEvent.click(send) + await waitFor(() => + expect(getComposerEditor(view.container).getAttribute('contenteditable')).toBe('false') + ) + + await act(async () => { + if (kind === 'rejection') { + request.reject(new Error('Request failed')) + } else { + request.resolve({ ok: false, error: 'Request failed' }) + } + }) + + await waitFor(() => { + expect(getComposerEditor(view.container).textContent).toBe('Submitted comment') + expect(getComposerEditor(view.container).getAttribute('contenteditable')).toBe('true') + expect(send.hasAttribute('disabled')).toBe(false) + }) + expect(view.onCommentAdded).not.toHaveBeenCalled() + const retry = Promise.withResolvers() + addIssueComment.mockReturnValueOnce(retry.promise) + fireEvent.click(send) + await waitFor(() => + expect(getComposerEditor(view.container).getAttribute('contenteditable')).toBe('false') + ) + await act(async () => retry.resolve({ ok: true, comment })) + await waitFor(() => { + expect(getComposerEditor(view.container).textContent).toBe('') + expect(send.hasAttribute('disabled')).toBe(true) + }) + expect(view.onCommentAdded).toHaveBeenCalledExactlyOnceWith(comment) + } + ) + + it('does not report editability changes as draft edits', async () => { + const onChange = vi.fn() + const props = { value: 'Draft comment', onChange, placeholder: 'Add a comment…' } + const view = render() + await waitFor(() => expect(view.container.querySelector('.tiptap')).not.toBeNull()) + onChange.mockClear() + + view.rerender() + view.rerender() + + expect(onChange).not.toHaveBeenCalled() + }) + + it('accepts an external nonempty value while editability changes', async () => { + const onChange = vi.fn() + const props = { value: 'Original description', onChange, placeholder: 'Description' } + const view = render() + await waitFor(() => expect(getComposerEditor(view.container).textContent).toBe(props.value)) + onChange.mockClear() + + view.rerender() + await waitFor(() => + expect(getComposerEditor(view.container).textContent).toBe('Updated description') + ) + view.rerender( + + ) + + expect(getComposerEditor(view.container).getAttribute('contenteditable')).toBe('true') + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 52d7906df08..95901079162 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -201,7 +201,7 @@ export function GitHubMarkdownComposer({ return } editorRef.current = editor - editor.setEditable(!disabled) + editor.setEditable(!disabled, false) }, [disabled, editor]) useEffect(() => {