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')
})
})
})