import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import PluginIcon from '../../components/PluginIcon.vue' import { PLUGIN_ICON_FALLBACK_PATH } from '../../utils/pluginIcon' const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z' const SAMPLE_HTTPS_URL = 'https://example.com/icon.png' const SAMPLE_STATIC_SVG_URL = '/api/plugins/LEAF/assets/icon.svg' const SAMPLE_STRUCTURED_ICON = JSON.stringify({ type: 'mint-icon', viewBox: '0 0 24 24', gradient: { type: 'linear', angle: 135, stops: [ { offset: '0%', color: '#AAECBF' }, { offset: '100%', color: '#14A1A9' }, ], }, background: { fill: 'gradient', radius: 5 }, paths: [ { d: 'M7 17c5.8-.2 9.5-3.8 10-10-5.7.5-9.3 4.2-10 10z', fill: 'white', opacity: 0.95, }, ], }) describe('PluginIcon', () => { describe('format detection', () => { it('should render SVG path when icon starts with M followed by digit', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } }) const path = wrapper.find('svg path') expect(path.exists()).toBe(true) expect(path.attributes('d')).toBe(SAMPLE_PATH) expect(wrapper.find('img').exists()).toBe(false) }) it('should render structured gradient vector icons without an image tag', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_STRUCTURED_ICON } }) const svg = wrapper.find('svg.mint-plugin-icon__svg--structured') const rect = wrapper.find('rect') const stops = wrapper.findAll('stop') const path = wrapper.find('path') expect(svg.exists()).toBe(true) expect(svg.attributes('viewBox')).toBe('0 0 24 24') expect(rect.attributes('fill')).toMatch(/^url\(#mint-plugin-icon-gradient-/) expect(rect.attributes('rx')).toBe('5') expect(stops).toHaveLength(2) expect(stops[0].attributes('stop-color')).toBe('#AAECBF') expect(stops[1].attributes('stop-color')).toBe('#14A1A9') expect(path.attributes('fill')).toBe('white') expect(wrapper.find('img').exists()).toBe(false) }) it('should reject structured icons without paths as fallback', () => { const icon = JSON.stringify({ type: 'mint-icon', background: { fill: '#14A1A9', radius: 5 }, paths: [], }) const wrapper = mount(PluginIcon, { props: { icon } }) expect(wrapper.find('svg path').attributes('d')).toBe(PLUGIN_ICON_FALLBACK_PATH) expect(wrapper.find('.mint-plugin-icon__svg--structured').exists()).toBe(false) }) it('should accept structured gradient shorthand with from and to colors', () => { const icon = JSON.stringify({ type: 'mint-icon', gradient: { type: 'linear', from: '#AAECBF', to: '#14A1A9' }, background: { fill: 'gradient' }, paths: [{ d: SAMPLE_PATH, fill: 'white' }], }) const wrapper = mount(PluginIcon, { props: { icon } }) const stops = wrapper.findAll('stop') expect(stops).toHaveLength(2) expect(stops[0].attributes('stop-color')).toBe('#AAECBF') expect(stops[1].attributes('stop-color')).toBe('#14A1A9') }) it('should ignore unsafe structured icon colors and still render safe paths', () => { const icon = JSON.stringify({ type: 'mint-icon', background: { fill: 'url(javascript:alert(1))', radius: 5 }, paths: [{ d: SAMPLE_PATH, fill: 'javascript:alert(1)', stroke: '#14A1A9' }], }) const wrapper = mount(PluginIcon, { props: { icon } }) const path = wrapper.find('path') expect(wrapper.find('rect').exists()).toBe(false) expect(path.attributes('fill')).toBe('none') expect(path.attributes('stroke')).toBe('#14A1A9') }) it('should render SVG path when icon starts with lowercase m and a space', () => { const wrapper = mount(PluginIcon, { props: { icon: 'm 5,5 l 10,10' } }) const path = wrapper.find('svg path') expect(path.exists()).toBe(true) expect(path.attributes('d')).toBe('m 5,5 l 10,10') }) it('should render SVG path after stripping leading whitespace', () => { const wrapper = mount(PluginIcon, { props: { icon: ' ' + SAMPLE_PATH } }) const path = wrapper.find('svg path') expect(path.exists()).toBe(true) expect(path.attributes('d')).toBe(SAMPLE_PATH) }) it('should render SVG path after stripping leading BOM', () => { const wrapper = mount(PluginIcon, { props: { icon: '' + SAMPLE_PATH } }) const path = wrapper.find('svg path') expect(path.exists()).toBe(true) expect(path.attributes('d')).toBe(SAMPLE_PATH) }) it('should render for raster data URLs (png, jpeg, gif, webp)', () => { for (const mime of ['png', 'jpeg', 'jpg', 'gif', 'webp']) { const url = `data:image/${mime};base64,iVBORw0KGgo=` const wrapper = mount(PluginIcon, { props: { icon: url } }) expect(wrapper.find('img').attributes('src')).toBe(url) expect(wrapper.find('svg path').exists()).toBe(false) } }) it('should reject data:image/svg+xml as fallback (security)', () => { const svgDataUrl = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' const wrapper = mount(PluginIcon, { props: { icon: svgDataUrl } }) expect(wrapper.find('img').exists()).toBe(false) expect(wrapper.find('svg path').exists()).toBe(true) expect(wrapper.find('svg path').attributes('d')).not.toBe(svgDataUrl) }) it('should render for https URLs with referrer/lazy attributes', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_HTTPS_URL } }) const img = wrapper.find('img') expect(img.attributes('src')).toBe(SAMPLE_HTTPS_URL) expect(img.attributes('referrerpolicy')).toBe('no-referrer') expect(img.attributes('loading')).toBe('lazy') }) it('should render same-origin SVG assets as images', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_STATIC_SVG_URL } }) expect(wrapper.find('img').attributes('src')).toBe(SAMPLE_STATIC_SVG_URL) }) it('should reject protocol-relative SVG assets', () => { const wrapper = mount(PluginIcon, { props: { icon: '//example.com/icon.svg' } }) expect(wrapper.find('img').exists()).toBe(false) }) it('should reject http URLs as fallback (mixed-content)', () => { const wrapper = mount(PluginIcon, { props: { icon: 'http://example.com/icon.png' } }) expect(wrapper.find('img').exists()).toBe(false) expect(wrapper.find('svg path').exists()).toBe(true) }) it('should render fallback path when icon is empty', () => { const wrapper = mount(PluginIcon, { props: { icon: '' } }) expect(wrapper.find('svg path').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) }) it('should render fallback when icon is unrecognized', () => { const wrapper = mount(PluginIcon, { props: { icon: 'markdown:foo' } }) expect(wrapper.find('svg path').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) }) }) describe('size and variant classes', () => { it('should apply size class', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, size: 'lg' } }) expect(wrapper.classes()).toContain('mint-plugin-icon--lg') }) it('should apply the dedicated topbar size class', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, size: 'topbar' } }) expect(wrapper.classes()).toContain('mint-plugin-icon--topbar') }) it('should default size to md', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } }) expect(wrapper.classes()).toContain('mint-plugin-icon--md') }) it('should apply variant class', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, variant: 'tinted' } }) expect(wrapper.classes()).toContain('mint-plugin-icon--tinted') }) it('should default variant to solid', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } }) expect(wrapper.classes()).toContain('mint-plugin-icon--solid') }) }) describe('tone', () => { it('should apply tone via inline custom property', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, tone: '#0EA5E9' } }) const style = wrapper.attributes('style') || '' expect(style).toContain('--mint-plugin-icon-tone') expect(style).toContain('#0EA5E9') }) it('should not apply tone style when undefined', () => { const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } }) const style = wrapper.attributes('style') || '' expect(style).not.toContain('--mint-plugin-icon-tone') }) }) })