From f5b949762dfecd84c824a431dcc4e9ecc1a99024 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 16 Sep 2025 16:06:33 -0700 Subject: [PATCH] [test] Add Vue node markdown widget component test (#5575) * add markdown widget test * [fix] correct test comments from 'is exposed' to 'is not exposed' - addresses review feedback The TypeScript suppression comments incorrectly stated that properties were exposed when they should indicate they are not exposed, since @ts-expect-error is used to access private properties. Co-authored-by: christian-byrne --------- Co-authored-by: christian-byrne --- .../widgets/components/WidgetMarkdown.test.ts | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts new file mode 100644 index 000000000..836f2b357 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts @@ -0,0 +1,432 @@ +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import Textarea from 'primevue/textarea' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import type { SimplifiedWidget } from '@/types/simplifiedWidget' + +import WidgetMarkdown from './WidgetMarkdown.vue' + +// Mock the markdown renderer utility +vi.mock('@/utils/markdownRendererUtil', () => ({ + renderMarkdownToHtml: vi.fn((markdown: string) => { + // Simple mock that converts some markdown to HTML + return markdown + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/^# (.*?)$/gm, '

$1

') + .replace(/^## (.*?)$/gm, '

$1

') + .replace(/\n/g, '
') + }) +})) + +describe('WidgetMarkdown Dual Mode Display', () => { + const createMockWidget = ( + value: string = '# Default Heading\nSome **bold** text.', + options: Record = {}, + callback?: (value: string) => void + ): SimplifiedWidget => ({ + name: 'test_markdown', + type: 'string', + value, + options, + callback + }) + + const mountComponent = ( + widget: SimplifiedWidget, + modelValue: string, + readonly = false + ) => { + return mount(WidgetMarkdown, { + global: { + plugins: [PrimeVue], + components: { Textarea } + }, + props: { + widget, + modelValue, + readonly + } + }) + } + + const clickToEdit = async (wrapper: ReturnType) => { + const container = wrapper.find('.widget-markdown') + await container.trigger('click') + await nextTick() + return container + } + + const blurTextarea = async (wrapper: ReturnType) => { + const textarea = wrapper.find('textarea') + if (textarea.exists()) { + await textarea.trigger('blur') + await nextTick() + } + return textarea + } + + describe('Display Mode', () => { + it('renders markdown content as HTML in display mode', () => { + const markdown = '# Heading\nSome **bold** and *italic* text.' + const widget = createMockWidget(markdown) + const wrapper = mountComponent(widget, markdown) + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.exists()).toBe(true) + expect(displayDiv.html()).toContain('

Heading

') + expect(displayDiv.html()).toContain('bold') + expect(displayDiv.html()).toContain('italic') + }) + + it('starts in display mode by default', () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true) + expect(wrapper.find('textarea').exists()).toBe(false) + }) + + it('applies styling classes to display container', () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.classes()).toContain('text-xs') + expect(displayDiv.classes()).toContain('min-h-[60px]') + expect(displayDiv.classes()).toContain('rounded-lg') + expect(displayDiv.classes()).toContain('px-4') + expect(displayDiv.classes()).toContain('py-2') + expect(displayDiv.classes()).toContain('overflow-y-auto') + }) + + it('handles empty markdown content', () => { + const widget = createMockWidget('') + const wrapper = mountComponent(widget, '') + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.exists()).toBe(true) + expect(displayDiv.text()).toBe('') + }) + }) + + describe('Edit Mode Toggle', () => { + it('switches to edit mode when clicked', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true) + + await clickToEdit(wrapper) + + expect(wrapper.find('.comfy-markdown-content').exists()).toBe(false) + expect(wrapper.find('textarea').exists()).toBe(true) + }) + + it('does not switch to edit mode when readonly', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test', true) + + await clickToEdit(wrapper) + + expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true) + expect(wrapper.find('textarea').exists()).toBe(false) + }) + + it('does not switch to edit mode when already editing', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + // First click to enter edit mode + await clickToEdit(wrapper) + expect(wrapper.find('textarea').exists()).toBe(true) + + // Second click should not have any effect + await clickToEdit(wrapper) + expect(wrapper.find('textarea').exists()).toBe(true) + }) + + it('switches back to display mode on textarea blur', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + await clickToEdit(wrapper) + expect(wrapper.find('textarea').exists()).toBe(true) + + await blurTextarea(wrapper) + expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true) + expect(wrapper.find('textarea').exists()).toBe(false) + }) + }) + + describe('Edit Mode', () => { + it('displays textarea with current value when editing', async () => { + const markdown = '# Original Content' + const widget = createMockWidget(markdown) + const wrapper = mountComponent(widget, markdown) + + await clickToEdit(wrapper) + + const textarea = wrapper.find('textarea') + expect(textarea.exists()).toBe(true) + expect(textarea.element.value).toBe('# Original Content') + }) + + it('applies styling and configuration to textarea', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + await clickToEdit(wrapper) + + const textarea = wrapper.findComponent({ name: 'Textarea' }) + expect(textarea.props('size')).toBe('small') + // Check rows attribute in the DOM instead of props + const textareaElement = wrapper.find('textarea') + expect(textareaElement.attributes('rows')).toBe('6') + expect(textarea.classes()).toContain('text-xs') + expect(textarea.classes()).toContain('w-full') + }) + + it('disables textarea when readonly', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test', true) + + // Readonly should prevent entering edit mode + await clickToEdit(wrapper) + expect(wrapper.find('textarea').exists()).toBe(false) + }) + + it('stops click and keydown event propagation in edit mode', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + await clickToEdit(wrapper) + + const textarea = wrapper.find('textarea') + const clickSpy = vi.fn() + const keydownSpy = vi.fn() + + wrapper.element.addEventListener('click', clickSpy) + wrapper.element.addEventListener('keydown', keydownSpy) + + await textarea.trigger('click') + await textarea.trigger('keydown', { key: 'Enter' }) + + // Events should be stopped from propagating + expect(clickSpy).not.toHaveBeenCalled() + expect(keydownSpy).not.toHaveBeenCalled() + }) + }) + + describe('Value Updates', () => { + it('emits update:modelValue when textarea content changes', async () => { + const widget = createMockWidget('# Original') + const wrapper = mountComponent(widget, '# Original') + + await clickToEdit(wrapper) + + const textarea = wrapper.find('textarea') + await textarea.setValue('# Updated Content') + await textarea.trigger('input') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted![emitted!.length - 1]).toEqual(['# Updated Content']) + }) + + it('renders updated HTML after value change and blur', async () => { + const widget = createMockWidget('# Original') + const wrapper = mountComponent(widget, '# Original') + + await clickToEdit(wrapper) + + const textarea = wrapper.find('textarea') + await textarea.setValue('## New Heading\nWith **bold** text') + await textarea.trigger('input') + await blurTextarea(wrapper) + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.html()).toContain('

New Heading

') + expect(displayDiv.html()).toContain('bold') + }) + + it('emits update:modelValue for callback handling at parent level', async () => { + const widget = createMockWidget('# Test', {}) + const wrapper = mountComponent(widget, '# Test') + + await clickToEdit(wrapper) + + const textarea = wrapper.find('textarea') + await textarea.setValue('# Changed') + await textarea.trigger('input') + + // The widget should emit the change for parent (NodeWidgets) to handle callbacks + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted![emitted!.length - 1]).toEqual(['# Changed']) + }) + + it('handles missing callback gracefully', async () => { + const widget = createMockWidget('# Test', {}, undefined) + const wrapper = mountComponent(widget, '# Test') + + await clickToEdit(wrapper) + + const textarea = wrapper.find('textarea') + await textarea.setValue('# Changed') + + // Should not throw error and should still emit Vue event + await expect(textarea.trigger('input')).resolves.not.toThrow() + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + }) + }) + + describe('Complex Markdown Rendering', () => { + it('handles multiple markdown elements', () => { + const complexMarkdown = `# Main Heading +## Subheading +This paragraph has **bold** and *italic* text. +Another line with more content.` + + const widget = createMockWidget(complexMarkdown) + const wrapper = mountComponent(widget, complexMarkdown) + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.html()).toContain('

Main Heading

') + expect(displayDiv.html()).toContain('

Subheading

') + expect(displayDiv.html()).toContain('bold') + expect(displayDiv.html()).toContain('italic') + }) + + it('handles line breaks in markdown', () => { + const markdownWithBreaks = 'Line 1\nLine 2\nLine 3' + const widget = createMockWidget(markdownWithBreaks) + const wrapper = mountComponent(widget, markdownWithBreaks) + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.html()).toContain('
') + }) + + it('handles empty or whitespace-only markdown', () => { + const whitespaceMarkdown = ' \n\n ' + const widget = createMockWidget(whitespaceMarkdown) + const wrapper = mountComponent(widget, whitespaceMarkdown) + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.exists()).toBe(true) + }) + }) + + describe('Edge Cases', () => { + it('handles very long markdown content', async () => { + const longMarkdown = '# Heading\n' + 'Lorem ipsum '.repeat(1000) + const widget = createMockWidget(longMarkdown) + const wrapper = mountComponent(widget, longMarkdown) + + // Should render without issues + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.exists()).toBe(true) + + // Should switch to edit mode + await clickToEdit(wrapper) + const textarea = wrapper.find('textarea') + expect(textarea.exists()).toBe(true) + expect(textarea.element.value).toBe(longMarkdown) + }) + + it('handles special characters in markdown', async () => { + const specialChars = '# Special: @#$%^&*()[]{}|\\:";\'<>?,./' + const widget = createMockWidget(specialChars) + const wrapper = mountComponent(widget, specialChars) + + await clickToEdit(wrapper) + const textarea = wrapper.find('textarea') + expect(textarea.element.value).toBe(specialChars) + }) + + it('handles unicode characters', async () => { + const unicode = '# Unicode: 🎨 αβγ 中文 العربية 🚀' + const widget = createMockWidget(unicode) + const wrapper = mountComponent(widget, unicode) + + await clickToEdit(wrapper) + const textarea = wrapper.find('textarea') + expect(textarea.element.value).toBe(unicode) + + await textarea.setValue(unicode + ' more unicode') + await textarea.trigger('input') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted![emitted!.length - 1]).toEqual([unicode + ' more unicode']) + }) + + it('handles rapid edit mode toggling', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + // Rapid toggling + await clickToEdit(wrapper) + expect(wrapper.find('textarea').exists()).toBe(true) + + await blurTextarea(wrapper) + expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true) + + await clickToEdit(wrapper) + expect(wrapper.find('textarea').exists()).toBe(true) + }) + }) + + describe('Styling and Layout', () => { + it('applies widget-markdown class to container', () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + + const container = wrapper.find('.widget-markdown') + expect(container.exists()).toBe(true) + expect(container.classes()).toContain('relative') + expect(container.classes()).toContain('w-full') + expect(container.classes()).toContain('cursor-text') + }) + + it('applies overflow handling to display mode', () => { + const widget = createMockWidget( + '# Long Content\n' + 'Content '.repeat(100) + ) + const wrapper = mountComponent( + widget, + '# Long Content\n' + 'Content '.repeat(100) + ) + + const displayDiv = wrapper.find('.comfy-markdown-content') + expect(displayDiv.classes()).toContain('overflow-y-auto') + }) + }) + + describe('Focus Management', () => { + it('creates textarea reference when entering edit mode', async () => { + const widget = createMockWidget('# Test') + const wrapper = mountComponent(widget, '# Test') + const vm = wrapper.vm as InstanceType + + // Test that the component creates a textarea reference when entering edit mode + // @ts-expect-error - isEditing is not exposed + expect(vm.isEditing).toBe(false) + + // @ts-expect-error - startEditing is not exposed + await vm.startEditing() + + // @ts-expect-error - isEditing is not exposed + expect(vm.isEditing).toBe(true) + await wrapper.vm.$nextTick() + + // Check that textarea exists after entering edit mode + const textarea = wrapper.findComponent({ name: 'Textarea' }) + expect(textarea.exists()).toBe(true) + }) + }) +})